Convert polygerrit to es6-modules

This change replace all HTML imports with es6-modules. The only exceptions are:
* gr-app.html file, which can be deleted only after updating the
  gerrit/httpd/raw/PolyGerritIndexHtml.soy file.
* dark-theme.html which is loaded via importHref. Must be updated manually
  later in a separate change.

This change was produced automatically by ./es6-modules-converter.sh script.
No manual changes were made.

Change-Id: I0c447dd8c05757741e2c940720652d01d9fb7d67
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js
index f560ea8..5e6f7c6 100644
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js
+++ b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js
@@ -1,21 +1,19 @@
-<!--
-@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.
--->
-
-<script>
+/**
+ * @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.
+ */
 (function(window) {
   'use strict';
 
@@ -63,4 +61,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
index f2f0e6b..fa5409b 100644
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
@@ -19,40 +19,42 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>async-foreach-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="async-foreach-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="./async-foreach-behavior.js"></script>
 
-<script>
-  suite('async-foreach-behavior tests', async () => {
-    await readyToTest();
-    test('loops over each item', () => {
-      const fn = sinon.stub().returns(Promise.resolve());
-      return Gerrit.AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
-          .then(() => {
-            assert.isTrue(fn.calledThrice);
-            assert.equal(fn.getCall(0).args[0], 1);
-            assert.equal(fn.getCall(1).args[0], 2);
-            assert.equal(fn.getCall(2).args[0], 3);
-          });
-    });
-
-    test('halts on stop condition', () => {
-      const stub = sinon.stub();
-      const fn = (e, stop) => {
-        stub(e);
-        stop();
-        return Promise.resolve();
-      };
-      return Gerrit.AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
-          .then(() => {
-            assert.isTrue(stub.calledOnce);
-            assert.equal(stub.lastCall.args[0], 1);
-          });
-    });
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './async-foreach-behavior.js';
+suite('async-foreach-behavior tests', () => {
+  test('loops over each item', () => {
+    const fn = sinon.stub().returns(Promise.resolve());
+    return Gerrit.AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
+        .then(() => {
+          assert.isTrue(fn.calledThrice);
+          assert.equal(fn.getCall(0).args[0], 1);
+          assert.equal(fn.getCall(1).args[0], 2);
+          assert.equal(fn.getCall(2).args[0], 3);
+        });
   });
+
+  test('halts on stop condition', () => {
+    const stub = sinon.stub();
+    const fn = (e, stop) => {
+      stub(e);
+      stop();
+      return Promise.resolve();
+    };
+    return Gerrit.AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
+        .then(() => {
+          assert.isTrue(stub.calledOnce);
+          assert.equal(stub.lastCall.args[0], 1);
+        });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.js b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.js
index 92596e0..9682776 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.js
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.js
@@ -1,21 +1,19 @@
-<!--
-@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.
--->
-
-<script>
+/**
+ * @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.
+ */
 (function(window) {
   'use strict';
 
@@ -46,4 +44,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
index eb6fd3f..4f6f1f5 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
@@ -19,17 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>base-url-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<script>
-  /** @type {string} */
-  window.CANONICAL_PATH = '/r';
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './base-url-behavior.js';
+/** @type {string} */
+window.CANONICAL_PATH = '/r';
 </script>
-<link rel="import" href="base-url-behavior.html">
+<script type="module" src="./base-url-behavior.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -45,30 +48,33 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('base-url-behavior tests', async () => {
-    await readyToTest();
-    let element;
-    // eslint-disable-next-line no-unused-vars
-    let overlay;
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './base-url-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('base-url-behavior tests', () => {
+  let element;
+  // eslint-disable-next-line no-unused-vars
+  let overlay;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [
-          Gerrit.BaseUrlBehavior,
-        ],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-      overlay = fixture('within-overlay');
-    });
-
-    test('getBaseUrl', () => {
-      assert.deepEqual(element.getBaseUrl(), '/r');
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [
+        Gerrit.BaseUrlBehavior,
+      ],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+    overlay = fixture('within-overlay');
+  });
+
+  test('getBaseUrl', () => {
+    assert.deepEqual(element.getBaseUrl(), '/r');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.js b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.js
index 05a7a58..01bcc87 100644
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.js
+++ b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.js
@@ -1,21 +1,21 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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 '../base-url-behavior/base-url-behavior.js';
 
-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.
--->
-<link rel="import" href="../base-url-behavior/base-url-behavior.html">
-<script>
 (function(window) {
   'use strict';
 
@@ -77,4 +77,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
index e480e30..4b72748 100644
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
@@ -15,17 +15,22 @@
 limitations under the License.
 -->
 <!-- Polymer included for the html import polyfill. -->
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
 <title>docs-url-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="docs-url-behavior.html">
+<script type="module" src="./docs-url-behavior.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './docs-url-behavior.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -33,71 +38,74 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('docs-url-behavior tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './docs-url-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('docs-url-behavior tests', () => {
+  let element;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'docs-url-behavior-element',
-        behaviors: [Gerrit.DocsUrlBehavior],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-      element._clearDocsBaseUrlCache();
-    });
-
-    test('null config', () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(true)),
-      };
-      return element.getDocsBaseUrl(null, mockRestApi)
-          .then(docsBaseUrl => {
-            assert.isTrue(
-                mockRestApi.probePath.calledWith('/Documentation/index.html'));
-            assert.equal(docsBaseUrl, '/Documentation');
-          });
-    });
-
-    test('no doc config', () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(true)),
-      };
-      const config = {gerrit: {}};
-      return element.getDocsBaseUrl(config, mockRestApi)
-          .then(docsBaseUrl => {
-            assert.isTrue(
-                mockRestApi.probePath.calledWith('/Documentation/index.html'));
-            assert.equal(docsBaseUrl, '/Documentation');
-          });
-    });
-
-    test('has doc config', () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(true)),
-      };
-      const config = {gerrit: {doc_url: 'foobar'}};
-      return element.getDocsBaseUrl(config, mockRestApi)
-          .then(docsBaseUrl => {
-            assert.isFalse(mockRestApi.probePath.called);
-            assert.equal(docsBaseUrl, 'foobar');
-          });
-    });
-
-    test('no probe', () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(false)),
-      };
-      return element.getDocsBaseUrl(null, mockRestApi)
-          .then(docsBaseUrl => {
-            assert.isTrue(
-                mockRestApi.probePath.calledWith('/Documentation/index.html'));
-            assert.isNotOk(docsBaseUrl);
-          });
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'docs-url-behavior-element',
+      behaviors: [Gerrit.DocsUrlBehavior],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+    element._clearDocsBaseUrlCache();
+  });
+
+  test('null config', () => {
+    const mockRestApi = {
+      probePath: sinon.stub().returns(Promise.resolve(true)),
+    };
+    return element.getDocsBaseUrl(null, mockRestApi)
+        .then(docsBaseUrl => {
+          assert.isTrue(
+              mockRestApi.probePath.calledWith('/Documentation/index.html'));
+          assert.equal(docsBaseUrl, '/Documentation');
+        });
+  });
+
+  test('no doc config', () => {
+    const mockRestApi = {
+      probePath: sinon.stub().returns(Promise.resolve(true)),
+    };
+    const config = {gerrit: {}};
+    return element.getDocsBaseUrl(config, mockRestApi)
+        .then(docsBaseUrl => {
+          assert.isTrue(
+              mockRestApi.probePath.calledWith('/Documentation/index.html'));
+          assert.equal(docsBaseUrl, '/Documentation');
+        });
+  });
+
+  test('has doc config', () => {
+    const mockRestApi = {
+      probePath: sinon.stub().returns(Promise.resolve(true)),
+    };
+    const config = {gerrit: {doc_url: 'foobar'}};
+    return element.getDocsBaseUrl(config, mockRestApi)
+        .then(docsBaseUrl => {
+          assert.isFalse(mockRestApi.probePath.called);
+          assert.equal(docsBaseUrl, 'foobar');
+        });
+  });
+
+  test('no probe', () => {
+    const mockRestApi = {
+      probePath: sinon.stub().returns(Promise.resolve(false)),
+    };
+    return element.getDocsBaseUrl(null, mockRestApi)
+        .then(docsBaseUrl => {
+          assert.isTrue(
+              mockRestApi.probePath.calledWith('/Documentation/index.html'));
+          assert.isNotOk(docsBaseUrl);
+        });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
index 1377627..1607ba8 100644
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
+++ b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
@@ -1,20 +1,19 @@
-<!--
-@license
-Copyright (C) 2018 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.
--->
-<script>
+/**
+ * @license
+ * Copyright (C) 2018 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.
+ */
 (function(window) {
   'use strict';
 
@@ -61,4 +60,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
index 9acc749..09a7cc6 100644
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
@@ -19,13 +19,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>dom-util-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="dom-util-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="./dom-util-behavior.js"></script>
 
 <test-fixture id="nested-structure">
   <template>
@@ -40,34 +40,37 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('dom-util-behavior tests', async () => {
-    await readyToTest();
-    let element;
-    let divs;
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './dom-util-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('dom-util-behavior tests', () => {
+  let element;
+  let divs;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [Gerrit.DomUtilBehavior],
-      });
-    });
-
-    setup(() => {
-      const testDom = fixture('nested-structure');
-      element = testDom[0];
-      divs = testDom[1];
-    });
-
-    test('descendedFromClass', () => {
-      // .c is a child of .a and not vice versa.
-      assert.isTrue(element.descendedFromClass(divs.querySelector('.c'), 'a'));
-      assert.isFalse(element.descendedFromClass(divs.querySelector('.a'), 'c'));
-
-      // Stops at stop element.
-      assert.isFalse(element.descendedFromClass(divs.querySelector('.c'), 'a',
-          divs.querySelector('.b')));
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [Gerrit.DomUtilBehavior],
     });
   });
+
+  setup(() => {
+    const testDom = fixture('nested-structure');
+    element = testDom[0];
+    divs = testDom[1];
+  });
+
+  test('descendedFromClass', () => {
+    // .c is a child of .a and not vice versa.
+    assert.isTrue(element.descendedFromClass(divs.querySelector('.c'), 'a'));
+    assert.isFalse(element.descendedFromClass(divs.querySelector('.a'), 'c'));
+
+    // Stops at stop element.
+    assert.isFalse(element.descendedFromClass(divs.querySelector('.c'), 'a',
+        divs.querySelector('.b')));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js b/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js
index 5b3d420..9e9df1d 100644
--- a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js
+++ b/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js
@@ -1,21 +1,19 @@
-<!--
-@license
-Copyright (C) 2019 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.
--->
-
-<script>
+/**
+ * @license
+ * Copyright (C) 2019 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.
+ */
 (function(window) {
   'use strict';
 
@@ -69,4 +67,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.js b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.js
index 7f01789..18cd356 100644
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.js
@@ -1,21 +1,19 @@
-<!--
-@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.
--->
-
-<script>
+/**
+ * @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.
+ */
 (function(window) {
   'use strict';
 
@@ -177,4 +175,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
index f4b3ab0..e989a74 100644
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
@@ -19,13 +19,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-access-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-access-behavior.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,41 +33,44 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-access-behavior tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './gr-access-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('gr-access-behavior tests', () => {
+  let element;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [Gerrit.AccessBehavior],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('toSortedArray', () => {
-      const rules = {
-        'global:Project-Owners': {
-          action: 'ALLOW', force: false,
-        },
-        '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-          action: 'ALLOW', force: false,
-        },
-      };
-      const expectedResult = [
-        {id: '4c97682e6ce6b7247f3381b6f1789356666de7f', value: {
-          action: 'ALLOW', force: false,
-        }},
-        {id: 'global:Project-Owners', value: {
-          action: 'ALLOW', force: false,
-        }},
-      ];
-      assert.deepEqual(element.toSortedArray(rules), expectedResult);
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [Gerrit.AccessBehavior],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+  });
+
+  test('toSortedArray', () => {
+    const rules = {
+      'global:Project-Owners': {
+        action: 'ALLOW', force: false,
+      },
+      '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+        action: 'ALLOW', force: false,
+      },
+    };
+    const expectedResult = [
+      {id: '4c97682e6ce6b7247f3381b6f1789356666de7f', value: {
+        action: 'ALLOW', force: false,
+      }},
+      {id: 'global:Project-Owners', value: {
+        action: 'ALLOW', force: false,
+      }},
+    ];
+    assert.deepEqual(element.toSortedArray(rules), expectedResult);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js
index 49160da..90800a3 100644
--- a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js
@@ -1,20 +1,19 @@
-<!--
-@license
-Copyright (C) 2018 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.
--->
-<script>
+/**
+ * @license
+ * Copyright (C) 2018 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.
+ */
 (function(window) {
   'use strict';
 
@@ -220,4 +219,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
index 7c179b8..c2659bb 100644
--- a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
@@ -19,13 +19,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-admin-nav-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-admin-nav-behavior.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,338 +33,341 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-admin-nav-behavior tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let capabilityStub;
-    let menuLinkStub;
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './gr-admin-nav-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('gr-admin-nav-behavior tests', () => {
+  let element;
+  let sandbox;
+  let capabilityStub;
+  let menuLinkStub;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [
-          Gerrit.AdminNavBehavior,
-        ],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      capabilityStub = sinon.stub();
-      menuLinkStub = sinon.stub().returns([]);
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    const testAdminLinks = (account, options, expected, done) => {
-      element.getAdminLinks(account,
-          capabilityStub,
-          menuLinkStub,
-          options)
-          .then(res => {
-            assert.equal(expected.totalLength, res.links.length);
-            assert.equal(res.links[0].name, 'Repositories');
-            // Repos
-            if (expected.groupListShown) {
-              assert.equal(res.links[1].name, 'Groups');
-            }
-
-            if (expected.pluginListShown) {
-              assert.equal(res.links[2].name, 'Plugins');
-              assert.isNotOk(res.links[2].subsection);
-            }
-
-            if (expected.projectPageShown) {
-              assert.isOk(res.links[0].subsection);
-              assert.equal(res.links[0].subsection.children.length, 5);
-            } else {
-              assert.isNotOk(res.links[0].subsection);
-            }
-            // Groups
-            if (expected.groupPageShown) {
-              assert.isOk(res.links[1].subsection);
-              assert.equal(res.links[1].subsection.children.length,
-                  expected.groupSubpageLength);
-            } else if ( expected.totalLength > 1) {
-              assert.isNotOk(res.links[1].subsection);
-            }
-
-            if (expected.pluginGeneratedLinks) {
-              for (const link of expected.pluginGeneratedLinks) {
-                const linkMatch = res.links
-                    .find(l => (l.url === link.url && l.name === link.text));
-                assert.isTrue(!!linkMatch);
-
-                // External links should open in new tab.
-                if (link.url[0] !== '/') {
-                  assert.equal(linkMatch.target, '_blank');
-                } else {
-                  assert.isNotOk(linkMatch.target);
-                }
-              }
-            }
-
-            // Current section
-            if (expected.projectPageShown || expected.groupPageShown) {
-              assert.isOk(res.expandedSection);
-              assert.isOk(res.expandedSection.children);
-            } else {
-              assert.isNotOk(res.expandedSection);
-            }
-            if (expected.projectPageShown) {
-              assert.equal(res.expandedSection.name, 'my-repo');
-              assert.equal(res.expandedSection.children.length, 5);
-            } else if (expected.groupPageShown) {
-              assert.equal(res.expandedSection.name, 'my-group');
-              assert.equal(res.expandedSection.children.length,
-                  expected.groupSubpageLength);
-            }
-            done();
-          });
-    };
-
-    suite('logged out', () => {
-      let account;
-      let expected;
-
-      setup(() => {
-        expected = {
-          groupListShown: false,
-          groupPageShown: false,
-          pluginListShown: false,
-        };
-      });
-
-      test('without a specific repo or group', done => {
-        let options;
-        expected = Object.assign(expected, {
-          totalLength: 1,
-          projectPageShown: false,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('with a repo', done => {
-        const options = {repoName: 'my-repo'};
-        expected = Object.assign(expected, {
-          totalLength: 1,
-          projectPageShown: true,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('with plugin generated links', done => {
-        let options;
-        const generatedLinks = [
-          {text: 'internal link text', url: '/internal/link/url'},
-          {text: 'external link text', url: 'http://external/link/url'},
-        ];
-        menuLinkStub.returns(generatedLinks);
-        expected = Object.assign(expected, {
-          totalLength: 3,
-          projectPageShown: false,
-          pluginGeneratedLinks: generatedLinks,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-    });
-
-    suite('no plugin capability logged in', () => {
-      const account = {
-        name: 'test-user',
-      };
-      let expected;
-
-      setup(() => {
-        expected = {
-          totalLength: 2,
-          pluginListShown: false,
-        };
-        capabilityStub.returns(Promise.resolve({}));
-      });
-
-      test('without a specific project or group', done => {
-        let options;
-        expected = Object.assign(expected, {
-          projectPageShown: false,
-          groupListShown: true,
-          groupPageShown: false,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('with a repo', done => {
-        const account = {
-          name: 'test-user',
-        };
-        const options = {repoName: 'my-repo'};
-        expected = Object.assign(expected, {
-          projectPageShown: true,
-          groupListShown: true,
-          groupPageShown: false,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-    });
-
-    suite('view plugin capability logged in', () => {
-      const account = {
-        name: 'test-user',
-      };
-      let expected;
-
-      setup(() => {
-        capabilityStub.returns(Promise.resolve({viewPlugins: true}));
-        expected = {
-          totalLength: 3,
-          groupListShown: true,
-          pluginListShown: true,
-        };
-      });
-
-      test('without a specific repo or group', done => {
-        let options;
-        expected = Object.assign(expected, {
-          projectPageShown: false,
-          groupPageShown: false,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('with a repo', done => {
-        const options = {repoName: 'my-repo'};
-        expected = Object.assign(expected, {
-          projectPageShown: true,
-          groupPageShown: false,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('admin with internal group', done => {
-        const options = {
-          groupId: 'a15262',
-          groupName: 'my-group',
-          groupIsInternal: true,
-          isAdmin: true,
-          groupOwner: false,
-        };
-        expected = Object.assign(expected, {
-          projectPageShown: false,
-          groupPageShown: true,
-          groupSubpageLength: 2,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('group owner with internal group', done => {
-        const options = {
-          groupId: 'a15262',
-          groupName: 'my-group',
-          groupIsInternal: true,
-          isAdmin: false,
-          groupOwner: true,
-        };
-        expected = Object.assign(expected, {
-          projectPageShown: false,
-          groupPageShown: true,
-          groupSubpageLength: 2,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('non owner or admin with internal group', done => {
-        const options = {
-          groupId: 'a15262',
-          groupName: 'my-group',
-          groupIsInternal: true,
-          isAdmin: false,
-          groupOwner: false,
-        };
-        expected = Object.assign(expected, {
-          projectPageShown: false,
-          groupPageShown: true,
-          groupSubpageLength: 1,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('admin with external group', done => {
-        const options = {
-          groupId: 'a15262',
-          groupName: 'my-group',
-          groupIsInternal: false,
-          isAdmin: true,
-          groupOwner: true,
-        };
-        expected = Object.assign(expected, {
-          projectPageShown: false,
-          groupPageShown: true,
-          groupSubpageLength: 0,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-    });
-
-    suite('view plugin screen with plugin capability', () => {
-      const account = {
-        name: 'test-user',
-      };
-      let expected;
-
-      setup(() => {
-        capabilityStub.returns(Promise.resolve({pluginCapability: true}));
-        expected = {};
-      });
-
-      test('with plugin with capabilities', done => {
-        let options;
-        const generatedLinks = [
-          {text: 'without capability', url: '/without'},
-          {text: 'with capability',
-            url: '/with',
-            capability: 'pluginCapability'},
-        ];
-        menuLinkStub.returns(generatedLinks);
-        expected = Object.assign(expected, {
-          totalLength: 4,
-          pluginGeneratedLinks: generatedLinks,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-    });
-
-    suite('view plugin screen without plugin capability', () => {
-      const account = {
-        name: 'test-user',
-      };
-      let expected;
-
-      setup(() => {
-        capabilityStub.returns(Promise.resolve({}));
-        expected = {};
-      });
-
-      test('with plugin with capabilities', done => {
-        let options;
-        const generatedLinks = [
-          {text: 'without capability', url: '/without'},
-          {text: 'with capability',
-            url: '/with',
-            capability: 'pluginCapability'},
-        ];
-        menuLinkStub.returns(generatedLinks);
-        expected = Object.assign(expected, {
-          totalLength: 3,
-          pluginGeneratedLinks: [generatedLinks[0]],
-        });
-        testAdminLinks(account, options, expected, done);
-      });
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [
+        Gerrit.AdminNavBehavior,
+      ],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    capabilityStub = sinon.stub();
+    menuLinkStub = sinon.stub().returns([]);
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  const testAdminLinks = (account, options, expected, done) => {
+    element.getAdminLinks(account,
+        capabilityStub,
+        menuLinkStub,
+        options)
+        .then(res => {
+          assert.equal(expected.totalLength, res.links.length);
+          assert.equal(res.links[0].name, 'Repositories');
+          // Repos
+          if (expected.groupListShown) {
+            assert.equal(res.links[1].name, 'Groups');
+          }
+
+          if (expected.pluginListShown) {
+            assert.equal(res.links[2].name, 'Plugins');
+            assert.isNotOk(res.links[2].subsection);
+          }
+
+          if (expected.projectPageShown) {
+            assert.isOk(res.links[0].subsection);
+            assert.equal(res.links[0].subsection.children.length, 5);
+          } else {
+            assert.isNotOk(res.links[0].subsection);
+          }
+          // Groups
+          if (expected.groupPageShown) {
+            assert.isOk(res.links[1].subsection);
+            assert.equal(res.links[1].subsection.children.length,
+                expected.groupSubpageLength);
+          } else if ( expected.totalLength > 1) {
+            assert.isNotOk(res.links[1].subsection);
+          }
+
+          if (expected.pluginGeneratedLinks) {
+            for (const link of expected.pluginGeneratedLinks) {
+              const linkMatch = res.links
+                  .find(l => (l.url === link.url && l.name === link.text));
+              assert.isTrue(!!linkMatch);
+
+              // External links should open in new tab.
+              if (link.url[0] !== '/') {
+                assert.equal(linkMatch.target, '_blank');
+              } else {
+                assert.isNotOk(linkMatch.target);
+              }
+            }
+          }
+
+          // Current section
+          if (expected.projectPageShown || expected.groupPageShown) {
+            assert.isOk(res.expandedSection);
+            assert.isOk(res.expandedSection.children);
+          } else {
+            assert.isNotOk(res.expandedSection);
+          }
+          if (expected.projectPageShown) {
+            assert.equal(res.expandedSection.name, 'my-repo');
+            assert.equal(res.expandedSection.children.length, 5);
+          } else if (expected.groupPageShown) {
+            assert.equal(res.expandedSection.name, 'my-group');
+            assert.equal(res.expandedSection.children.length,
+                expected.groupSubpageLength);
+          }
+          done();
+        });
+  };
+
+  suite('logged out', () => {
+    let account;
+    let expected;
+
+    setup(() => {
+      expected = {
+        groupListShown: false,
+        groupPageShown: false,
+        pluginListShown: false,
+      };
+    });
+
+    test('without a specific repo or group', done => {
+      let options;
+      expected = Object.assign(expected, {
+        totalLength: 1,
+        projectPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('with a repo', done => {
+      const options = {repoName: 'my-repo'};
+      expected = Object.assign(expected, {
+        totalLength: 1,
+        projectPageShown: true,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('with plugin generated links', done => {
+      let options;
+      const generatedLinks = [
+        {text: 'internal link text', url: '/internal/link/url'},
+        {text: 'external link text', url: 'http://external/link/url'},
+      ];
+      menuLinkStub.returns(generatedLinks);
+      expected = Object.assign(expected, {
+        totalLength: 3,
+        projectPageShown: false,
+        pluginGeneratedLinks: generatedLinks,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+
+  suite('no plugin capability logged in', () => {
+    const account = {
+      name: 'test-user',
+    };
+    let expected;
+
+    setup(() => {
+      expected = {
+        totalLength: 2,
+        pluginListShown: false,
+      };
+      capabilityStub.returns(Promise.resolve({}));
+    });
+
+    test('without a specific project or group', done => {
+      let options;
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupListShown: true,
+        groupPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('with a repo', done => {
+      const account = {
+        name: 'test-user',
+      };
+      const options = {repoName: 'my-repo'};
+      expected = Object.assign(expected, {
+        projectPageShown: true,
+        groupListShown: true,
+        groupPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+
+  suite('view plugin capability logged in', () => {
+    const account = {
+      name: 'test-user',
+    };
+    let expected;
+
+    setup(() => {
+      capabilityStub.returns(Promise.resolve({viewPlugins: true}));
+      expected = {
+        totalLength: 3,
+        groupListShown: true,
+        pluginListShown: true,
+      };
+    });
+
+    test('without a specific repo or group', done => {
+      let options;
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('with a repo', done => {
+      const options = {repoName: 'my-repo'};
+      expected = Object.assign(expected, {
+        projectPageShown: true,
+        groupPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('admin with internal group', done => {
+      const options = {
+        groupId: 'a15262',
+        groupName: 'my-group',
+        groupIsInternal: true,
+        isAdmin: true,
+        groupOwner: false,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 2,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('group owner with internal group', done => {
+      const options = {
+        groupId: 'a15262',
+        groupName: 'my-group',
+        groupIsInternal: true,
+        isAdmin: false,
+        groupOwner: true,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 2,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('non owner or admin with internal group', done => {
+      const options = {
+        groupId: 'a15262',
+        groupName: 'my-group',
+        groupIsInternal: true,
+        isAdmin: false,
+        groupOwner: false,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 1,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('admin with external group', done => {
+      const options = {
+        groupId: 'a15262',
+        groupName: 'my-group',
+        groupIsInternal: false,
+        isAdmin: true,
+        groupOwner: true,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 0,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+
+  suite('view plugin screen with plugin capability', () => {
+    const account = {
+      name: 'test-user',
+    };
+    let expected;
+
+    setup(() => {
+      capabilityStub.returns(Promise.resolve({pluginCapability: true}));
+      expected = {};
+    });
+
+    test('with plugin with capabilities', done => {
+      let options;
+      const generatedLinks = [
+        {text: 'without capability', url: '/without'},
+        {text: 'with capability',
+          url: '/with',
+          capability: 'pluginCapability'},
+      ];
+      menuLinkStub.returns(generatedLinks);
+      expected = Object.assign(expected, {
+        totalLength: 4,
+        pluginGeneratedLinks: generatedLinks,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+
+  suite('view plugin screen without plugin capability', () => {
+    const account = {
+      name: 'test-user',
+    };
+    let expected;
+
+    setup(() => {
+      capabilityStub.returns(Promise.resolve({}));
+      expected = {};
+    });
+
+    test('with plugin with capabilities', done => {
+      let options;
+      const generatedLinks = [
+        {text: 'without capability', url: '/without'},
+        {text: 'with capability',
+          url: '/with',
+          capability: 'pluginCapability'},
+      ];
+      menuLinkStub.returns(generatedLinks);
+      expected = Object.assign(expected, {
+        totalLength: 3,
+        pluginGeneratedLinks: [generatedLinks[0]],
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.js b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.js
index d03316a..d12b279 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.js
@@ -1,20 +1,19 @@
-<!--
-@license
-Copyright (C) 2016 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.
--->
-<script>
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
 (function(window) {
   'use strict';
 
@@ -105,4 +104,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
index a8d4041..c314c18 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
@@ -19,13 +19,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-change-table-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-change-table-behavior.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -41,87 +41,90 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-table-behavior tests', async () => {
-    await readyToTest();
-    let element;
-    // eslint-disable-next-line no-unused-vars
-    let overlay;
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './gr-change-table-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('gr-change-table-behavior tests', () => {
+  let element;
+  // eslint-disable-next-line no-unused-vars
+  let overlay;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [Gerrit.ChangeTableBehavior],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-      overlay = fixture('within-overlay');
-    });
-
-    test('getComplementColumns', () => {
-      let columns = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Repo',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
-      assert.deepEqual(element.getComplementColumns(columns), []);
-
-      columns = [
-        'Subject',
-        'Status',
-        'Assignee',
-        'Repo',
-        'Branch',
-        'Size',
-      ];
-      assert.deepEqual(element.getComplementColumns(columns),
-          ['Owner', 'Updated']);
-    });
-
-    test('isColumnHidden', () => {
-      const columnToCheck = 'Repo';
-      let columnsToDisplay = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Repo',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
-      assert.isFalse(element.isColumnHidden(columnToCheck, columnsToDisplay));
-
-      columnsToDisplay = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
-      assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
-    });
-
-    test('getVisibleColumns maps Project to Repo', () => {
-      const columns = [
-        'Subject',
-        'Status',
-        'Owner',
-      ];
-      assert.deepEqual(element.getVisibleColumns(columns), columns.slice(0));
-      assert.deepEqual(
-          element.getVisibleColumns(columns.concat(['Project'])),
-          columns.slice(0).concat(['Repo']));
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [Gerrit.ChangeTableBehavior],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+    overlay = fixture('within-overlay');
+  });
+
+  test('getComplementColumns', () => {
+    let columns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+    assert.deepEqual(element.getComplementColumns(columns), []);
+
+    columns = [
+      'Subject',
+      'Status',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Size',
+    ];
+    assert.deepEqual(element.getComplementColumns(columns),
+        ['Owner', 'Updated']);
+  });
+
+  test('isColumnHidden', () => {
+    const columnToCheck = 'Repo';
+    let columnsToDisplay = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+    assert.isFalse(element.isColumnHidden(columnToCheck, columnsToDisplay));
+
+    columnsToDisplay = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+    assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
+  });
+
+  test('getVisibleColumns maps Project to Repo', () => {
+    const columns = [
+      'Subject',
+      'Status',
+      'Owner',
+    ];
+    assert.deepEqual(element.getVisibleColumns(columns), columns.slice(0));
+    assert.deepEqual(
+        element.getVisibleColumns(columns.concat(['Project'])),
+        columns.slice(0).concat(['Repo']));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js
index e5ded0e..1ba8fd9 100644
--- a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js
@@ -1,23 +1,21 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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 '../../scripts/gr-display-name-utils/gr-display-name-utils.js';
 
-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.
--->
-
-<script src="../../scripts/gr-display-name-utils/gr-display-name-utils.js"></script>
-
-<script>
 (function(window) {
   'use strict';
 
@@ -57,4 +55,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
index 931f6fa..863f708 100644
--- a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
@@ -19,13 +19,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-display-name-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-display-name-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-display-name-behavior.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,69 +33,72 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-display-name-behavior tests', async () => {
-    await readyToTest();
-    let element;
-    // eslint-disable-next-line no-unused-vars
-    const config = {
-      user: {
-        anonymous_coward_name: 'Anonymous Coward',
-      },
-    };
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './gr-display-name-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('gr-display-name-behavior tests', () => {
+  let element;
+  // eslint-disable-next-line no-unused-vars
+  const config = {
+    user: {
+      anonymous_coward_name: 'Anonymous Coward',
+    },
+  };
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element-anon',
-        behaviors: [
-          Gerrit.DisplayNameBehavior,
-        ],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('getUserName name only', () => {
-      const account = {
-        name: 'test-name',
-      };
-      assert.deepEqual(element.getUserName(config, account, true), 'test-name');
-    });
-
-    test('getUserName username only', () => {
-      const account = {
-        username: 'test-user',
-      };
-      assert.deepEqual(element.getUserName(config, account, true), 'test-user');
-    });
-
-    test('getUserName email only', () => {
-      const account = {
-        email: 'test-user@test-url.com',
-      };
-      assert.deepEqual(element.getUserName(config, account, true),
-          'test-user@test-url.com');
-    });
-
-    test('getUserName returns not Anonymous Coward as the anon name', () => {
-      assert.deepEqual(element.getUserName(config, null, true), 'Anonymous');
-    });
-
-    test('getUserName for the config returning the anon name', () => {
-      const config = {
-        user: {
-          anonymous_coward_name: 'Test Anon',
-        },
-      };
-      assert.deepEqual(element.getUserName(config, null, true), 'Test Anon');
-    });
-
-    test('getGroupDisplayName', () => {
-      assert.equal(element.getGroupDisplayName({name: 'Some user name'}),
-          'Some user name (group)');
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element-anon',
+      behaviors: [
+        Gerrit.DisplayNameBehavior,
+      ],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+  });
+
+  test('getUserName name only', () => {
+    const account = {
+      name: 'test-name',
+    };
+    assert.deepEqual(element.getUserName(config, account, true), 'test-name');
+  });
+
+  test('getUserName username only', () => {
+    const account = {
+      username: 'test-user',
+    };
+    assert.deepEqual(element.getUserName(config, account, true), 'test-user');
+  });
+
+  test('getUserName email only', () => {
+    const account = {
+      email: 'test-user@test-url.com',
+    };
+    assert.deepEqual(element.getUserName(config, account, true),
+        'test-user@test-url.com');
+  });
+
+  test('getUserName returns not Anonymous Coward as the anon name', () => {
+    assert.deepEqual(element.getUserName(config, null, true), 'Anonymous');
+  });
+
+  test('getUserName for the config returning the anon name', () => {
+    const config = {
+      user: {
+        anonymous_coward_name: 'Test Anon',
+      },
+    };
+    assert.deepEqual(element.getUserName(config, null, true), 'Test Anon');
+  });
+
+  test('getGroupDisplayName', () => {
+    assert.equal(element.getGroupDisplayName({name: 'Some user name'}),
+        'Some user name (group)');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js
index 06912d5..6e64a4d 100644
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js
@@ -1,22 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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 '../base-url-behavior/base-url-behavior.js';
 
-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.
--->
-<link rel="import" href="../base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<script>
+import '../gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 (function(window) {
   'use strict';
 
@@ -80,5 +80,3 @@
       };
   }
 })(window);
-
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
index c2cb073..37577df 100644
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
@@ -19,13 +19,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-list-view-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-list-view-behavior.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,62 +33,65 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-list-view-behavior tests', async () => {
-    await readyToTest();
-    let element;
-    // eslint-disable-next-line no-unused-vars
-    let overlay;
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './gr-list-view-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('gr-list-view-behavior tests', () => {
+  let element;
+  // eslint-disable-next-line no-unused-vars
+  let overlay;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [Gerrit.ListViewBehavior],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('computeLoadingClass', () => {
-      assert.equal(element.computeLoadingClass(true), 'loading');
-      assert.equal(element.computeLoadingClass(false), '');
-    });
-
-    test('computeShownItems', () => {
-      const myArr = new Array(26);
-      assert.equal(element.computeShownItems(myArr).length, 25);
-    });
-
-    test('getUrl', () => {
-      assert.equal(element.getUrl('/path/to/something/', 'item'),
-          '/path/to/something/item');
-      assert.equal(element.getUrl('/path/to/something/', 'item%test'),
-          '/path/to/something/item%2525test');
-    });
-
-    test('getFilterValue', () => {
-      let params;
-      assert.equal(element.getFilterValue(params), '');
-
-      params = {filter: null};
-      assert.equal(element.getFilterValue(params), '');
-
-      params = {filter: 'test'};
-      assert.equal(element.getFilterValue(params), 'test');
-    });
-
-    test('getOffsetValue', () => {
-      let params;
-      assert.equal(element.getOffsetValue(params), 0);
-
-      params = {offset: null};
-      assert.equal(element.getOffsetValue(params), 0);
-
-      params = {offset: 1};
-      assert.equal(element.getOffsetValue(params), 1);
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [Gerrit.ListViewBehavior],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+  });
+
+  test('computeLoadingClass', () => {
+    assert.equal(element.computeLoadingClass(true), 'loading');
+    assert.equal(element.computeLoadingClass(false), '');
+  });
+
+  test('computeShownItems', () => {
+    const myArr = new Array(26);
+    assert.equal(element.computeShownItems(myArr).length, 25);
+  });
+
+  test('getUrl', () => {
+    assert.equal(element.getUrl('/path/to/something/', 'item'),
+        '/path/to/something/item');
+    assert.equal(element.getUrl('/path/to/something/', 'item%test'),
+        '/path/to/something/item%2525test');
+  });
+
+  test('getFilterValue', () => {
+    let params;
+    assert.equal(element.getFilterValue(params), '');
+
+    params = {filter: null};
+    assert.equal(element.getFilterValue(params), '');
+
+    params = {filter: 'test'};
+    assert.equal(element.getFilterValue(params), 'test');
+  });
+
+  test('getOffsetValue', () => {
+    let params;
+    assert.equal(element.getOffsetValue(params), 0);
+
+    params = {offset: null};
+    assert.equal(element.getOffsetValue(params), 0);
+
+    params = {offset: 1};
+    assert.equal(element.getOffsetValue(params), 1);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js
index 61e6b0a..b36375e 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js
@@ -1,20 +1,19 @@
-<!--
-@license
-Copyright (C) 2016 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.
--->
-<script>
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
 (function(window) {
   'use strict';
 
@@ -298,4 +297,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
index d9adb8b..8e05228 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -15,313 +15,315 @@
 limitations under the License.
 -->
 <!-- Polymer included for the html import polyfill. -->
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
 <title>gr-patch-set-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-patch-set-behavior.html">
+<script type="module" src="./gr-patch-set-behavior.js"></script>
 
-<script>
-  suite('gr-patch-set-behavior tests', async () => {
-    await readyToTest();
-    test('getRevisionByPatchNum', () => {
-      const get = Gerrit.PatchSetBehavior.getRevisionByPatchNum;
-      const revisions = [
-        {_number: 0},
-        {_number: 1},
-        {_number: 2},
-      ];
-      assert.deepEqual(get(revisions, '1'), revisions[1]);
-      assert.deepEqual(get(revisions, 2), revisions[2]);
-      assert.equal(get(revisions, '3'), undefined);
-    });
-
-    test('fetchChangeUpdates on latest', done => {
-      const knownChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-        },
-        status: 'NEW',
-        messages: [],
-      };
-      const mockRestApi = {
-        getChangeDetail() {
-          return Promise.resolve(knownChange);
-        },
-      };
-      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
-          .then(result => {
-            assert.isTrue(result.isLatest);
-            assert.isNotOk(result.newStatus);
-            assert.isFalse(result.newMessages);
-            done();
-          });
-    });
-
-    test('fetchChangeUpdates not on latest', done => {
-      const knownChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-        },
-        status: 'NEW',
-        messages: [],
-      };
-      const actualChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-          sha3: {description: 'patch 3', _number: 3},
-        },
-        status: 'NEW',
-        messages: [],
-      };
-      const mockRestApi = {
-        getChangeDetail() {
-          return Promise.resolve(actualChange);
-        },
-      };
-      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
-          .then(result => {
-            assert.isFalse(result.isLatest);
-            assert.isNotOk(result.newStatus);
-            assert.isFalse(result.newMessages);
-            done();
-          });
-    });
-
-    test('fetchChangeUpdates new status', done => {
-      const knownChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-        },
-        status: 'NEW',
-        messages: [],
-      };
-      const actualChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-        },
-        status: 'MERGED',
-        messages: [],
-      };
-      const mockRestApi = {
-        getChangeDetail() {
-          return Promise.resolve(actualChange);
-        },
-      };
-      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
-          .then(result => {
-            assert.isTrue(result.isLatest);
-            assert.equal(result.newStatus, 'MERGED');
-            assert.isFalse(result.newMessages);
-            done();
-          });
-    });
-
-    test('fetchChangeUpdates new messages', done => {
-      const knownChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-        },
-        status: 'NEW',
-        messages: [],
-      };
-      const actualChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-        },
-        status: 'NEW',
-        messages: [{message: 'blah blah'}],
-      };
-      const mockRestApi = {
-        getChangeDetail() {
-          return Promise.resolve(actualChange);
-        },
-      };
-      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
-          .then(result => {
-            assert.isTrue(result.isLatest);
-            assert.isNotOk(result.newStatus);
-            assert.isTrue(result.newMessages);
-            done();
-          });
-    });
-
-    test('_computeWipForPatchSets', () => {
-      // Compute patch sets for a given timeline on a change. The initial WIP
-      // property of the change can be true or false. The map of tags by
-      // revision is keyed by patch set number. Each value is a list of change
-      // message tags in the order that they occurred in the timeline. These
-      // indicate actions that modify the WIP property of the change and/or
-      // create new patch sets.
-      //
-      // Returns the actual results with an assertWip method that can be used
-      // to compare against an expected value for a particular patch set.
-      const compute = (initialWip, tagsByRevision) => {
-        const change = {
-          messages: [],
-          work_in_progress: initialWip,
-        };
-        const revs = Object.keys(tagsByRevision).sort((a, b) => a - b);
-        for (const rev of revs) {
-          for (const tag of tagsByRevision[rev]) {
-            change.messages.push({
-              tag,
-              _revision_number: rev,
-            });
-          }
-        }
-        let patchNums = revs.map(rev => { return {num: rev}; });
-        patchNums = Gerrit.PatchSetBehavior._computeWipForPatchSets(
-            change, patchNums);
-        const actualWipsByRevision = {};
-        for (const patchNum of patchNums) {
-          actualWipsByRevision[patchNum.num] = patchNum.wip;
-        }
-        const verifier = {
-          assertWip(revision, expectedWip) {
-            const patchNum = patchNums.find(patchNum => patchNum.num == revision);
-            if (!patchNum) {
-              assert.fail('revision ' + revision + ' not found');
-            }
-            assert.equal(patchNum.wip, expectedWip,
-                'wip state for ' + revision + ' is ' +
-              patchNum.wip + '; expected ' + expectedWip);
-            return verifier;
-          },
-        };
-        return verifier;
-      };
-
-      compute(false, {1: ['upload']}).assertWip(1, false);
-      compute(true, {1: ['upload']}).assertWip(1, true);
-
-      const setWip = 'autogenerated:gerrit:setWorkInProgress';
-      const uploadInWip = 'autogenerated:gerrit:newWipPatchSet';
-      const clearWip = 'autogenerated:gerrit:setReadyForReview';
-
-      compute(false, {
-        1: ['upload', setWip],
-        2: ['upload'],
-        3: ['upload', clearWip],
-        4: ['upload', setWip],
-      }).assertWip(1, false) // Change was created with PS1 ready for review
-          .assertWip(2, true) // PS2 was uploaded during WIP
-          .assertWip(3, false) // PS3 was marked ready for review after upload
-          .assertWip(4, false); // PS4 was uploaded ready for review
-
-      compute(false, {
-        1: [uploadInWip, null, 'addReviewer'],
-        2: ['upload'],
-        3: ['upload', clearWip, setWip],
-        4: ['upload'],
-        5: ['upload', clearWip],
-        6: [uploadInWip],
-      }).assertWip(1, true) // Change was created in WIP
-          .assertWip(2, true) // PS2 was uploaded during WIP
-          .assertWip(3, false) // PS3 was marked ready for review
-          .assertWip(4, true) // PS4 was uploaded during WIP
-          .assertWip(5, false) // PS5 was marked ready for review
-          .assertWip(6, true); // PS6 was uploaded with WIP option
-    });
-
-    test('patchNumEquals', () => {
-      const equals = Gerrit.PatchSetBehavior.patchNumEquals;
-      assert.isFalse(equals('edit', 'PARENT'));
-      assert.isFalse(equals('edit', NaN));
-      assert.isFalse(equals(1, '2'));
-
-      assert.isTrue(equals(1, '1'));
-      assert.isTrue(equals(1, 1));
-      assert.isTrue(equals('edit', 'edit'));
-      assert.isTrue(equals('PARENT', 'PARENT'));
-    });
-
-    test('isMergeParent', () => {
-      const isParent = Gerrit.PatchSetBehavior.isMergeParent;
-      assert.isFalse(isParent(1));
-      assert.isFalse(isParent(4321));
-      assert.isFalse(isParent('52'));
-      assert.isFalse(isParent('edit'));
-      assert.isFalse(isParent('PARENT'));
-      assert.isFalse(isParent(0));
-
-      assert.isTrue(isParent(-23));
-      assert.isTrue(isParent(-1));
-      assert.isTrue(isParent('-42'));
-    });
-
-    test('findEditParentRevision', () => {
-      const findParent = Gerrit.PatchSetBehavior.findEditParentRevision;
-      let revisions = [
-        {_number: 0},
-        {_number: 1},
-        {_number: 2},
-      ];
-      assert.strictEqual(findParent(revisions), null);
-
-      revisions = [...revisions, {_number: 'edit', basePatchNum: 3}];
-      assert.strictEqual(findParent(revisions), null);
-
-      revisions = [...revisions, {_number: 3}];
-      assert.deepEqual(findParent(revisions), {_number: 3});
-    });
-
-    test('findEditParentPatchNum', () => {
-      const findNum = Gerrit.PatchSetBehavior.findEditParentPatchNum;
-      let revisions = [
-        {_number: 0},
-        {_number: 1},
-        {_number: 2},
-      ];
-      assert.equal(findNum(revisions), -1);
-
-      revisions =
-          [...revisions, {_number: 'edit', basePatchNum: 3}, {_number: 3}];
-      assert.deepEqual(findNum(revisions), 3);
-    });
-
-    test('sortRevisions', () => {
-      const sort = Gerrit.PatchSetBehavior.sortRevisions;
-      const revisions = [
-        {_number: 0},
-        {_number: 2},
-        {_number: 1},
-      ];
-      const sorted = [
-        {_number: 2},
-        {_number: 1},
-        {_number: 0},
-      ];
-
-      assert.deepEqual(sort(revisions), sorted);
-
-      // Edit patchset should follow directly after its basePatchNum.
-      revisions.push({_number: 'edit', basePatchNum: 2});
-      sorted.unshift({_number: 'edit', basePatchNum: 2});
-      assert.deepEqual(sort(revisions), sorted);
-
-      revisions[0].basePatchNum = 0;
-      const edit = sorted.shift();
-      edit.basePatchNum = 0;
-      // Edit patchset should be at index 2.
-      sorted.splice(2, 0, edit);
-      assert.deepEqual(sort(revisions), sorted);
-    });
-
-    test('getParentIndex', () => {
-      assert.equal(Gerrit.PatchSetBehavior.getParentIndex('-13'), 13);
-      assert.equal(Gerrit.PatchSetBehavior.getParentIndex(-4), 4);
-    });
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './gr-patch-set-behavior.js';
+suite('gr-patch-set-behavior tests', () => {
+  test('getRevisionByPatchNum', () => {
+    const get = Gerrit.PatchSetBehavior.getRevisionByPatchNum;
+    const revisions = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    assert.deepEqual(get(revisions, '1'), revisions[1]);
+    assert.deepEqual(get(revisions, 2), revisions[2]);
+    assert.equal(get(revisions, '3'), undefined);
   });
+
+  test('fetchChangeUpdates on latest', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(knownChange);
+      },
+    };
+    Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isTrue(result.isLatest);
+          assert.isNotOk(result.newStatus);
+          assert.isFalse(result.newMessages);
+          done();
+        });
+  });
+
+  test('fetchChangeUpdates not on latest', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const actualChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+        sha3: {description: 'patch 3', _number: 3},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(actualChange);
+      },
+    };
+    Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isFalse(result.isLatest);
+          assert.isNotOk(result.newStatus);
+          assert.isFalse(result.newMessages);
+          done();
+        });
+  });
+
+  test('fetchChangeUpdates new status', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const actualChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'MERGED',
+      messages: [],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(actualChange);
+      },
+    };
+    Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isTrue(result.isLatest);
+          assert.equal(result.newStatus, 'MERGED');
+          assert.isFalse(result.newMessages);
+          done();
+        });
+  });
+
+  test('fetchChangeUpdates new messages', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const actualChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [{message: 'blah blah'}],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(actualChange);
+      },
+    };
+    Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isTrue(result.isLatest);
+          assert.isNotOk(result.newStatus);
+          assert.isTrue(result.newMessages);
+          done();
+        });
+  });
+
+  test('_computeWipForPatchSets', () => {
+    // Compute patch sets for a given timeline on a change. The initial WIP
+    // property of the change can be true or false. The map of tags by
+    // revision is keyed by patch set number. Each value is a list of change
+    // message tags in the order that they occurred in the timeline. These
+    // indicate actions that modify the WIP property of the change and/or
+    // create new patch sets.
+    //
+    // Returns the actual results with an assertWip method that can be used
+    // to compare against an expected value for a particular patch set.
+    const compute = (initialWip, tagsByRevision) => {
+      const change = {
+        messages: [],
+        work_in_progress: initialWip,
+      };
+      const revs = Object.keys(tagsByRevision).sort((a, b) => a - b);
+      for (const rev of revs) {
+        for (const tag of tagsByRevision[rev]) {
+          change.messages.push({
+            tag,
+            _revision_number: rev,
+          });
+        }
+      }
+      let patchNums = revs.map(rev => { return {num: rev}; });
+      patchNums = Gerrit.PatchSetBehavior._computeWipForPatchSets(
+          change, patchNums);
+      const actualWipsByRevision = {};
+      for (const patchNum of patchNums) {
+        actualWipsByRevision[patchNum.num] = patchNum.wip;
+      }
+      const verifier = {
+        assertWip(revision, expectedWip) {
+          const patchNum = patchNums.find(patchNum => patchNum.num == revision);
+          if (!patchNum) {
+            assert.fail('revision ' + revision + ' not found');
+          }
+          assert.equal(patchNum.wip, expectedWip,
+              'wip state for ' + revision + ' is ' +
+            patchNum.wip + '; expected ' + expectedWip);
+          return verifier;
+        },
+      };
+      return verifier;
+    };
+
+    compute(false, {1: ['upload']}).assertWip(1, false);
+    compute(true, {1: ['upload']}).assertWip(1, true);
+
+    const setWip = 'autogenerated:gerrit:setWorkInProgress';
+    const uploadInWip = 'autogenerated:gerrit:newWipPatchSet';
+    const clearWip = 'autogenerated:gerrit:setReadyForReview';
+
+    compute(false, {
+      1: ['upload', setWip],
+      2: ['upload'],
+      3: ['upload', clearWip],
+      4: ['upload', setWip],
+    }).assertWip(1, false) // Change was created with PS1 ready for review
+        .assertWip(2, true) // PS2 was uploaded during WIP
+        .assertWip(3, false) // PS3 was marked ready for review after upload
+        .assertWip(4, false); // PS4 was uploaded ready for review
+
+    compute(false, {
+      1: [uploadInWip, null, 'addReviewer'],
+      2: ['upload'],
+      3: ['upload', clearWip, setWip],
+      4: ['upload'],
+      5: ['upload', clearWip],
+      6: [uploadInWip],
+    }).assertWip(1, true) // Change was created in WIP
+        .assertWip(2, true) // PS2 was uploaded during WIP
+        .assertWip(3, false) // PS3 was marked ready for review
+        .assertWip(4, true) // PS4 was uploaded during WIP
+        .assertWip(5, false) // PS5 was marked ready for review
+        .assertWip(6, true); // PS6 was uploaded with WIP option
+  });
+
+  test('patchNumEquals', () => {
+    const equals = Gerrit.PatchSetBehavior.patchNumEquals;
+    assert.isFalse(equals('edit', 'PARENT'));
+    assert.isFalse(equals('edit', NaN));
+    assert.isFalse(equals(1, '2'));
+
+    assert.isTrue(equals(1, '1'));
+    assert.isTrue(equals(1, 1));
+    assert.isTrue(equals('edit', 'edit'));
+    assert.isTrue(equals('PARENT', 'PARENT'));
+  });
+
+  test('isMergeParent', () => {
+    const isParent = Gerrit.PatchSetBehavior.isMergeParent;
+    assert.isFalse(isParent(1));
+    assert.isFalse(isParent(4321));
+    assert.isFalse(isParent('52'));
+    assert.isFalse(isParent('edit'));
+    assert.isFalse(isParent('PARENT'));
+    assert.isFalse(isParent(0));
+
+    assert.isTrue(isParent(-23));
+    assert.isTrue(isParent(-1));
+    assert.isTrue(isParent('-42'));
+  });
+
+  test('findEditParentRevision', () => {
+    const findParent = Gerrit.PatchSetBehavior.findEditParentRevision;
+    let revisions = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    assert.strictEqual(findParent(revisions), null);
+
+    revisions = [...revisions, {_number: 'edit', basePatchNum: 3}];
+    assert.strictEqual(findParent(revisions), null);
+
+    revisions = [...revisions, {_number: 3}];
+    assert.deepEqual(findParent(revisions), {_number: 3});
+  });
+
+  test('findEditParentPatchNum', () => {
+    const findNum = Gerrit.PatchSetBehavior.findEditParentPatchNum;
+    let revisions = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    assert.equal(findNum(revisions), -1);
+
+    revisions =
+        [...revisions, {_number: 'edit', basePatchNum: 3}, {_number: 3}];
+    assert.deepEqual(findNum(revisions), 3);
+  });
+
+  test('sortRevisions', () => {
+    const sort = Gerrit.PatchSetBehavior.sortRevisions;
+    const revisions = [
+      {_number: 0},
+      {_number: 2},
+      {_number: 1},
+    ];
+    const sorted = [
+      {_number: 2},
+      {_number: 1},
+      {_number: 0},
+    ];
+
+    assert.deepEqual(sort(revisions), sorted);
+
+    // Edit patchset should follow directly after its basePatchNum.
+    revisions.push({_number: 'edit', basePatchNum: 2});
+    sorted.unshift({_number: 'edit', basePatchNum: 2});
+    assert.deepEqual(sort(revisions), sorted);
+
+    revisions[0].basePatchNum = 0;
+    const edit = sorted.shift();
+    edit.basePatchNum = 0;
+    // Edit patchset should be at index 2.
+    sorted.splice(2, 0, edit);
+    assert.deepEqual(sort(revisions), sorted);
+  });
+
+  test('getParentIndex', () => {
+    assert.equal(Gerrit.PatchSetBehavior.getParentIndex('-13'), 13);
+    assert.equal(Gerrit.PatchSetBehavior.getParentIndex(-4), 4);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.js b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.js
index 67e4ca6..9ec7c8e 100644
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.js
@@ -1,21 +1,21 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2016 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 '../../scripts/util.js';
 
-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.
--->
-<script src="../../scripts/util.js"></script>
-<script>
 (function(window) {
   'use strict';
 
@@ -137,4 +137,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
index 740a8c8..8395ce9 100644
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
@@ -15,89 +15,91 @@
 limitations under the License.
 -->
 <!-- Polymer included for the html import polyfill. -->
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
 <title>gr-path-list-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-path-list-behavior.html">
+<script type="module" src="./gr-path-list-behavior.js"></script>
 
-<script>
-  suite('gr-path-list-behavior tests', async () => {
-    await readyToTest();
-    test('special sort', () => {
-      const sort = Gerrit.PathListBehavior.specialFilePathCompare;
-      const testFiles = [
-        '/a.h',
-        '/MERGE_LIST',
-        '/a.cpp',
-        '/COMMIT_MSG',
-        '/asdasd',
-        '/mrPeanutbutter.py',
-      ];
-      assert.deepEqual(
-          testFiles.sort(sort),
-          [
-            '/COMMIT_MSG',
-            '/MERGE_LIST',
-            '/a.h',
-            '/a.cpp',
-            '/asdasd',
-            '/mrPeanutbutter.py',
-          ]);
-    });
-
-    test('file display name', () => {
-      const name = Gerrit.PathListBehavior.computeDisplayPath;
-      assert.equal(name('/foo/bar/baz'), '/foo/bar/baz');
-      assert.equal(name('/foobarbaz'), '/foobarbaz');
-      assert.equal(name('/COMMIT_MSG'), 'Commit message');
-      assert.equal(name('/MERGE_LIST'), 'Merge list');
-    });
-
-    test('isMagicPath', () => {
-      const isMagic = Gerrit.PathListBehavior.isMagicPath;
-      assert.isFalse(isMagic(undefined));
-      assert.isFalse(isMagic('/foo.cc'));
-      assert.isTrue(isMagic('/COMMIT_MSG'));
-      assert.isTrue(isMagic('/MERGE_LIST'));
-    });
-
-    test('truncatePath with long path should add ellipsis', () => {
-      const truncatePath = Gerrit.PathListBehavior.truncatePath;
-      let path = 'level1/level2/level3/level4/file.js';
-      let shortenedPath = truncatePath(path);
-      // The expected path is truncated with an ellipsis.
-      const expectedPath = '\u2026/file.js';
-      assert.equal(shortenedPath, expectedPath);
-
-      path = 'level2/file.js';
-      shortenedPath = truncatePath(path);
-      assert.equal(shortenedPath, expectedPath);
-    });
-
-    test('truncatePath with opt_threshold', () => {
-      const truncatePath = Gerrit.PathListBehavior.truncatePath;
-      let path = 'level1/level2/level3/level4/file.js';
-      let shortenedPath = truncatePath(path, 2);
-      // The expected path is truncated with an ellipsis.
-      const expectedPath = '\u2026/level4/file.js';
-      assert.equal(shortenedPath, expectedPath);
-
-      path = 'level2/file.js';
-      shortenedPath = truncatePath(path, 2);
-      assert.equal(shortenedPath, path);
-    });
-
-    test('truncatePath with short path should not add ellipsis', () => {
-      const truncatePath = Gerrit.PathListBehavior.truncatePath;
-      const path = 'file.js';
-      const expectedPath = 'file.js';
-      const shortenedPath = truncatePath(path);
-      assert.equal(shortenedPath, expectedPath);
-    });
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './gr-path-list-behavior.js';
+suite('gr-path-list-behavior tests', () => {
+  test('special sort', () => {
+    const sort = Gerrit.PathListBehavior.specialFilePathCompare;
+    const testFiles = [
+      '/a.h',
+      '/MERGE_LIST',
+      '/a.cpp',
+      '/COMMIT_MSG',
+      '/asdasd',
+      '/mrPeanutbutter.py',
+    ];
+    assert.deepEqual(
+        testFiles.sort(sort),
+        [
+          '/COMMIT_MSG',
+          '/MERGE_LIST',
+          '/a.h',
+          '/a.cpp',
+          '/asdasd',
+          '/mrPeanutbutter.py',
+        ]);
   });
+
+  test('file display name', () => {
+    const name = Gerrit.PathListBehavior.computeDisplayPath;
+    assert.equal(name('/foo/bar/baz'), '/foo/bar/baz');
+    assert.equal(name('/foobarbaz'), '/foobarbaz');
+    assert.equal(name('/COMMIT_MSG'), 'Commit message');
+    assert.equal(name('/MERGE_LIST'), 'Merge list');
+  });
+
+  test('isMagicPath', () => {
+    const isMagic = Gerrit.PathListBehavior.isMagicPath;
+    assert.isFalse(isMagic(undefined));
+    assert.isFalse(isMagic('/foo.cc'));
+    assert.isTrue(isMagic('/COMMIT_MSG'));
+    assert.isTrue(isMagic('/MERGE_LIST'));
+  });
+
+  test('truncatePath with long path should add ellipsis', () => {
+    const truncatePath = Gerrit.PathListBehavior.truncatePath;
+    let path = 'level1/level2/level3/level4/file.js';
+    let shortenedPath = truncatePath(path);
+    // The expected path is truncated with an ellipsis.
+    const expectedPath = '\u2026/file.js';
+    assert.equal(shortenedPath, expectedPath);
+
+    path = 'level2/file.js';
+    shortenedPath = truncatePath(path);
+    assert.equal(shortenedPath, expectedPath);
+  });
+
+  test('truncatePath with opt_threshold', () => {
+    const truncatePath = Gerrit.PathListBehavior.truncatePath;
+    let path = 'level1/level2/level3/level4/file.js';
+    let shortenedPath = truncatePath(path, 2);
+    // The expected path is truncated with an ellipsis.
+    const expectedPath = '\u2026/level4/file.js';
+    assert.equal(shortenedPath, expectedPath);
+
+    path = 'level2/file.js';
+    shortenedPath = truncatePath(path, 2);
+    assert.equal(shortenedPath, path);
+  });
+
+  test('truncatePath with short path should not add ellipsis', () => {
+    const truncatePath = Gerrit.PathListBehavior.truncatePath;
+    const path = 'file.js';
+    const expectedPath = 'file.js';
+    const shortenedPath = truncatePath(path);
+    assert.equal(shortenedPath, expectedPath);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js
index 69ebf23..3758b79 100644
--- a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js
@@ -1,20 +1,19 @@
-<!--
-@license
-Copyright (C) 2019 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.
--->
-<script>
+/**
+ * @license
+ * Copyright (C) 2019 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.
+ */
 (function(window) {
   'use strict';
 
@@ -52,4 +51,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
deleted file mode 100644
index 0e2e99f..0000000
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
+++ /dev/null
@@ -1,21 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../elements/shared/gr-tooltip/gr-tooltip.html">
-<script src="../../scripts/rootElement.js"></script>
-
-<script src="gr-tooltip-behavior.js"></script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
index b65c63b..481642b 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
@@ -14,6 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../scripts/bundled-polymer.js';
+
+import '../../elements/shared/gr-tooltip/gr-tooltip.js';
+import '../../scripts/rootElement.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
 (function(window) {
   'use strict';
 
@@ -135,7 +141,7 @@
     _positionTooltip(tooltip) {
       // This flush is needed for tooltips to be positioned correctly in Firefox
       // and Safari.
-      Polymer.dom.flush();
+      flush();
       const rect = this.getBoundingClientRect();
       const boxRect = tooltip.getBoundingClientRect();
       const parentRect = tooltip.parentElement.getBoundingClientRect();
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
index e0218ad..7387a17 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
@@ -18,15 +18,20 @@
 
 <title>tooltip-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-tooltip-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-tooltip-behavior.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './gr-tooltip-behavior.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -34,124 +39,127 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-tooltip-behavior tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './gr-tooltip-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('gr-tooltip-behavior tests', () => {
+  let element;
+  let sandbox;
 
-    function makeTooltip(tooltipRect, parentRect) {
-      return {
-        getBoundingClientRect() { return tooltipRect; },
-        updateStyles: sinon.stub(),
-        style: {left: 0, top: 0},
-        parentElement: {
-          getBoundingClientRect() { return parentRect; },
-        },
-      };
-    }
+  function makeTooltip(tooltipRect, parentRect) {
+    return {
+      getBoundingClientRect() { return tooltipRect; },
+      updateStyles: sinon.stub(),
+      style: {left: 0, top: 0},
+      parentElement: {
+        getBoundingClientRect() { return parentRect; },
+      },
+    };
+  }
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'tooltip-behavior-element',
-        behaviors: [Gerrit.TooltipBehavior],
-      });
-    });
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('normal position', () => {
-      sandbox.stub(element, 'getBoundingClientRect', () => {
-        return {top: 100, left: 100, width: 200};
-      });
-      const tooltip = makeTooltip(
-          {height: 30, width: 50},
-          {top: 0, left: 0, width: 1000});
-
-      element._positionTooltip(tooltip);
-      assert.isFalse(tooltip.updateStyles.called);
-      assert.equal(tooltip.style.left, '175px');
-      assert.equal(tooltip.style.top, '100px');
-    });
-
-    test('left side position', () => {
-      sandbox.stub(element, 'getBoundingClientRect', () => {
-        return {top: 100, left: 10, width: 50};
-      });
-      const tooltip = makeTooltip(
-          {height: 30, width: 120},
-          {top: 0, left: 0, width: 1000});
-
-      element._positionTooltip(tooltip);
-      assert.isTrue(tooltip.updateStyles.called);
-      const offset = tooltip.updateStyles
-          .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-      assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
-      assert.equal(tooltip.style.left, '0px');
-      assert.equal(tooltip.style.top, '100px');
-    });
-
-    test('right side position', () => {
-      sandbox.stub(element, 'getBoundingClientRect', () => {
-        return {top: 100, left: 950, width: 50};
-      });
-      const tooltip = makeTooltip(
-          {height: 30, width: 120},
-          {top: 0, left: 0, width: 1000});
-
-      element._positionTooltip(tooltip);
-      assert.isTrue(tooltip.updateStyles.called);
-      const offset = tooltip.updateStyles
-          .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-      assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
-      assert.equal(tooltip.style.left, '915px');
-      assert.equal(tooltip.style.top, '100px');
-    });
-
-    test('position to bottom', () => {
-      sandbox.stub(element, 'getBoundingClientRect', () => {
-        return {top: 100, left: 950, width: 50, height: 50};
-      });
-      const tooltip = makeTooltip(
-          {height: 30, width: 120},
-          {top: 0, left: 0, width: 1000});
-
-      element.positionBelow = true;
-      element._positionTooltip(tooltip);
-      assert.isTrue(tooltip.updateStyles.called);
-      const offset = tooltip.updateStyles
-          .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-      assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
-      assert.equal(tooltip.style.left, '915px');
-      assert.equal(tooltip.style.top, '157.2px');
-    });
-
-    test('hides tooltip when detached', () => {
-      sandbox.stub(element, '_handleHideTooltip');
-      element.remove();
-      flushAsynchronousOperations();
-      assert.isTrue(element._handleHideTooltip.called);
-    });
-
-    test('sets up listeners when has-tooltip is changed', () => {
-      const addListenerStub = sandbox.stub(element, 'addEventListener');
-      element.hasTooltip = true;
-      assert.isTrue(addListenerStub.called);
-    });
-
-    test('clean up listeners when has-tooltip changed to false', () => {
-      const removeListenerStub = sandbox.stub(element, 'removeEventListener');
-      element.hasTooltip = true;
-      element.hasTooltip = false;
-      assert.isTrue(removeListenerStub.called);
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'tooltip-behavior-element',
+      behaviors: [Gerrit.TooltipBehavior],
     });
   });
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('normal position', () => {
+    sandbox.stub(element, 'getBoundingClientRect', () => {
+      return {top: 100, left: 100, width: 200};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 50},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isFalse(tooltip.updateStyles.called);
+    assert.equal(tooltip.style.left, '175px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('left side position', () => {
+    sandbox.stub(element, 'getBoundingClientRect', () => {
+      return {top: 100, left: 10, width: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '0px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('right side position', () => {
+    sandbox.stub(element, 'getBoundingClientRect', () => {
+      return {top: 100, left: 950, width: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('position to bottom', () => {
+    sandbox.stub(element, 'getBoundingClientRect', () => {
+      return {top: 100, left: 950, width: 50, height: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element.positionBelow = true;
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '157.2px');
+  });
+
+  test('hides tooltip when detached', () => {
+    sandbox.stub(element, '_handleHideTooltip');
+    element.remove();
+    flushAsynchronousOperations();
+    assert.isTrue(element._handleHideTooltip.called);
+  });
+
+  test('sets up listeners when has-tooltip is changed', () => {
+    const addListenerStub = sandbox.stub(element, 'addEventListener');
+    element.hasTooltip = true;
+    assert.isTrue(addListenerStub.called);
+  });
+
+  test('clean up listeners when has-tooltip changed to false', () => {
+    const removeListenerStub = sandbox.stub(element, 'removeEventListener');
+    element.hasTooltip = true;
+    element.hasTooltip = false;
+    assert.isTrue(removeListenerStub.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js
index 0396c4f..8b139da 100644
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js
@@ -1,20 +1,19 @@
-<!--
-@license
-Copyright (C) 2016 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.
--->
-<script>
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
 (function(window) {
   'use strict';
 
@@ -73,4 +72,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
index b7a1d92..94d6c12 100644
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
@@ -18,15 +18,20 @@
 
 <title>gr-url-encoding-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-url-encoding-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-url-encoding-behavior.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './gr-url-encoding-behavior.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -34,62 +39,65 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-url-encoding-behavior tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './gr-url-encoding-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('gr-url-encoding-behavior tests', () => {
+  let element;
+  let sandbox;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [Gerrit.URLEncodingBehavior],
-      });
-    });
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('encodeURL', () => {
-      test('double encodes', () => {
-        assert.equal(element.encodeURL('abc?123'), 'abc%253F123');
-        assert.equal(element.encodeURL('def/ghi'), 'def%252Fghi');
-        assert.equal(element.encodeURL('jkl'), 'jkl');
-        assert.equal(element.encodeURL(''), '');
-      });
-
-      test('does not convert colons', () => {
-        assert.equal(element.encodeURL('mno:pqr'), 'mno:pqr');
-      });
-
-      test('converts spaces to +', () => {
-        assert.equal(element.encodeURL('words with spaces'), 'words+with+spaces');
-      });
-
-      test('does not convert slashes when configured', () => {
-        assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
-      });
-
-      test('does not convert slashes when configured', () => {
-        assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
-      });
-    });
-
-    suite('singleDecodeUrl', () => {
-      test('single decodes', () => {
-        assert.equal(element.singleDecodeURL('abc%3Fdef'), 'abc?def');
-      });
-
-      test('converts + to space', () => {
-        assert.equal(element.singleDecodeURL('ghi+jkl'), 'ghi jkl');
-      });
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [Gerrit.URLEncodingBehavior],
     });
   });
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('encodeURL', () => {
+    test('double encodes', () => {
+      assert.equal(element.encodeURL('abc?123'), 'abc%253F123');
+      assert.equal(element.encodeURL('def/ghi'), 'def%252Fghi');
+      assert.equal(element.encodeURL('jkl'), 'jkl');
+      assert.equal(element.encodeURL(''), '');
+    });
+
+    test('does not convert colons', () => {
+      assert.equal(element.encodeURL('mno:pqr'), 'mno:pqr');
+    });
+
+    test('converts spaces to +', () => {
+      assert.equal(element.encodeURL('words with spaces'), 'words+with+spaces');
+    });
+
+    test('does not convert slashes when configured', () => {
+      assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
+    });
+
+    test('does not convert slashes when configured', () => {
+      assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
+    });
+  });
+
+  suite('singleDecodeUrl', () => {
+    test('single decodes', () => {
+      assert.equal(element.singleDecodeURL('abc%3Fdef'), 'abc?def');
+    });
+
+    test('converts + to space', () => {
+      assert.equal(element.singleDecodeURL('ghi+jkl'), 'ghi jkl');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
index a22c3ba..360a85b 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
@@ -1,21 +1,20 @@
-<!--
-@license
-Copyright (C) 2016 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.
--->
-
-<!--
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
+/*
 
 How to Add a Keyboard Shortcut
 ==============================
@@ -95,12 +94,17 @@
 
 NOTE: doc-only shortcuts will not be customizable in the same way that other
 shortcuts are.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
-<script src="../../types/polymer-behaviors.js"></script>
+*/
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+import '../../scripts/bundled-polymer.js';
 
-<script>
+import {IronA11yKeysBehavior} from '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior.js';
+import '../../types/polymer-behaviors.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 (function(window) {
   'use strict';
 
@@ -307,7 +311,7 @@
 
   /** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
   const getKeyboardEvent = function(e) {
-    e = Polymer.dom(e.detail ? e.detail.keyboardEvent : e);
+    e = dom(e.detail ? e.detail.keyboardEvent : e);
     // When e is a keyboardEvent, e.event is not null.
     if (e.event) { e = e.event; }
     return e;
@@ -482,7 +486,7 @@
 
   /** @polymerBehavior Gerrit.KeyboardShortcutBehavior*/
   Gerrit.KeyboardShortcutBehavior = [
-    Polymer.IronA11yKeysBehavior,
+    IronA11yKeysBehavior,
     {
       // Exports for convenience. Note: Closure compiler crashes when
       // object-shorthand syntax is used here.
@@ -518,7 +522,7 @@
 
       shouldSuppressKeyboardShortcut(e) {
         e = getKeyboardEvent(e);
-        const tagName = Polymer.dom(e).rootTarget.tagName;
+        const tagName = dom(e).rootTarget.tagName;
         if (tagName === 'INPUT' || tagName === 'TEXTAREA' ||
             (e.keyCode === 13 && tagName === 'A')) {
           // Suppress shortcuts if the key is 'enter' and target is an anchor.
@@ -542,7 +546,7 @@
       },
 
       getRootTarget(e) {
-        return Polymer.dom(getKeyboardEvent(e)).rootTarget;
+        return dom(getKeyboardEvent(e)).rootTarget;
       },
 
       bindShortcut(shortcut, ...bindings) {
@@ -671,4 +675,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
index e7407f7..f7231fb 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
@@ -19,13 +19,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="keyboard-shortcut-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="./keyboard-shortcut-behavior.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -41,403 +41,406 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('keyboard-shortcut-behavior tests', async () => {
-    await readyToTest();
-    const kb = window.Gerrit.KeyboardShortcutBinder;
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './keyboard-shortcut-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('keyboard-shortcut-behavior tests', () => {
+  const kb = window.Gerrit.KeyboardShortcutBinder;
 
-    let element;
-    let overlay;
-    let sandbox;
+  let element;
+  let overlay;
+  let sandbox;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [Gerrit.KeyboardShortcutBehavior],
-        keyBindings: {
-          k: '_handleKey',
-          enter: '_handleKey',
-        },
-        _handleKey() {},
-      });
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [Gerrit.KeyboardShortcutBehavior],
+      keyBindings: {
+        k: '_handleKey',
+        enter: '_handleKey',
+      },
+      _handleKey() {},
+    });
+  });
+
+  setup(() => {
+    element = fixture('basic');
+    overlay = fixture('within-overlay');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('ShortcutManager', () => {
+    test('bindings management', () => {
+      const mgr = new kb.ShortcutManager();
+      const {NEXT_FILE} = kb.Shortcut;
+
+      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+      assert.deepEqual(
+          mgr.getBindingsForShortcut(NEXT_FILE),
+          [']', '}', 'right']);
     });
 
-    setup(() => {
-      element = fixture('basic');
-      overlay = fixture('within-overlay');
-      sandbox = sinon.sandbox.create();
-    });
+    suite('binding descriptions', () => {
+      function mapToObject(m) {
+        const o = {};
+        m.forEach((v, k) => o[k] = v);
+        return o;
+      }
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('ShortcutManager', () => {
-      test('bindings management', () => {
+      test('single combo description', () => {
         const mgr = new kb.ShortcutManager();
-        const {NEXT_FILE} = kb.Shortcut;
-
-        assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
-        mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+        assert.deepEqual(mgr.describeBinding('a'), ['a']);
+        assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
+        assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
         assert.deepEqual(
-            mgr.getBindingsForShortcut(NEXT_FILE),
-            [']', '}', 'right']);
+            mgr.describeBinding('ctrl+shift+up:keyup'),
+            ['Ctrl', 'Shift', '↑']);
       });
 
-      suite('binding descriptions', () => {
-        function mapToObject(m) {
-          const o = {};
-          m.forEach((v, k) => o[k] = v);
-          return o;
-        }
+      test('combo set description', () => {
+        const {GO_KEY, DOC_ONLY, ShortcutManager} = kb;
+        const {GO_TO_OPENED_CHANGES, NEXT_FILE, PREV_FILE} = kb.Shortcut;
 
-        test('single combo description', () => {
-          const mgr = new kb.ShortcutManager();
-          assert.deepEqual(mgr.describeBinding('a'), ['a']);
-          assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
-          assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
-          assert.deepEqual(
-              mgr.describeBinding('ctrl+shift+up:keyup'),
-              ['Ctrl', 'Shift', '↑']);
+        const mgr = new ShortcutManager();
+        assert.isNull(mgr.describeBindings(NEXT_FILE));
+
+        mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
+        assert.deepEqual(
+            mgr.describeBindings(GO_TO_OPENED_CHANGES),
+            [['g', 'o']]);
+
+        mgr.bindShortcut(NEXT_FILE, DOC_ONLY, ']', 'ctrl+shift+right:keyup');
+        assert.deepEqual(
+            mgr.describeBindings(NEXT_FILE),
+            [[']'], ['Ctrl', 'Shift', '→']]);
+
+        mgr.bindShortcut(PREV_FILE, '[');
+        assert.deepEqual(mgr.describeBindings(PREV_FILE), [['[']]);
+      });
+
+      test('combo set description width', () => {
+        const mgr = new kb.ShortcutManager();
+        assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
+        assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
+        assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
+        assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
+        assert.strictEqual(
+            mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
+            12);
+      });
+
+      test('distribute shortcut help', () => {
+        const mgr = new kb.ShortcutManager();
+        assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
+        assert.deepEqual(
+            mgr.distributeBindingDesc([['g', 'o']]),
+            [[['g', 'o']]]);
+        assert.deepEqual(
+            mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
+            [[['ctrl', 'shift', 'meta', 'enter']]]);
+        assert.deepEqual(
+            mgr.distributeBindingDesc([
+              ['ctrl', 'shift', 'meta', 'enter'],
+              ['o'],
+            ]),
+            [
+              [['ctrl', 'shift', 'meta', 'enter']],
+              [['o']],
+            ]);
+        assert.deepEqual(
+            mgr.distributeBindingDesc([
+              ['ctrl', 'enter'],
+              ['meta', 'enter'],
+              ['ctrl', 's'],
+              ['meta', 's'],
+            ]),
+            [
+              [['ctrl', 'enter'], ['meta', 'enter']],
+              [['ctrl', 's'], ['meta', 's']],
+            ]);
+      });
+
+      test('active shortcuts by section', () => {
+        const {NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH} =
+            kb.Shortcut;
+        const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
+
+        const mgr = new kb.ShortcutManager();
+        mgr.bindShortcut(NEXT_FILE, ']');
+        mgr.bindShortcut(NEXT_LINE, 'j');
+        mgr.bindShortcut(GO_TO_OPENED_CHANGES, 'g+o');
+        mgr.bindShortcut(SEARCH, '/');
+
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {});
+
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [NEXT_FILE]: null,
+            };
+          },
         });
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {
+              [NAVIGATION]: [
+                {shortcut: NEXT_FILE, text: 'Go to next file'},
+              ],
+            });
 
-        test('combo set description', () => {
-          const {GO_KEY, DOC_ONLY, ShortcutManager} = kb;
-          const {GO_TO_OPENED_CHANGES, NEXT_FILE, PREV_FILE} = kb.Shortcut;
-
-          const mgr = new ShortcutManager();
-          assert.isNull(mgr.describeBindings(NEXT_FILE));
-
-          mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
-          assert.deepEqual(
-              mgr.describeBindings(GO_TO_OPENED_CHANGES),
-              [['g', 'o']]);
-
-          mgr.bindShortcut(NEXT_FILE, DOC_ONLY, ']', 'ctrl+shift+right:keyup');
-          assert.deepEqual(
-              mgr.describeBindings(NEXT_FILE),
-              [[']'], ['Ctrl', 'Shift', '→']]);
-
-          mgr.bindShortcut(PREV_FILE, '[');
-          assert.deepEqual(mgr.describeBindings(PREV_FILE), [['[']]);
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [NEXT_LINE]: null,
+            };
+          },
         });
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {
+              [DIFFS]: [
+                {shortcut: NEXT_LINE, text: 'Go to next line'},
+              ],
+              [NAVIGATION]: [
+                {shortcut: NEXT_FILE, text: 'Go to next file'},
+              ],
+            });
 
-        test('combo set description width', () => {
-          const mgr = new kb.ShortcutManager();
-          assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
-          assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
-          assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
-          assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
-          assert.strictEqual(
-              mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
-              12);
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [SEARCH]: null,
+              [GO_TO_OPENED_CHANGES]: null,
+            };
+          },
         });
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {
+              [DIFFS]: [
+                {shortcut: NEXT_LINE, text: 'Go to next line'},
+              ],
+              [EVERYWHERE]: [
+                {shortcut: SEARCH, text: 'Search'},
+                {
+                  shortcut: GO_TO_OPENED_CHANGES,
+                  text: 'Go to Opened Changes',
+                },
+              ],
+              [NAVIGATION]: [
+                {shortcut: NEXT_FILE, text: 'Go to next file'},
+              ],
+            });
+      });
 
-        test('distribute shortcut help', () => {
-          const mgr = new kb.ShortcutManager();
-          assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
-          assert.deepEqual(
-              mgr.distributeBindingDesc([['g', 'o']]),
-              [[['g', 'o']]]);
-          assert.deepEqual(
-              mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
-              [[['ctrl', 'shift', 'meta', 'enter']]]);
-          assert.deepEqual(
-              mgr.distributeBindingDesc([
-                ['ctrl', 'shift', 'meta', 'enter'],
-                ['o'],
-              ]),
-              [
-                [['ctrl', 'shift', 'meta', 'enter']],
-                [['o']],
-              ]);
-          assert.deepEqual(
-              mgr.distributeBindingDesc([
-                ['ctrl', 'enter'],
-                ['meta', 'enter'],
-                ['ctrl', 's'],
-                ['meta', 's'],
-              ]),
-              [
-                [['ctrl', 'enter'], ['meta', 'enter']],
-                [['ctrl', 's'], ['meta', 's']],
-              ]);
+      test('directory view', () => {
+        const {
+          NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH,
+          SAVE_COMMENT,
+        } = kb.Shortcut;
+        const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
+        const {GO_KEY, ShortcutManager} = kb;
+
+        const mgr = new ShortcutManager();
+        mgr.bindShortcut(NEXT_FILE, ']');
+        mgr.bindShortcut(NEXT_LINE, 'j');
+        mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
+        mgr.bindShortcut(SEARCH, '/');
+        mgr.bindShortcut(
+            SAVE_COMMENT, 'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
+
+        assert.deepEqual(mapToObject(mgr.directoryView()), {});
+
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [GO_TO_OPENED_CHANGES]: null,
+              [NEXT_FILE]: null,
+              [NEXT_LINE]: null,
+              [SAVE_COMMENT]: null,
+              [SEARCH]: null,
+            };
+          },
         });
-
-        test('active shortcuts by section', () => {
-          const {NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH} =
-              kb.Shortcut;
-          const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
-
-          const mgr = new kb.ShortcutManager();
-          mgr.bindShortcut(NEXT_FILE, ']');
-          mgr.bindShortcut(NEXT_LINE, 'j');
-          mgr.bindShortcut(GO_TO_OPENED_CHANGES, 'g+o');
-          mgr.bindShortcut(SEARCH, '/');
-
-          assert.deepEqual(
-              mapToObject(mgr.activeShortcutsBySection()),
-              {});
-
-          mgr.attachHost({
-            keyboardShortcuts() {
-              return {
-                [NEXT_FILE]: null,
-              };
-            },
-          });
-          assert.deepEqual(
-              mapToObject(mgr.activeShortcutsBySection()),
-              {
-                [NAVIGATION]: [
-                  {shortcut: NEXT_FILE, text: 'Go to next file'},
-                ],
-              });
-
-          mgr.attachHost({
-            keyboardShortcuts() {
-              return {
-                [NEXT_LINE]: null,
-              };
-            },
-          });
-          assert.deepEqual(
-              mapToObject(mgr.activeShortcutsBySection()),
-              {
-                [DIFFS]: [
-                  {shortcut: NEXT_LINE, text: 'Go to next line'},
-                ],
-                [NAVIGATION]: [
-                  {shortcut: NEXT_FILE, text: 'Go to next file'},
-                ],
-              });
-
-          mgr.attachHost({
-            keyboardShortcuts() {
-              return {
-                [SEARCH]: null,
-                [GO_TO_OPENED_CHANGES]: null,
-              };
-            },
-          });
-          assert.deepEqual(
-              mapToObject(mgr.activeShortcutsBySection()),
-              {
-                [DIFFS]: [
-                  {shortcut: NEXT_LINE, text: 'Go to next line'},
-                ],
-                [EVERYWHERE]: [
-                  {shortcut: SEARCH, text: 'Search'},
-                  {
-                    shortcut: GO_TO_OPENED_CHANGES,
-                    text: 'Go to Opened Changes',
-                  },
-                ],
-                [NAVIGATION]: [
-                  {shortcut: NEXT_FILE, text: 'Go to next file'},
-                ],
-              });
-        });
-
-        test('directory view', () => {
-          const {
-            NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH,
-            SAVE_COMMENT,
-          } = kb.Shortcut;
-          const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
-          const {GO_KEY, ShortcutManager} = kb;
-
-          const mgr = new ShortcutManager();
-          mgr.bindShortcut(NEXT_FILE, ']');
-          mgr.bindShortcut(NEXT_LINE, 'j');
-          mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
-          mgr.bindShortcut(SEARCH, '/');
-          mgr.bindShortcut(
-              SAVE_COMMENT, 'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
-
-          assert.deepEqual(mapToObject(mgr.directoryView()), {});
-
-          mgr.attachHost({
-            keyboardShortcuts() {
-              return {
-                [GO_TO_OPENED_CHANGES]: null,
-                [NEXT_FILE]: null,
-                [NEXT_LINE]: null,
-                [SAVE_COMMENT]: null,
-                [SEARCH]: null,
-              };
-            },
-          });
-          assert.deepEqual(
-              mapToObject(mgr.directoryView()),
-              {
-                [DIFFS]: [
-                  {binding: [['j']], text: 'Go to next line'},
-                  {
-                    binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
-                    text: 'Save comment',
-                  },
-                  {
-                    binding: [['Ctrl', 's'], ['Meta', 's']],
-                    text: 'Save comment',
-                  },
-                ],
-                [EVERYWHERE]: [
-                  {binding: [['/']], text: 'Search'},
-                  {binding: [['g', 'o']], text: 'Go to Opened Changes'},
-                ],
-                [NAVIGATION]: [
-                  {binding: [[']']], text: 'Go to next file'},
-                ],
-              });
-        });
-      });
-    });
-
-    test('doesn’t block kb shortcuts for non-whitelisted els', done => {
-      const divEl = document.createElement('div');
-      element.appendChild(divEl);
-      element._handleKey = e => {
-        assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
-        done();
-      };
-      MockInteractions.keyDownOn(divEl, 75, null, 'k');
-    });
-
-    test('blocks kb shortcuts for input els', done => {
-      const inputEl = document.createElement('input');
-      element.appendChild(inputEl);
-      element._handleKey = e => {
-        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-        done();
-      };
-      MockInteractions.keyDownOn(inputEl, 75, null, 'k');
-    });
-
-    test('blocks kb shortcuts for textarea els', done => {
-      const textareaEl = document.createElement('textarea');
-      element.appendChild(textareaEl);
-      element._handleKey = e => {
-        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-        done();
-      };
-      MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
-    });
-
-    test('blocks kb shortcuts for anything in a gr-overlay', done => {
-      const divEl = document.createElement('div');
-      const element = overlay.querySelector('test-element');
-      element.appendChild(divEl);
-      element._handleKey = e => {
-        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-        done();
-      };
-      MockInteractions.keyDownOn(divEl, 75, null, 'k');
-    });
-
-    test('blocks enter shortcut on an anchor', done => {
-      const anchorEl = document.createElement('a');
-      const element = overlay.querySelector('test-element');
-      element.appendChild(anchorEl);
-      element._handleKey = e => {
-        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-        done();
-      };
-      MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
-    });
-
-    test('modifierPressed returns accurate values', () => {
-      const spy = sandbox.spy(element, 'modifierPressed');
-      element._handleKey = e => {
-        element.modifierPressed(e);
-      };
-      MockInteractions.keyDownOn(element, 75, 'shift', 'k');
-      assert.isTrue(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, null, 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
-      assert.isTrue(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, null, 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, 'meta', 'k');
-      assert.isTrue(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, null, 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, 'alt', 'k');
-      assert.isTrue(spy.lastCall.returnValue);
-    });
-
-    test('isModifierPressed returns accurate value', () => {
-      const spy = sandbox.spy(element, 'isModifierPressed');
-      element._handleKey = e => {
-        element.isModifierPressed(e, 'shiftKey');
-      };
-      MockInteractions.keyDownOn(element, 75, 'shift', 'k');
-      assert.isTrue(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, null, 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, null, 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, 'meta', 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, null, 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, 'alt', 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-    });
-
-    suite('GO_KEY timing', () => {
-      let handlerStub;
-
-      setup(() => {
-        element._shortcut_go_table.set('a', '_handleA');
-        handlerStub = element._handleA = sinon.stub();
-        sandbox.stub(Date, 'now').returns(10000);
-      });
-
-      test('success', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._shortcut_go_key_last_pressed = 9000;
-        element._handleGoAction(e);
-        assert.isTrue(handlerStub.calledOnce);
-        assert.strictEqual(handlerStub.lastCall.args[0], e);
-      });
-
-      test('go key not pressed', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._shortcut_go_key_last_pressed = null;
-        element._handleGoAction(e);
-        assert.isFalse(handlerStub.called);
-      });
-
-      test('go key pressed too long ago', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._shortcut_go_key_last_pressed = 3000;
-        element._handleGoAction(e);
-        assert.isFalse(handlerStub.called);
-      });
-
-      test('should suppress', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
-        element._shortcut_go_key_last_pressed = 9000;
-        element._handleGoAction(e);
-        assert.isFalse(handlerStub.called);
-      });
-
-      test('unrecognized key', () => {
-        const e = {detail: {key: 'f'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._shortcut_go_key_last_pressed = 9000;
-        element._handleGoAction(e);
-        assert.isFalse(handlerStub.called);
+        assert.deepEqual(
+            mapToObject(mgr.directoryView()),
+            {
+              [DIFFS]: [
+                {binding: [['j']], text: 'Go to next line'},
+                {
+                  binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
+                  text: 'Save comment',
+                },
+                {
+                  binding: [['Ctrl', 's'], ['Meta', 's']],
+                  text: 'Save comment',
+                },
+              ],
+              [EVERYWHERE]: [
+                {binding: [['/']], text: 'Search'},
+                {binding: [['g', 'o']], text: 'Go to Opened Changes'},
+              ],
+              [NAVIGATION]: [
+                {binding: [[']']], text: 'Go to next file'},
+              ],
+            });
       });
     });
   });
+
+  test('doesn’t block kb shortcuts for non-whitelisted els', done => {
+    const divEl = document.createElement('div');
+    element.appendChild(divEl);
+    element._handleKey = e => {
+      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(divEl, 75, null, 'k');
+  });
+
+  test('blocks kb shortcuts for input els', done => {
+    const inputEl = document.createElement('input');
+    element.appendChild(inputEl);
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+  });
+
+  test('blocks kb shortcuts for textarea els', done => {
+    const textareaEl = document.createElement('textarea');
+    element.appendChild(textareaEl);
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
+  });
+
+  test('blocks kb shortcuts for anything in a gr-overlay', done => {
+    const divEl = document.createElement('div');
+    const element = overlay.querySelector('test-element');
+    element.appendChild(divEl);
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(divEl, 75, null, 'k');
+  });
+
+  test('blocks enter shortcut on an anchor', done => {
+    const anchorEl = document.createElement('a');
+    const element = overlay.querySelector('test-element');
+    element.appendChild(anchorEl);
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
+  });
+
+  test('modifierPressed returns accurate values', () => {
+    const spy = sandbox.spy(element, 'modifierPressed');
+    element._handleKey = e => {
+      element.modifierPressed(e);
+    };
+    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+  });
+
+  test('isModifierPressed returns accurate value', () => {
+    const spy = sandbox.spy(element, 'isModifierPressed');
+    element._handleKey = e => {
+      element.isModifierPressed(e, 'shiftKey');
+    };
+    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+  });
+
+  suite('GO_KEY timing', () => {
+    let handlerStub;
+
+    setup(() => {
+      element._shortcut_go_table.set('a', '_handleA');
+      handlerStub = element._handleA = sinon.stub();
+      sandbox.stub(Date, 'now').returns(10000);
+    });
+
+    test('success', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isTrue(handlerStub.calledOnce);
+      assert.strictEqual(handlerStub.lastCall.args[0], e);
+    });
+
+    test('go key not pressed', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = null;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('go key pressed too long ago', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 3000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('should suppress', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('unrecognized key', () => {
+      const e = {detail: {key: 'f'}, preventDefault: () => {}};
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.js b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.js
index 709cc8a..d52ffbf 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.js
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.js
@@ -1,22 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2016 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 '../../scripts/bundled-polymer.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../base-url-behavior/base-url-behavior.html">
-<script>
+import '../base-url-behavior/base-url-behavior.js';
 (function(window) {
   'use strict';
 
@@ -198,4 +198,3 @@
       };
   }
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
index 4bcd928..af078e5 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
@@ -19,19 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<script>
-  /** @type {string} */
-  window.CANONICAL_PATH = '/r';
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import '../base-url-behavior/base-url-behavior.js';
+import './rest-client-behavior.js';
+/** @type {string} */
+window.CANONICAL_PATH = '/r';
 </script>
 
-<link rel="import" href="../base-url-behavior/base-url-behavior.html">
-<link rel="import" href="rest-client-behavior.html">
+<script type="module" src="../base-url-behavior/base-url-behavior.js"></script>
+<script type="module" src="./rest-client-behavior.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -47,191 +51,195 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('rest-client-behavior tests', async () => {
-    await readyToTest();
-    let element;
-    // eslint-disable-next-line no-unused-vars
-    let overlay;
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import '../base-url-behavior/base-url-behavior.js';
+import './rest-client-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('rest-client-behavior tests', () => {
+  let element;
+  // eslint-disable-next-line no-unused-vars
+  let overlay;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [
-          Gerrit.BaseUrlBehavior,
-          Gerrit.RESTClientBehavior,
-        ],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-      overlay = fixture('within-overlay');
-    });
-
-    test('changeBaseURL', () => {
-      assert.deepEqual(
-          element.changeBaseURL('test/project', '1', '2'),
-          '/r/changes/test%2Fproject~1/revisions/2'
-      );
-    });
-
-    test('changePath', () => {
-      assert.deepEqual(element.changePath('1'), '/r/c/1');
-    });
-
-    test('Open status', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      let statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, []);
-      assert.equal(statusString, '');
-
-      change.submittable = false;
-      statuses = element.changeStatuses(change,
-          {includeDerived: true});
-      assert.deepEqual(statuses, ['Active']);
-
-      // With no missing labels but no submitEnabled option.
-      change.submittable = true;
-      statuses = element.changeStatuses(change,
-          {includeDerived: true});
-      assert.deepEqual(statuses, ['Active']);
-
-      // Without missing labels and enabled submit
-      statuses = element.changeStatuses(change,
-          {includeDerived: true, submitEnabled: true});
-      assert.deepEqual(statuses, ['Ready to submit']);
-
-      change.mergeable = false;
-      change.submittable = true;
-      statuses = element.changeStatuses(change,
-          {includeDerived: true});
-      assert.deepEqual(statuses, ['Merge Conflict']);
-
-      delete change.mergeable;
-      change.submittable = true;
-      statuses = element.changeStatuses(change,
-          {includeDerived: true, mergeable: true, submitEnabled: true});
-      assert.deepEqual(statuses, ['Ready to submit']);
-
-      change.submittable = true;
-      statuses = element.changeStatuses(change,
-          {includeDerived: true, mergeable: false});
-      assert.deepEqual(statuses, ['Merge Conflict']);
-    });
-
-    test('Merge conflict', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: false,
-      };
-      const statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, ['Merge Conflict']);
-      assert.equal(statusString, 'Merge Conflict');
-    });
-
-    test('mergeable prop undefined', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-      };
-      const statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, []);
-      assert.equal(statusString, '');
-    });
-
-    test('Merged status', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'MERGED',
-        labels: {},
-      };
-      const statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, ['Merged']);
-      assert.equal(statusString, 'Merged');
-    });
-
-    test('Abandoned status', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'ABANDONED',
-        labels: {},
-      };
-      const statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, ['Abandoned']);
-      assert.equal(statusString, 'Abandoned');
-    });
-
-    test('Open status with private and wip', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        is_private: true,
-        work_in_progress: true,
-        labels: {},
-        mergeable: true,
-      };
-      const statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, ['WIP', 'Private']);
-      assert.equal(statusString, 'WIP, Private');
-    });
-
-    test('Merge conflict with private and wip', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        is_private: true,
-        work_in_progress: true,
-        labels: {},
-        mergeable: false,
-      };
-      const statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
-      assert.equal(statusString, 'Merge Conflict, WIP, Private');
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [
+        Gerrit.BaseUrlBehavior,
+        Gerrit.RESTClientBehavior,
+      ],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+    overlay = fixture('within-overlay');
+  });
+
+  test('changeBaseURL', () => {
+    assert.deepEqual(
+        element.changeBaseURL('test/project', '1', '2'),
+        '/r/changes/test%2Fproject~1/revisions/2'
+    );
+  });
+
+  test('changePath', () => {
+    assert.deepEqual(element.changePath('1'), '/r/c/1');
+  });
+
+  test('Open status', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: true,
+    };
+    let statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, []);
+    assert.equal(statusString, '');
+
+    change.submittable = false;
+    statuses = element.changeStatuses(change,
+        {includeDerived: true});
+    assert.deepEqual(statuses, ['Active']);
+
+    // With no missing labels but no submitEnabled option.
+    change.submittable = true;
+    statuses = element.changeStatuses(change,
+        {includeDerived: true});
+    assert.deepEqual(statuses, ['Active']);
+
+    // Without missing labels and enabled submit
+    statuses = element.changeStatuses(change,
+        {includeDerived: true, submitEnabled: true});
+    assert.deepEqual(statuses, ['Ready to submit']);
+
+    change.mergeable = false;
+    change.submittable = true;
+    statuses = element.changeStatuses(change,
+        {includeDerived: true});
+    assert.deepEqual(statuses, ['Merge Conflict']);
+
+    delete change.mergeable;
+    change.submittable = true;
+    statuses = element.changeStatuses(change,
+        {includeDerived: true, mergeable: true, submitEnabled: true});
+    assert.deepEqual(statuses, ['Ready to submit']);
+
+    change.submittable = true;
+    statuses = element.changeStatuses(change,
+        {includeDerived: true, mergeable: false});
+    assert.deepEqual(statuses, ['Merge Conflict']);
+  });
+
+  test('Merge conflict', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: false,
+    };
+    const statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, ['Merge Conflict']);
+    assert.equal(statusString, 'Merge Conflict');
+  });
+
+  test('mergeable prop undefined', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+    };
+    const statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, []);
+    assert.equal(statusString, '');
+  });
+
+  test('Merged status', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'MERGED',
+      labels: {},
+    };
+    const statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, ['Merged']);
+    assert.equal(statusString, 'Merged');
+  });
+
+  test('Abandoned status', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'ABANDONED',
+      labels: {},
+    };
+    const statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, ['Abandoned']);
+    assert.equal(statusString, 'Abandoned');
+  });
+
+  test('Open status with private and wip', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+      mergeable: true,
+    };
+    const statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, ['WIP', 'Private']);
+    assert.equal(statusString, 'WIP, Private');
+  });
+
+  test('Merge conflict with private and wip', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+      mergeable: false,
+    };
+    const statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
+    assert.equal(statusString, 'Merge Conflict, WIP, Private');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.js b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.js
index 8f08f0c..23f2290 100644
--- a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.js
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.js
@@ -1,20 +1,19 @@
-<!--
-@license
-Copyright (C) 2018 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.
--->
-<script>
+/**
+ * @license
+ * Copyright (C) 2018 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.
+ */
 (function(window) {
   'use strict';
 
@@ -74,4 +73,3 @@
     throw new Error(`Refused to bind value as ${type}: ${value}`);
   };
 })(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
index e123c96..fc1224f 100644
--- a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
@@ -18,15 +18,20 @@
 
 <title>safe-types-behavior</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="safe-types-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="./safe-types-behavior.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './safe-types-behavior.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -34,92 +39,95 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-tooltip-behavior tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './safe-types-behavior.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+suite('gr-tooltip-behavior tests', () => {
+  let element;
+  let sandbox;
 
-    suiteSetup(() => {
-      Polymer({
-        is: 'safe-types-element',
-        behaviors: [Gerrit.SafeTypes],
-      });
-    });
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('SafeUrl accepts valid urls', () => {
-      function accepts(url) {
-        const safeUrl = new element.SafeUrl(url);
-        assert.isOk(safeUrl);
-        assert.equal(url, safeUrl.asString());
-      }
-      accepts('http://www.google.com/');
-      accepts('https://www.google.com/');
-      accepts('HtTpS://www.google.com/');
-      accepts('//www.google.com/');
-      accepts('/c/1234/file/path.html@45');
-      accepts('#hash-url');
-      accepts('mailto:name@example.com');
-    });
-
-    test('SafeUrl rejects invalid urls', () => {
-      function rejects(url) {
-        assert.throws(() => { new element.SafeUrl(url); });
-      }
-      rejects('javascript://alert("evil");');
-      rejects('ftp:example.com');
-      rejects('data:text/html,scary business');
-    });
-
-    suite('safeTypesBridge', () => {
-      function acceptsString(value, type) {
-        assert.equal(Gerrit.SafeTypes.safeTypesBridge(value, type),
-            value);
-      }
-
-      function rejects(value, type) {
-        assert.throws(() => { Gerrit.SafeTypes.safeTypesBridge(value, type); });
-      }
-
-      test('accepts valid URL strings', () => {
-        acceptsString('/foo/bar', 'URL');
-        acceptsString('#baz', 'URL');
-      });
-
-      test('rejects invalid URL strings', () => {
-        rejects('javascript://void();', 'URL');
-      });
-
-      test('accepts SafeUrl values', () => {
-        const url = '/abc/123';
-        const safeUrl = new element.SafeUrl(url);
-        assert.equal(Gerrit.SafeTypes.safeTypesBridge(safeUrl, 'URL'), url);
-      });
-
-      test('rejects non-string or non-SafeUrl types', () => {
-        rejects(3.1415926, 'URL');
-      });
-
-      test('accepts any binding to STRING or CONSTANT', () => {
-        acceptsString('foo/bar/baz', 'STRING');
-        acceptsString('lorem ipsum dolor', 'CONSTANT');
-      });
-
-      test('rejects all other types', () => {
-        rejects('foo', 'JAVASCRIPT');
-        rejects('foo', 'HTML');
-        rejects('foo', 'RESOURCE_URL');
-        rejects('foo', 'STYLE');
-      });
+  suiteSetup(() => {
+    Polymer({
+      is: 'safe-types-element',
+      behaviors: [Gerrit.SafeTypes],
     });
   });
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('SafeUrl accepts valid urls', () => {
+    function accepts(url) {
+      const safeUrl = new element.SafeUrl(url);
+      assert.isOk(safeUrl);
+      assert.equal(url, safeUrl.asString());
+    }
+    accepts('http://www.google.com/');
+    accepts('https://www.google.com/');
+    accepts('HtTpS://www.google.com/');
+    accepts('//www.google.com/');
+    accepts('/c/1234/file/path.html@45');
+    accepts('#hash-url');
+    accepts('mailto:name@example.com');
+  });
+
+  test('SafeUrl rejects invalid urls', () => {
+    function rejects(url) {
+      assert.throws(() => { new element.SafeUrl(url); });
+    }
+    rejects('javascript://alert("evil");');
+    rejects('ftp:example.com');
+    rejects('data:text/html,scary business');
+  });
+
+  suite('safeTypesBridge', () => {
+    function acceptsString(value, type) {
+      assert.equal(Gerrit.SafeTypes.safeTypesBridge(value, type),
+          value);
+    }
+
+    function rejects(value, type) {
+      assert.throws(() => { Gerrit.SafeTypes.safeTypesBridge(value, type); });
+    }
+
+    test('accepts valid URL strings', () => {
+      acceptsString('/foo/bar', 'URL');
+      acceptsString('#baz', 'URL');
+    });
+
+    test('rejects invalid URL strings', () => {
+      rejects('javascript://void();', 'URL');
+    });
+
+    test('accepts SafeUrl values', () => {
+      const url = '/abc/123';
+      const safeUrl = new element.SafeUrl(url);
+      assert.equal(Gerrit.SafeTypes.safeTypesBridge(safeUrl, 'URL'), url);
+    });
+
+    test('rejects non-string or non-SafeUrl types', () => {
+      rejects(3.1415926, 'URL');
+    });
+
+    test('accepts any binding to STRING or CONSTANT', () => {
+      acceptsString('foo/bar/baz', 'STRING');
+      acceptsString('lorem ipsum dolor', 'CONSTANT');
+    });
+
+    test('rejects all other types', () => {
+      rejects('foo', 'JAVASCRIPT');
+      rejects('foo', 'HTML');
+      rejects('foo', 'RESOURCE_URL');
+      rejects('foo', 'STYLE');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
index a421043..cfb28bb 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
@@ -14,291 +14,308 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-permission/gr-permission.js';
+import '../../../scripts/util.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {htmlTemplate} from './gr-access-section_html.js';
+
+/**
+ * Fired when the section has been modified or removed.
+ *
+ * @event access-modified
+ */
+
+/**
+ * Fired when a section that was previously added was removed.
+ *
+ * @event added-section-removed
+ */
+
+const GLOBAL_NAME = 'GLOBAL_CAPABILITIES';
+
+// The name that gets automatically input when a new reference is added.
+const NEW_NAME = 'refs/heads/*';
+const REFS_NAME = 'refs/';
+const ON_BEHALF_OF = '(On Behalf Of)';
+const LABEL = 'Label';
+
+/**
+ * @appliesMixin Gerrit.AccessMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrAccessSection extends mixinBehaviors( [
+  Gerrit.AccessBehavior,
   /**
-   * Fired when the section has been modified or removed.
-   *
-   * @event access-modified
+   * Unused in this element, but called by other elements in tests
+   * e.g gr-repo-access_test.
    */
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-  /**
-   * Fired when a section that was previously added was removed.
-   *
-   * @event added-section-removed
-   */
+  static get is() { return 'gr-access-section'; }
 
-  const GLOBAL_NAME = 'GLOBAL_CAPABILITIES';
+  static get properties() {
+    return {
+      capabilities: Object,
+      /** @type {?} */
+      section: {
+        type: Object,
+        notify: true,
+        observer: '_updateSection',
+      },
+      groups: Object,
+      labels: Object,
+      editing: {
+        type: Boolean,
+        value: false,
+        observer: '_handleEditingChanged',
+      },
+      canUpload: Boolean,
+      ownerOf: Array,
+      _originalId: String,
+      _editingRef: {
+        type: Boolean,
+        value: false,
+      },
+      _deleted: {
+        type: Boolean,
+        value: false,
+      },
+      _permissions: Array,
+    };
+  }
 
-  // The name that gets automatically input when a new reference is added.
-  const NEW_NAME = 'refs/heads/*';
-  const REFS_NAME = 'refs/';
-  const ON_BEHALF_OF = '(On Behalf Of)';
-  const LABEL = 'Label';
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('access-saved',
+        () => this._handleAccessSaved());
+  }
 
-  /**
-   * @appliesMixin Gerrit.AccessMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
-   */
-  class GrAccessSection extends Polymer.mixinBehaviors( [
-    Gerrit.AccessBehavior,
-    /**
-     * Unused in this element, but called by other elements in tests
-     * e.g gr-repo-access_test.
-     */
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-access-section'; }
+  _updateSection(section) {
+    this._permissions = this.toSortedArray(section.value.permissions);
+    this._originalId = section.id;
+  }
 
-    static get properties() {
-      return {
-        capabilities: Object,
-        /** @type {?} */
-        section: {
-          type: Object,
-          notify: true,
-          observer: '_updateSection',
-        },
-        groups: Object,
-        labels: Object,
-        editing: {
-          type: Boolean,
-          value: false,
-          observer: '_handleEditingChanged',
-        },
-        canUpload: Boolean,
-        ownerOf: Array,
-        _originalId: String,
-        _editingRef: {
-          type: Boolean,
-          value: false,
-        },
-        _deleted: {
-          type: Boolean,
-          value: false,
-        },
-        _permissions: Array,
-      };
+  _handleAccessSaved() {
+    // Set a new 'original' value to keep track of after the value has been
+    // saved.
+    this._updateSection(this.section);
+  }
+
+  _handleValueChange() {
+    if (!this.section.value.added) {
+      this.section.value.modified = this.section.id !== this._originalId;
+      // Allows overall access page to know a change has been made.
+      // 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}));
     }
+    this.section.value.updatedId = this.section.id;
+  }
 
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('access-saved',
-          () => this._handleAccessSaved());
-    }
-
-    _updateSection(section) {
-      this._permissions = this.toSortedArray(section.value.permissions);
-      this._originalId = section.id;
-    }
-
-    _handleAccessSaved() {
-      // Set a new 'original' value to keep track of after the value has been
-      // saved.
-      this._updateSection(this.section);
-    }
-
-    _handleValueChange() {
-      if (!this.section.value.added) {
-        this.section.value.modified = this.section.id !== this._originalId;
-        // Allows overall access page to know a change has been made.
-        // 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}));
-      }
-      this.section.value.updatedId = this.section.id;
-    }
-
-    _handleEditingChanged(editing, editingOld) {
-      // Ignore when editing gets set initially.
-      if (!editingOld) { return; }
-      // Restore original values if no longer editing.
-      if (!editing) {
-        this._editingRef = false;
-        this._deleted = false;
-        delete this.section.value.deleted;
-        // Restore section ref.
-        this.set(['section', 'id'], this._originalId);
-        // Remove any unsaved but added permissions.
-        this._permissions = this._permissions.filter(p => !p.value.added);
-        for (const key of Object.keys(this.section.value.permissions)) {
-          if (this.section.value.permissions[key].added) {
-            delete this.section.value.permissions[key];
-          }
-        }
-      }
-    }
-
-    _computePermissions(name, capabilities, labels) {
-      let allPermissions;
-      if (!this.section || !this.section.value) {
-        return [];
-      }
-      if (name === GLOBAL_NAME) {
-        allPermissions = this.toSortedArray(capabilities);
-      } else {
-        const labelOptions = this._computeLabelOptions(labels);
-        allPermissions = labelOptions.concat(
-            this.toSortedArray(this.permissionValues));
-      }
-      return allPermissions
-          .filter(permission => !this.section.value.permissions[permission.id]);
-    }
-
-    _computeHideEditClass(section) {
-      return section.id === 'GLOBAL_CAPABILITIES' ? 'hide' : '';
-    }
-
-    _handleAddedPermissionRemoved(e) {
-      const index = e.model.index;
-      this._permissions = this._permissions.slice(0, index).concat(
-          this._permissions.slice(index + 1, this._permissions.length));
-    }
-
-    _computeLabelOptions(labels) {
-      const labelOptions = [];
-      if (!labels) { return []; }
-      for (const labelName of Object.keys(labels)) {
-        labelOptions.push({
-          id: 'label-' + labelName,
-          value: {
-            name: `${LABEL} ${labelName}`,
-            id: 'label-' + labelName,
-          },
-        });
-        labelOptions.push({
-          id: 'labelAs-' + labelName,
-          value: {
-            name: `${LABEL} ${labelName} ${ON_BEHALF_OF}`,
-            id: 'labelAs-' + labelName,
-          },
-        });
-      }
-      return labelOptions;
-    }
-
-    _computePermissionName(name, permission, permissionValues, capabilities) {
-      if (name === GLOBAL_NAME) {
-        return capabilities[permission.id].name;
-      } else if (permissionValues[permission.id]) {
-        return permissionValues[permission.id].name;
-      } else if (permission.value.label) {
-        let behalfOf = '';
-        if (permission.id.startsWith('labelAs-')) {
-          behalfOf = ON_BEHALF_OF;
-        }
-        return `${LABEL} ${permission.value.label}${behalfOf}`;
-      }
-    }
-
-    _computeSectionName(name) {
-      // When a new section is created, it doesn't yet have a ref. Set into
-      // edit mode so that the user can input one.
-      if (!name) {
-        this._editingRef = true;
-        // Needed for the title value. This is the same default as GWT.
-        name = NEW_NAME;
-        // Needed for the input field value.
-        this.set('section.id', name);
-      }
-      if (name === GLOBAL_NAME) {
-        return 'Global Capabilities';
-      } else if (name.startsWith(REFS_NAME)) {
-        return `Reference: ${name}`;
-      }
-      return name;
-    }
-
-    _handleRemoveReference() {
-      if (this.section.value.added) {
-        this.dispatchEvent(new CustomEvent(
-            'added-section-removed', {bubbles: true, composed: true}));
-      }
-      this._deleted = true;
-      this.section.value.deleted = true;
-      this.dispatchEvent(
-          new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    }
-
-    _handleUndoRemove() {
+  _handleEditingChanged(editing, editingOld) {
+    // Ignore when editing gets set initially.
+    if (!editingOld) { return; }
+    // Restore original values if no longer editing.
+    if (!editing) {
+      this._editingRef = false;
       this._deleted = false;
       delete this.section.value.deleted;
-    }
-
-    editRefInput() {
-      return Polymer.dom(this.root).querySelector(Polymer.Element ?
-        'iron-input.editRefInput' :
-        'input[is=iron-input].editRefInput');
-    }
-
-    editReference() {
-      this._editingRef = true;
-      this.editRefInput().focus();
-    }
-
-    _isEditEnabled(canUpload, ownerOf, sectionId) {
-      return canUpload || (ownerOf && ownerOf.indexOf(sectionId) >= 0);
-    }
-
-    _computeSectionClass(editing, canUpload, ownerOf, editingRef, deleted) {
-      const classList = [];
-      if (editing
-         && this._isEditEnabled(canUpload, ownerOf, this.section.id)) {
-        classList.push('editing');
+      // Restore section ref.
+      this.set(['section', 'id'], this._originalId);
+      // Remove any unsaved but added permissions.
+      this._permissions = this._permissions.filter(p => !p.value.added);
+      for (const key of Object.keys(this.section.value.permissions)) {
+        if (this.section.value.permissions[key].added) {
+          delete this.section.value.permissions[key];
+        }
       }
-      if (editingRef) {
-        classList.push('editingRef');
-      }
-      if (deleted) {
-        classList.push('deleted');
-      }
-      return classList.join(' ');
-    }
-
-    _computeEditBtnClass(name) {
-      return name === GLOBAL_NAME ? 'global' : '';
-    }
-
-    _handleAddPermission() {
-      const value = this.$.permissionSelect.value;
-      const permission = {
-        id: value,
-        value: {rules: {}, added: true},
-      };
-
-      // This is needed to update the 'label' property of the
-      // 'label-<label-name>' permission.
-      //
-      // The value from the add permission dropdown will either be
-      // label-<label-name> or labelAs-<labelName>.
-      // But, the format of the API response is as such:
-      // "permissions": {
-      //  "label-Code-Review": {
-      //    "label": "Code-Review",
-      //    "rules": {...}
-      //    }
-      //  }
-      // }
-      // When we add a new item, we have to push the new permission in the same
-      // format as the ones that have been returned by the API.
-      if (value.startsWith('label')) {
-        permission.value.label =
-            value.replace('label-', '').replace('labelAs-', '');
-      }
-      // Add to the end of the array (used in dom-repeat) and also to the
-      // section object that is two way bound with its parent element.
-      this.push('_permissions', permission);
-      this.set(['section.value.permissions', permission.id],
-          permission.value);
     }
   }
 
-  customElements.define(GrAccessSection.is, GrAccessSection);
-})();
+  _computePermissions(name, capabilities, labels) {
+    let allPermissions;
+    if (!this.section || !this.section.value) {
+      return [];
+    }
+    if (name === GLOBAL_NAME) {
+      allPermissions = this.toSortedArray(capabilities);
+    } else {
+      const labelOptions = this._computeLabelOptions(labels);
+      allPermissions = labelOptions.concat(
+          this.toSortedArray(this.permissionValues));
+    }
+    return allPermissions
+        .filter(permission => !this.section.value.permissions[permission.id]);
+  }
+
+  _computeHideEditClass(section) {
+    return section.id === 'GLOBAL_CAPABILITIES' ? 'hide' : '';
+  }
+
+  _handleAddedPermissionRemoved(e) {
+    const index = e.model.index;
+    this._permissions = this._permissions.slice(0, index).concat(
+        this._permissions.slice(index + 1, this._permissions.length));
+  }
+
+  _computeLabelOptions(labels) {
+    const labelOptions = [];
+    if (!labels) { return []; }
+    for (const labelName of Object.keys(labels)) {
+      labelOptions.push({
+        id: 'label-' + labelName,
+        value: {
+          name: `${LABEL} ${labelName}`,
+          id: 'label-' + labelName,
+        },
+      });
+      labelOptions.push({
+        id: 'labelAs-' + labelName,
+        value: {
+          name: `${LABEL} ${labelName} ${ON_BEHALF_OF}`,
+          id: 'labelAs-' + labelName,
+        },
+      });
+    }
+    return labelOptions;
+  }
+
+  _computePermissionName(name, permission, permissionValues, capabilities) {
+    if (name === GLOBAL_NAME) {
+      return capabilities[permission.id].name;
+    } else if (permissionValues[permission.id]) {
+      return permissionValues[permission.id].name;
+    } else if (permission.value.label) {
+      let behalfOf = '';
+      if (permission.id.startsWith('labelAs-')) {
+        behalfOf = ON_BEHALF_OF;
+      }
+      return `${LABEL} ${permission.value.label}${behalfOf}`;
+    }
+  }
+
+  _computeSectionName(name) {
+    // When a new section is created, it doesn't yet have a ref. Set into
+    // edit mode so that the user can input one.
+    if (!name) {
+      this._editingRef = true;
+      // Needed for the title value. This is the same default as GWT.
+      name = NEW_NAME;
+      // Needed for the input field value.
+      this.set('section.id', name);
+    }
+    if (name === GLOBAL_NAME) {
+      return 'Global Capabilities';
+    } else if (name.startsWith(REFS_NAME)) {
+      return `Reference: ${name}`;
+    }
+    return name;
+  }
+
+  _handleRemoveReference() {
+    if (this.section.value.added) {
+      this.dispatchEvent(new CustomEvent(
+          'added-section-removed', {bubbles: true, composed: true}));
+    }
+    this._deleted = true;
+    this.section.value.deleted = true;
+    this.dispatchEvent(
+        new CustomEvent('access-modified', {bubbles: true, composed: true}));
+  }
+
+  _handleUndoRemove() {
+    this._deleted = false;
+    delete this.section.value.deleted;
+  }
+
+  editRefInput() {
+    return dom(this.root).querySelector(PolymerElement ?
+      'iron-input.editRefInput' :
+      'input[is=iron-input].editRefInput');
+  }
+
+  editReference() {
+    this._editingRef = true;
+    this.editRefInput().focus();
+  }
+
+  _isEditEnabled(canUpload, ownerOf, sectionId) {
+    return canUpload || (ownerOf && ownerOf.indexOf(sectionId) >= 0);
+  }
+
+  _computeSectionClass(editing, canUpload, ownerOf, editingRef, deleted) {
+    const classList = [];
+    if (editing
+       && this._isEditEnabled(canUpload, ownerOf, this.section.id)) {
+      classList.push('editing');
+    }
+    if (editingRef) {
+      classList.push('editingRef');
+    }
+    if (deleted) {
+      classList.push('deleted');
+    }
+    return classList.join(' ');
+  }
+
+  _computeEditBtnClass(name) {
+    return name === GLOBAL_NAME ? 'global' : '';
+  }
+
+  _handleAddPermission() {
+    const value = this.$.permissionSelect.value;
+    const permission = {
+      id: value,
+      value: {rules: {}, added: true},
+    };
+
+    // This is needed to update the 'label' property of the
+    // 'label-<label-name>' permission.
+    //
+    // The value from the add permission dropdown will either be
+    // label-<label-name> or labelAs-<labelName>.
+    // But, the format of the API response is as such:
+    // "permissions": {
+    //  "label-Code-Review": {
+    //    "label": "Code-Review",
+    //    "rules": {...}
+    //    }
+    //  }
+    // }
+    // When we add a new item, we have to push the new permission in the same
+    // format as the ones that have been returned by the API.
+    if (value.startsWith('label')) {
+      permission.value.label =
+          value.replace('label-', '').replace('labelAs-', '');
+    }
+    // Add to the end of the array (used in dom-repeat) and also to the
+    // section object that is two way bound with its parent element.
+    this.push('_permissions', permission);
+    this.set(['section.value.permissions', permission.id],
+        permission.value);
+  }
+}
+
+customElements.define(GrAccessSection.is, GrAccessSection);
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js
index a52cb1a..5f35f55 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js
@@ -1,36 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-permission/gr-permission.html">
-
-<script src="../../../scripts/util.js"></script>
-
-<dom-module id="gr-access-section">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -89,50 +75,23 @@
     <style include="gr-form-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
-    <fieldset id="section"
-        class$="gr-form-styles [[_computeSectionClass(editing, canUpload, ownerOf, _editingRef, _deleted)]]">
+    <fieldset id="section" class\$="gr-form-styles [[_computeSectionClass(editing, canUpload, ownerOf, _editingRef, _deleted)]]">
       <div id="mainContainer">
         <div class="header">
           <div class="name">
             <h3>[[_computeSectionName(section.id)]]</h3>
-            <gr-button
-                id="editBtn"
-                link
-                class$="[[_computeEditBtnClass(section.id)]]"
-                on-click="editReference">
+            <gr-button id="editBtn" link="" class\$="[[_computeEditBtnClass(section.id)]]" on-click="editReference">
               <iron-icon id="icon" icon="gr-icons:create"></iron-icon>
             </gr-button>
           </div>
-          <iron-input
-              class="editRefInput"
-              bind-value="{{section.id}}"
-              type="text"
-              on-input="_handleValueChange">
-            <input
-                class="editRefInput"
-                bind-value="{{section.id}}"
-                is="iron-input"
-                type="text"
-                on-input="_handleValueChange">
+          <iron-input class="editRefInput" bind-value="{{section.id}}" type="text" on-input="_handleValueChange">
+            <input class="editRefInput" bind-value="{{section.id}}" is="iron-input" type="text" on-input="_handleValueChange">
           </iron-input>
-          <gr-button
-              link
-              id="deleteBtn"
-              on-click="_handleRemoveReference">Remove</gr-button>
+          <gr-button link="" id="deleteBtn" on-click="_handleRemoveReference">Remove</gr-button>
         </div><!-- end header -->
         <div class="sectionContent">
-          <template
-              is="dom-repeat"
-              items="{{_permissions}}"
-              as="permission">
-            <gr-permission
-                name="[[_computePermissionName(section.id, permission, permissionValues, capabilities)]]"
-                permission="{{permission}}"
-                labels="[[labels]]"
-                section="[[section.id]]"
-                editing="[[editing]]"
-                groups="[[groups]]"
-                on-added-permission-removed="_handleAddedPermissionRemoved">
+          <template is="dom-repeat" items="{{_permissions}}" as="permission">
+            <gr-permission name="[[_computePermissionName(section.id, permission, permissionValues, capabilities)]]" permission="{{permission}}" labels="[[labels]]" section="[[section.id]]" editing="[[editing]]" groups="[[groups]]" on-added-permission-removed="_handleAddedPermissionRemoved">
             </gr-permission>
           </template>
           <div id="addPermission">
@@ -140,29 +99,19 @@
             <select id="permissionSelect">
               <!-- called with a third parameter so that permissions update
                   after a new section is added. -->
-              <template
-                  is="dom-repeat"
-                  items="[[_computePermissions(section.id, capabilities, labels, section.value.permissions.*)]]">
+              <template is="dom-repeat" items="[[_computePermissions(section.id, capabilities, labels, section.value.permissions.*)]]">
                 <option value="[[item.value.id]]">[[item.value.name]]</option>
               </template>
             </select>
-            <gr-button
-                link
-                id="addBtn"
-                on-click="_handleAddPermission">Add</gr-button>
+            <gr-button link="" id="addBtn" on-click="_handleAddPermission">Add</gr-button>
           </div>
           <!-- end addPermission -->
         </div><!-- end sectionContent -->
       </div><!-- end mainContainer -->
       <div id="deletedContainer">
         <span>[[_computeSectionName(section.id)]] was deleted</span>
-        <gr-button
-            link
-            id="undoRemoveBtn"
-            on-click="_handleUndoRemove">Undo</gr-button>
+        <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove">Undo</gr-button>
       </div><!-- end deletedContainer -->
     </fieldset>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-access-section.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
index 2a3044e..4754c4a 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-access-section</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-access-section.html">
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-access-section.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-access-section.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,28 +41,310 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-access-section tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-access-section.js';
+suite('gr-access-section tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('unit tests', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+      element.section = {
+        id: 'refs/*',
+        value: {
+          permissions: {
+            read: {
+              rules: {},
+            },
+          },
+        },
+      };
+      element.capabilities = {
+        accessDatabase: {
+          id: 'accessDatabase',
+          name: 'Access Database',
+        },
+        administrateServer: {
+          id: 'administrateServer',
+          name: 'Administrate Server',
+        },
+        batchChangesLimit: {
+          id: 'batchChangesLimit',
+          name: 'Batch Changes Limit',
+        },
+        createAccount: {
+          id: 'createAccount',
+          name: 'Create Account',
+        },
+      };
+      element.labels = {
+        'Code-Review': {
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not merged as is',
+            '-2': 'This shall not be merged',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      };
+      element._updateSection(element.section);
+      flushAsynchronousOperations();
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('_updateSection', () => {
+      // _updateSection was called in setup, so just make assertions.
+      const expectedPermissions = [
+        {
+          id: 'read',
+          value: {
+            rules: {},
+          },
+        },
+      ];
+      assert.deepEqual(element._permissions, expectedPermissions);
+      assert.equal(element._originalId, element.section.id);
     });
 
-    suite('unit tests', () => {
+    test('_computeLabelOptions', () => {
+      const expectedLabelOptions = [
+        {
+          id: 'label-Code-Review',
+          value: {
+            name: 'Label Code-Review',
+            id: 'label-Code-Review',
+          },
+        },
+        {
+          id: 'labelAs-Code-Review',
+          value: {
+            name: 'Label Code-Review (On Behalf Of)',
+            id: 'labelAs-Code-Review',
+          },
+        },
+      ];
+
+      assert.deepEqual(element._computeLabelOptions(element.labels),
+          expectedLabelOptions);
+    });
+
+    test('_handleAccessSaved', () => {
+      assert.equal(element._originalId, 'refs/*');
+      element.section.id = 'refs/for/bar';
+      element._handleAccessSaved();
+      assert.equal(element._originalId, 'refs/for/bar');
+    });
+
+    test('_computePermissions', () => {
+      sandbox.stub(element, 'toSortedArray').returns(
+          [{
+            id: 'push',
+            value: {
+              rules: {},
+            },
+          },
+          {
+            id: 'read',
+            value: {
+              rules: {},
+            },
+          },
+          ]);
+
+      const expectedPermissions = [{
+        id: 'push',
+        value: {
+          rules: {},
+        },
+      },
+      ];
+      const labelOptions = [
+        {
+          id: 'label-Code-Review',
+          value: {
+            name: 'Label Code-Review',
+            id: 'label-Code-Review',
+          },
+        },
+        {
+          id: 'labelAs-Code-Review',
+          value: {
+            name: 'Label Code-Review (On Behalf Of)',
+            id: 'labelAs-Code-Review',
+          },
+        },
+      ];
+
+      // For global capabilities, just return the sorted array filtered by
+      // existing permissions.
+      let name = 'GLOBAL_CAPABILITIES';
+      assert.deepEqual(element._computePermissions(name, element.capabilities,
+          element.labels), expectedPermissions);
+
+      // Uses the capabilities array to come up with possible values.
+      assert.isTrue(element.toSortedArray.lastCall.
+          calledWithExactly(element.capabilities));
+
+      // For everything else, include possible label values before filtering.
+      name = 'refs/for/*';
+      assert.deepEqual(element._computePermissions(name, element.capabilities,
+          element.labels), labelOptions.concat(expectedPermissions));
+
+      // Uses permissionValues (defined in gr-access-behavior) to come up with
+      // possible values.
+      assert.isTrue(element.toSortedArray.lastCall.
+          calledWithExactly(element.permissionValues));
+    });
+
+    test('_computePermissionName', () => {
+      let name = 'GLOBAL_CAPABILITIES';
+      let permission = {
+        id: 'administrateServer',
+        value: {},
+      };
+      assert.equal(element._computePermissionName(name, permission,
+          element.permissionValues, element.capabilities),
+      element.capabilities[permission.id].name);
+
+      name = 'refs/for/*';
+      permission = {
+        id: 'abandon',
+        value: {},
+      };
+
+      assert.equal(element._computePermissionName(
+          name, permission, element.permissionValues, element.capabilities),
+      element.permissionValues[permission.id].name);
+
+      name = 'refs/for/*';
+      permission = {
+        id: 'label-Code-Review',
+        value: {
+          label: 'Code-Review',
+        },
+      };
+
+      assert.equal(element._computePermissionName(name, permission,
+          element.permissionValues, element.capabilities),
+      'Label Code-Review');
+
+      permission = {
+        id: 'labelAs-Code-Review',
+        value: {
+          label: 'Code-Review',
+        },
+      };
+
+      assert.equal(element._computePermissionName(name, permission,
+          element.permissionValues, element.capabilities),
+      'Label Code-Review(On Behalf Of)');
+    });
+
+    test('_computeSectionName', () => {
+      let name;
+      // When computing the section name for an undefined name, it means a
+      // new section is being added. In this case, it should defualt to
+      // 'refs/heads/*'.
+      element._editingRef = false;
+      assert.equal(element._computeSectionName(name),
+          'Reference: refs/heads/*');
+      assert.isTrue(element._editingRef);
+      assert.equal(element.section.id, 'refs/heads/*');
+
+      // Reset editing to false.
+      element._editingRef = false;
+      name = 'GLOBAL_CAPABILITIES';
+      assert.equal(element._computeSectionName(name), 'Global Capabilities');
+      assert.isFalse(element._editingRef);
+
+      name = 'refs/for/*';
+      assert.equal(element._computeSectionName(name),
+          'Reference: refs/for/*');
+      assert.isFalse(element._editingRef);
+    });
+
+    test('editReference', () => {
+      element.editReference();
+      assert.isTrue(element._editingRef);
+    });
+
+    test('_computeSectionClass', () => {
+      let editingRef = false;
+      let canUpload = false;
+      let ownerOf = [];
+      let editing = false;
+      let deleted = false;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), '');
+
+      editing = true;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), '');
+
+      ownerOf = ['refs/*'];
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing');
+
+      ownerOf = [];
+      canUpload = true;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing');
+
+      editingRef = true;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing editingRef');
+
+      deleted = true;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing editingRef deleted');
+
+      editingRef = false;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing deleted');
+    });
+
+    test('_computeEditBtnClass', () => {
+      let name = 'GLOBAL_CAPABILITIES';
+      assert.equal(element._computeEditBtnClass(name), 'global');
+      name = 'refs/for/*';
+      assert.equal(element._computeEditBtnClass(name), '');
+    });
+  });
+
+  suite('interactive tests', () => {
+    setup(() => {
+      element.labels = {
+        'Code-Review': {
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not merged as is',
+            '-2': 'This shall not be merged',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      };
+    });
+    suite('Global section', () => {
       setup(() => {
         element.section = {
-          id: 'refs/*',
+          id: 'GLOBAL_CAPABILITIES',
           value: {
             permissions: {
-              read: {
+              accessDatabase: {
                 rules: {},
               },
             },
@@ -81,476 +368,196 @@
             name: 'Create Account',
           },
         };
-        element.labels = {
-          'Code-Review': {
-            values: {
-              ' 0': 'No score',
-              '-1': 'I would prefer this is not merged as is',
-              '-2': 'This shall not be merged',
-              '+1': 'Looks good to me, but someone else must approve',
-              '+2': 'Looks good to me, approved',
-            },
-            default_value: 0,
-          },
-        };
         element._updateSection(element.section);
         flushAsynchronousOperations();
       });
 
-      test('_updateSection', () => {
-        // _updateSection was called in setup, so just make assertions.
-        const expectedPermissions = [
-          {
-            id: 'read',
-            value: {
-              rules: {},
-            },
-          },
-        ];
-        assert.deepEqual(element._permissions, expectedPermissions);
-        assert.equal(element._originalId, element.section.id);
-      });
-
-      test('_computeLabelOptions', () => {
-        const expectedLabelOptions = [
-          {
-            id: 'label-Code-Review',
-            value: {
-              name: 'Label Code-Review',
-              id: 'label-Code-Review',
-            },
-          },
-          {
-            id: 'labelAs-Code-Review',
-            value: {
-              name: 'Label Code-Review (On Behalf Of)',
-              id: 'labelAs-Code-Review',
-            },
-          },
-        ];
-
-        assert.deepEqual(element._computeLabelOptions(element.labels),
-            expectedLabelOptions);
-      });
-
-      test('_handleAccessSaved', () => {
-        assert.equal(element._originalId, 'refs/*');
-        element.section.id = 'refs/for/bar';
-        element._handleAccessSaved();
-        assert.equal(element._originalId, 'refs/for/bar');
-      });
-
-      test('_computePermissions', () => {
-        sandbox.stub(element, 'toSortedArray').returns(
-            [{
-              id: 'push',
-              value: {
-                rules: {},
-              },
-            },
-            {
-              id: 'read',
-              value: {
-                rules: {},
-              },
-            },
-            ]);
-
-        const expectedPermissions = [{
-          id: 'push',
-          value: {
-            rules: {},
-          },
-        },
-        ];
-        const labelOptions = [
-          {
-            id: 'label-Code-Review',
-            value: {
-              name: 'Label Code-Review',
-              id: 'label-Code-Review',
-            },
-          },
-          {
-            id: 'labelAs-Code-Review',
-            value: {
-              name: 'Label Code-Review (On Behalf Of)',
-              id: 'labelAs-Code-Review',
-            },
-          },
-        ];
-
-        // For global capabilities, just return the sorted array filtered by
-        // existing permissions.
-        let name = 'GLOBAL_CAPABILITIES';
-        assert.deepEqual(element._computePermissions(name, element.capabilities,
-            element.labels), expectedPermissions);
-
-        // Uses the capabilities array to come up with possible values.
-        assert.isTrue(element.toSortedArray.lastCall.
-            calledWithExactly(element.capabilities));
-
-        // For everything else, include possible label values before filtering.
-        name = 'refs/for/*';
-        assert.deepEqual(element._computePermissions(name, element.capabilities,
-            element.labels), labelOptions.concat(expectedPermissions));
-
-        // Uses permissionValues (defined in gr-access-behavior) to come up with
-        // possible values.
-        assert.isTrue(element.toSortedArray.lastCall.
-            calledWithExactly(element.permissionValues));
-      });
-
-      test('_computePermissionName', () => {
-        let name = 'GLOBAL_CAPABILITIES';
-        let permission = {
-          id: 'administrateServer',
-          value: {},
-        };
-        assert.equal(element._computePermissionName(name, permission,
-            element.permissionValues, element.capabilities),
-        element.capabilities[permission.id].name);
-
-        name = 'refs/for/*';
-        permission = {
-          id: 'abandon',
-          value: {},
-        };
-
-        assert.equal(element._computePermissionName(
-            name, permission, element.permissionValues, element.capabilities),
-        element.permissionValues[permission.id].name);
-
-        name = 'refs/for/*';
-        permission = {
-          id: 'label-Code-Review',
-          value: {
-            label: 'Code-Review',
-          },
-        };
-
-        assert.equal(element._computePermissionName(name, permission,
-            element.permissionValues, element.capabilities),
-        'Label Code-Review');
-
-        permission = {
-          id: 'labelAs-Code-Review',
-          value: {
-            label: 'Code-Review',
-          },
-        };
-
-        assert.equal(element._computePermissionName(name, permission,
-            element.permissionValues, element.capabilities),
-        'Label Code-Review(On Behalf Of)');
-      });
-
-      test('_computeSectionName', () => {
-        let name;
-        // When computing the section name for an undefined name, it means a
-        // new section is being added. In this case, it should defualt to
-        // 'refs/heads/*'.
-        element._editingRef = false;
-        assert.equal(element._computeSectionName(name),
-            'Reference: refs/heads/*');
-        assert.isTrue(element._editingRef);
-        assert.equal(element.section.id, 'refs/heads/*');
-
-        // Reset editing to false.
-        element._editingRef = false;
-        name = 'GLOBAL_CAPABILITIES';
-        assert.equal(element._computeSectionName(name), 'Global Capabilities');
-        assert.isFalse(element._editingRef);
-
-        name = 'refs/for/*';
-        assert.equal(element._computeSectionName(name),
-            'Reference: refs/for/*');
-        assert.isFalse(element._editingRef);
-      });
-
-      test('editReference', () => {
-        element.editReference();
-        assert.isTrue(element._editingRef);
-      });
-
-      test('_computeSectionClass', () => {
-        let editingRef = false;
-        let canUpload = false;
-        let ownerOf = [];
-        let editing = false;
-        let deleted = false;
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), '');
-
-        editing = true;
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), '');
-
-        ownerOf = ['refs/*'];
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), 'editing');
-
-        ownerOf = [];
-        canUpload = true;
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), 'editing');
-
-        editingRef = true;
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), 'editing editingRef');
-
-        deleted = true;
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), 'editing editingRef deleted');
-
-        editingRef = false;
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), 'editing deleted');
-      });
-
-      test('_computeEditBtnClass', () => {
-        let name = 'GLOBAL_CAPABILITIES';
-        assert.equal(element._computeEditBtnClass(name), 'global');
-        name = 'refs/for/*';
-        assert.equal(element._computeEditBtnClass(name), '');
+      test('classes are assigned correctly', () => {
+        assert.isFalse(element.$.section.classList.contains('editing'));
+        assert.isFalse(element.$.section.classList.contains('deleted'));
+        assert.isTrue(element.$.editBtn.classList.contains('global'));
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
       });
     });
 
-    suite('interactive tests', () => {
+    suite('Non-global section', () => {
       setup(() => {
-        element.labels = {
-          'Code-Review': {
-            values: {
-              ' 0': 'No score',
-              '-1': 'I would prefer this is not merged as is',
-              '-2': 'This shall not be merged',
-              '+1': 'Looks good to me, but someone else must approve',
-              '+2': 'Looks good to me, approved',
+        element.section = {
+          id: 'refs/*',
+          value: {
+            permissions: {
+              read: {
+                rules: {},
+              },
             },
-            default_value: 0,
           },
         };
-      });
-      suite('Global section', () => {
-        setup(() => {
-          element.section = {
-            id: 'GLOBAL_CAPABILITIES',
-            value: {
-              permissions: {
-                accessDatabase: {
-                  rules: {},
-                },
-              },
-            },
-          };
-          element.capabilities = {
-            accessDatabase: {
-              id: 'accessDatabase',
-              name: 'Access Database',
-            },
-            administrateServer: {
-              id: 'administrateServer',
-              name: 'Administrate Server',
-            },
-            batchChangesLimit: {
-              id: 'batchChangesLimit',
-              name: 'Batch Changes Limit',
-            },
-            createAccount: {
-              id: 'createAccount',
-              name: 'Create Account',
-            },
-          };
-          element._updateSection(element.section);
-          flushAsynchronousOperations();
-        });
-
-        test('classes are assigned correctly', () => {
-          assert.isFalse(element.$.section.classList.contains('editing'));
-          assert.isFalse(element.$.section.classList.contains('deleted'));
-          assert.isTrue(element.$.editBtn.classList.contains('global'));
-          element.editing = true;
-          element.canUpload = true;
-          element.ownerOf = [];
-          assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
-        });
+        element.capabilities = {};
+        element._updateSection(element.section);
+        flushAsynchronousOperations();
       });
 
-      suite('Non-global section', () => {
-        setup(() => {
-          element.section = {
-            id: 'refs/*',
-            value: {
-              permissions: {
-                read: {
-                  rules: {},
-                },
-              },
-            },
-          };
-          element.capabilities = {};
-          element._updateSection(element.section);
-          flushAsynchronousOperations();
-        });
+      test('classes are assigned correctly', () => {
+        assert.isFalse(element.$.section.classList.contains('editing'));
+        assert.isFalse(element.$.section.classList.contains('deleted'));
+        assert.isFalse(element.$.editBtn.classList.contains('global'));
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        flushAsynchronousOperations();
+        assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
+      });
 
-        test('classes are assigned correctly', () => {
-          assert.isFalse(element.$.section.classList.contains('editing'));
-          assert.isFalse(element.$.section.classList.contains('deleted'));
-          assert.isFalse(element.$.editBtn.classList.contains('global'));
-          element.editing = true;
-          element.canUpload = true;
-          element.ownerOf = [];
-          flushAsynchronousOperations();
-          assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
-        });
+      test('add permission', () => {
+        element.editing = true;
+        element.$.permissionSelect.value = 'label-Code-Review';
+        assert.equal(element._permissions.length, 1);
+        assert.equal(Object.keys(element.section.value.permissions).length,
+            1);
+        MockInteractions.tap(element.$.addBtn);
+        flushAsynchronousOperations();
 
-        test('add permission', () => {
-          element.editing = true;
-          element.$.permissionSelect.value = 'label-Code-Review';
-          assert.equal(element._permissions.length, 1);
-          assert.equal(Object.keys(element.section.value.permissions).length,
-              1);
-          MockInteractions.tap(element.$.addBtn);
-          flushAsynchronousOperations();
+        // The permission is added to both the permissions array and also
+        // the section's permission object.
+        assert.equal(element._permissions.length, 2);
+        let permission = {
+          id: 'label-Code-Review',
+          value: {
+            added: true,
+            label: 'Code-Review',
+            rules: {},
+          },
+        };
+        assert.equal(element._permissions.length, 2);
+        assert.deepEqual(element._permissions[1], permission);
+        assert.equal(Object.keys(element.section.value.permissions).length,
+            2);
+        assert.deepEqual(
+            element.section.value.permissions['label-Code-Review'],
+            permission.value);
 
-          // The permission is added to both the permissions array and also
-          // the section's permission object.
-          assert.equal(element._permissions.length, 2);
-          let permission = {
-            id: 'label-Code-Review',
-            value: {
-              added: true,
-              label: 'Code-Review',
-              rules: {},
-            },
-          };
-          assert.equal(element._permissions.length, 2);
-          assert.deepEqual(element._permissions[1], permission);
-          assert.equal(Object.keys(element.section.value.permissions).length,
-              2);
-          assert.deepEqual(
-              element.section.value.permissions['label-Code-Review'],
-              permission.value);
+        element.$.permissionSelect.value = 'abandon';
+        MockInteractions.tap(element.$.addBtn);
+        flushAsynchronousOperations();
 
-          element.$.permissionSelect.value = 'abandon';
-          MockInteractions.tap(element.$.addBtn);
-          flushAsynchronousOperations();
+        permission = {
+          id: 'abandon',
+          value: {
+            added: true,
+            rules: {},
+          },
+        };
 
-          permission = {
-            id: 'abandon',
-            value: {
-              added: true,
-              rules: {},
-            },
-          };
+        assert.equal(element._permissions.length, 3);
+        assert.deepEqual(element._permissions[2], permission);
+        assert.equal(Object.keys(element.section.value.permissions).length,
+            3);
+        assert.deepEqual(element.section.value.permissions['abandon'],
+            permission.value);
 
-          assert.equal(element._permissions.length, 3);
-          assert.deepEqual(element._permissions[2], permission);
-          assert.equal(Object.keys(element.section.value.permissions).length,
-              3);
-          assert.deepEqual(element.section.value.permissions['abandon'],
-              permission.value);
+        // Unsaved changes are discarded when editing is cancelled.
+        element.editing = false;
+        assert.equal(element._permissions.length, 1);
+        assert.equal(Object.keys(element.section.value.permissions).length,
+            1);
+      });
 
-          // Unsaved changes are discarded when editing is cancelled.
+      test('edit section reference', done => {
+        element.canUpload = true;
+        element.ownerOf = [];
+        element.section = {id: 'refs/for/bar', value: {permissions: {}}};
+        assert.isFalse(element.$.section.classList.contains('editing'));
+        element.editing = true;
+        assert.isTrue(element.$.section.classList.contains('editing'));
+        assert.isFalse(element._editingRef);
+        MockInteractions.tap(element.$.editBtn);
+        element.editRefInput().bindValue='new/ref';
+        setTimeout(() => {
+          assert.equal(element.section.id, 'new/ref');
+          assert.isTrue(element._editingRef);
+          assert.isTrue(element.$.section.classList.contains('editingRef'));
           element.editing = false;
-          assert.equal(element._permissions.length, 1);
-          assert.equal(Object.keys(element.section.value.permissions).length,
-              1);
-        });
-
-        test('edit section reference', done => {
-          element.canUpload = true;
-          element.ownerOf = [];
-          element.section = {id: 'refs/for/bar', value: {permissions: {}}};
-          assert.isFalse(element.$.section.classList.contains('editing'));
-          element.editing = true;
-          assert.isTrue(element.$.section.classList.contains('editing'));
           assert.isFalse(element._editingRef);
-          MockInteractions.tap(element.$.editBtn);
-          element.editRefInput().bindValue='new/ref';
-          setTimeout(() => {
-            assert.equal(element.section.id, 'new/ref');
-            assert.isTrue(element._editingRef);
-            assert.isTrue(element.$.section.classList.contains('editingRef'));
-            element.editing = false;
-            assert.isFalse(element._editingRef);
-            assert.equal(element.section.id, 'refs/for/bar');
-            done();
-          });
+          assert.equal(element.section.id, 'refs/for/bar');
+          done();
         });
+      });
 
-        test('_handleValueChange', () => {
-          // For an exising section.
-          const modifiedHandler = sandbox.stub();
-          element.section = {id: 'refs/for/bar', value: {permissions: {}}};
-          assert.notOk(element.section.value.updatedId);
-          element.section.id = 'refs/for/baz';
-          element.addEventListener('access-modified', modifiedHandler);
-          assert.isNotOk(element.section.value.modified);
-          element._handleValueChange();
-          assert.equal(element.section.value.updatedId, 'refs/for/baz');
-          assert.isTrue(element.section.value.modified);
-          assert.equal(modifiedHandler.callCount, 1);
-          element.section.id = 'refs/for/bar';
-          element._handleValueChange();
-          assert.isFalse(element.section.value.modified);
-          assert.equal(modifiedHandler.callCount, 2);
+      test('_handleValueChange', () => {
+        // For an exising section.
+        const modifiedHandler = sandbox.stub();
+        element.section = {id: 'refs/for/bar', value: {permissions: {}}};
+        assert.notOk(element.section.value.updatedId);
+        element.section.id = 'refs/for/baz';
+        element.addEventListener('access-modified', modifiedHandler);
+        assert.isNotOk(element.section.value.modified);
+        element._handleValueChange();
+        assert.equal(element.section.value.updatedId, 'refs/for/baz');
+        assert.isTrue(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 1);
+        element.section.id = 'refs/for/bar';
+        element._handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
 
-          // For a new section.
-          element.section.value.added = true;
-          element._handleValueChange();
-          assert.isFalse(element.section.value.modified);
-          assert.equal(modifiedHandler.callCount, 2);
-          element.section.id = 'refs/for/bar';
-          element._handleValueChange();
-          assert.isFalse(element.section.value.modified);
-          assert.equal(modifiedHandler.callCount, 2);
-        });
+        // For a new section.
+        element.section.value.added = true;
+        element._handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+        element.section.id = 'refs/for/bar';
+        element._handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+      });
 
-        test('remove section', () => {
-          element.editing = true;
-          element.canUpload = true;
-          element.ownerOf = [];
-          assert.isFalse(element._deleted);
-          assert.isNotOk(element.section.value.deleted);
-          MockInteractions.tap(element.$.deleteBtn);
-          flushAsynchronousOperations();
-          assert.isTrue(element._deleted);
-          assert.isTrue(element.section.value.deleted);
-          assert.isTrue(element.$.section.classList.contains('deleted'));
-          assert.isTrue(element.section.value.deleted);
+      test('remove section', () => {
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        assert.isFalse(element._deleted);
+        assert.isNotOk(element.section.value.deleted);
+        MockInteractions.tap(element.$.deleteBtn);
+        flushAsynchronousOperations();
+        assert.isTrue(element._deleted);
+        assert.isTrue(element.section.value.deleted);
+        assert.isTrue(element.$.section.classList.contains('deleted'));
+        assert.isTrue(element.section.value.deleted);
 
-          MockInteractions.tap(element.$.undoRemoveBtn);
-          flushAsynchronousOperations();
-          assert.isFalse(element._deleted);
-          assert.isNotOk(element.section.value.deleted);
+        MockInteractions.tap(element.$.undoRemoveBtn);
+        flushAsynchronousOperations();
+        assert.isFalse(element._deleted);
+        assert.isNotOk(element.section.value.deleted);
 
-          MockInteractions.tap(element.$.deleteBtn);
-          assert.isTrue(element._deleted);
-          assert.isTrue(element.section.value.deleted);
-          element.editing = false;
-          assert.isFalse(element._deleted);
-          assert.isNotOk(element.section.value.deleted);
-        });
+        MockInteractions.tap(element.$.deleteBtn);
+        assert.isTrue(element._deleted);
+        assert.isTrue(element.section.value.deleted);
+        element.editing = false;
+        assert.isFalse(element._deleted);
+        assert.isNotOk(element.section.value.deleted);
+      });
 
-        test('removing an added permission', () => {
-          element.editing = true;
-          assert.equal(element._permissions.length, 1);
-          element.shadowRoot
-              .querySelector('gr-permission').fire('added-permission-removed');
-          flushAsynchronousOperations();
-          assert.equal(element._permissions.length, 0);
-        });
+      test('removing an added permission', () => {
+        element.editing = true;
+        assert.equal(element._permissions.length, 1);
+        element.shadowRoot
+            .querySelector('gr-permission').fire('added-permission-removed');
+        flushAsynchronousOperations();
+        assert.equal(element._permissions.length, 0);
+      });
 
-        test('remove an added section', () => {
-          const removeStub = sandbox.stub();
-          element.addEventListener('added-section-removed', removeStub);
-          element.editing = true;
-          element.section.value.added = true;
-          MockInteractions.tap(element.$.deleteBtn);
-          assert.isTrue(removeStub.called);
-        });
+      test('remove an added section', () => {
+        const removeStub = sandbox.stub();
+        element.addEventListener('added-section-removed', removeStub);
+        element.editing = true;
+        element.section.value.added = true;
+        MockInteractions.tap(element.$.deleteBtn);
+        assert.isTrue(removeStub.called);
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
index 96008b7..bdf64de 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
@@ -14,155 +14,171 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-list-view/gr-list-view.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-create-group-dialog/gr-create-group-dialog.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-admin-group-list_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.ListViewMixin
+ * @extends Polymer.Element
+ */
+class GrAdminGroupList extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.ListViewBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-admin-group-list'; }
+
+  static get properties() {
+    return {
+    /**
+     * URL params passed from the router.
+     */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: Number,
+      _path: {
+        type: String,
+        readOnly: true,
+        value: '/admin/groups',
+      },
+      _hasNewGroupName: Boolean,
+      _createNewCapability: {
+        type: Boolean,
+        value: false,
+      },
+      _groups: Array,
+
+      /**
+       * Because  we request one more than the groupsPerPage, _shownGroups
+       * may be one less than _groups.
+       * */
+      _shownGroups: {
+        type: Array,
+        computed: 'computeShownItems(_groups)',
+      },
+
+      _groupsPerPage: {
+        type: Number,
+        value: 25,
+      },
+
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: String,
+    };
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getCreateGroupCapability();
+    this.fire('title-change', {title: 'Groups'});
+    this._maybeOpenCreateOverlay(this.params);
+  }
+
+  _paramsChanged(params) {
+    this._loading = true;
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
+
+    return this._getGroups(this._filter, this._groupsPerPage,
+        this._offset);
+  }
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.ListViewMixin
-   * @extends Polymer.Element
+   * Opens the create overlay if the route has a hash 'create'
+   *
+   * @param {!Object} params
    */
-  class GrAdminGroupList extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.ListViewBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-admin-group-list'; }
-
-    static get properties() {
-      return {
-      /**
-       * URL params passed from the router.
-       */
-        params: {
-          type: Object,
-          observer: '_paramsChanged',
-        },
-
-        /**
-         * Offset of currently visible query results.
-         */
-        _offset: Number,
-        _path: {
-          type: String,
-          readOnly: true,
-          value: '/admin/groups',
-        },
-        _hasNewGroupName: Boolean,
-        _createNewCapability: {
-          type: Boolean,
-          value: false,
-        },
-        _groups: Array,
-
-        /**
-         * Because  we request one more than the groupsPerPage, _shownGroups
-         * may be one less than _groups.
-         * */
-        _shownGroups: {
-          type: Array,
-          computed: 'computeShownItems(_groups)',
-        },
-
-        _groupsPerPage: {
-          type: Number,
-          value: 25,
-        },
-
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-        _filter: String,
-      };
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this._getCreateGroupCapability();
-      this.fire('title-change', {title: 'Groups'});
-      this._maybeOpenCreateOverlay(this.params);
-    }
-
-    _paramsChanged(params) {
-      this._loading = true;
-      this._filter = this.getFilterValue(params);
-      this._offset = this.getOffsetValue(params);
-
-      return this._getGroups(this._filter, this._groupsPerPage,
-          this._offset);
-    }
-
-    /**
-     * Opens the create overlay if the route has a hash 'create'
-     *
-     * @param {!Object} params
-     */
-    _maybeOpenCreateOverlay(params) {
-      if (params && params.openCreateModal) {
-        this.$.createOverlay.open();
-      }
-    }
-
-    _computeGroupUrl(id) {
-      return Gerrit.Nav.getUrlForGroup(id);
-    }
-
-    _getCreateGroupCapability() {
-      return this.$.restAPI.getAccount().then(account => {
-        if (!account) { return; }
-        return this.$.restAPI.getAccountCapabilities(['createGroup'])
-            .then(capabilities => {
-              if (capabilities.createGroup) {
-                this._createNewCapability = true;
-              }
-            });
-      });
-    }
-
-    _getGroups(filter, groupsPerPage, offset) {
-      this._groups = [];
-      return this.$.restAPI.getGroups(filter, groupsPerPage, offset)
-          .then(groups => {
-            if (!groups) {
-              return;
-            }
-            this._groups = Object.keys(groups)
-                .map(key => {
-                  const group = groups[key];
-                  group.name = key;
-                  return group;
-                });
-            this._loading = false;
-          });
-    }
-
-    _refreshGroupsList() {
-      this.$.restAPI.invalidateGroupsCache();
-      return this._getGroups(this._filter, this._groupsPerPage,
-          this._offset);
-    }
-
-    _handleCreateGroup() {
-      this.$.createNewModal.handleCreateGroup().then(() => {
-        this._refreshGroupsList();
-      });
-    }
-
-    _handleCloseCreate() {
-      this.$.createOverlay.close();
-    }
-
-    _handleCreateClicked() {
+  _maybeOpenCreateOverlay(params) {
+    if (params && params.openCreateModal) {
       this.$.createOverlay.open();
     }
-
-    _visibleToAll(item) {
-      return item.options.visible_to_all === true ? 'Y' : 'N';
-    }
   }
 
-  customElements.define(GrAdminGroupList.is, GrAdminGroupList);
-})();
+  _computeGroupUrl(id) {
+    return Gerrit.Nav.getUrlForGroup(id);
+  }
+
+  _getCreateGroupCapability() {
+    return this.$.restAPI.getAccount().then(account => {
+      if (!account) { return; }
+      return this.$.restAPI.getAccountCapabilities(['createGroup'])
+          .then(capabilities => {
+            if (capabilities.createGroup) {
+              this._createNewCapability = true;
+            }
+          });
+    });
+  }
+
+  _getGroups(filter, groupsPerPage, offset) {
+    this._groups = [];
+    return this.$.restAPI.getGroups(filter, groupsPerPage, offset)
+        .then(groups => {
+          if (!groups) {
+            return;
+          }
+          this._groups = Object.keys(groups)
+              .map(key => {
+                const group = groups[key];
+                group.name = key;
+                return group;
+              });
+          this._loading = false;
+        });
+  }
+
+  _refreshGroupsList() {
+    this.$.restAPI.invalidateGroupsCache();
+    return this._getGroups(this._filter, this._groupsPerPage,
+        this._offset);
+  }
+
+  _handleCreateGroup() {
+    this.$.createNewModal.handleCreateGroup().then(() => {
+      this._refreshGroupsList();
+    });
+  }
+
+  _handleCloseCreate() {
+    this.$.createOverlay.close();
+  }
+
+  _handleCreateClicked() {
+    this.$.createOverlay.open();
+  }
+
+  _visibleToAll(item) {
+    return item.options.visible_to_all === true ? 'Y' : 'N';
+  }
+}
+
+customElements.define(GrAdminGroupList.is, GrAdminGroupList);
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.js
index 5207717..ffc10d7 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.js
@@ -1,64 +1,43 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-create-group-dialog/gr-create-group-dialog.html">
-
-<dom-module id="gr-admin-group-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
     <style include="gr-table-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
-    <gr-list-view
-        create-new="[[_createNewCapability]]"
-        filter="[[_filter]]"
-        items="[[_groups]]"
-        items-per-page="[[_groupsPerPage]]"
-        loading="[[_loading]]"
-        offset="[[_offset]]"
-        on-create-clicked="_handleCreateClicked"
-        path="[[_path]]">
+    <gr-list-view create-new="[[_createNewCapability]]" filter="[[_filter]]" items="[[_groups]]" items-per-page="[[_groupsPerPage]]" loading="[[_loading]]" offset="[[_offset]]" on-create-clicked="_handleCreateClicked" path="[[_path]]">
       <table id="list" class="genericList">
-        <tr class="headerRow">
+        <tbody><tr class="headerRow">
           <th class="name topHeader">Group Name</th>
           <th class="description topHeader">Group Description</th>
           <th class="visibleToAll topHeader">Visible To All</th>
         </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+        <tr id="loading" class\$="loadingMsg [[computeLoadingClass(_loading)]]">
           <td>Loading...</td>
         </tr>
-        <tbody class$="[[computeLoadingClass(_loading)]]">
+        </tbody><tbody class\$="[[computeLoadingClass(_loading)]]">
           <template is="dom-repeat" items="[[_shownGroups]]">
             <tr class="table">
               <td class="name">
-                <a href$="[[_computeGroupUrl(item.group_id)]]">[[item.name]]</a>
+                <a href\$="[[_computeGroupUrl(item.group_id)]]">[[item.name]]</a>
               </td>
               <td class="description">[[item.description]]</td>
               <td class="visibleToAll">[[_visibleToAll(item)]]</td>
@@ -67,27 +46,15 @@
         </tbody>
       </table>
     </gr-list-view>
-    <gr-overlay id="createOverlay" with-backdrop>
-      <gr-dialog
-          id="createDialog"
-          class="confirmDialog"
-          disabled="[[!_hasNewGroupName]]"
-          confirm-label="Create"
-          confirm-on-enter
-          on-confirm="_handleCreateGroup"
-          on-cancel="_handleCloseCreate">
+    <gr-overlay id="createOverlay" with-backdrop="">
+      <gr-dialog id="createDialog" class="confirmDialog" disabled="[[!_hasNewGroupName]]" confirm-label="Create" confirm-on-enter="" on-confirm="_handleCreateGroup" on-cancel="_handleCloseCreate">
         <div class="header" slot="header">
           Create Group
         </div>
         <div class="main" slot="main">
-          <gr-create-group-dialog
-              has-new-group-name="{{_hasNewGroupName}}"
-              params="[[params]]"
-              id="createNewModal"></gr-create-group-dialog>
+          <gr-create-group-dialog has-new-group-name="{{_hasNewGroupName}}" params="[[params]]" id="createNewModal"></gr-create-group-dialog>
         </div>
       </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-admin-group-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
index c0558d2..36c2081 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
@@ -19,18 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-group-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel="import" href="gr-admin-group-list.html">
+<script type="module" src="./gr-admin-group-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-admin-group-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -38,153 +43,155 @@
   </template>
 </test-fixture>
 
-<script>
-  let counter = 0;
-  const groupGenerator = () => {
-    return {
-      name: `test${++counter}`,
-      id: '59b92f35489e62c80d1ab1bf0c2d17843038df8b',
-      url: '#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b',
-      options: {
-        visible_to_all: false,
-      },
-      description: 'Gerrit Site Administrators',
-      group_id: 1,
-      owner: 'Administrators',
-      owner_id: '7ca042f4d5847936fcb90ca91057673157fd06fc',
-    };
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-admin-group-list.js';
+let counter = 0;
+const groupGenerator = () => {
+  return {
+    name: `test${++counter}`,
+    id: '59b92f35489e62c80d1ab1bf0c2d17843038df8b',
+    url: '#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b',
+    options: {
+      visible_to_all: false,
+    },
+    description: 'Gerrit Site Administrators',
+    group_id: 1,
+    owner: 'Administrators',
+    owner_id: '7ca042f4d5847936fcb90ca91057673157fd06fc',
   };
+};
 
-  suite('gr-admin-group-list tests', async () => {
-    await readyToTest();
-    let element;
-    let groups;
-    let sandbox;
-    let value;
+suite('gr-admin-group-list tests', () => {
+  let element;
+  let groups;
+  let sandbox;
+  let value;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('list with groups', () => {
+    setup(done => {
+      groups = _.times(26, groupGenerator);
+
+      stub('gr-rest-api-interface', {
+        getGroups(num, offset) {
+          return Promise.resolve(groups);
+        },
+      });
+
+      element._paramsChanged(value).then(() => { flush(done); });
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('list with groups', () => {
-      setup(done => {
-        groups = _.times(26, groupGenerator);
-
-        stub('gr-rest-api-interface', {
-          getGroups(num, offset) {
-            return Promise.resolve(groups);
-          },
-        });
-
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('test for test group in the list', done => {
-        flush(() => {
-          assert.equal(element._groups[1].name, '1');
-          assert.equal(element._groups[1].options.visible_to_all, false);
-          done();
-        });
-      });
-
-      test('_shownGroups', () => {
-        assert.equal(element._shownGroups.length, 25);
-      });
-
-      test('_maybeOpenCreateOverlay', () => {
-        const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
-        element._maybeOpenCreateOverlay();
-        assert.isFalse(overlayOpen.called);
-        const params = {};
-        element._maybeOpenCreateOverlay(params);
-        assert.isFalse(overlayOpen.called);
-        params.openCreateModal = true;
-        element._maybeOpenCreateOverlay(params);
-        assert.isTrue(overlayOpen.called);
+    test('test for test group in the list', done => {
+      flush(() => {
+        assert.equal(element._groups[1].name, '1');
+        assert.equal(element._groups[1].options.visible_to_all, false);
+        done();
       });
     });
 
-    suite('test with less then 25 groups', () => {
-      setup(done => {
-        groups = _.times(25, groupGenerator);
-
-        stub('gr-rest-api-interface', {
-          getGroups(num, offset) {
-            return Promise.resolve(groups);
-          },
-        });
-
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('_shownGroups', () => {
-        assert.equal(element._shownGroups.length, 25);
-      });
+    test('_shownGroups', () => {
+      assert.equal(element._shownGroups.length, 25);
     });
 
-    suite('filter', () => {
-      test('_paramsChanged', done => {
-        sandbox.stub(
-            element.$.restAPI,
-            'getGroups',
-            () => Promise.resolve(groups));
-        const value = {
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(value).then(() => {
-          assert.isTrue(element.$.restAPI.getGroups.lastCall
-              .calledWithExactly('test', 25, 25));
-          done();
-        });
+    test('_maybeOpenCreateOverlay', () => {
+      const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
+      element._maybeOpenCreateOverlay();
+      assert.isFalse(overlayOpen.called);
+      const params = {};
+      element._maybeOpenCreateOverlay(params);
+      assert.isFalse(overlayOpen.called);
+      params.openCreateModal = true;
+      element._maybeOpenCreateOverlay(params);
+      assert.isTrue(overlayOpen.called);
+    });
+  });
+
+  suite('test with less then 25 groups', () => {
+    setup(done => {
+      groups = _.times(25, groupGenerator);
+
+      stub('gr-rest-api-interface', {
+        getGroups(num, offset) {
+          return Promise.resolve(groups);
+        },
       });
+
+      element._paramsChanged(value).then(() => { flush(done); });
     });
 
-    suite('loading', () => {
-      test('correct contents are displayed', () => {
-        assert.isTrue(element._loading);
-        assert.equal(element.computeLoadingClass(element._loading), 'loading');
-        assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-        element._loading = false;
-        element._groups = _.times(25, groupGenerator);
-
-        flushAsynchronousOperations();
-        assert.equal(element.computeLoadingClass(element._loading), '');
-        assert.equal(getComputedStyle(element.$.loading).display, 'none');
-      });
+    test('_shownGroups', () => {
+      assert.equal(element._shownGroups.length, 25);
     });
+  });
 
-    suite('create new', () => {
-      test('_handleCreateClicked called when create-click fired', () => {
-        sandbox.stub(element, '_handleCreateClicked');
-        element.shadowRoot
-            .querySelector('gr-list-view').fire('create-clicked');
-        assert.isTrue(element._handleCreateClicked.called);
-      });
-
-      test('_handleCreateClicked opens modal', () => {
-        const openStub = sandbox.stub(element.$.createOverlay, 'open');
-        element._handleCreateClicked();
-        assert.isTrue(openStub.called);
-      });
-
-      test('_handleCreateGroup called when confirm fired', () => {
-        sandbox.stub(element, '_handleCreateGroup');
-        element.$.createDialog.fire('confirm');
-        assert.isTrue(element._handleCreateGroup.called);
-      });
-
-      test('_handleCloseCreate called when cancel fired', () => {
-        sandbox.stub(element, '_handleCloseCreate');
-        element.$.createDialog.fire('cancel');
-        assert.isTrue(element._handleCloseCreate.called);
+  suite('filter', () => {
+    test('_paramsChanged', done => {
+      sandbox.stub(
+          element.$.restAPI,
+          'getGroups',
+          () => Promise.resolve(groups));
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value).then(() => {
+        assert.isTrue(element.$.restAPI.getGroups.lastCall
+            .calledWithExactly('test', 25, 25));
+        done();
       });
     });
   });
+
+  suite('loading', () => {
+    test('correct contents are displayed', () => {
+      assert.isTrue(element._loading);
+      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+      element._loading = false;
+      element._groups = _.times(25, groupGenerator);
+
+      flushAsynchronousOperations();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+
+  suite('create new', () => {
+    test('_handleCreateClicked called when create-click fired', () => {
+      sandbox.stub(element, '_handleCreateClicked');
+      element.shadowRoot
+          .querySelector('gr-list-view').fire('create-clicked');
+      assert.isTrue(element._handleCreateClicked.called);
+    });
+
+    test('_handleCreateClicked opens modal', () => {
+      const openStub = sandbox.stub(element.$.createOverlay, 'open');
+      element._handleCreateClicked();
+      assert.isTrue(openStub.called);
+    });
+
+    test('_handleCreateGroup called when confirm fired', () => {
+      sandbox.stub(element, '_handleCreateGroup');
+      element.$.createDialog.fire('confirm');
+      assert.isTrue(element._handleCreateGroup.called);
+    });
+
+    test('_handleCloseCreate called when cancel fired', () => {
+      sandbox.stub(element, '_handleCloseCreate');
+      element.$.createDialog.fire('cancel');
+      assert.isTrue(element._handleCloseCreate.called);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
index e300c90..d03df39 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
@@ -14,281 +14,310 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '../../../styles/gr-menu-page-styles.js';
+import '../../../styles/gr-page-nav-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import '../../shared/gr-page-nav/gr-page-nav.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-admin-group-list/gr-admin-group-list.js';
+import '../gr-group/gr-group.js';
+import '../gr-group-audit-log/gr-group-audit-log.js';
+import '../gr-group-members/gr-group-members.js';
+import '../gr-plugin-list/gr-plugin-list.js';
+import '../gr-repo/gr-repo.js';
+import '../gr-repo-access/gr-repo-access.js';
+import '../gr-repo-commands/gr-repo-commands.js';
+import '../gr-repo-dashboards/gr-repo-dashboards.js';
+import '../gr-repo-detail-list/gr-repo-detail-list.js';
+import '../gr-repo-list/gr-repo-list.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-admin-view_html.js';
 
-  /**
-   * @appliesMixin Gerrit.AdminNavMixin
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
-   */
-  class GrAdminView extends Polymer.mixinBehaviors( [
-    Gerrit.AdminNavBehavior,
-    Gerrit.BaseUrlBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-admin-view'; }
+const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
-    static get properties() {
-      return {
-      /** @type {?} */
-        params: Object,
-        path: String,
-        adminView: String,
+/**
+ * @appliesMixin Gerrit.AdminNavMixin
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrAdminView extends mixinBehaviors( [
+  Gerrit.AdminNavBehavior,
+  Gerrit.BaseUrlBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-        _breadcrumbParentName: String,
-        _repoName: String,
-        _groupId: {
-          type: Number,
-          observer: '_computeGroupName',
-        },
-        _groupIsInternal: Boolean,
-        _groupName: String,
-        _groupOwner: {
-          type: Boolean,
-          value: false,
-        },
-        _subsectionLinks: Array,
-        _filteredLinks: Array,
-        _showDownload: {
-          type: Boolean,
-          value: false,
-        },
-        _isAdmin: {
-          type: Boolean,
-          value: false,
-        },
-        _showGroup: Boolean,
-        _showGroupAuditLog: Boolean,
-        _showGroupList: Boolean,
-        _showGroupMembers: Boolean,
-        _showRepoAccess: Boolean,
-        _showRepoCommands: Boolean,
-        _showRepoDashboards: Boolean,
-        _showRepoDetailList: Boolean,
-        _showRepoMain: Boolean,
-        _showRepoList: Boolean,
-        _showPluginList: Boolean,
-      };
-    }
+  static get is() { return 'gr-admin-view'; }
 
-    static get observers() {
-      return [
-        '_paramsChanged(params)',
-      ];
-    }
+  static get properties() {
+    return {
+    /** @type {?} */
+      params: Object,
+      path: String,
+      adminView: String,
 
-    /** @override */
-    attached() {
-      super.attached();
-      this.reload();
-    }
-
-    reload() {
-      const promises = [
-        this.$.restAPI.getAccount(),
-        Gerrit.awaitPluginsLoaded(),
-      ];
-      return Promise.all(promises).then(result => {
-        this._account = result[0];
-        let options;
-        if (this._repoName) {
-          options = {repoName: this._repoName};
-        } else if (this._groupId) {
-          options = {
-            groupId: this._groupId,
-            groupName: this._groupName,
-            groupIsInternal: this._groupIsInternal,
-            isAdmin: this._isAdmin,
-            groupOwner: this._groupOwner,
-          };
-        }
-
-        return this.getAdminLinks(this._account,
-            this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
-            this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI),
-            options)
-            .then(res => {
-              this._filteredLinks = res.links;
-              this._breadcrumbParentName = res.expandedSection ?
-                res.expandedSection.name : '';
-
-              if (!res.expandedSection) {
-                this._subsectionLinks = [];
-                return;
-              }
-              this._subsectionLinks = [res.expandedSection]
-                  .concat(res.expandedSection.children).map(section => {
-                    return {
-                      text: !section.detailType ? 'Home' : section.name,
-                      value: section.view + (section.detailType || ''),
-                      view: section.view,
-                      url: section.url,
-                      detailType: section.detailType,
-                      parent: this._groupId || this._repoName || '',
-                    };
-                  });
-            });
-      });
-    }
-
-    _computeSelectValue(params) {
-      if (!params || !params.view) { return; }
-      return params.view + (params.detail || '');
-    }
-
-    _selectedIsCurrentPage(selected) {
-      return (selected.parent === (this._repoName || this._groupId) &&
-          selected.view === this.params.view &&
-          selected.detailType === this.params.detail);
-    }
-
-    _handleSubsectionChange(e) {
-      const selected = this._subsectionLinks
-          .find(section => section.value === e.detail.value);
-
-      // This is when it gets set initially.
-      if (this._selectedIsCurrentPage(selected)) {
-        return;
-      }
-      Gerrit.Nav.navigateToRelativeUrl(selected.url);
-    }
-
-    _paramsChanged(params) {
-      const isGroupView = params.view === Gerrit.Nav.View.GROUP;
-      const isRepoView = params.view === Gerrit.Nav.View.REPO;
-      const isAdminView = params.view === Gerrit.Nav.View.ADMIN;
-
-      this.set('_showGroup', isGroupView && !params.detail);
-      this.set('_showGroupAuditLog', isGroupView &&
-          params.detail === Gerrit.Nav.GroupDetailView.LOG);
-      this.set('_showGroupMembers', isGroupView &&
-          params.detail === Gerrit.Nav.GroupDetailView.MEMBERS);
-
-      this.set('_showGroupList', isAdminView &&
-          params.adminView === 'gr-admin-group-list');
-
-      this.set('_showRepoAccess', isRepoView &&
-          params.detail === Gerrit.Nav.RepoDetailView.ACCESS);
-      this.set('_showRepoCommands', isRepoView &&
-          params.detail === Gerrit.Nav.RepoDetailView.COMMANDS);
-      this.set('_showRepoDetailList', isRepoView &&
-          (params.detail === Gerrit.Nav.RepoDetailView.BRANCHES ||
-           params.detail === Gerrit.Nav.RepoDetailView.TAGS));
-      this.set('_showRepoDashboards', isRepoView &&
-          params.detail === Gerrit.Nav.RepoDetailView.DASHBOARDS);
-      this.set('_showRepoMain', isRepoView && !params.detail);
-
-      this.set('_showRepoList', isAdminView &&
-          params.adminView === 'gr-repo-list');
-
-      this.set('_showPluginList', isAdminView &&
-          params.adminView === 'gr-plugin-list');
-
-      let needsReload = false;
-      if (params.repo !== this._repoName) {
-        this._repoName = params.repo || '';
-        // Reloads the admin menu.
-        needsReload = true;
-      }
-      if (params.groupId !== this._groupId) {
-        this._groupId = params.groupId || '';
-        // Reloads the admin menu.
-        needsReload = true;
-      }
-      if (this._breadcrumbParentName && !params.groupId && !params.repo) {
-        needsReload = true;
-      }
-      if (!needsReload) { return; }
-      this.reload();
-    }
-
-    // TODO (beckysiegel): Update these functions after router abstraction is
-    // updated. They are currently copied from gr-dropdown (and should be
-    // updated there as well once complete).
-    _computeURLHelper(host, path) {
-      return '//' + host + this.getBaseUrl() + path;
-    }
-
-    _computeRelativeURL(path) {
-      const host = window.location.host;
-      return this._computeURLHelper(host, path);
-    }
-
-    _computeLinkURL(link) {
-      if (!link || typeof link.url === 'undefined') { return ''; }
-      if (link.target || !link.noBaseUrl) {
-        return link.url;
-      }
-      return this._computeRelativeURL(link.url);
-    }
-
-    /**
-     * @param {string} itemView
-     * @param {Object} params
-     * @param {string=} opt_detailType
-     */
-    _computeSelectedClass(itemView, params, opt_detailType) {
-      if (!params) return '';
-      // Group params are structured differently from admin params. Compute
-      // selected differently for groups.
-      // TODO(wyatta): Simplify this when all routes work like group params.
-      if (params.view === Gerrit.Nav.View.GROUP &&
-          itemView === Gerrit.Nav.View.GROUP) {
-        if (!params.detail && !opt_detailType) { return 'selected'; }
-        if (params.detail === opt_detailType) { return 'selected'; }
-        return '';
-      }
-
-      if (params.view === Gerrit.Nav.View.REPO &&
-          itemView === Gerrit.Nav.View.REPO) {
-        if (!params.detail && !opt_detailType) { return 'selected'; }
-        if (params.detail === opt_detailType) { return 'selected'; }
-        return '';
-      }
-
-      if (params.detailType && params.detailType !== opt_detailType) {
-        return '';
-      }
-      return itemView === params.adminView ? 'selected' : '';
-    }
-
-    _computeGroupName(groupId) {
-      if (!groupId) { return ''; }
-
-      const promises = [];
-      this.$.restAPI.getGroupConfig(groupId).then(group => {
-        if (!group || !group.name) { return; }
-
-        this._groupName = group.name;
-        this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
-        this.reload();
-
-        promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
-          this._isAdmin = isAdmin;
-        }));
-
-        promises.push(this.$.restAPI.getIsGroupOwner(group.name).then(
-            isOwner => {
-              this._groupOwner = isOwner;
-            }));
-
-        return Promise.all(promises).then(() => {
-          this.reload();
-        });
-      });
-    }
-
-    _updateGroupName(e) {
-      this._groupName = e.detail.name;
-      this.reload();
-    }
+      _breadcrumbParentName: String,
+      _repoName: String,
+      _groupId: {
+        type: Number,
+        observer: '_computeGroupName',
+      },
+      _groupIsInternal: Boolean,
+      _groupName: String,
+      _groupOwner: {
+        type: Boolean,
+        value: false,
+      },
+      _subsectionLinks: Array,
+      _filteredLinks: Array,
+      _showDownload: {
+        type: Boolean,
+        value: false,
+      },
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+      _showGroup: Boolean,
+      _showGroupAuditLog: Boolean,
+      _showGroupList: Boolean,
+      _showGroupMembers: Boolean,
+      _showRepoAccess: Boolean,
+      _showRepoCommands: Boolean,
+      _showRepoDashboards: Boolean,
+      _showRepoDetailList: Boolean,
+      _showRepoMain: Boolean,
+      _showRepoList: Boolean,
+      _showPluginList: Boolean,
+    };
   }
 
-  customElements.define(GrAdminView.is, GrAdminView);
-})();
+  static get observers() {
+    return [
+      '_paramsChanged(params)',
+    ];
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.reload();
+  }
+
+  reload() {
+    const promises = [
+      this.$.restAPI.getAccount(),
+      Gerrit.awaitPluginsLoaded(),
+    ];
+    return Promise.all(promises).then(result => {
+      this._account = result[0];
+      let options;
+      if (this._repoName) {
+        options = {repoName: this._repoName};
+      } else if (this._groupId) {
+        options = {
+          groupId: this._groupId,
+          groupName: this._groupName,
+          groupIsInternal: this._groupIsInternal,
+          isAdmin: this._isAdmin,
+          groupOwner: this._groupOwner,
+        };
+      }
+
+      return this.getAdminLinks(this._account,
+          this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
+          this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI),
+          options)
+          .then(res => {
+            this._filteredLinks = res.links;
+            this._breadcrumbParentName = res.expandedSection ?
+              res.expandedSection.name : '';
+
+            if (!res.expandedSection) {
+              this._subsectionLinks = [];
+              return;
+            }
+            this._subsectionLinks = [res.expandedSection]
+                .concat(res.expandedSection.children).map(section => {
+                  return {
+                    text: !section.detailType ? 'Home' : section.name,
+                    value: section.view + (section.detailType || ''),
+                    view: section.view,
+                    url: section.url,
+                    detailType: section.detailType,
+                    parent: this._groupId || this._repoName || '',
+                  };
+                });
+          });
+    });
+  }
+
+  _computeSelectValue(params) {
+    if (!params || !params.view) { return; }
+    return params.view + (params.detail || '');
+  }
+
+  _selectedIsCurrentPage(selected) {
+    return (selected.parent === (this._repoName || this._groupId) &&
+        selected.view === this.params.view &&
+        selected.detailType === this.params.detail);
+  }
+
+  _handleSubsectionChange(e) {
+    const selected = this._subsectionLinks
+        .find(section => section.value === e.detail.value);
+
+    // This is when it gets set initially.
+    if (this._selectedIsCurrentPage(selected)) {
+      return;
+    }
+    Gerrit.Nav.navigateToRelativeUrl(selected.url);
+  }
+
+  _paramsChanged(params) {
+    const isGroupView = params.view === Gerrit.Nav.View.GROUP;
+    const isRepoView = params.view === Gerrit.Nav.View.REPO;
+    const isAdminView = params.view === Gerrit.Nav.View.ADMIN;
+
+    this.set('_showGroup', isGroupView && !params.detail);
+    this.set('_showGroupAuditLog', isGroupView &&
+        params.detail === Gerrit.Nav.GroupDetailView.LOG);
+    this.set('_showGroupMembers', isGroupView &&
+        params.detail === Gerrit.Nav.GroupDetailView.MEMBERS);
+
+    this.set('_showGroupList', isAdminView &&
+        params.adminView === 'gr-admin-group-list');
+
+    this.set('_showRepoAccess', isRepoView &&
+        params.detail === Gerrit.Nav.RepoDetailView.ACCESS);
+    this.set('_showRepoCommands', isRepoView &&
+        params.detail === Gerrit.Nav.RepoDetailView.COMMANDS);
+    this.set('_showRepoDetailList', isRepoView &&
+        (params.detail === Gerrit.Nav.RepoDetailView.BRANCHES ||
+         params.detail === Gerrit.Nav.RepoDetailView.TAGS));
+    this.set('_showRepoDashboards', isRepoView &&
+        params.detail === Gerrit.Nav.RepoDetailView.DASHBOARDS);
+    this.set('_showRepoMain', isRepoView && !params.detail);
+
+    this.set('_showRepoList', isAdminView &&
+        params.adminView === 'gr-repo-list');
+
+    this.set('_showPluginList', isAdminView &&
+        params.adminView === 'gr-plugin-list');
+
+    let needsReload = false;
+    if (params.repo !== this._repoName) {
+      this._repoName = params.repo || '';
+      // Reloads the admin menu.
+      needsReload = true;
+    }
+    if (params.groupId !== this._groupId) {
+      this._groupId = params.groupId || '';
+      // Reloads the admin menu.
+      needsReload = true;
+    }
+    if (this._breadcrumbParentName && !params.groupId && !params.repo) {
+      needsReload = true;
+    }
+    if (!needsReload) { return; }
+    this.reload();
+  }
+
+  // TODO (beckysiegel): Update these functions after router abstraction is
+  // updated. They are currently copied from gr-dropdown (and should be
+  // updated there as well once complete).
+  _computeURLHelper(host, path) {
+    return '//' + host + this.getBaseUrl() + path;
+  }
+
+  _computeRelativeURL(path) {
+    const host = window.location.host;
+    return this._computeURLHelper(host, path);
+  }
+
+  _computeLinkURL(link) {
+    if (!link || typeof link.url === 'undefined') { return ''; }
+    if (link.target || !link.noBaseUrl) {
+      return link.url;
+    }
+    return this._computeRelativeURL(link.url);
+  }
+
+  /**
+   * @param {string} itemView
+   * @param {Object} params
+   * @param {string=} opt_detailType
+   */
+  _computeSelectedClass(itemView, params, opt_detailType) {
+    if (!params) return '';
+    // Group params are structured differently from admin params. Compute
+    // selected differently for groups.
+    // TODO(wyatta): Simplify this when all routes work like group params.
+    if (params.view === Gerrit.Nav.View.GROUP &&
+        itemView === Gerrit.Nav.View.GROUP) {
+      if (!params.detail && !opt_detailType) { return 'selected'; }
+      if (params.detail === opt_detailType) { return 'selected'; }
+      return '';
+    }
+
+    if (params.view === Gerrit.Nav.View.REPO &&
+        itemView === Gerrit.Nav.View.REPO) {
+      if (!params.detail && !opt_detailType) { return 'selected'; }
+      if (params.detail === opt_detailType) { return 'selected'; }
+      return '';
+    }
+
+    if (params.detailType && params.detailType !== opt_detailType) {
+      return '';
+    }
+    return itemView === params.adminView ? 'selected' : '';
+  }
+
+  _computeGroupName(groupId) {
+    if (!groupId) { return ''; }
+
+    const promises = [];
+    this.$.restAPI.getGroupConfig(groupId).then(group => {
+      if (!group || !group.name) { return; }
+
+      this._groupName = group.name;
+      this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
+      this.reload();
+
+      promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
+        this._isAdmin = isAdmin;
+      }));
+
+      promises.push(this.$.restAPI.getIsGroupOwner(group.name).then(
+          isOwner => {
+            this._groupOwner = isOwner;
+          }));
+
+      return Promise.all(promises).then(() => {
+        this.reload();
+      });
+    });
+  }
+
+  _updateGroupName(e) {
+    this._groupName = e.detail.name;
+    this.reload();
+  }
+}
+
+customElements.define(GrAdminView.is, GrAdminView);
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.js
index aae11d3..0bc9431 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.js
@@ -1,48 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../styles/gr-menu-page-styles.html">
-<link rel="import" href="../../../styles/gr-page-nav-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-dropdown-list/gr-dropdown-list.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../../shared/gr-page-nav/gr-page-nav.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-admin-group-list/gr-admin-group-list.html">
-<link rel="import" href="../gr-group/gr-group.html">
-<link rel="import" href="../gr-group-audit-log/gr-group-audit-log.html">
-<link rel="import" href="../gr-group-members/gr-group-members.html">
-<link rel="import" href="../gr-plugin-list/gr-plugin-list.html">
-<link rel="import" href="../gr-repo/gr-repo.html">
-<link rel="import" href="../gr-repo-access/gr-repo-access.html">
-<link rel="import" href="../gr-repo-commands/gr-repo-commands.html">
-<link rel="import" href="../gr-repo-dashboards/gr-repo-dashboards.html">
-<link rel="import" href="../gr-repo-detail-list/gr-repo-detail-list.html">
-<link rel="import" href="../gr-repo-list/gr-repo-list.html">
-
-<dom-module id="gr-admin-view">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -84,28 +58,24 @@
     <gr-page-nav class="navStyles">
       <ul class="sectionContent">
         <template id="adminNav" is="dom-repeat" items="[[_filteredLinks]]">
-          <li class$="sectionTitle [[_computeSelectedClass(item.view, params)]]">
-            <a class="title" href="[[_computeLinkURL(item)]]"
-                  rel="noopener">[[item.name]]</a>
+          <li class\$="sectionTitle [[_computeSelectedClass(item.view, params)]]">
+            <a class="title" href="[[_computeLinkURL(item)]]" rel="noopener">[[item.name]]</a>
           </li>
           <template is="dom-repeat" items="[[item.children]]" as="child">
-            <li class$="[[_computeSelectedClass(child.view, params)]]">
-              <a href$="[[_computeLinkURL(child)]]"
-                  rel="noopener">[[child.name]]</a>
+            <li class\$="[[_computeSelectedClass(child.view, params)]]">
+              <a href\$="[[_computeLinkURL(child)]]" rel="noopener">[[child.name]]</a>
             </li>
           </template>
           <template is="dom-if" if="[[item.subsection]]">
             <!--If a section has a subsection, render that.-->
-            <li class$="[[_computeSelectedClass(item.subsection.view, params)]]">
-              <a class="title" href$="[[_computeLinkURL(item.subsection)]]"
-                  rel="noopener">
+            <li class\$="[[_computeSelectedClass(item.subsection.view, params)]]">
+              <a class="title" href\$="[[_computeLinkURL(item.subsection)]]" rel="noopener">
                 [[item.subsection.name]]</a>
             </li>
             <!--Loop through the links in the sub-section.-->
-            <template is="dom-repeat"
-                items="[[item.subsection.children]]" as="child">
-              <li class$="subsectionItem [[_computeSelectedClass(child.view, params, child.detailType)]]">
-                <a href$="[[_computeLinkURL(child)]]">[[child.name]]</a>
+            <template is="dom-repeat" items="[[item.subsection.children]]" as="child">
+              <li class\$="subsectionItem [[_computeSelectedClass(child.view, params, child.detailType)]]">
+                <a href\$="[[_computeLinkURL(child)]]">[[child.name]]</a>
               </li>
             </template>
           </template>
@@ -118,12 +88,7 @@
           <span class="breadcrumbText">[[_breadcrumbParentName]]</span>
           <iron-icon icon="gr-icons:chevron-right"></iron-icon>
         </span>
-        <gr-dropdown-list
-            lowercase
-            id="pageSelect"
-            value="[[_computeSelectValue(params)]]"
-            items="[[_subsectionLinks]]"
-            on-value-change="_handleSubsectionChange">
+        <gr-dropdown-list lowercase="" id="pageSelect" value="[[_computeSelectValue(params)]]" items="[[_subsectionLinks]]" on-value-change="_handleSubsectionChange">
         </gr-dropdown-list>
       </section>
     </template>
@@ -150,42 +115,32 @@
     </template>
     <template is="dom-if" if="[[_showGroup]]" restamp="true">
       <main class="breadcrumbs">
-        <gr-group
-            group-id="[[params.groupId]]"
-            on-name-changed="_updateGroupName"></gr-group>
+        <gr-group group-id="[[params.groupId]]" on-name-changed="_updateGroupName"></gr-group>
       </main>
     </template>
     <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
       <main class="breadcrumbs">
-        <gr-group-members
-            group-id="[[params.groupId]]"></gr-group-members>
+        <gr-group-members group-id="[[params.groupId]]"></gr-group-members>
       </main>
     </template>
     <template is="dom-if" if="[[_showRepoDetailList]]" restamp="true">
       <main class="table breadcrumbs">
-        <gr-repo-detail-list
-            params="[[params]]"
-            class="table"></gr-repo-detail-list>
+        <gr-repo-detail-list params="[[params]]" class="table"></gr-repo-detail-list>
       </main>
     </template>
     <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
       <main class="table breadcrumbs">
-        <gr-group-audit-log
-            group-id="[[params.groupId]]"
-            class="table"></gr-group-audit-log>
+        <gr-group-audit-log group-id="[[params.groupId]]" class="table"></gr-group-audit-log>
       </main>
     </template>
     <template is="dom-if" if="[[_showRepoCommands]]" restamp="true">
       <main class="breadcrumbs">
-        <gr-repo-commands
-            repo="[[params.repo]]"></gr-repo-commands>
+        <gr-repo-commands repo="[[params.repo]]"></gr-repo-commands>
       </main>
     </template>
     <template is="dom-if" if="[[_showRepoAccess]]" restamp="true">
       <main class="breadcrumbs">
-        <gr-repo-access
-            path="[[path]]"
-            repo="[[params.repo]]"></gr-repo-access>
+        <gr-repo-access path="[[path]]" repo="[[params.repo]]"></gr-repo-access>
       </main>
     </template>
     <template is="dom-if" if="[[_showRepoDashboards]]" restamp="true">
@@ -195,6 +150,4 @@
     </template>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  </template>
-  <script src="gr-admin-view.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
index 416099d..584eb88 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-admin-view.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-admin-view.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-admin-view.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,645 +40,648 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-admin-view tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-admin-view.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-admin-view tests', () => {
+  let element;
+  let sandbox;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      stub('gr-rest-api-interface', {
-        getProjectConfig() {
-          return Promise.resolve({});
-        },
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    stub('gr-rest-api-interface', {
+      getProjectConfig() {
+        return Promise.resolve({});
+      },
+    });
+    const pluginsLoaded = Promise.resolve();
+    sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(pluginsLoaded);
+    pluginsLoaded.then(() => flush(done));
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_computeURLHelper', () => {
+    const path = '/test';
+    const host = 'http://www.testsite.com';
+    const computedPath = element._computeURLHelper(host, path);
+    assert.equal(computedPath, '//http://www.testsite.com/test');
+  });
+
+  test('link URLs', () => {
+    assert.equal(
+        element._computeLinkURL({url: '/test', noBaseUrl: true}),
+        '//' + window.location.host + '/test');
+
+    sandbox.stub(element, 'getBaseUrl').returns('/foo');
+    assert.equal(
+        element._computeLinkURL({url: '/test', noBaseUrl: true}),
+        '//' + window.location.host + '/foo/test');
+    assert.equal(element._computeLinkURL({url: '/test'}), '/test');
+    assert.equal(
+        element._computeLinkURL({url: '/test', target: '_blank'}),
+        '/test');
+  });
+
+  test('current page gets selected and is displayed', () => {
+    element._filteredLinks = [{
+      name: 'Repositories',
+      url: '/admin/repos',
+      view: 'gr-repo-list',
+    }];
+
+    element.params = {
+      view: 'admin',
+      adminView: 'gr-repo-list',
+    };
+
+    flushAsynchronousOperations();
+    assert.equal(dom(element.root).querySelectorAll(
+        '.selected').length, 1);
+    assert.ok(element.shadowRoot
+        .querySelector('gr-repo-list'));
+    assert.isNotOk(element.shadowRoot
+        .querySelector('gr-admin-create-repo'));
+  });
+
+  test('_filteredLinks admin', done => {
+    sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccountCapabilities',
+        () => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        })
+    );
+    element.reload().then(() => {
+      assert.equal(element._filteredLinks.length, 3);
+
+      // Repos
+      assert.isNotOk(element._filteredLinks[0].subsection);
+
+      // Groups
+      assert.isNotOk(element._filteredLinks[0].subsection);
+
+      // Plugins
+      assert.isNotOk(element._filteredLinks[0].subsection);
+      done();
+    });
+  });
+
+  test('_filteredLinks non admin authenticated', done => {
+    sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccountCapabilities',
+        () => Promise.resolve({})
+    );
+    element.reload().then(() => {
+      assert.equal(element._filteredLinks.length, 2);
+
+      // Repos
+      assert.isNotOk(element._filteredLinks[0].subsection);
+
+      // Groups
+      assert.isNotOk(element._filteredLinks[0].subsection);
+      done();
+    });
+  });
+
+  test('_filteredLinks non admin unathenticated', done => {
+    element.reload().then(() => {
+      assert.equal(element._filteredLinks.length, 1);
+
+      // Repos
+      assert.isNotOk(element._filteredLinks[0].subsection);
+      done();
+    });
+  });
+
+  test('_filteredLinks from plugin', () => {
+    sandbox.stub(element.$.jsAPI, 'getAdminMenuLinks').returns([
+      {text: 'internal link text', url: '/internal/link/url'},
+      {text: 'external link text', url: 'http://external/link/url'},
+    ]);
+    return element.reload().then(() => {
+      assert.equal(element._filteredLinks.length, 3);
+      assert.deepEqual(element._filteredLinks[1], {
+        capability: null,
+        url: '/internal/link/url',
+        name: 'internal link text',
+        noBaseUrl: true,
+        view: null,
+        viewableToAll: true,
+        target: null,
       });
-      const pluginsLoaded = Promise.resolve();
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(pluginsLoaded);
-      pluginsLoaded.then(() => flush(done));
+      assert.deepEqual(element._filteredLinks[2], {
+        capability: null,
+        url: 'http://external/link/url',
+        name: 'external link text',
+        noBaseUrl: false,
+        view: null,
+        viewableToAll: true,
+        target: '_blank',
+      });
     });
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_computeURLHelper', () => {
-      const path = '/test';
-      const host = 'http://www.testsite.com';
-      const computedPath = element._computeURLHelper(host, path);
-      assert.equal(computedPath, '//http://www.testsite.com/test');
-    });
-
-    test('link URLs', () => {
+  test('Repo shows up in nav', done => {
+    element._repoName = 'Test Repo';
+    sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccountCapabilities',
+        () => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    element.reload().then(() => {
+      flushAsynchronousOperations();
+      assert.equal(dom(element.root)
+          .querySelectorAll('.sectionTitle').length, 3);
+      assert.equal(element.shadowRoot
+          .querySelector('.breadcrumbText').innerText, 'Test Repo');
       assert.equal(
-          element._computeLinkURL({url: '/test', noBaseUrl: true}),
-          '//' + window.location.host + '/test');
-
-      sandbox.stub(element, 'getBaseUrl').returns('/foo');
-      assert.equal(
-          element._computeLinkURL({url: '/test', noBaseUrl: true}),
-          '//' + window.location.host + '/foo/test');
-      assert.equal(element._computeLinkURL({url: '/test'}), '/test');
-      assert.equal(
-          element._computeLinkURL({url: '/test', target: '_blank'}),
-          '/test');
+          element.shadowRoot.querySelector('#pageSelect').items.length,
+          6
+      );
+      done();
     });
+  });
 
-    test('current page gets selected and is displayed', () => {
-      element._filteredLinks = [{
+  test('Group shows up in nav', done => {
+    element._groupId = 'a15262';
+    element._groupName = 'my-group';
+    element._groupIsInternal = true;
+    element._isAdmin = true;
+    element._groupOwner = false;
+    sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccountCapabilities',
+        () => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    element.reload().then(() => {
+      flushAsynchronousOperations();
+      assert.equal(element._filteredLinks.length, 3);
+
+      // Repos
+      assert.isNotOk(element._filteredLinks[0].subsection);
+
+      // Groups
+      assert.equal(element._filteredLinks[1].subsection.children.length, 2);
+      assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
+
+      // Plugins
+      assert.isNotOk(element._filteredLinks[2].subsection);
+      done();
+    });
+  });
+
+  test('Nav is reloaded when repo changes', () => {
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccountCapabilities',
+        () => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccount',
+        () => Promise.resolve({_id: 1}));
+    sandbox.stub(element, 'reload');
+    element.params = {repo: 'Test Repo', adminView: 'gr-repo'};
+    assert.equal(element.reload.callCount, 1);
+    element.params = {repo: 'Test Repo 2',
+      adminView: 'gr-repo'};
+    assert.equal(element.reload.callCount, 2);
+  });
+
+  test('Nav is reloaded when group changes', () => {
+    sandbox.stub(element, '_computeGroupName');
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccountCapabilities',
+        () => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccount',
+        () => Promise.resolve({_id: 1}));
+    sandbox.stub(element, 'reload');
+    element.params = {groupId: '1', adminView: 'gr-group'};
+    assert.equal(element.reload.callCount, 1);
+  });
+
+  test('Nav is reloaded when group name changes', done => {
+    const newName = 'newName';
+    sandbox.stub(element, '_computeGroupName');
+    sandbox.stub(element, 'reload', () => {
+      assert.equal(element._groupName, newName);
+      assert.isTrue(element.reload.called);
+      done();
+    });
+    element.params = {group: 1, view: Gerrit.Nav.View.GROUP};
+    element._groupName = 'oldName';
+    flushAsynchronousOperations();
+    element.shadowRoot
+        .querySelector('gr-group').fire('name-changed', {name: newName});
+  });
+
+  test('dropdown displays if there is a subsection', () => {
+    assert.isNotOk(element.shadowRoot
+        .querySelector('.mainHeader'));
+    element._subsectionLinks = [
+      {
+        text: 'Home',
+        value: 'repo',
+        view: 'repo',
+        url: '',
+        parent: 'my-repo',
+        detailType: undefined,
+      },
+    ];
+    flushAsynchronousOperations();
+    assert.isOk(element.shadowRoot
+        .querySelector('.mainHeader'));
+    element._subsectionLinks = undefined;
+    flushAsynchronousOperations();
+    assert.equal(
+        getComputedStyle(element.shadowRoot
+            .querySelector('.mainHeader')).display,
+        'none');
+  });
+
+  test('Dropdown only triggers navigation on explicit select', done => {
+    element._repoName = 'my-repo';
+    element.params = {
+      repo: 'my-repo',
+      view: Gerrit.Nav.View.REPO,
+      detail: Gerrit.Nav.RepoDetailView.ACCESS,
+    };
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccountCapabilities',
+        () => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccount',
+        () => Promise.resolve({_id: 1}));
+    flushAsynchronousOperations();
+    const expectedFilteredLinks = [
+      {
         name: 'Repositories',
+        noBaseUrl: true,
         url: '/admin/repos',
         view: 'gr-repo-list',
-      }];
-
-      element.params = {
-        view: 'admin',
-        adminView: 'gr-repo-list',
-      };
-
-      flushAsynchronousOperations();
-      assert.equal(Polymer.dom(element.root).querySelectorAll(
-          '.selected').length, 1);
-      assert.ok(element.shadowRoot
-          .querySelector('gr-repo-list'));
-      assert.isNotOk(element.shadowRoot
-          .querySelector('gr-admin-create-repo'));
-    });
-
-    test('_filteredLinks admin', done => {
-      sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-        name: 'test-user',
-      }));
-      sandbox.stub(
-          element.$.restAPI,
-          'getAccountCapabilities',
-          () => Promise.resolve({
-            createGroup: true,
-            createProject: true,
-            viewPlugins: true,
-          })
-      );
-      element.reload().then(() => {
-        assert.equal(element._filteredLinks.length, 3);
-
-        // Repos
-        assert.isNotOk(element._filteredLinks[0].subsection);
-
-        // Groups
-        assert.isNotOk(element._filteredLinks[0].subsection);
-
-        // Plugins
-        assert.isNotOk(element._filteredLinks[0].subsection);
-        done();
-      });
-    });
-
-    test('_filteredLinks non admin authenticated', done => {
-      sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-        name: 'test-user',
-      }));
-      sandbox.stub(
-          element.$.restAPI,
-          'getAccountCapabilities',
-          () => Promise.resolve({})
-      );
-      element.reload().then(() => {
-        assert.equal(element._filteredLinks.length, 2);
-
-        // Repos
-        assert.isNotOk(element._filteredLinks[0].subsection);
-
-        // Groups
-        assert.isNotOk(element._filteredLinks[0].subsection);
-        done();
-      });
-    });
-
-    test('_filteredLinks non admin unathenticated', done => {
-      element.reload().then(() => {
-        assert.equal(element._filteredLinks.length, 1);
-
-        // Repos
-        assert.isNotOk(element._filteredLinks[0].subsection);
-        done();
-      });
-    });
-
-    test('_filteredLinks from plugin', () => {
-      sandbox.stub(element.$.jsAPI, 'getAdminMenuLinks').returns([
-        {text: 'internal link text', url: '/internal/link/url'},
-        {text: 'external link text', url: 'http://external/link/url'},
-      ]);
-      return element.reload().then(() => {
-        assert.equal(element._filteredLinks.length, 3);
-        assert.deepEqual(element._filteredLinks[1], {
-          capability: null,
-          url: '/internal/link/url',
-          name: 'internal link text',
-          noBaseUrl: true,
-          view: null,
-          viewableToAll: true,
-          target: null,
-        });
-        assert.deepEqual(element._filteredLinks[2], {
-          capability: null,
-          url: 'http://external/link/url',
-          name: 'external link text',
-          noBaseUrl: false,
-          view: null,
-          viewableToAll: true,
-          target: '_blank',
-        });
-      });
-    });
-
-    test('Repo shows up in nav', done => {
-      element._repoName = 'Test Repo';
-      sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-        name: 'test-user',
-      }));
-      sandbox.stub(
-          element.$.restAPI,
-          'getAccountCapabilities',
-          () => Promise.resolve({
-            createGroup: true,
-            createProject: true,
-            viewPlugins: true,
-          }));
-      element.reload().then(() => {
-        flushAsynchronousOperations();
-        assert.equal(Polymer.dom(element.root)
-            .querySelectorAll('.sectionTitle').length, 3);
-        assert.equal(element.shadowRoot
-            .querySelector('.breadcrumbText').innerText, 'Test Repo');
-        assert.equal(
-            element.shadowRoot.querySelector('#pageSelect').items.length,
-            6
-        );
-        done();
-      });
-    });
-
-    test('Group shows up in nav', done => {
-      element._groupId = 'a15262';
-      element._groupName = 'my-group';
-      element._groupIsInternal = true;
-      element._isAdmin = true;
-      element._groupOwner = false;
-      sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-        name: 'test-user',
-      }));
-      sandbox.stub(
-          element.$.restAPI,
-          'getAccountCapabilities',
-          () => Promise.resolve({
-            createGroup: true,
-            createProject: true,
-            viewPlugins: true,
-          }));
-      element.reload().then(() => {
-        flushAsynchronousOperations();
-        assert.equal(element._filteredLinks.length, 3);
-
-        // Repos
-        assert.isNotOk(element._filteredLinks[0].subsection);
-
-        // Groups
-        assert.equal(element._filteredLinks[1].subsection.children.length, 2);
-        assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
-
-        // Plugins
-        assert.isNotOk(element._filteredLinks[2].subsection);
-        done();
-      });
-    });
-
-    test('Nav is reloaded when repo changes', () => {
-      sandbox.stub(
-          element.$.restAPI,
-          'getAccountCapabilities',
-          () => Promise.resolve({
-            createGroup: true,
-            createProject: true,
-            viewPlugins: true,
-          }));
-      sandbox.stub(
-          element.$.restAPI,
-          'getAccount',
-          () => Promise.resolve({_id: 1}));
-      sandbox.stub(element, 'reload');
-      element.params = {repo: 'Test Repo', adminView: 'gr-repo'};
-      assert.equal(element.reload.callCount, 1);
-      element.params = {repo: 'Test Repo 2',
-        adminView: 'gr-repo'};
-      assert.equal(element.reload.callCount, 2);
-    });
-
-    test('Nav is reloaded when group changes', () => {
-      sandbox.stub(element, '_computeGroupName');
-      sandbox.stub(
-          element.$.restAPI,
-          'getAccountCapabilities',
-          () => Promise.resolve({
-            createGroup: true,
-            createProject: true,
-            viewPlugins: true,
-          }));
-      sandbox.stub(
-          element.$.restAPI,
-          'getAccount',
-          () => Promise.resolve({_id: 1}));
-      sandbox.stub(element, 'reload');
-      element.params = {groupId: '1', adminView: 'gr-group'};
-      assert.equal(element.reload.callCount, 1);
-    });
-
-    test('Nav is reloaded when group name changes', done => {
-      const newName = 'newName';
-      sandbox.stub(element, '_computeGroupName');
-      sandbox.stub(element, 'reload', () => {
-        assert.equal(element._groupName, newName);
-        assert.isTrue(element.reload.called);
-        done();
-      });
-      element.params = {group: 1, view: Gerrit.Nav.View.GROUP};
-      element._groupName = 'oldName';
-      flushAsynchronousOperations();
-      element.shadowRoot
-          .querySelector('gr-group').fire('name-changed', {name: newName});
-    });
-
-    test('dropdown displays if there is a subsection', () => {
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.mainHeader'));
-      element._subsectionLinks = [
-        {
-          text: 'Home',
-          value: 'repo',
+        viewableToAll: true,
+        subsection: {
+          name: 'my-repo',
           view: 'repo',
           url: '',
-          parent: 'my-repo',
-          detailType: undefined,
+          children: [
+            {
+              name: 'Access',
+              view: 'repo',
+              detailType: 'access',
+              url: '',
+            },
+            {
+              name: 'Commands',
+              view: 'repo',
+              detailType: 'commands',
+              url: '',
+            },
+            {
+              name: 'Branches',
+              view: 'repo',
+              detailType: 'branches',
+              url: '',
+            },
+            {
+              name: 'Tags',
+              view: 'repo',
+              detailType: 'tags',
+              url: '',
+            },
+            {
+              name: 'Dashboards',
+              view: 'repo',
+              detailType: 'dashboards',
+              url: '',
+            },
+          ],
         },
-      ];
-      flushAsynchronousOperations();
-      assert.isOk(element.shadowRoot
-          .querySelector('.mainHeader'));
-      element._subsectionLinks = undefined;
-      flushAsynchronousOperations();
-      assert.equal(
-          getComputedStyle(element.shadowRoot
-              .querySelector('.mainHeader')).display,
-          'none');
-    });
-
-    test('Dropdown only triggers navigation on explicit select', done => {
-      element._repoName = 'my-repo';
-      element.params = {
-        repo: 'my-repo',
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.ACCESS,
-      };
-      sandbox.stub(
-          element.$.restAPI,
-          'getAccountCapabilities',
-          () => Promise.resolve({
-            createGroup: true,
-            createProject: true,
-            viewPlugins: true,
-          }));
-      sandbox.stub(
-          element.$.restAPI,
-          'getAccount',
-          () => Promise.resolve({_id: 1}));
-      flushAsynchronousOperations();
-      const expectedFilteredLinks = [
-        {
-          name: 'Repositories',
-          noBaseUrl: true,
-          url: '/admin/repos',
-          view: 'gr-repo-list',
-          viewableToAll: true,
-          subsection: {
-            name: 'my-repo',
-            view: 'repo',
-            url: '',
-            children: [
-              {
-                name: 'Access',
-                view: 'repo',
-                detailType: 'access',
-                url: '',
-              },
-              {
-                name: 'Commands',
-                view: 'repo',
-                detailType: 'commands',
-                url: '',
-              },
-              {
-                name: 'Branches',
-                view: 'repo',
-                detailType: 'branches',
-                url: '',
-              },
-              {
-                name: 'Tags',
-                view: 'repo',
-                detailType: 'tags',
-                url: '',
-              },
-              {
-                name: 'Dashboards',
-                view: 'repo',
-                detailType: 'dashboards',
-                url: '',
-              },
-            ],
-          },
-        },
-        {
-          name: 'Groups',
-          section: 'Groups',
-          noBaseUrl: true,
-          url: '/admin/groups',
-          view: 'gr-admin-group-list',
-        },
-        {
-          name: 'Plugins',
-          capability: 'viewPlugins',
-          section: 'Plugins',
-          noBaseUrl: true,
-          url: '/admin/plugins',
-          view: 'gr-plugin-list',
-        },
-      ];
-      const expectedSubsectionLinks = [
-        {
-          text: 'Home',
-          value: 'repo',
-          view: 'repo',
-          url: '',
-          parent: 'my-repo',
-          detailType: undefined,
-        },
-        {
-          text: 'Access',
-          value: 'repoaccess',
-          view: 'repo',
-          url: '',
-          detailType: 'access',
-          parent: 'my-repo',
-        },
-        {
-          text: 'Commands',
-          value: 'repocommands',
-          view: 'repo',
-          url: '',
-          detailType: 'commands',
-          parent: 'my-repo',
-        },
-        {
-          text: 'Branches',
-          value: 'repobranches',
-          view: 'repo',
-          url: '',
-          detailType: 'branches',
-          parent: 'my-repo',
-        },
-        {
-          text: 'Tags',
-          value: 'repotags',
-          view: 'repo',
-          url: '',
-          detailType: 'tags',
-          parent: 'my-repo',
-        },
-        {
-          text: 'Dashboards',
-          value: 'repodashboards',
-          view: 'repo',
-          url: '',
-          detailType: 'dashboards',
-          parent: 'my-repo',
-        },
-      ];
-      sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
-      sandbox.spy(element, '_selectedIsCurrentPage');
-      sandbox.spy(element, '_handleSubsectionChange');
-      element.reload().then(() => {
-        assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
-        assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
-        assert.equal(
-            element.shadowRoot.querySelector('#pageSelect').value,
-            'repoaccess'
-        );
-        assert.isTrue(element._selectedIsCurrentPage.calledOnce);
-        // Doesn't trigger navigation from the page select menu.
-        assert.isFalse(Gerrit.Nav.navigateToRelativeUrl.called);
-
-        // When explicitly changed, navigation is called
-        element.shadowRoot.querySelector('#pageSelect').value = 'repo';
-        assert.isTrue(element._selectedIsCurrentPage.calledTwice);
-        assert.isTrue(Gerrit.Nav.navigateToRelativeUrl.calledOnce);
-        done();
-      });
-    });
-
-    test('_selectedIsCurrentPage', () => {
-      element._repoName = 'my-repo';
-      element.params = {view: 'repo', repo: 'my-repo'};
-      const selected = {
+      },
+      {
+        name: 'Groups',
+        section: 'Groups',
+        noBaseUrl: true,
+        url: '/admin/groups',
+        view: 'gr-admin-group-list',
+      },
+      {
+        name: 'Plugins',
+        capability: 'viewPlugins',
+        section: 'Plugins',
+        noBaseUrl: true,
+        url: '/admin/plugins',
+        view: 'gr-plugin-list',
+      },
+    ];
+    const expectedSubsectionLinks = [
+      {
+        text: 'Home',
+        value: 'repo',
         view: 'repo',
-        detailType: undefined,
+        url: '',
         parent: 'my-repo',
-      };
-      assert.isTrue(element._selectedIsCurrentPage(selected));
-      selected.parent = 'my-second-repo';
-      assert.isFalse(element._selectedIsCurrentPage(selected));
-      selected.detailType = 'detailType';
-      assert.isFalse(element._selectedIsCurrentPage(selected));
+        detailType: undefined,
+      },
+      {
+        text: 'Access',
+        value: 'repoaccess',
+        view: 'repo',
+        url: '',
+        detailType: 'access',
+        parent: 'my-repo',
+      },
+      {
+        text: 'Commands',
+        value: 'repocommands',
+        view: 'repo',
+        url: '',
+        detailType: 'commands',
+        parent: 'my-repo',
+      },
+      {
+        text: 'Branches',
+        value: 'repobranches',
+        view: 'repo',
+        url: '',
+        detailType: 'branches',
+        parent: 'my-repo',
+      },
+      {
+        text: 'Tags',
+        value: 'repotags',
+        view: 'repo',
+        url: '',
+        detailType: 'tags',
+        parent: 'my-repo',
+      },
+      {
+        text: 'Dashboards',
+        value: 'repodashboards',
+        view: 'repo',
+        url: '',
+        detailType: 'dashboards',
+        parent: 'my-repo',
+      },
+    ];
+    sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
+    sandbox.spy(element, '_selectedIsCurrentPage');
+    sandbox.spy(element, '_handleSubsectionChange');
+    element.reload().then(() => {
+      assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
+      assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
+      assert.equal(
+          element.shadowRoot.querySelector('#pageSelect').value,
+          'repoaccess'
+      );
+      assert.isTrue(element._selectedIsCurrentPage.calledOnce);
+      // Doesn't trigger navigation from the page select menu.
+      assert.isFalse(Gerrit.Nav.navigateToRelativeUrl.called);
+
+      // When explicitly changed, navigation is called
+      element.shadowRoot.querySelector('#pageSelect').value = 'repo';
+      assert.isTrue(element._selectedIsCurrentPage.calledTwice);
+      assert.isTrue(Gerrit.Nav.navigateToRelativeUrl.calledOnce);
+      done();
+    });
+  });
+
+  test('_selectedIsCurrentPage', () => {
+    element._repoName = 'my-repo';
+    element.params = {view: 'repo', repo: 'my-repo'};
+    const selected = {
+      view: 'repo',
+      detailType: undefined,
+      parent: 'my-repo',
+    };
+    assert.isTrue(element._selectedIsCurrentPage(selected));
+    selected.parent = 'my-second-repo';
+    assert.isFalse(element._selectedIsCurrentPage(selected));
+    selected.detailType = 'detailType';
+    assert.isFalse(element._selectedIsCurrentPage(selected));
+  });
+
+  suite('_computeSelectedClass', () => {
+    setup(() => {
+      sandbox.stub(
+          element.$.restAPI,
+          'getAccountCapabilities',
+          () => Promise.resolve({
+            createGroup: true,
+            createProject: true,
+            viewPlugins: true,
+          }));
+      sandbox.stub(
+          element.$.restAPI,
+          'getAccount',
+          () => Promise.resolve({_id: 1}));
+
+      return element.reload();
     });
 
-    suite('_computeSelectedClass', () => {
+    suite('repos', () => {
       setup(() => {
-        sandbox.stub(
-            element.$.restAPI,
-            'getAccountCapabilities',
-            () => Promise.resolve({
-              createGroup: true,
-              createProject: true,
-              viewPlugins: true,
-            }));
-        sandbox.stub(
-            element.$.restAPI,
-            'getAccount',
-            () => Promise.resolve({_id: 1}));
+        stub('gr-repo-access', {
+          _repoChanged: () => {},
+        });
+      });
 
+      test('repo list', () => {
+        element.params = {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-repo-list',
+          openCreateModal: false,
+        };
+        flushAsynchronousOperations();
+        const selected = element.shadowRoot
+            .querySelector('gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent.trim(), 'Repositories');
+      });
+
+      test('repo', () => {
+        element.params = {
+          view: Gerrit.Nav.View.REPO,
+          repoName: 'foo',
+        };
+        element._repoName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'foo');
+        });
+      });
+
+      test('repo access', () => {
+        element.params = {
+          view: Gerrit.Nav.View.REPO,
+          detail: Gerrit.Nav.RepoDetailView.ACCESS,
+          repoName: 'foo',
+        };
+        element._repoName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'Access');
+        });
+      });
+
+      test('repo dashboards', () => {
+        element.params = {
+          view: Gerrit.Nav.View.REPO,
+          detail: Gerrit.Nav.RepoDetailView.DASHBOARDS,
+          repoName: 'foo',
+        };
+        element._repoName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'Dashboards');
+        });
+      });
+    });
+
+    suite('groups', () => {
+      setup(() => {
+        stub('gr-group', {
+          _loadGroup: () => Promise.resolve({}),
+        });
+        stub('gr-group-members', {
+          _loadGroupDetails: () => {},
+        });
+
+        sandbox.stub(element.$.restAPI, 'getGroupConfig')
+            .returns(Promise.resolve({
+              name: 'foo',
+              id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
+            }));
+        sandbox.stub(element.$.restAPI, 'getIsGroupOwner')
+            .returns(Promise.resolve(true));
         return element.reload();
       });
 
-      suite('repos', () => {
-        setup(() => {
-          stub('gr-repo-access', {
-            _repoChanged: () => {},
-          });
-        });
+      test('group list', () => {
+        element.params = {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          openCreateModal: false,
+        };
+        flushAsynchronousOperations();
+        const selected = element.shadowRoot
+            .querySelector('gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent.trim(), 'Groups');
+      });
 
-        test('repo list', () => {
-          element.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            openCreateModal: false,
-          };
+      test('internal group', () => {
+        element.params = {
+          view: Gerrit.Nav.View.GROUP,
+          groupId: 1234,
+        };
+        element._groupName = 'foo';
+        return element.reload().then(() => {
           flushAsynchronousOperations();
+          const subsectionItems = dom(element.root)
+              .querySelectorAll('.subsectionItem');
+          assert.equal(subsectionItems.length, 2);
+          assert.isTrue(element._groupIsInternal);
           const selected = element.shadowRoot
               .querySelector('gr-page-nav .selected');
           assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Repositories');
-        });
-
-        test('repo', () => {
-          element.params = {
-            view: Gerrit.Nav.View.REPO,
-            repoName: 'foo',
-          };
-          element._repoName = 'foo';
-          return element.reload().then(() => {
-            flushAsynchronousOperations();
-            const selected = element.shadowRoot
-                .querySelector('gr-page-nav .selected');
-            assert.isOk(selected);
-            assert.equal(selected.textContent.trim(), 'foo');
-          });
-        });
-
-        test('repo access', () => {
-          element.params = {
-            view: Gerrit.Nav.View.REPO,
-            detail: Gerrit.Nav.RepoDetailView.ACCESS,
-            repoName: 'foo',
-          };
-          element._repoName = 'foo';
-          return element.reload().then(() => {
-            flushAsynchronousOperations();
-            const selected = element.shadowRoot
-                .querySelector('gr-page-nav .selected');
-            assert.isOk(selected);
-            assert.equal(selected.textContent.trim(), 'Access');
-          });
-        });
-
-        test('repo dashboards', () => {
-          element.params = {
-            view: Gerrit.Nav.View.REPO,
-            detail: Gerrit.Nav.RepoDetailView.DASHBOARDS,
-            repoName: 'foo',
-          };
-          element._repoName = 'foo';
-          return element.reload().then(() => {
-            flushAsynchronousOperations();
-            const selected = element.shadowRoot
-                .querySelector('gr-page-nav .selected');
-            assert.isOk(selected);
-            assert.equal(selected.textContent.trim(), 'Dashboards');
-          });
+          assert.equal(selected.textContent.trim(), 'foo');
         });
       });
 
-      suite('groups', () => {
-        setup(() => {
-          stub('gr-group', {
-            _loadGroup: () => Promise.resolve({}),
-          });
-          stub('gr-group-members', {
-            _loadGroupDetails: () => {},
-          });
-
-          sandbox.stub(element.$.restAPI, 'getGroupConfig')
-              .returns(Promise.resolve({
-                name: 'foo',
-                id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
-              }));
-          sandbox.stub(element.$.restAPI, 'getIsGroupOwner')
-              .returns(Promise.resolve(true));
-          return element.reload();
+      test('external group', () => {
+        element.$.restAPI.getGroupConfig.restore();
+        sandbox.stub(element.$.restAPI, 'getGroupConfig')
+            .returns(Promise.resolve({
+              name: 'foo',
+              id: 'external-id',
+            }));
+        element.params = {
+          view: Gerrit.Nav.View.GROUP,
+          groupId: 1234,
+        };
+        element._groupName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const subsectionItems = dom(element.root)
+              .querySelectorAll('.subsectionItem');
+          assert.equal(subsectionItems.length, 0);
+          assert.isFalse(element._groupIsInternal);
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'foo');
         });
+      });
 
-        test('group list', () => {
-          element.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
-            openCreateModal: false,
-          };
+      test('group members', () => {
+        element.params = {
+          view: Gerrit.Nav.View.GROUP,
+          detail: Gerrit.Nav.GroupDetailView.MEMBERS,
+          groupId: 1234,
+        };
+        element._groupName = 'foo';
+        return element.reload().then(() => {
           flushAsynchronousOperations();
           const selected = element.shadowRoot
               .querySelector('gr-page-nav .selected');
           assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Groups');
-        });
-
-        test('internal group', () => {
-          element.params = {
-            view: Gerrit.Nav.View.GROUP,
-            groupId: 1234,
-          };
-          element._groupName = 'foo';
-          return element.reload().then(() => {
-            flushAsynchronousOperations();
-            const subsectionItems = Polymer.dom(element.root)
-                .querySelectorAll('.subsectionItem');
-            assert.equal(subsectionItems.length, 2);
-            assert.isTrue(element._groupIsInternal);
-            const selected = element.shadowRoot
-                .querySelector('gr-page-nav .selected');
-            assert.isOk(selected);
-            assert.equal(selected.textContent.trim(), 'foo');
-          });
-        });
-
-        test('external group', () => {
-          element.$.restAPI.getGroupConfig.restore();
-          sandbox.stub(element.$.restAPI, 'getGroupConfig')
-              .returns(Promise.resolve({
-                name: 'foo',
-                id: 'external-id',
-              }));
-          element.params = {
-            view: Gerrit.Nav.View.GROUP,
-            groupId: 1234,
-          };
-          element._groupName = 'foo';
-          return element.reload().then(() => {
-            flushAsynchronousOperations();
-            const subsectionItems = Polymer.dom(element.root)
-                .querySelectorAll('.subsectionItem');
-            assert.equal(subsectionItems.length, 0);
-            assert.isFalse(element._groupIsInternal);
-            const selected = element.shadowRoot
-                .querySelector('gr-page-nav .selected');
-            assert.isOk(selected);
-            assert.equal(selected.textContent.trim(), 'foo');
-          });
-        });
-
-        test('group members', () => {
-          element.params = {
-            view: Gerrit.Nav.View.GROUP,
-            detail: Gerrit.Nav.GroupDetailView.MEMBERS,
-            groupId: 1234,
-          };
-          element._groupName = 'foo';
-          return element.reload().then(() => {
-            flushAsynchronousOperations();
-            const selected = element.shadowRoot
-                .querySelector('gr-page-nav .selected');
-            assert.isOk(selected);
-            assert.equal(selected.textContent.trim(), 'Members');
-          });
+          assert.equal(selected.textContent.trim(), 'Members');
         });
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
index 3fde410..5ebf00e 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
@@ -14,67 +14,76 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const DETAIL_TYPES = {
-    BRANCHES: 'branches',
-    ID: 'id',
-    TAGS: 'tags',
-  };
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-delete-item-dialog_html.js';
+
+const DETAIL_TYPES = {
+  BRANCHES: 'branches',
+  ID: 'id',
+  TAGS: 'tags',
+};
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrConfirmDeleteItemDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-confirm-delete-item-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
    */
-  class GrConfirmDeleteItemDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-confirm-delete-item-dialog'; }
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
-
-    static get properties() {
-      return {
-        item: String,
-        itemType: String,
-      };
-    }
-
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    }
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    }
-
-    _computeItemName(detailType) {
-      if (detailType === DETAIL_TYPES.BRANCHES) {
-        return 'Branch';
-      } else if (detailType === DETAIL_TYPES.TAGS) {
-        return 'Tag';
-      } else if (detailType === DETAIL_TYPES.ID) {
-        return 'ID';
-      }
-    }
+  static get properties() {
+    return {
+      item: String,
+      itemType: String,
+    };
   }
 
-  customElements.define(GrConfirmDeleteItemDialog.is,
-      GrConfirmDeleteItemDialog);
-})();
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('confirm', null, {bubbles: false});
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('cancel', null, {bubbles: false});
+  }
+
+  _computeItemName(detailType) {
+    if (detailType === DETAIL_TYPES.BRANCHES) {
+      return 'Branch';
+    } else if (detailType === DETAIL_TYPES.TAGS) {
+      return 'Tag';
+    } else if (detailType === DETAIL_TYPES.ID) {
+      return 'ID';
+    }
+  }
+}
+
+customElements.define(GrConfirmDeleteItemDialog.is,
+    GrConfirmDeleteItemDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.js
index 9d8ee18..12dc29c 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.js
@@ -1,38 +1,29 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-confirm-delete-item-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
         width: 30em;
       }
     </style>
-    <gr-dialog
-        confirm-label="Delete [[_computeItemName(itemType)]]"
-        confirm-on-enter
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
+    <gr-dialog confirm-label="Delete [[_computeItemName(itemType)]]" confirm-on-enter="" on-confirm="_handleConfirmTap" on-cancel="_handleCancelTap">
       <div class="header" slot="header">[[_computeItemName(itemType)]] Deletion</div>
       <div class="main" slot="main">
         <label for="branchInput">
@@ -43,6 +34,4 @@
         </div>
       </div>
     </gr-dialog>
-  </template>
-  <script src="gr-confirm-delete-item-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
index b937e76..e948a31 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-delete-item-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-confirm-delete-item-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-confirm-delete-item-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-delete-item-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,53 +40,55 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-delete-item-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-delete-item-dialog.js';
+suite('gr-confirm-delete-item-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_handleConfirmTap', () => {
-      const confirmHandler = sandbox.stub();
-      element.addEventListener('confirm', confirmHandler);
-      sandbox.spy(element, '_handleConfirmTap');
-      element.shadowRoot
-          .querySelector('gr-dialog').fire('confirm');
-      assert.isTrue(confirmHandler.called);
-      assert.isTrue(confirmHandler.calledOnce);
-      assert.isTrue(element._handleConfirmTap.called);
-      assert.isTrue(element._handleConfirmTap.calledOnce);
-    });
-
-    test('_handleCancelTap', () => {
-      const cancelHandler = sandbox.stub();
-      element.addEventListener('cancel', cancelHandler);
-      sandbox.spy(element, '_handleCancelTap');
-      element.shadowRoot
-          .querySelector('gr-dialog').fire('cancel');
-      assert.isTrue(cancelHandler.called);
-      assert.isTrue(cancelHandler.calledOnce);
-      assert.isTrue(element._handleCancelTap.called);
-      assert.isTrue(element._handleCancelTap.calledOnce);
-    });
-
-    test('_computeItemName function for branches', () => {
-      assert.deepEqual(element._computeItemName('branches'), 'Branch');
-      assert.notEqual(element._computeItemName('branches'), 'Tag');
-    });
-
-    test('_computeItemName function for tags', () => {
-      assert.deepEqual(element._computeItemName('tags'), 'Tag');
-      assert.notEqual(element._computeItemName('tags'), 'Branch');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sandbox.stub();
+    element.addEventListener('confirm', confirmHandler);
+    sandbox.spy(element, '_handleConfirmTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').fire('confirm');
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(element._handleConfirmTap.called);
+    assert.isTrue(element._handleConfirmTap.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sandbox.stub();
+    element.addEventListener('cancel', cancelHandler);
+    sandbox.spy(element, '_handleCancelTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').fire('cancel');
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(element._handleCancelTap.called);
+    assert.isTrue(element._handleCancelTap.calledOnce);
+  });
+
+  test('_computeItemName function for branches', () => {
+    assert.deepEqual(element._computeItemName('branches'), 'Branch');
+    assert.notEqual(element._computeItemName('branches'), 'Tag');
+  });
+
+  test('_computeItemName function for tags', () => {
+    assert.deepEqual(element._computeItemName('tags'), 'Tag');
+    assert.notEqual(element._computeItemName('tags'), 'Branch');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
index 3b85304..94de228 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
@@ -14,148 +14,166 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 
-  const SUGGESTIONS_LIMIT = 15;
-  const REF_PREFIX = 'refs/heads/';
+import '@polymer/iron-input/iron-input.js';
+import '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-change-dialog_html.js';
 
+const SUGGESTIONS_LIMIT = 15;
+const REF_PREFIX = 'refs/heads/';
+
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrCreateChangeDialog extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
   /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
+   * Unused in this element, but called by other elements in tests
+   * e.g gr-repo-commands_test.
    */
-  class GrCreateChangeDialog extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    /**
-     * Unused in this element, but called by other elements in tests
-     * e.g gr-repo-commands_test.
-     */
-    Gerrit.FireBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-create-change-dialog'; }
+  Gerrit.FireBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    static get properties() {
-      return {
-        repoName: String,
-        branch: String,
-        /** @type {?} */
-        _repoConfig: Object,
-        subject: String,
-        topic: String,
-        _query: {
-          type: Function,
-          value() {
-            return this._getRepoBranchesSuggestions.bind(this);
-          },
+  static get is() { return 'gr-create-change-dialog'; }
+
+  static get properties() {
+    return {
+      repoName: String,
+      branch: String,
+      /** @type {?} */
+      _repoConfig: Object,
+      subject: String,
+      topic: String,
+      _query: {
+        type: Function,
+        value() {
+          return this._getRepoBranchesSuggestions.bind(this);
         },
-        baseChange: String,
-        baseCommit: String,
-        privateByDefault: String,
-        canCreate: {
-          type: Boolean,
-          notify: true,
-          value: false,
-        },
-        _privateChangesEnabled: Boolean,
-      };
+      },
+      baseChange: String,
+      baseCommit: String,
+      privateByDefault: String,
+      canCreate: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+      _privateChangesEnabled: Boolean,
+    };
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    if (!this.repoName) { return Promise.resolve(); }
+
+    const promises = [];
+
+    promises.push(this.$.restAPI.getProjectConfig(this.repoName)
+        .then(config => {
+          this.privateByDefault = config.private_by_default;
+        }));
+
+    promises.push(this.$.restAPI.getConfig().then(config => {
+      if (!config) { return; }
+
+      this._privateConfig = config && config.change &&
+          config.change.disable_private_changes;
+    }));
+
+    return Promise.all(promises);
+  }
+
+  static get observers() {
+    return [
+      '_allowCreate(branch, subject)',
+    ];
+  }
+
+  _computeBranchClass(baseChange) {
+    return baseChange ? 'hide' : '';
+  }
+
+  _allowCreate(branch, subject) {
+    this.canCreate = !!branch && !!subject;
+  }
+
+  handleCreateChange() {
+    const isPrivate = this.$.privateChangeCheckBox.checked;
+    const isWip = true;
+    return this.$.restAPI.createChange(this.repoName, this.branch,
+        this.subject, this.topic, isPrivate, isWip, this.baseChange,
+        this.baseCommit || null)
+        .then(changeCreated => {
+          if (!changeCreated) { return; }
+          Gerrit.Nav.navigateToChange(changeCreated);
+        });
+  }
+
+  _getRepoBranchesSuggestions(input) {
+    if (input.startsWith(REF_PREFIX)) {
+      input = input.substring(REF_PREFIX.length);
     }
-
-    /** @override */
-    attached() {
-      super.attached();
-      if (!this.repoName) { return Promise.resolve(); }
-
-      const promises = [];
-
-      promises.push(this.$.restAPI.getProjectConfig(this.repoName)
-          .then(config => {
-            this.privateByDefault = config.private_by_default;
-          }));
-
-      promises.push(this.$.restAPI.getConfig().then(config => {
-        if (!config) { return; }
-
-        this._privateConfig = config && config.change &&
-            config.change.disable_private_changes;
-      }));
-
-      return Promise.all(promises);
-    }
-
-    static get observers() {
-      return [
-        '_allowCreate(branch, subject)',
-      ];
-    }
-
-    _computeBranchClass(baseChange) {
-      return baseChange ? 'hide' : '';
-    }
-
-    _allowCreate(branch, subject) {
-      this.canCreate = !!branch && !!subject;
-    }
-
-    handleCreateChange() {
-      const isPrivate = this.$.privateChangeCheckBox.checked;
-      const isWip = true;
-      return this.$.restAPI.createChange(this.repoName, this.branch,
-          this.subject, this.topic, isPrivate, isWip, this.baseChange,
-          this.baseCommit || null)
-          .then(changeCreated => {
-            if (!changeCreated) { return; }
-            Gerrit.Nav.navigateToChange(changeCreated);
-          });
-    }
-
-    _getRepoBranchesSuggestions(input) {
-      if (input.startsWith(REF_PREFIX)) {
-        input = input.substring(REF_PREFIX.length);
-      }
-      return this.$.restAPI.getRepoBranches(
-          input, this.repoName, SUGGESTIONS_LIMIT).then(response => {
-        const branches = [];
-        let branch;
-        for (const key in response) {
-          if (!response.hasOwnProperty(key)) { continue; }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
-          } else {
-            branch = response[key].ref;
-          }
-          branches.push({
-            name: branch,
-          });
-        }
-        return branches;
-      });
-    }
-
-    _formatBooleanString(config) {
-      if (config && config.configured_value === 'TRUE') {
-        return true;
-      } else if (config && config.configured_value === 'FALSE') {
-        return false;
-      } else if (config && config.configured_value === 'INHERIT') {
-        if (config && config.inherited_value) {
-          return true;
+    return this.$.restAPI.getRepoBranches(
+        input, this.repoName, SUGGESTIONS_LIMIT).then(response => {
+      const branches = [];
+      let branch;
+      for (const key in response) {
+        if (!response.hasOwnProperty(key)) { continue; }
+        if (response[key].ref.startsWith('refs/heads/')) {
+          branch = response[key].ref.substring('refs/heads/'.length);
         } else {
-          return false;
+          branch = response[key].ref;
         }
+        branches.push({
+          name: branch,
+        });
+      }
+      return branches;
+    });
+  }
+
+  _formatBooleanString(config) {
+    if (config && config.configured_value === 'TRUE') {
+      return true;
+    } else if (config && config.configured_value === 'FALSE') {
+      return false;
+    } else if (config && config.configured_value === 'INHERIT') {
+      if (config && config.inherited_value) {
+        return true;
       } else {
         return false;
       }
-    }
-
-    _computePrivateSectionClass(config) {
-      return config ? 'hide' : '';
+    } else {
+      return false;
     }
   }
 
-  customElements.define(GrCreateChangeDialog.is, GrCreateChangeDialog);
-})();
+  _computePrivateSectionClass(config) {
+    return config ? 'hide' : '';
+  }
+}
+
+customElements.define(GrCreateChangeDialog.is, GrCreateChangeDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.js
index 1d6e706..8f11af3 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.js
@@ -1,36 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-create-change-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -53,76 +39,42 @@
       }
     </style>
     <div class="gr-form-styles">
-      <section class$="[[_computeBranchClass(baseChange)]]">
+      <section class\$="[[_computeBranchClass(baseChange)]]">
         <span class="title">Select branch for new change</span>
         <span class="value">
-          <gr-autocomplete
-              id="branchInput"
-              text="{{branch}}"
-              query="[[_query]]"
-              placeholder="Destination branch">
+          <gr-autocomplete id="branchInput" text="{{branch}}" query="[[_query]]" placeholder="Destination branch">
           </gr-autocomplete>
         </span>
       </section>
-      <section class$="[[_computeBranchClass(baseChange)]]">
+      <section class\$="[[_computeBranchClass(baseChange)]]">
         <span class="title">Provide base commit sha1 for change</span>
         <span class="value">
-          <iron-input
-              maxlength="40"
-              placeholder="(optional)"
-              bind-value="{{baseCommit}}">
-            <input
-                is="iron-input"
-                id="baseCommitInput"
-                maxlength="40"
-                placeholder="(optional)"
-                bind-value="{{baseCommit}}">
+          <iron-input maxlength="40" placeholder="(optional)" bind-value="{{baseCommit}}">
+            <input is="iron-input" id="baseCommitInput" maxlength="40" placeholder="(optional)" bind-value="{{baseCommit}}">
           </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Enter topic for new change</span>
         <span class="value">
-          <iron-input
-              maxlength="1024"
-              placeholder="(optional)"
-              bind-value="{{topic}}">
-            <input
-                is="iron-input"
-                id="tagNameInput"
-                maxlength="1024"
-                placeholder="(optional)"
-                bind-value="{{topic}}">
+          <iron-input maxlength="1024" placeholder="(optional)" bind-value="{{topic}}">
+            <input is="iron-input" id="tagNameInput" maxlength="1024" placeholder="(optional)" bind-value="{{topic}}">
           </iron-input>
         </span>
       </section>
       <section id="description">
         <span class="title">Description</span>
         <span class="value">
-          <iron-autogrow-textarea
-              id="messageInput"
-              class="message"
-              autocomplete="on"
-              rows="4"
-              max-rows="15"
-              bind-value="{{subject}}"
-              placeholder="Insert the description of the change.">
+          <iron-autogrow-textarea id="messageInput" class="message" autocomplete="on" rows="4" max-rows="15" bind-value="{{subject}}" placeholder="Insert the description of the change.">
           </iron-autogrow-textarea>
         </span>
       </section>
-      <section class$="[[_computePrivateSectionClass(_privateChangesEnabled)]]">
-        <label
-            class="title"
-            for="privateChangeCheckBox">Private change</label>
+      <section class\$="[[_computePrivateSectionClass(_privateChangesEnabled)]]">
+        <label class="title" for="privateChangeCheckBox">Private change</label>
         <span class="value">
-          <input
-              type="checkbox"
-              id="privateChangeCheckBox"
-              checked$="[[_formatBooleanString(privateByDefault)]]">
+          <input type="checkbox" id="privateChangeCheckBox" checked\$="[[_formatBooleanString(privateByDefault)]]">
         </span>
       </section>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-create-change-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
index aad2428f..20226dd 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-change-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-create-change-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-create-change-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-create-change-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,136 +40,138 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-create-change-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-create-change-dialog.js';
+suite('gr-create-change-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getRepoBranches(input) {
-          if (input.startsWith('test')) {
-            return Promise.resolve([
-              {
-                ref: 'refs/heads/test-branch',
-                revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-                can_delete: true,
-              },
-            ]);
-          } else {
-            return Promise.resolve({});
-          }
-        },
-      });
-      element = fixture('basic');
-      element.repoName = 'test-repo',
-      element._repoConfig = {
-        private_by_default: {
-          configured_value: 'FALSE',
-          inherited_value: false,
-        },
-      };
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getRepoBranches(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              ref: 'refs/heads/test-branch',
+              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+              can_delete: true,
+            },
+          ]);
+        } else {
+          return Promise.resolve({});
+        }
+      },
     });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('new change created with default', done => {
-      const configInputObj = {
-        branch: 'test-branch',
-        subject: 'first change created with polygerrit ui',
-        topic: 'test-topic',
-        is_private: false,
-        work_in_progress: true,
-      };
-
-      const saveStub = sandbox.stub(element.$.restAPI,
-          'createChange', () => Promise.resolve({}));
-
-      element.branch = 'test-branch';
-      element.topic = 'test-topic';
-      element.subject = 'first change created with polygerrit ui';
-      assert.isFalse(element.$.privateChangeCheckBox.checked);
-
-      element.$.branchInput.bindValue = configInputObj.branch;
-      element.$.tagNameInput.bindValue = configInputObj.topic;
-      element.$.messageInput.bindValue = configInputObj.subject;
-
-      element.handleCreateChange().then(() => {
-        // Private change
-        assert.isFalse(saveStub.lastCall.args[4]);
-        // WIP Change
-        assert.isTrue(saveStub.lastCall.args[5]);
-        assert.isTrue(saveStub.called);
-        done();
-      });
-    });
-
-    test('new change created with private', done => {
-      element.privateByDefault = {
-        configured_value: 'TRUE',
+    element = fixture('basic');
+    element.repoName = 'test-repo',
+    element._repoConfig = {
+      private_by_default: {
+        configured_value: 'FALSE',
         inherited_value: false,
-      };
-      sandbox.stub(element, '_formatBooleanString', () => Promise.resolve(true));
-      flushAsynchronousOperations();
+      },
+    };
+  });
 
-      const configInputObj = {
-        branch: 'test-branch',
-        subject: 'first change created with polygerrit ui',
-        topic: 'test-topic',
-        is_private: true,
-        work_in_progress: true,
-      };
+  teardown(() => {
+    sandbox.restore();
+  });
 
-      const saveStub = sandbox.stub(element.$.restAPI,
-          'createChange', () => Promise.resolve({}));
+  test('new change created with default', done => {
+    const configInputObj = {
+      branch: 'test-branch',
+      subject: 'first change created with polygerrit ui',
+      topic: 'test-topic',
+      is_private: false,
+      work_in_progress: true,
+    };
 
-      element.branch = 'test-branch';
-      element.topic = 'test-topic';
-      element.subject = 'first change created with polygerrit ui';
-      assert.isTrue(element.$.privateChangeCheckBox.checked);
+    const saveStub = sandbox.stub(element.$.restAPI,
+        'createChange', () => Promise.resolve({}));
 
-      element.$.branchInput.bindValue = configInputObj.branch;
-      element.$.tagNameInput.bindValue = configInputObj.topic;
-      element.$.messageInput.bindValue = configInputObj.subject;
+    element.branch = 'test-branch';
+    element.topic = 'test-topic';
+    element.subject = 'first change created with polygerrit ui';
+    assert.isFalse(element.$.privateChangeCheckBox.checked);
 
-      element.handleCreateChange().then(() => {
-        // Private change
-        assert.isTrue(saveStub.lastCall.args[4]);
-        // WIP Change
-        assert.isTrue(saveStub.lastCall.args[5]);
-        assert.isTrue(saveStub.called);
-        done();
-      });
-    });
+    element.$.branchInput.bindValue = configInputObj.branch;
+    element.$.tagNameInput.bindValue = configInputObj.topic;
+    element.$.messageInput.bindValue = configInputObj.subject;
 
-    test('_getRepoBranchesSuggestions empty', done => {
-      element._getRepoBranchesSuggestions('nonexistent').then(branches => {
-        assert.equal(branches.length, 0);
-        done();
-      });
-    });
-
-    test('_getRepoBranchesSuggestions non-empty', done => {
-      element._getRepoBranchesSuggestions('test-branch').then(branches => {
-        assert.equal(branches.length, 1);
-        assert.equal(branches[0].name, 'test-branch');
-        done();
-      });
-    });
-
-    test('_computeBranchClass', () => {
-      assert.equal(element._computeBranchClass(true), 'hide');
-      assert.equal(element._computeBranchClass(false), '');
-    });
-
-    test('_computePrivateSectionClass', () => {
-      assert.equal(element._computePrivateSectionClass(true), 'hide');
-      assert.equal(element._computePrivateSectionClass(false), '');
+    element.handleCreateChange().then(() => {
+      // Private change
+      assert.isFalse(saveStub.lastCall.args[4]);
+      // WIP Change
+      assert.isTrue(saveStub.lastCall.args[5]);
+      assert.isTrue(saveStub.called);
+      done();
     });
   });
+
+  test('new change created with private', done => {
+    element.privateByDefault = {
+      configured_value: 'TRUE',
+      inherited_value: false,
+    };
+    sandbox.stub(element, '_formatBooleanString', () => Promise.resolve(true));
+    flushAsynchronousOperations();
+
+    const configInputObj = {
+      branch: 'test-branch',
+      subject: 'first change created with polygerrit ui',
+      topic: 'test-topic',
+      is_private: true,
+      work_in_progress: true,
+    };
+
+    const saveStub = sandbox.stub(element.$.restAPI,
+        'createChange', () => Promise.resolve({}));
+
+    element.branch = 'test-branch';
+    element.topic = 'test-topic';
+    element.subject = 'first change created with polygerrit ui';
+    assert.isTrue(element.$.privateChangeCheckBox.checked);
+
+    element.$.branchInput.bindValue = configInputObj.branch;
+    element.$.tagNameInput.bindValue = configInputObj.topic;
+    element.$.messageInput.bindValue = configInputObj.subject;
+
+    element.handleCreateChange().then(() => {
+      // Private change
+      assert.isTrue(saveStub.lastCall.args[4]);
+      // WIP Change
+      assert.isTrue(saveStub.lastCall.args[5]);
+      assert.isTrue(saveStub.called);
+      done();
+    });
+  });
+
+  test('_getRepoBranchesSuggestions empty', done => {
+    element._getRepoBranchesSuggestions('nonexistent').then(branches => {
+      assert.equal(branches.length, 0);
+      done();
+    });
+  });
+
+  test('_getRepoBranchesSuggestions non-empty', done => {
+    element._getRepoBranchesSuggestions('test-branch').then(branches => {
+      assert.equal(branches.length, 1);
+      assert.equal(branches[0].name, 'test-branch');
+      done();
+    });
+  });
+
+  test('_computeBranchClass', () => {
+    assert.equal(element._computeBranchClass(true), 'hide');
+    assert.equal(element._computeBranchClass(false), '');
+  });
+
+  test('_computePrivateSectionClass', () => {
+    assert.equal(element._computePrivateSectionClass(true), 'hide');
+    assert.equal(element._computePrivateSectionClass(false), '');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
index 8a4edab..0860fdb 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
@@ -14,65 +14,77 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
-   */
-  class GrCreateGroupDialog extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-create-group-dialog'; }
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-group-dialog_html.js';
 
-    static get properties() {
-      return {
-        params: Object,
-        hasNewGroupName: {
-          type: Boolean,
-          notify: true,
-          value: false,
-        },
-        _name: Object,
-        _groupCreated: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrCreateGroupDialog extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    static get observers() {
-      return [
-        '_updateGroupName(_name)',
-      ];
-    }
+  static get is() { return 'gr-create-group-dialog'; }
 
-    _computeGroupUrl(groupId) {
-      return this.getBaseUrl() + '/admin/groups/' +
-          this.encodeURL(groupId, true);
-    }
-
-    _updateGroupName(name) {
-      this.hasNewGroupName = !!name;
-    }
-
-    handleCreateGroup() {
-      return this.$.restAPI.createGroup({name: this._name})
-          .then(groupRegistered => {
-            if (groupRegistered.status !== 201) { return; }
-            this._groupCreated = true;
-            return this.$.restAPI.getGroupConfig(this._name)
-                .then(group => {
-                  page.show(this._computeGroupUrl(group.group_id));
-                });
-          });
-    }
+  static get properties() {
+    return {
+      params: Object,
+      hasNewGroupName: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+      _name: Object,
+      _groupCreated: {
+        type: Boolean,
+        value: false,
+      },
+    };
   }
 
-  customElements.define(GrCreateGroupDialog.is, GrCreateGroupDialog);
-})();
+  static get observers() {
+    return [
+      '_updateGroupName(_name)',
+    ];
+  }
+
+  _computeGroupUrl(groupId) {
+    return this.getBaseUrl() + '/admin/groups/' +
+        this.encodeURL(groupId, true);
+  }
+
+  _updateGroupName(name) {
+    this.hasNewGroupName = !!name;
+  }
+
+  handleCreateGroup() {
+    return this.$.restAPI.createGroup({name: this._name})
+        .then(groupRegistered => {
+          if (groupRegistered.status !== 201) { return; }
+          this._groupCreated = true;
+          return this.$.restAPI.getGroupConfig(this._name)
+              .then(group => {
+                page.show(this._computeGroupUrl(group.group_id));
+              });
+        });
+  }
+}
+
+customElements.define(GrCreateGroupDialog.is, GrCreateGroupDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.js
index d0a1fca..2cdde81 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.js
@@ -1,31 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-create-group-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -41,16 +32,11 @@
       <div id="form">
         <section>
           <span class="title">Group name</span>
-          <iron-input
-              bind-value="{{_name}}">
-            <input
-                is="iron-input"
-                bind-value="{{_name}}">
+          <iron-input bind-value="{{_name}}">
+            <input is="iron-input" bind-value="{{_name}}">
           </iron-input>
         </section>
       </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-create-group-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
index d630556..e9585d3 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-group-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-create-group-dialog.html">
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-create-group-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-create-group-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,66 +41,68 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-create-group-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    const GROUP_NAME = 'test-group';
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-create-group-dialog.js';
+suite('gr-create-group-dialog tests', () => {
+  let element;
+  let sandbox;
+  const GROUP_NAME = 'test-group';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-      });
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
     });
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('name is updated correctly', done => {
-      assert.isFalse(element.hasNewGroupName);
+  test('name is updated correctly', done => {
+    assert.isFalse(element.hasNewGroupName);
 
-      const inputEl = element.root.querySelector('iron-input');
-      inputEl.bindValue = GROUP_NAME;
+    const inputEl = element.root.querySelector('iron-input');
+    inputEl.bindValue = GROUP_NAME;
 
-      setTimeout(() => {
-        assert.isTrue(element.hasNewGroupName);
-        assert.deepEqual(element._name, GROUP_NAME);
-        done();
-      });
-    });
-
-    test('test for redirecting to group on successful creation', done => {
-      sandbox.stub(element.$.restAPI, 'createGroup')
-          .returns(Promise.resolve({status: 201}));
-
-      sandbox.stub(element.$.restAPI, 'getGroupConfig')
-          .returns(Promise.resolve({group_id: 551}));
-
-      const showStub = sandbox.stub(page, 'show');
-      element.handleCreateGroup()
-          .then(() => {
-            assert.isTrue(showStub.calledWith('/admin/groups/551'));
-            done();
-          });
-    });
-
-    test('test for unsuccessful group creation', done => {
-      sandbox.stub(element.$.restAPI, 'createGroup')
-          .returns(Promise.resolve({status: 409}));
-
-      sandbox.stub(element.$.restAPI, 'getGroupConfig')
-          .returns(Promise.resolve({group_id: 551}));
-
-      const showStub = sandbox.stub(page, 'show');
-      element.handleCreateGroup()
-          .then(() => {
-            assert.isFalse(showStub.called);
-            done();
-          });
+    setTimeout(() => {
+      assert.isTrue(element.hasNewGroupName);
+      assert.deepEqual(element._name, GROUP_NAME);
+      done();
     });
   });
+
+  test('test for redirecting to group on successful creation', done => {
+    sandbox.stub(element.$.restAPI, 'createGroup')
+        .returns(Promise.resolve({status: 201}));
+
+    sandbox.stub(element.$.restAPI, 'getGroupConfig')
+        .returns(Promise.resolve({group_id: 551}));
+
+    const showStub = sandbox.stub(page, 'show');
+    element.handleCreateGroup()
+        .then(() => {
+          assert.isTrue(showStub.calledWith('/admin/groups/551'));
+          done();
+        });
+  });
+
+  test('test for unsuccessful group creation', done => {
+    sandbox.stub(element.$.restAPI, 'createGroup')
+        .returns(Promise.resolve({status: 409}));
+
+    sandbox.stub(element.$.restAPI, 'getGroupConfig')
+        .returns(Promise.resolve({group_id: 551}));
+
+    const showStub = sandbox.stub(page, 'show');
+    element.handleCreateGroup()
+        .then(() => {
+          assert.isFalse(showStub.called);
+          done();
+        });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
index 2d6b4aa..40ddb66 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
@@ -14,89 +14,103 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const DETAIL_TYPES = {
-    branches: 'branches',
-    tags: 'tags',
-  };
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-pointer-dialog_html.js';
 
-  /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
-   */
-  class GrCreatePointerDialog extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-create-pointer-dialog'; }
+const DETAIL_TYPES = {
+  branches: 'branches',
+  tags: 'tags',
+};
 
-    static get properties() {
-      return {
-        detailType: String,
-        repoName: String,
-        hasNewItemName: {
-          type: Boolean,
-          notify: true,
-          value: false,
-        },
-        itemDetail: String,
-        _itemName: String,
-        _itemRevision: String,
-        _itemAnnotation: String,
-      };
-    }
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrCreatePointerDialog extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    static get observers() {
-      return [
-        '_updateItemName(_itemName)',
-      ];
-    }
+  static get is() { return 'gr-create-pointer-dialog'; }
 
-    _updateItemName(name) {
-      this.hasNewItemName = !!name;
-    }
+  static get properties() {
+    return {
+      detailType: String,
+      repoName: String,
+      hasNewItemName: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+      itemDetail: String,
+      _itemName: String,
+      _itemRevision: String,
+      _itemAnnotation: String,
+    };
+  }
 
-    _computeItemUrl(project) {
-      if (this.itemDetail === DETAIL_TYPES.branches) {
-        return this.getBaseUrl() + '/admin/repos/' +
-            this.encodeURL(this.repoName, true) + ',branches';
-      } else if (this.itemDetail === DETAIL_TYPES.tags) {
-        return this.getBaseUrl() + '/admin/repos/' +
-            this.encodeURL(this.repoName, true) + ',tags';
-      }
-    }
+  static get observers() {
+    return [
+      '_updateItemName(_itemName)',
+    ];
+  }
 
-    handleCreateItem() {
-      const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
-      if (this.itemDetail === DETAIL_TYPES.branches) {
-        return this.$.restAPI.createRepoBranch(this.repoName,
-            this._itemName, {revision: USE_HEAD})
-            .then(itemRegistered => {
-              if (itemRegistered.status === 201) {
-                page.show(this._computeItemUrl(this.itemDetail));
-              }
-            });
-      } else if (this.itemDetail === DETAIL_TYPES.tags) {
-        return this.$.restAPI.createRepoTag(this.repoName,
-            this._itemName,
-            {revision: USE_HEAD, message: this._itemAnnotation || null})
-            .then(itemRegistered => {
-              if (itemRegistered.status === 201) {
-                page.show(this._computeItemUrl(this.itemDetail));
-              }
-            });
-      }
-    }
+  _updateItemName(name) {
+    this.hasNewItemName = !!name;
+  }
 
-    _computeHideItemClass(type) {
-      return type === DETAIL_TYPES.branches ? 'hideItem' : '';
+  _computeItemUrl(project) {
+    if (this.itemDetail === DETAIL_TYPES.branches) {
+      return this.getBaseUrl() + '/admin/repos/' +
+          this.encodeURL(this.repoName, true) + ',branches';
+    } else if (this.itemDetail === DETAIL_TYPES.tags) {
+      return this.getBaseUrl() + '/admin/repos/' +
+          this.encodeURL(this.repoName, true) + ',tags';
     }
   }
 
-  customElements.define(GrCreatePointerDialog.is, GrCreatePointerDialog);
-})();
+  handleCreateItem() {
+    const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
+    if (this.itemDetail === DETAIL_TYPES.branches) {
+      return this.$.restAPI.createRepoBranch(this.repoName,
+          this._itemName, {revision: USE_HEAD})
+          .then(itemRegistered => {
+            if (itemRegistered.status === 201) {
+              page.show(this._computeItemUrl(this.itemDetail));
+            }
+          });
+    } else if (this.itemDetail === DETAIL_TYPES.tags) {
+      return this.$.restAPI.createRepoTag(this.repoName,
+          this._itemName,
+          {revision: USE_HEAD, message: this._itemAnnotation || null})
+          .then(itemRegistered => {
+            if (itemRegistered.status === 201) {
+              page.show(this._computeItemUrl(this.itemDetail));
+            }
+          });
+    }
+  }
+
+  _computeHideItemClass(type) {
+    return type === DETAIL_TYPES.branches ? 'hideItem' : '';
+  }
+}
+
+customElements.define(GrCreatePointerDialog.is, GrCreatePointerDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.js
index d1980a5..3a6df2f 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.js
@@ -1,33 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-create-pointer-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -49,41 +38,23 @@
       <div id="form">
         <section id="itemNameSection">
           <span class="title">[[detailType]] name</span>
-          <iron-input
-              placeholder="[[detailType]] Name"
-              bind-value="{{_itemName}}">
-            <input
-                is="iron-input"
-                placeholder="[[detailType]] Name"
-                bind-value="{{_itemName}}">
+          <iron-input placeholder="[[detailType]] Name" bind-value="{{_itemName}}">
+            <input is="iron-input" placeholder="[[detailType]] Name" bind-value="{{_itemName}}">
           </iron-input>
         </section>
         <section id="itemRevisionSection">
           <span class="title">Initial Revision</span>
-          <iron-input
-              placeholder="Revision (Branch or SHA-1)"
-              bind-value="{{_itemRevision}}">
-            <input
-                is="iron-input"
-                placeholder="Revision (Branch or SHA-1)"
-                bind-value="{{_itemRevision}}">
+          <iron-input placeholder="Revision (Branch or SHA-1)" bind-value="{{_itemRevision}}">
+            <input is="iron-input" placeholder="Revision (Branch or SHA-1)" bind-value="{{_itemRevision}}">
           </iron-input>
         </section>
-        <section id="itemAnnotationSection"
-                 class$="[[_computeHideItemClass(itemDetail)]]">
+        <section id="itemAnnotationSection" class\$="[[_computeHideItemClass(itemDetail)]]">
           <span class="title">Annotation</span>
-          <iron-input
-              placeholder="Annotation (Optional)"
-              bind-value="{{_itemAnnotation}}">
-            <input
-                is="iron-input"
-                placeholder="Annotation (Optional)"
-                bind-value="{{_itemAnnotation}}">
+          <iron-input placeholder="Annotation (Optional)" bind-value="{{_itemAnnotation}}">
+            <input is="iron-input" placeholder="Annotation (Optional)" bind-value="{{_itemAnnotation}}">
           </iron-input>
         </section>
       </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-create-pointer-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
index db33587..28cf2e8 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-pointer-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-create-pointer-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-create-pointer-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-create-pointer-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,103 +40,106 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-create-pointer-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-create-pointer-dialog.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-create-pointer-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    const ironInput = function(element) {
-      return Polymer.dom(element).querySelector('iron-input');
-    };
+  const ironInput = function(element) {
+    return dom(element).querySelector('iron-input');
+  };
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-      });
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
     });
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('branch created', done => {
-      sandbox.stub(
-          element.$.restAPI,
-          'createRepoBranch',
-          () => Promise.resolve({}));
+  test('branch created', done => {
+    sandbox.stub(
+        element.$.restAPI,
+        'createRepoBranch',
+        () => Promise.resolve({}));
 
-      assert.isFalse(element.hasNewItemName);
+    assert.isFalse(element.hasNewItemName);
 
-      element._itemName = 'test-branch';
-      element.itemDetail = 'branches';
+    element._itemName = 'test-branch';
+    element.itemDetail = 'branches';
 
-      ironInput(element.$.itemNameSection).bindValue = 'test-branch2';
-      ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+    ironInput(element.$.itemNameSection).bindValue = 'test-branch2';
+    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
 
-      setTimeout(() => {
-        assert.isTrue(element.hasNewItemName);
-        assert.equal(element._itemName, 'test-branch2');
-        assert.equal(element._itemRevision, 'HEAD');
-        done();
-      });
-    });
-
-    test('tag created', done => {
-      sandbox.stub(
-          element.$.restAPI,
-          'createRepoTag',
-          () => Promise.resolve({}));
-
-      assert.isFalse(element.hasNewItemName);
-
-      element._itemName = 'test-tag';
-      element.itemDetail = 'tags';
-
-      ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
-      ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
-
-      setTimeout(() => {
-        assert.isTrue(element.hasNewItemName);
-        assert.equal(element._itemName, 'test-tag2');
-        assert.equal(element._itemRevision, 'HEAD');
-        done();
-      });
-    });
-
-    test('tag created with annotations', done => {
-      sandbox.stub(
-          element.$.restAPI,
-          'createRepoTag',
-          () => Promise.resolve({}));
-
-      assert.isFalse(element.hasNewItemName);
-
-      element._itemName = 'test-tag';
-      element._itemAnnotation = 'test-message';
-      element.itemDetail = 'tags';
-
-      ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
-      ironInput(element.$.itemAnnotationSection).bindValue = 'test-message2';
-      ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
-
-      setTimeout(() => {
-        assert.isTrue(element.hasNewItemName);
-        assert.equal(element._itemName, 'test-tag2');
-        assert.equal(element._itemAnnotation, 'test-message2');
-        assert.equal(element._itemRevision, 'HEAD');
-        done();
-      });
-    });
-
-    test('_computeHideItemClass returns hideItem if type is branches', () => {
-      assert.equal(element._computeHideItemClass('branches'), 'hideItem');
-    });
-
-    test('_computeHideItemClass returns strings if not branches', () => {
-      assert.equal(element._computeHideItemClass('tags'), '');
+    setTimeout(() => {
+      assert.isTrue(element.hasNewItemName);
+      assert.equal(element._itemName, 'test-branch2');
+      assert.equal(element._itemRevision, 'HEAD');
+      done();
     });
   });
+
+  test('tag created', done => {
+    sandbox.stub(
+        element.$.restAPI,
+        'createRepoTag',
+        () => Promise.resolve({}));
+
+    assert.isFalse(element.hasNewItemName);
+
+    element._itemName = 'test-tag';
+    element.itemDetail = 'tags';
+
+    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
+    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+
+    setTimeout(() => {
+      assert.isTrue(element.hasNewItemName);
+      assert.equal(element._itemName, 'test-tag2');
+      assert.equal(element._itemRevision, 'HEAD');
+      done();
+    });
+  });
+
+  test('tag created with annotations', done => {
+    sandbox.stub(
+        element.$.restAPI,
+        'createRepoTag',
+        () => Promise.resolve({}));
+
+    assert.isFalse(element.hasNewItemName);
+
+    element._itemName = 'test-tag';
+    element._itemAnnotation = 'test-message';
+    element.itemDetail = 'tags';
+
+    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
+    ironInput(element.$.itemAnnotationSection).bindValue = 'test-message2';
+    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+
+    setTimeout(() => {
+      assert.isTrue(element.hasNewItemName);
+      assert.equal(element._itemName, 'test-tag2');
+      assert.equal(element._itemAnnotation, 'test-message2');
+      assert.equal(element._itemRevision, 'HEAD');
+      done();
+    });
+  });
+
+  test('_computeHideItemClass returns hideItem if type is branches', () => {
+    assert.equal(element._computeHideItemClass('branches'), 'hideItem');
+  });
+
+  test('_computeHideItemClass returns strings if not branches', () => {
+    assert.equal(element._computeHideItemClass('tags'), '');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
index 290f025..7a77874 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
@@ -14,130 +14,145 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
-   */
-  class GrCreateRepoDialog extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-create-repo-dialog'; }
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-repo-dialog_html.js';
 
-    static get properties() {
-      return {
-        params: Object,
-        hasNewRepoName: {
-          type: Boolean,
-          notify: true,
-          value: false,
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrCreateRepoDialog extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-create-repo-dialog'; }
+
+  static get properties() {
+    return {
+      params: Object,
+      hasNewRepoName: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+
+      /** @type {?} */
+      _repoConfig: {
+        type: Object,
+        value: () => {
+        // Set default values for dropdowns.
+          return {
+            create_empty_commit: true,
+            permissions_only: false,
+          };
         },
+      },
+      _repoCreated: {
+        type: Boolean,
+        value: false,
+      },
+      _repoOwner: String,
+      _repoOwnerId: {
+        type: String,
+        observer: '_repoOwnerIdUpdate',
+      },
 
-        /** @type {?} */
-        _repoConfig: {
-          type: Object,
-          value: () => {
-          // Set default values for dropdowns.
-            return {
-              create_empty_commit: true,
-              permissions_only: false,
-            };
-          },
+      _query: {
+        type: Function,
+        value() {
+          return this._getRepoSuggestions.bind(this);
         },
-        _repoCreated: {
-          type: Boolean,
-          value: false,
+      },
+      _queryGroups: {
+        type: Function,
+        value() {
+          return this._getGroupSuggestions.bind(this);
         },
-        _repoOwner: String,
-        _repoOwnerId: {
-          type: String,
-          observer: '_repoOwnerIdUpdate',
-        },
+      },
+    };
+  }
 
-        _query: {
-          type: Function,
-          value() {
-            return this._getRepoSuggestions.bind(this);
-          },
-        },
-        _queryGroups: {
-          type: Function,
-          value() {
-            return this._getGroupSuggestions.bind(this);
-          },
-        },
-      };
-    }
+  static get observers() {
+    return [
+      '_updateRepoName(_repoConfig.name)',
+    ];
+  }
 
-    static get observers() {
-      return [
-        '_updateRepoName(_repoConfig.name)',
-      ];
-    }
+  _computeRepoUrl(repoName) {
+    return this.getBaseUrl() + '/admin/repos/' +
+        this.encodeURL(repoName, true);
+  }
 
-    _computeRepoUrl(repoName) {
-      return this.getBaseUrl() + '/admin/repos/' +
-          this.encodeURL(repoName, true);
-    }
+  _updateRepoName(name) {
+    this.hasNewRepoName = !!name;
+  }
 
-    _updateRepoName(name) {
-      this.hasNewRepoName = !!name;
-    }
-
-    _repoOwnerIdUpdate(id) {
-      if (id) {
-        this.set('_repoConfig.owners', [id]);
-      } else {
-        this.set('_repoConfig.owners', undefined);
-      }
-    }
-
-    handleCreateRepo() {
-      return this.$.restAPI.createRepo(this._repoConfig)
-          .then(repoRegistered => {
-            if (repoRegistered.status === 201) {
-              this._repoCreated = true;
-              page.show(this._computeRepoUrl(this._repoConfig.name));
-            }
-          });
-    }
-
-    _getRepoSuggestions(input) {
-      return this.$.restAPI.getSuggestedProjects(input)
-          .then(response => {
-            const repos = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              repos.push({
-                name: key,
-                value: response[key],
-              });
-            }
-            return repos;
-          });
-    }
-
-    _getGroupSuggestions(input) {
-      return this.$.restAPI.getSuggestedGroups(input)
-          .then(response => {
-            const groups = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              groups.push({
-                name: key,
-                value: decodeURIComponent(response[key].id),
-              });
-            }
-            return groups;
-          });
+  _repoOwnerIdUpdate(id) {
+    if (id) {
+      this.set('_repoConfig.owners', [id]);
+    } else {
+      this.set('_repoConfig.owners', undefined);
     }
   }
 
-  customElements.define(GrCreateRepoDialog.is, GrCreateRepoDialog);
-})();
+  handleCreateRepo() {
+    return this.$.restAPI.createRepo(this._repoConfig)
+        .then(repoRegistered => {
+          if (repoRegistered.status === 201) {
+            this._repoCreated = true;
+            page.show(this._computeRepoUrl(this._repoConfig.name));
+          }
+        });
+  }
+
+  _getRepoSuggestions(input) {
+    return this.$.restAPI.getSuggestedProjects(input)
+        .then(response => {
+          const repos = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            repos.push({
+              name: key,
+              value: response[key],
+            });
+          }
+          return repos;
+        });
+  }
+
+  _getGroupSuggestions(input) {
+    return this.$.restAPI.getSuggestedGroups(input)
+        .then(response => {
+          const groups = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            groups.push({
+              name: key,
+              value: decodeURIComponent(response[key].id),
+            });
+          }
+          return groups;
+        });
+  }
+}
+
+customElements.define(GrCreateRepoDialog.is, GrCreateRepoDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.js
index b78090c..65666ea 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.js
@@ -1,34 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-create-repo-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -48,42 +36,28 @@
       <div id="form">
         <section>
           <span class="title">Repository name</span>
-          <iron-input autocomplete="on"
-                      bind-value="{{_repoConfig.name}}">
-            <input is="iron-input"
-                   id="repoNameInput"
-                   autocomplete="on"
-                   bind-value="{{_repoConfig.name}}">
+          <iron-input autocomplete="on" bind-value="{{_repoConfig.name}}">
+            <input is="iron-input" id="repoNameInput" autocomplete="on" bind-value="{{_repoConfig.name}}">
           </iron-input>
         </section>
         <section>
           <span class="title">Rights inherit from</span>
           <span class="value">
-            <gr-autocomplete
-                id="rightsInheritFromInput"
-                text="{{_repoConfig.parent}}"
-                query="[[_query]]"
-                placeholder="Optional, defaults to 'All-Projects'">
+            <gr-autocomplete id="rightsInheritFromInput" text="{{_repoConfig.parent}}" query="[[_query]]" placeholder="Optional, defaults to 'All-Projects'">
             </gr-autocomplete>
           </span>
         </section>
         <section>
           <span class="title">Owner</span>
           <span class="value">
-            <gr-autocomplete
-                id="ownerInput"
-                text="{{_repoOwner}}"
-                value="{{_repoOwnerId}}"
-                query="[[_queryGroups]]">
+            <gr-autocomplete id="ownerInput" text="{{_repoOwner}}" value="{{_repoOwnerId}}" query="[[_queryGroups]]">
             </gr-autocomplete>
           </span>
         </section>
         <section>
           <span class="title">Create initial empty commit</span>
           <span class="value">
-            <gr-select
-                id="initialCommit"
-                bind-value="{{_repoConfig.create_empty_commit}}">
+            <gr-select id="initialCommit" bind-value="{{_repoConfig.create_empty_commit}}">
               <select>
                 <option value="false">False</option>
                 <option value="true">True</option>
@@ -94,9 +68,7 @@
         <section>
           <span class="title">Only serve as parent for other repositories</span>
           <span class="value">
-            <gr-select
-                id="parentRepo"
-                bind-value="{{_repoConfig.permissions_only}}">
+            <gr-select id="parentRepo" bind-value="{{_repoConfig.permissions_only}}">
               <select>
                 <option value="false">False</option>
                 <option value="true">True</option>
@@ -107,6 +79,4 @@
       </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-create-repo-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
index 578c074..09bb63e 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-repo-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-create-repo-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-create-repo-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-create-repo-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,74 +40,76 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-create-repo-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-create-repo-dialog.js';
+suite('gr-create-repo-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-      });
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
     });
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('default values are populated', () => {
-      assert.isTrue(element.$.initialCommit.bindValue);
-      assert.isFalse(element.$.parentRepo.bindValue);
-    });
+  test('default values are populated', () => {
+    assert.isTrue(element.$.initialCommit.bindValue);
+    assert.isFalse(element.$.parentRepo.bindValue);
+  });
 
-    test('repo created', done => {
-      const configInputObj = {
-        name: 'test-repo',
-        create_empty_commit: true,
-        parent: 'All-Project',
-        permissions_only: false,
-        owners: ['testId'],
-      };
+  test('repo created', done => {
+    const configInputObj = {
+      name: 'test-repo',
+      create_empty_commit: true,
+      parent: 'All-Project',
+      permissions_only: false,
+      owners: ['testId'],
+    };
 
-      const saveStub = sandbox.stub(element.$.restAPI,
-          'createRepo', () => Promise.resolve({}));
+    const saveStub = sandbox.stub(element.$.restAPI,
+        'createRepo', () => Promise.resolve({}));
 
-      assert.isFalse(element.hasNewRepoName);
+    assert.isFalse(element.hasNewRepoName);
 
-      element._repoConfig = {
-        name: 'test-repo',
-        create_empty_commit: true,
-        parent: 'All-Project',
-        permissions_only: false,
-      };
+    element._repoConfig = {
+      name: 'test-repo',
+      create_empty_commit: true,
+      parent: 'All-Project',
+      permissions_only: false,
+    };
 
-      element._repoOwner = 'test';
-      element._repoOwnerId = 'testId';
+    element._repoOwner = 'test';
+    element._repoOwnerId = 'testId';
 
-      element.$.repoNameInput.bindValue = configInputObj.name;
-      element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
-      element.$.ownerInput.text = configInputObj.owners[0];
-      element.$.initialCommit.bindValue =
-          configInputObj.create_empty_commit;
-      element.$.parentRepo.bindValue =
-          configInputObj.permissions_only;
+    element.$.repoNameInput.bindValue = configInputObj.name;
+    element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
+    element.$.ownerInput.text = configInputObj.owners[0];
+    element.$.initialCommit.bindValue =
+        configInputObj.create_empty_commit;
+    element.$.parentRepo.bindValue =
+        configInputObj.permissions_only;
 
-      assert.isTrue(element.hasNewRepoName);
+    assert.isTrue(element.hasNewRepoName);
 
-      assert.deepEqual(element._repoConfig, configInputObj);
+    assert.deepEqual(element._repoConfig, configInputObj);
 
-      element.handleCreateRepo().then(() => {
-        assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
-        done();
-      });
-    });
-
-    test('testing observer of _repoOwner', () => {
-      element._repoOwnerId = 'test-5';
-      assert.deepEqual(element._repoConfig.owners, ['test-5']);
+    element.handleCreateRepo().then(() => {
+      assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
+      done();
     });
   });
+
+  test('testing observer of _repoOwner', () => {
+    element._repoOwnerId = 'test-5';
+    assert.deepEqual(element._repoConfig.owners, ['test-5']);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
index 11517d6..81c9cde 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
@@ -14,113 +14,127 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
 
-  const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
+import '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-account-link/gr-account-link.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-group-audit-log_html.js';
 
-  /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.ListViewMixin
-   * @extends Polymer.Element
-   */
-  class GrGroupAuditLog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.ListViewBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-group-audit-log'; }
+const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
 
-    static get properties() {
-      return {
-        groupId: String,
-        _auditLog: Array,
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-      };
-    }
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.ListViewMixin
+ * @extends Polymer.Element
+ */
+class GrGroupAuditLog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.ListViewBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this.fire('title-change', {title: 'Audit Log'});
-    }
+  static get is() { return 'gr-group-audit-log'; }
 
-    /** @override */
-    ready() {
-      super.ready();
-      this._getAuditLogs();
-    }
-
-    _getAuditLogs() {
-      if (!this.groupId) { return ''; }
-
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
-
-      return this.$.restAPI.getGroupAuditLog(this.groupId, errFn)
-          .then(auditLog => {
-            if (!auditLog) {
-              this._auditLog = [];
-              return;
-            }
-            this._auditLog = auditLog;
-            this._loading = false;
-          });
-    }
-
-    _status(item) {
-      return item.disabled ? 'Disabled' : 'Enabled';
-    }
-
-    itemType(type) {
-      let item;
-      switch (type) {
-        case 'ADD_GROUP':
-        case 'ADD_USER':
-          item = 'Added';
-          break;
-        case 'REMOVE_GROUP':
-        case 'REMOVE_USER':
-          item = 'Removed';
-          break;
-        default:
-          item = '';
-      }
-      return item;
-    }
-
-    _isGroupEvent(type) {
-      return GROUP_EVENTS.indexOf(type) !== -1;
-    }
-
-    _computeGroupUrl(group) {
-      if (group && group.url && group.id) {
-        return Gerrit.Nav.getUrlForGroup(group.id);
-      }
-
-      return '';
-    }
-
-    _getIdForUser(account) {
-      return account._account_id ? ' (' + account._account_id + ')' : '';
-    }
-
-    _getNameForGroup(group) {
-      if (group && group.name) {
-        return group.name;
-      } else if (group && group.id) {
-        // The URL encoded id of the member
-        return decodeURIComponent(group.id);
-      }
-
-      return '';
-    }
+  static get properties() {
+    return {
+      groupId: String,
+      _auditLog: Array,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+    };
   }
 
-  customElements.define(GrGroupAuditLog.is, GrGroupAuditLog);
-})();
+  /** @override */
+  attached() {
+    super.attached();
+    this.fire('title-change', {title: 'Audit Log'});
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._getAuditLogs();
+  }
+
+  _getAuditLogs() {
+    if (!this.groupId) { return ''; }
+
+    const errFn = response => {
+      this.fire('page-error', {response});
+    };
+
+    return this.$.restAPI.getGroupAuditLog(this.groupId, errFn)
+        .then(auditLog => {
+          if (!auditLog) {
+            this._auditLog = [];
+            return;
+          }
+          this._auditLog = auditLog;
+          this._loading = false;
+        });
+  }
+
+  _status(item) {
+    return item.disabled ? 'Disabled' : 'Enabled';
+  }
+
+  itemType(type) {
+    let item;
+    switch (type) {
+      case 'ADD_GROUP':
+      case 'ADD_USER':
+        item = 'Added';
+        break;
+      case 'REMOVE_GROUP':
+      case 'REMOVE_USER':
+        item = 'Removed';
+        break;
+      default:
+        item = '';
+    }
+    return item;
+  }
+
+  _isGroupEvent(type) {
+    return GROUP_EVENTS.indexOf(type) !== -1;
+  }
+
+  _computeGroupUrl(group) {
+    if (group && group.url && group.id) {
+      return Gerrit.Nav.getUrlForGroup(group.id);
+    }
+
+    return '';
+  }
+
+  _getIdForUser(account) {
+    return account._account_id ? ' (' + account._account_id + ')' : '';
+  }
+
+  _getNameForGroup(group) {
+    if (group && group.name) {
+      return group.name;
+    } else if (group && group.id) {
+      // The URL encoded id of the member
+      return decodeURIComponent(group.id);
+    }
+
+    return '';
+  }
+}
+
+customElements.define(GrGroupAuditLog.is, GrGroupAuditLog);
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.js
index 4ed751d..0958e7c 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.js
@@ -1,32 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-
-<dom-module id="gr-group-audit-log">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -38,28 +28,26 @@
       }
     </style>
     <table id="list" class="genericList">
-      <tr class="headerRow">
+      <tbody><tr class="headerRow">
         <th class="date topHeader">Date</th>
         <th class="type topHeader">Type</th>
         <th class="member topHeader">Member</th>
         <th class="by-user topHeader">By User</th>
       </tr>
-      <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+      <tr id="loading" class\$="loadingMsg [[computeLoadingClass(_loading)]]">
         <td>Loading...</td>
       </tr>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
+      </tbody><tbody class\$="[[computeLoadingClass(_loading)]]">
         <template is="dom-repeat" items="[[_auditLog]]">
           <tr class="table">
             <td class="date">
-              <gr-date-formatter
-                  has-tooltip
-                  date-str="[[item.date]]">
+              <gr-date-formatter has-tooltip="" date-str="[[item.date]]">
               </gr-date-formatter>
             </td>
             <td class="type">[[itemType(item.type)]]</td>
             <td class="member">
               <template is="dom-if" if="[[_isGroupEvent(item.type)]]">
-                <a href$="[[_computeGroupUrl(item.member)]]">
+                <a href\$="[[_computeGroupUrl(item.member)]]">
                   [[_getNameForGroup(item.member)]]
                 </a>
               </template>
@@ -77,6 +65,4 @@
       </tbody>
     </table>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-group-audit-log.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
index 3a75611..d62874d 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group-audit-log</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-group-audit-log.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-group-audit-log.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-group-audit-log.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,85 +40,87 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-group-audit-log tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-group-audit-log.js';
+suite('gr-group-audit-log tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('members', () => {
+    test('test _getNameForGroup', () => {
+      let group = {
+        member: {
+          name: 'test-name',
+        },
+      };
+      assert.equal(element._getNameForGroup(group.member), 'test-name');
+
+      group = {
+        member: {
+          id: 'test-id',
+        },
+      };
+      assert.equal(element._getNameForGroup(group.member), 'test-id');
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    test('test _isGroupEvent', () => {
+      assert.isTrue(element._isGroupEvent('ADD_GROUP'));
+      assert.isTrue(element._isGroupEvent('REMOVE_GROUP'));
 
-    suite('members', () => {
-      test('test _getNameForGroup', () => {
-        let group = {
-          member: {
-            name: 'test-name',
-          },
-        };
-        assert.equal(element._getNameForGroup(group.member), 'test-name');
-
-        group = {
-          member: {
-            id: 'test-id',
-          },
-        };
-        assert.equal(element._getNameForGroup(group.member), 'test-id');
-      });
-
-      test('test _isGroupEvent', () => {
-        assert.isTrue(element._isGroupEvent('ADD_GROUP'));
-        assert.isTrue(element._isGroupEvent('REMOVE_GROUP'));
-
-        assert.isFalse(element._isGroupEvent('ADD_USER'));
-        assert.isFalse(element._isGroupEvent('REMOVE_USER'));
-      });
-    });
-
-    suite('users', () => {
-      test('test _getIdForUser', () => {
-        const account = {
-          user: {
-            username: 'test-user',
-            _account_id: 12,
-          },
-        };
-        assert.equal(element._getIdForUser(account.user), ' (12)');
-      });
-
-      test('test _account_id not present', () => {
-        const account = {
-          user: {
-            username: 'test-user',
-          },
-        };
-        assert.equal(element._getIdForUser(account.user), '');
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', done => {
-        element.groupId = 1;
-
-        const response = {status: 404};
-        sandbox.stub(
-            element.$.restAPI, 'getGroupAuditLog', (group, errFn) => {
-              errFn(response);
-            });
-
-        element.addEventListener('page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          done();
-        });
-
-        element._getAuditLogs();
-      });
+      assert.isFalse(element._isGroupEvent('ADD_USER'));
+      assert.isFalse(element._isGroupEvent('REMOVE_USER'));
     });
   });
+
+  suite('users', () => {
+    test('test _getIdForUser', () => {
+      const account = {
+        user: {
+          username: 'test-user',
+          _account_id: 12,
+        },
+      };
+      assert.equal(element._getIdForUser(account.user), ' (12)');
+    });
+
+    test('test _account_id not present', () => {
+      const account = {
+        user: {
+          username: 'test-user',
+        },
+      };
+      assert.equal(element._getIdForUser(account.user), '');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      element.groupId = 1;
+
+      const response = {status: 404};
+      sandbox.stub(
+          element.$.restAPI, 'getGroupAuditLog', (group, errFn) => {
+            errFn(response);
+          });
+
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      element._getAuditLogs();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
index 8c29f73..fc9e4a4 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
@@ -14,280 +14,300 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  const SUGGESTIONS_LIMIT = 15;
-  const SAVING_ERROR_TEXT = 'Group may not exist, or you may not have '+
-      'permission to add it';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '../../../scripts/bundled-polymer.js';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-subpage-styles.js';
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-account-link/gr-account-link.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-group-members_html.js';
 
-  const URL_REGEX = '^(?:[a-z]+:)?//';
+const SUGGESTIONS_LIMIT = 15;
+const SAVING_ERROR_TEXT = 'Group may not exist, or you may not have '+
+    'permission to add it';
 
-  /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
-   */
-  class GrGroupMembers extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.FireBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-group-members'; }
+const URL_REGEX = '^(?:[a-z]+:)?//';
 
-    static get properties() {
-      return {
-        groupId: Number,
-        _groupMemberSearchId: String,
-        _groupMemberSearchName: String,
-        _includedGroupSearchId: String,
-        _includedGroupSearchName: String,
-        _loading: {
-          type: Boolean,
-          value: true,
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrGroupMembers extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.FireBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-group-members'; }
+
+  static get properties() {
+    return {
+      groupId: Number,
+      _groupMemberSearchId: String,
+      _groupMemberSearchName: String,
+      _includedGroupSearchId: String,
+      _includedGroupSearchName: String,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _groupName: String,
+      _groupMembers: Object,
+      _includedGroups: Object,
+      _itemName: String,
+      _itemType: String,
+      _queryMembers: {
+        type: Function,
+        value() {
+          return this._getAccountSuggestions.bind(this);
         },
-        _groupName: String,
-        _groupMembers: Object,
-        _includedGroups: Object,
-        _itemName: String,
-        _itemType: String,
-        _queryMembers: {
-          type: Function,
-          value() {
-            return this._getAccountSuggestions.bind(this);
-          },
+      },
+      _queryIncludedGroup: {
+        type: Function,
+        value() {
+          return this._getGroupSuggestions.bind(this);
         },
-        _queryIncludedGroup: {
-          type: Function,
-          value() {
-            return this._getGroupSuggestions.bind(this);
-          },
-        },
-        _groupOwner: {
-          type: Boolean,
-          value: false,
-        },
-        _isAdmin: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
+      },
+      _groupOwner: {
+        type: Boolean,
+        value: false,
+      },
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this._loadGroupDetails();
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadGroupDetails();
 
-      this.fire('title-change', {title: 'Members'});
-    }
+    this.fire('title-change', {title: 'Members'});
+  }
 
-    _loadGroupDetails() {
-      if (!this.groupId) { return; }
+  _loadGroupDetails() {
+    if (!this.groupId) { return; }
 
-      const promises = [];
+    const promises = [];
 
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
+    const errFn = response => {
+      this.fire('page-error', {response});
+    };
 
-      return this.$.restAPI.getGroupConfig(this.groupId, errFn)
-          .then(config => {
-            if (!config || !config.name) { return Promise.resolve(); }
+    return this.$.restAPI.getGroupConfig(this.groupId, errFn)
+        .then(config => {
+          if (!config || !config.name) { return Promise.resolve(); }
 
-            this._groupName = config.name;
+          this._groupName = config.name;
 
-            promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
-              this._isAdmin = isAdmin ? true : false;
-            }));
+          promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
+            this._isAdmin = isAdmin ? true : false;
+          }));
 
-            promises.push(this.$.restAPI.getIsGroupOwner(config.name)
-                .then(isOwner => {
-                  this._groupOwner = isOwner ? true : false;
-                }));
-
-            promises.push(this.$.restAPI.getGroupMembers(config.name).then(
-                members => {
-                  this._groupMembers = members;
-                }));
-
-            promises.push(this.$.restAPI.getIncludedGroup(config.name)
-                .then(includedGroup => {
-                  this._includedGroups = includedGroup;
-                }));
-
-            return Promise.all(promises).then(() => {
-              this._loading = false;
-            });
-          });
-    }
-
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    }
-
-    _isLoading() {
-      return this._loading || this._loading === undefined;
-    }
-
-    _computeGroupUrl(url) {
-      if (!url) { return; }
-
-      const r = new RegExp(URL_REGEX, 'i');
-      if (r.test(url)) {
-        return url;
-      }
-
-      // For GWT compatibility
-      if (url.startsWith('#')) {
-        return this.getBaseUrl() + url.slice(1);
-      }
-      return this.getBaseUrl() + url;
-    }
-
-    _handleSavingGroupMember() {
-      return this.$.restAPI.saveGroupMembers(this._groupName,
-          this._groupMemberSearchId).then(config => {
-        if (!config) {
-          return;
-        }
-        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
-          this._groupMembers = members;
-        });
-        this._groupMemberSearchName = '';
-        this._groupMemberSearchId = '';
-      });
-    }
-
-    _handleDeleteConfirm() {
-      this.$.overlay.close();
-      if (this._itemType === 'member') {
-        return this.$.restAPI.deleteGroupMembers(this._groupName,
-            this._itemId)
-            .then(itemDeleted => {
-              if (itemDeleted.status === 204) {
-                this.$.restAPI.getGroupMembers(this._groupName)
-                    .then(members => {
-                      this._groupMembers = members;
-                    });
-              }
-            });
-      } else if (this._itemType === 'includedGroup') {
-        return this.$.restAPI.deleteIncludedGroup(this._groupName,
-            this._itemId)
-            .then(itemDeleted => {
-              if (itemDeleted.status === 204 || itemDeleted.status === 205) {
-                this.$.restAPI.getIncludedGroup(this._groupName)
-                    .then(includedGroup => {
-                      this._includedGroups = includedGroup;
-                    });
-              }
-            });
-      }
-    }
-
-    _handleConfirmDialogCancel() {
-      this.$.overlay.close();
-    }
-
-    _handleDeleteMember(e) {
-      const id = e.model.get('item._account_id');
-      const name = e.model.get('item.name');
-      const username = e.model.get('item.username');
-      const email = e.model.get('item.email');
-      const item = username || name || email || id;
-      if (!item) {
-        return '';
-      }
-      this._itemName = item;
-      this._itemId = id;
-      this._itemType = 'member';
-      this.$.overlay.open();
-    }
-
-    _handleSavingIncludedGroups() {
-      return this.$.restAPI.saveIncludedGroup(this._groupName,
-          this._includedGroupSearchId.replace(/\+/g, ' '), err => {
-            if (err.status === 404) {
-              this.dispatchEvent(new CustomEvent('show-alert', {
-                detail: {message: SAVING_ERROR_TEXT},
-                bubbles: true,
-                composed: true,
+          promises.push(this.$.restAPI.getIsGroupOwner(config.name)
+              .then(isOwner => {
+                this._groupOwner = isOwner ? true : false;
               }));
-              return err;
-            }
-            throw Error(err.statusText);
-          })
-          .then(config => {
-            if (!config) {
-              return;
-            }
-            this.$.restAPI.getIncludedGroup(this._groupName)
-                .then(includedGroup => {
-                  this._includedGroups = includedGroup;
-                });
-            this._includedGroupSearchName = '';
-            this._includedGroupSearchId = '';
+
+          promises.push(this.$.restAPI.getGroupMembers(config.name).then(
+              members => {
+                this._groupMembers = members;
+              }));
+
+          promises.push(this.$.restAPI.getIncludedGroup(config.name)
+              .then(includedGroup => {
+                this._includedGroups = includedGroup;
+              }));
+
+          return Promise.all(promises).then(() => {
+            this._loading = false;
           });
+        });
+  }
+
+  _computeLoadingClass(loading) {
+    return loading ? 'loading' : '';
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _computeGroupUrl(url) {
+    if (!url) { return; }
+
+    const r = new RegExp(URL_REGEX, 'i');
+    if (r.test(url)) {
+      return url;
     }
 
-    _handleDeleteIncludedGroup(e) {
-      const id = decodeURIComponent(e.model.get('item.id')).replace(/\+/g, ' ');
-      const name = e.model.get('item.name');
-      const item = name || id;
-      if (!item) { return ''; }
-      this._itemName = item;
-      this._itemId = id;
-      this._itemType = 'includedGroup';
-      this.$.overlay.open();
+    // For GWT compatibility
+    if (url.startsWith('#')) {
+      return this.getBaseUrl() + url.slice(1);
     }
+    return this.getBaseUrl() + url;
+  }
 
-    _getAccountSuggestions(input) {
-      if (input.length === 0) { return Promise.resolve([]); }
-      return this.$.restAPI.getSuggestedAccounts(
-          input, SUGGESTIONS_LIMIT).then(accounts => {
-        const accountSuggestions = [];
-        let nameAndEmail;
-        if (!accounts) { return []; }
-        for (const key in accounts) {
-          if (!accounts.hasOwnProperty(key)) { continue; }
-          if (accounts[key].email !== undefined) {
-            nameAndEmail = accounts[key].name +
-                  ' <' + accounts[key].email + '>';
-          } else {
-            nameAndEmail = accounts[key].name;
-          }
-          accountSuggestions.push({
-            name: nameAndEmail,
-            value: accounts[key]._account_id,
-          });
-        }
-        return accountSuggestions;
+  _handleSavingGroupMember() {
+    return this.$.restAPI.saveGroupMembers(this._groupName,
+        this._groupMemberSearchId).then(config => {
+      if (!config) {
+        return;
+      }
+      this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+        this._groupMembers = members;
       });
-    }
+      this._groupMemberSearchName = '';
+      this._groupMemberSearchId = '';
+    });
+  }
 
-    _getGroupSuggestions(input) {
-      return this.$.restAPI.getSuggestedGroups(input)
-          .then(response => {
-            const groups = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              groups.push({
-                name: key,
-                value: decodeURIComponent(response[key].id),
-              });
+  _handleDeleteConfirm() {
+    this.$.overlay.close();
+    if (this._itemType === 'member') {
+      return this.$.restAPI.deleteGroupMembers(this._groupName,
+          this._itemId)
+          .then(itemDeleted => {
+            if (itemDeleted.status === 204) {
+              this.$.restAPI.getGroupMembers(this._groupName)
+                  .then(members => {
+                    this._groupMembers = members;
+                  });
             }
-            return groups;
           });
-    }
-
-    _computeHideItemClass(owner, admin) {
-      return admin || owner ? '' : 'canModify';
+    } else if (this._itemType === 'includedGroup') {
+      return this.$.restAPI.deleteIncludedGroup(this._groupName,
+          this._itemId)
+          .then(itemDeleted => {
+            if (itemDeleted.status === 204 || itemDeleted.status === 205) {
+              this.$.restAPI.getIncludedGroup(this._groupName)
+                  .then(includedGroup => {
+                    this._includedGroups = includedGroup;
+                  });
+            }
+          });
     }
   }
 
-  customElements.define(GrGroupMembers.is, GrGroupMembers);
-})();
+  _handleConfirmDialogCancel() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteMember(e) {
+    const id = e.model.get('item._account_id');
+    const name = e.model.get('item.name');
+    const username = e.model.get('item.username');
+    const email = e.model.get('item.email');
+    const item = username || name || email || id;
+    if (!item) {
+      return '';
+    }
+    this._itemName = item;
+    this._itemId = id;
+    this._itemType = 'member';
+    this.$.overlay.open();
+  }
+
+  _handleSavingIncludedGroups() {
+    return this.$.restAPI.saveIncludedGroup(this._groupName,
+        this._includedGroupSearchId.replace(/\+/g, ' '), err => {
+          if (err.status === 404) {
+            this.dispatchEvent(new CustomEvent('show-alert', {
+              detail: {message: SAVING_ERROR_TEXT},
+              bubbles: true,
+              composed: true,
+            }));
+            return err;
+          }
+          throw Error(err.statusText);
+        })
+        .then(config => {
+          if (!config) {
+            return;
+          }
+          this.$.restAPI.getIncludedGroup(this._groupName)
+              .then(includedGroup => {
+                this._includedGroups = includedGroup;
+              });
+          this._includedGroupSearchName = '';
+          this._includedGroupSearchId = '';
+        });
+  }
+
+  _handleDeleteIncludedGroup(e) {
+    const id = decodeURIComponent(e.model.get('item.id')).replace(/\+/g, ' ');
+    const name = e.model.get('item.name');
+    const item = name || id;
+    if (!item) { return ''; }
+    this._itemName = item;
+    this._itemId = id;
+    this._itemType = 'includedGroup';
+    this.$.overlay.open();
+  }
+
+  _getAccountSuggestions(input) {
+    if (input.length === 0) { return Promise.resolve([]); }
+    return this.$.restAPI.getSuggestedAccounts(
+        input, SUGGESTIONS_LIMIT).then(accounts => {
+      const accountSuggestions = [];
+      let nameAndEmail;
+      if (!accounts) { return []; }
+      for (const key in accounts) {
+        if (!accounts.hasOwnProperty(key)) { continue; }
+        if (accounts[key].email !== undefined) {
+          nameAndEmail = accounts[key].name +
+                ' <' + accounts[key].email + '>';
+        } else {
+          nameAndEmail = accounts[key].name;
+        }
+        accountSuggestions.push({
+          name: nameAndEmail,
+          value: accounts[key]._account_id,
+        });
+      }
+      return accountSuggestions;
+    });
+  }
+
+  _getGroupSuggestions(input) {
+    return this.$.restAPI.getSuggestedGroups(input)
+        .then(response => {
+          const groups = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            groups.push({
+              name: key,
+              value: decodeURIComponent(response[key].id),
+            });
+          }
+          return groups;
+        });
+  }
+
+  _computeHideItemClass(owner, admin) {
+    return admin || owner ? '' : 'canModify';
+  }
+}
+
+customElements.define(GrGroupMembers.is, GrGroupMembers);
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js
index cf24793..79a88fd 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js
@@ -1,38 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-subpage-styles.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
-
-<dom-module id="gr-group-members">
-  <template>
+export const htmlTemplate = html`
     <style include="gr-form-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -72,37 +56,29 @@
         display: none;
       }
     </style>
-    <main class$="gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]">
-      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+    <main class\$="gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]">
+      <div id="loading" class\$="[[_computeLoadingClass(_loading)]]">
         Loading...
       </div>
-      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <div id="loadedContent" class\$="[[_computeLoadingClass(_loading)]]">
         <h1 id="Title">[[_groupName]]</h1>
         <div id="form">
           <h3 id="members">Members</h3>
           <fieldset>
             <span class="value">
-              <gr-autocomplete
-                  id="groupMemberSearchInput"
-                  text="{{_groupMemberSearchName}}"
-                  value="{{_groupMemberSearchId}}"
-                  query="[[_queryMembers]]"
-                  placeholder="Name Or Email">
+              <gr-autocomplete id="groupMemberSearchInput" text="{{_groupMemberSearchName}}" value="{{_groupMemberSearchId}}" query="[[_queryMembers]]" placeholder="Name Or Email">
               </gr-autocomplete>
             </span>
-            <gr-button
-                id="saveGroupMember"
-                on-click="_handleSavingGroupMember"
-                disabled="[[!_groupMemberSearchId]]">
+            <gr-button id="saveGroupMember" on-click="_handleSavingGroupMember" disabled="[[!_groupMemberSearchId]]">
               Add
             </gr-button>
             <table id="groupMembers">
-              <tr class="headerRow">
+              <tbody><tr class="headerRow">
                 <th class="nameHeader">Name</th>
                 <th class="emailAddressHeader">Email Address</th>
                 <th class="deleteHeader">Delete Member</th>
               </tr>
-              <tbody>
+              </tbody><tbody>
                 <template is="dom-repeat" items="[[_groupMembers]]">
                   <tr>
                     <td class="nameColumn">
@@ -110,9 +86,7 @@
                     </td>
                     <td>[[item.email]]</td>
                     <td class="deleteColumn">
-                      <gr-button
-                          class="deleteMembersButton"
-                          on-click="_handleDeleteMember">
+                      <gr-button class="deleteMembersButton" on-click="_handleDeleteMember">
                         Delete
                       </gr-button>
                     </td>
@@ -124,35 +98,26 @@
           <h3 id="includedGroups">Included Groups</h3>
           <fieldset>
             <span class="value">
-              <gr-autocomplete
-                  id="includedGroupSearchInput"
-                  text="{{_includedGroupSearchName}}"
-                  value="{{_includedGroupSearchId}}"
-                  query="[[_queryIncludedGroup]]"
-                  placeholder="Group Name">
+              <gr-autocomplete id="includedGroupSearchInput" text="{{_includedGroupSearchName}}" value="{{_includedGroupSearchId}}" query="[[_queryIncludedGroup]]" placeholder="Group Name">
               </gr-autocomplete>
             </span>
-            <gr-button
-                id="saveIncludedGroups"
-                on-click="_handleSavingIncludedGroups"
-                disabled="[[!_includedGroupSearchId]]">
+            <gr-button id="saveIncludedGroups" on-click="_handleSavingIncludedGroups" disabled="[[!_includedGroupSearchId]]">
               Add
             </gr-button>
             <table id="includedGroups">
-              <tr class="headerRow">
+              <tbody><tr class="headerRow">
                 <th class="groupNameHeader">Group Name</th>
                 <th class="descriptionHeader">Description</th>
                 <th class="deleteIncludedHeader">
                   Delete Group
                 </th>
               </tr>
-              <tbody>
+              </tbody><tbody>
                 <template is="dom-repeat" items="[[_includedGroups]]">
                   <tr>
                     <td class="nameColumn">
                       <template is="dom-if" if="[[item.url]]">
-                        <a href$="[[_computeGroupUrl(item.url)]]"
-                            rel="noopener">
+                        <a href\$="[[_computeGroupUrl(item.url)]]" rel="noopener">
                           [[item.name]]
                         </a>
                       </template>
@@ -162,9 +127,7 @@
                     </td>
                     <td>[[item.description]]</td>
                     <td class="deleteColumn">
-                      <gr-button
-                          class="deleteIncludedGroupButton"
-                          on-click="_handleDeleteIncludedGroup">
+                      <gr-button class="deleteIncludedGroupButton" on-click="_handleDeleteIncludedGroup">
                         Delete
                       </gr-button>
                     </td>
@@ -176,15 +139,8 @@
         </div>
       </div>
     </main>
-    <gr-overlay id="overlay" with-backdrop>
-      <gr-confirm-delete-item-dialog
-          class="confirmDialog"
-          on-confirm="_handleDeleteConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          item="[[_itemName]]"
-          item-type="[[_itemType]]"></gr-confirm-delete-item-dialog>
+    <gr-overlay id="overlay" with-backdrop="">
+      <gr-confirm-delete-item-dialog class="confirmDialog" on-confirm="_handleDeleteConfirm" on-cancel="_handleConfirmDialogCancel" item="[[_itemName]]" item-type="[[_itemType]]"></gr-confirm-delete-item-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-group-members.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
index ec9a80c..9380b86 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group-members</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-group-members.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-group-members.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-group-members.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,342 +40,345 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-group-members tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let groups;
-    let groupMembers;
-    let includedGroups;
-    let groupStub;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-group-members.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-group-members tests', () => {
+  let element;
+  let sandbox;
+  let groups;
+  let groupMembers;
+  let includedGroups;
+  let groupStub;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
 
-      groups = {
-        name: 'Administrators',
-        owner: 'Administrators',
-        group_id: 1,
-      };
+    groups = {
+      name: 'Administrators',
+      owner: 'Administrators',
+      group_id: 1,
+    };
 
-      groupMembers = [
-        {
-          _account_id: 1000097,
-          name: 'Jane Roe',
-          email: 'jane.roe@example.com',
-          username: 'jane',
-        },
-        {
-          _account_id: 1000096,
-          name: 'Test User',
-          email: 'john.doe@example.com',
-        },
-        {
-          _account_id: 1000095,
-          name: 'Gerrit',
-        },
-        {
-          _account_id: 1000098,
-        },
-      ];
-
-      includedGroups = [{
-        url: 'https://group/url',
-        options: {},
-        id: 'testId',
-        name: 'testName',
+    groupMembers = [
+      {
+        _account_id: 1000097,
+        name: 'Jane Roe',
+        email: 'jane.roe@example.com',
+        username: 'jane',
       },
       {
-        url: '/group/url',
-        options: {},
-        id: 'testId2',
-        name: 'testName2',
+        _account_id: 1000096,
+        name: 'Test User',
+        email: 'john.doe@example.com',
       },
       {
-        url: '#/group/url',
-        options: {},
-        id: 'testId3',
-        name: 'testName3',
+        _account_id: 1000095,
+        name: 'Gerrit',
       },
-      ];
+      {
+        _account_id: 1000098,
+      },
+    ];
 
-      stub('gr-rest-api-interface', {
-        getSuggestedAccounts(input) {
-          if (input.startsWith('test')) {
-            return Promise.resolve([
-              {
-                _account_id: 1000096,
-                name: 'test-account',
-                email: 'test.account@example.com',
-                username: 'test123',
-              },
-              {
-                _account_id: 1001439,
-                name: 'test-admin',
-                email: 'test.admin@example.com',
-                username: 'test_admin',
-              },
-              {
-                _account_id: 1001439,
-                name: 'test-git',
-                username: 'test_git',
-              },
-            ]);
-          } else {
-            return Promise.resolve({});
-          }
-        },
-        getSuggestedGroups(input) {
-          if (input.startsWith('test')) {
-            return Promise.resolve({
-              'test-admin': {
-                id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
-              },
-              'test/Administrator (admin)': {
-                id: 'test%3Aadmin',
-              },
-            });
-          } else {
-            return Promise.resolve({});
-          }
-        },
-        getLoggedIn() { return Promise.resolve(true); },
-        getConfig() {
-          return Promise.resolve();
-        },
-        getGroupMembers() {
-          return Promise.resolve(groupMembers);
-        },
-        getIsGroupOwner() {
-          return Promise.resolve(true);
-        },
-        getIncludedGroup() {
-          return Promise.resolve(includedGroups);
-        },
-        getAccountCapabilities() {
-          return Promise.resolve();
-        },
-      });
-      element = fixture('basic');
-      sandbox.stub(element, 'getBaseUrl').returns('https://test/site');
-      element.groupId = 1;
-      groupStub = sandbox.stub(
-          element.$.restAPI,
-          'getGroupConfig',
-          () => Promise.resolve(groups));
-      return element._loadGroupDetails();
+    includedGroups = [{
+      url: 'https://group/url',
+      options: {},
+      id: 'testId',
+      name: 'testName',
+    },
+    {
+      url: '/group/url',
+      options: {},
+      id: 'testId2',
+      name: 'testName2',
+    },
+    {
+      url: '#/group/url',
+      options: {},
+      id: 'testId3',
+      name: 'testName3',
+    },
+    ];
+
+    stub('gr-rest-api-interface', {
+      getSuggestedAccounts(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              _account_id: 1000096,
+              name: 'test-account',
+              email: 'test.account@example.com',
+              username: 'test123',
+            },
+            {
+              _account_id: 1001439,
+              name: 'test-admin',
+              email: 'test.admin@example.com',
+              username: 'test_admin',
+            },
+            {
+              _account_id: 1001439,
+              name: 'test-git',
+              username: 'test_git',
+            },
+          ]);
+        } else {
+          return Promise.resolve({});
+        }
+      },
+      getSuggestedGroups(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve({
+            'test-admin': {
+              id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
+            },
+            'test/Administrator (admin)': {
+              id: 'test%3Aadmin',
+            },
+          });
+        } else {
+          return Promise.resolve({});
+        }
+      },
+      getLoggedIn() { return Promise.resolve(true); },
+      getConfig() {
+        return Promise.resolve();
+      },
+      getGroupMembers() {
+        return Promise.resolve(groupMembers);
+      },
+      getIsGroupOwner() {
+        return Promise.resolve(true);
+      },
+      getIncludedGroup() {
+        return Promise.resolve(includedGroups);
+      },
+      getAccountCapabilities() {
+        return Promise.resolve();
+      },
     });
+    element = fixture('basic');
+    sandbox.stub(element, 'getBaseUrl').returns('https://test/site');
+    element.groupId = 1;
+    groupStub = sandbox.stub(
+        element.$.restAPI,
+        'getGroupConfig',
+        () => Promise.resolve(groups));
+    return element._loadGroupDetails();
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('_includedGroups', () => {
-      assert.equal(element._includedGroups.length, 3);
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('.nameColumn a')[0].href, includedGroups[0].url);
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('.nameColumn a')[1].href,
-      'https://test/site/group/url');
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('.nameColumn a')[2].href,
-      'https://test/site/group/url');
-    });
+  test('_includedGroups', () => {
+    assert.equal(element._includedGroups.length, 3);
+    assert.equal(dom(element.root)
+        .querySelectorAll('.nameColumn a')[0].href, includedGroups[0].url);
+    assert.equal(dom(element.root)
+        .querySelectorAll('.nameColumn a')[1].href,
+    'https://test/site/group/url');
+    assert.equal(dom(element.root)
+        .querySelectorAll('.nameColumn a')[2].href,
+    'https://test/site/group/url');
+  });
 
-    test('save members correctly', () => {
-      element._groupOwner = true;
+  test('save members correctly', () => {
+    element._groupOwner = true;
 
-      const memberName = 'test-admin';
+    const memberName = 'test-admin';
 
-      const saveStub = sandbox.stub(element.$.restAPI, 'saveGroupMembers',
-          () => Promise.resolve({}));
+    const saveStub = sandbox.stub(element.$.restAPI, 'saveGroupMembers',
+        () => Promise.resolve({}));
 
-      const button = element.$.saveGroupMember;
+    const button = element.$.saveGroupMember;
 
+    assert.isTrue(button.hasAttribute('disabled'));
+
+    element.$.groupMemberSearchInput.text = memberName;
+    element.$.groupMemberSearchInput.value = 1234;
+
+    assert.isFalse(button.hasAttribute('disabled'));
+
+    return element._handleSavingGroupMember().then(() => {
       assert.isTrue(button.hasAttribute('disabled'));
-
-      element.$.groupMemberSearchInput.text = memberName;
-      element.$.groupMemberSearchInput.value = 1234;
-
-      assert.isFalse(button.hasAttribute('disabled'));
-
-      return element._handleSavingGroupMember().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-        assert.isTrue(saveStub.lastCall.calledWithExactly('Administrators',
-            1234));
-      });
-    });
-
-    test('save included groups correctly', () => {
-      element._groupOwner = true;
-
-      const includedGroupName = 'testName';
-
-      const saveIncludedGroupStub = sandbox.stub(
-          element.$.restAPI, 'saveIncludedGroup', () => Promise.resolve({}));
-
-      const button = element.$.saveIncludedGroups;
-
-      assert.isTrue(button.hasAttribute('disabled'));
-
-      element.$.includedGroupSearchInput.text = includedGroupName;
-      element.$.includedGroupSearchInput.value = 'testId';
-
-      assert.isFalse(button.hasAttribute('disabled'));
-
-      return element._handleSavingIncludedGroups().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-        assert.equal(saveIncludedGroupStub.lastCall.args[0], 'Administrators');
-        assert.equal(saveIncludedGroupStub.lastCall.args[1], 'testId');
-      });
-    });
-
-    test('add included group 404 shows helpful error text', () => {
-      element._groupOwner = true;
-
-      const memberName = 'bad-name';
-      const alertStub = sandbox.stub();
-      element.addEventListener('show-alert', alertStub);
-      const error = new Error('error');
-      error.status = 404;
-      sandbox.stub(element.$.restAPI, 'saveGroupMembers',
-          () => Promise.reject(error));
-
-      element.$.groupMemberSearchInput.text = memberName;
-      element.$.groupMemberSearchInput.value = 1234;
-
-      return element._handleSavingIncludedGroups().then(() => {
-        assert.isTrue(alertStub.called);
-      });
-    });
-
-    test('_getAccountSuggestions empty', done => {
-      element
-          ._getAccountSuggestions('nonexistent').then(accounts => {
-            assert.equal(accounts.length, 0);
-            done();
-          });
-    });
-
-    test('_getAccountSuggestions non-empty', done => {
-      element
-          ._getAccountSuggestions('test-').then(accounts => {
-            assert.equal(accounts.length, 3);
-            assert.equal(accounts[0].name,
-                'test-account <test.account@example.com>');
-            assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
-            assert.equal(accounts[2].name, 'test-git');
-            done();
-          });
-    });
-
-    test('_getGroupSuggestions empty', done => {
-      element
-          ._getGroupSuggestions('nonexistent').then(groups => {
-            assert.equal(groups.length, 0);
-            done();
-          });
-    });
-
-    test('_getGroupSuggestions non-empty', done => {
-      element
-          ._getGroupSuggestions('test').then(groups => {
-            assert.equal(groups.length, 2);
-            assert.equal(groups[0].name, 'test-admin');
-            assert.equal(groups[1].name, 'test/Administrator (admin)');
-            done();
-          });
-    });
-
-    test('_computeHideItemClass returns string for admin', () => {
-      const admin = true;
-      const owner = false;
-      assert.equal(element._computeHideItemClass(owner, admin), '');
-    });
-
-    test('_computeHideItemClass returns hideItem for admin and owner', () => {
-      const admin = false;
-      const owner = false;
-      assert.equal(element._computeHideItemClass(owner, admin), 'canModify');
-    });
-
-    test('_computeHideItemClass returns string for owner', () => {
-      const admin = false;
-      const owner = true;
-      assert.equal(element._computeHideItemClass(owner, admin), '');
-    });
-
-    test('delete member', () => {
-      const deletelBtns = Polymer.dom(element.root)
-          .querySelectorAll('.deleteMembersButton');
-      MockInteractions.tap(deletelBtns[0]);
-      assert.equal(element._itemId, '1000097');
-      assert.equal(element._itemName, 'jane');
-      MockInteractions.tap(deletelBtns[1]);
-      assert.equal(element._itemId, '1000096');
-      assert.equal(element._itemName, 'Test User');
-      MockInteractions.tap(deletelBtns[2]);
-      assert.equal(element._itemId, '1000095');
-      assert.equal(element._itemName, 'Gerrit');
-      MockInteractions.tap(deletelBtns[3]);
-      assert.equal(element._itemId, '1000098');
-      assert.equal(element._itemName, '1000098');
-    });
-
-    test('delete included groups', () => {
-      const deletelBtns = Polymer.dom(element.root)
-          .querySelectorAll('.deleteIncludedGroupButton');
-      MockInteractions.tap(deletelBtns[0]);
-      assert.equal(element._itemId, 'testId');
-      assert.equal(element._itemName, 'testName');
-      MockInteractions.tap(deletelBtns[1]);
-      assert.equal(element._itemId, 'testId2');
-      assert.equal(element._itemName, 'testName2');
-      MockInteractions.tap(deletelBtns[2]);
-      assert.equal(element._itemId, 'testId3');
-      assert.equal(element._itemName, 'testName3');
-    });
-
-    test('_computeLoadingClass', () => {
-      assert.equal(element._computeLoadingClass(true), 'loading');
-
-      assert.equal(element._computeLoadingClass(false), '');
-    });
-
-    test('_computeGroupUrl', () => {
-      assert.isUndefined(element._computeGroupUrl(undefined));
-
-      assert.isUndefined(element._computeGroupUrl(false));
-
-      let url = '#/admin/groups/uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
-      assert.equal(element._computeGroupUrl(url),
-          'https://test/site/admin/groups/' +
-          'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498');
-
-      url = 'https://gerrit.local/admin/groups/' +
-          'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
-      assert.equal(element._computeGroupUrl(url), url);
-    });
-
-    test('fires page-error', done => {
-      groupStub.restore();
-
-      element.groupId = 1;
-
-      const response = {status: 404};
-      sandbox.stub(
-          element.$.restAPI, 'getGroupConfig', (group, errFn) => {
-            errFn(response);
-          });
-      element.addEventListener('page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element._loadGroupDetails();
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+      assert.isTrue(saveStub.lastCall.calledWithExactly('Administrators',
+          1234));
     });
   });
+
+  test('save included groups correctly', () => {
+    element._groupOwner = true;
+
+    const includedGroupName = 'testName';
+
+    const saveIncludedGroupStub = sandbox.stub(
+        element.$.restAPI, 'saveIncludedGroup', () => Promise.resolve({}));
+
+    const button = element.$.saveIncludedGroups;
+
+    assert.isTrue(button.hasAttribute('disabled'));
+
+    element.$.includedGroupSearchInput.text = includedGroupName;
+    element.$.includedGroupSearchInput.value = 'testId';
+
+    assert.isFalse(button.hasAttribute('disabled'));
+
+    return element._handleSavingIncludedGroups().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+      assert.equal(saveIncludedGroupStub.lastCall.args[0], 'Administrators');
+      assert.equal(saveIncludedGroupStub.lastCall.args[1], 'testId');
+    });
+  });
+
+  test('add included group 404 shows helpful error text', () => {
+    element._groupOwner = true;
+
+    const memberName = 'bad-name';
+    const alertStub = sandbox.stub();
+    element.addEventListener('show-alert', alertStub);
+    const error = new Error('error');
+    error.status = 404;
+    sandbox.stub(element.$.restAPI, 'saveGroupMembers',
+        () => Promise.reject(error));
+
+    element.$.groupMemberSearchInput.text = memberName;
+    element.$.groupMemberSearchInput.value = 1234;
+
+    return element._handleSavingIncludedGroups().then(() => {
+      assert.isTrue(alertStub.called);
+    });
+  });
+
+  test('_getAccountSuggestions empty', done => {
+    element
+        ._getAccountSuggestions('nonexistent').then(accounts => {
+          assert.equal(accounts.length, 0);
+          done();
+        });
+  });
+
+  test('_getAccountSuggestions non-empty', done => {
+    element
+        ._getAccountSuggestions('test-').then(accounts => {
+          assert.equal(accounts.length, 3);
+          assert.equal(accounts[0].name,
+              'test-account <test.account@example.com>');
+          assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
+          assert.equal(accounts[2].name, 'test-git');
+          done();
+        });
+  });
+
+  test('_getGroupSuggestions empty', done => {
+    element
+        ._getGroupSuggestions('nonexistent').then(groups => {
+          assert.equal(groups.length, 0);
+          done();
+        });
+  });
+
+  test('_getGroupSuggestions non-empty', done => {
+    element
+        ._getGroupSuggestions('test').then(groups => {
+          assert.equal(groups.length, 2);
+          assert.equal(groups[0].name, 'test-admin');
+          assert.equal(groups[1].name, 'test/Administrator (admin)');
+          done();
+        });
+  });
+
+  test('_computeHideItemClass returns string for admin', () => {
+    const admin = true;
+    const owner = false;
+    assert.equal(element._computeHideItemClass(owner, admin), '');
+  });
+
+  test('_computeHideItemClass returns hideItem for admin and owner', () => {
+    const admin = false;
+    const owner = false;
+    assert.equal(element._computeHideItemClass(owner, admin), 'canModify');
+  });
+
+  test('_computeHideItemClass returns string for owner', () => {
+    const admin = false;
+    const owner = true;
+    assert.equal(element._computeHideItemClass(owner, admin), '');
+  });
+
+  test('delete member', () => {
+    const deletelBtns = dom(element.root)
+        .querySelectorAll('.deleteMembersButton');
+    MockInteractions.tap(deletelBtns[0]);
+    assert.equal(element._itemId, '1000097');
+    assert.equal(element._itemName, 'jane');
+    MockInteractions.tap(deletelBtns[1]);
+    assert.equal(element._itemId, '1000096');
+    assert.equal(element._itemName, 'Test User');
+    MockInteractions.tap(deletelBtns[2]);
+    assert.equal(element._itemId, '1000095');
+    assert.equal(element._itemName, 'Gerrit');
+    MockInteractions.tap(deletelBtns[3]);
+    assert.equal(element._itemId, '1000098');
+    assert.equal(element._itemName, '1000098');
+  });
+
+  test('delete included groups', () => {
+    const deletelBtns = dom(element.root)
+        .querySelectorAll('.deleteIncludedGroupButton');
+    MockInteractions.tap(deletelBtns[0]);
+    assert.equal(element._itemId, 'testId');
+    assert.equal(element._itemName, 'testName');
+    MockInteractions.tap(deletelBtns[1]);
+    assert.equal(element._itemId, 'testId2');
+    assert.equal(element._itemName, 'testName2');
+    MockInteractions.tap(deletelBtns[2]);
+    assert.equal(element._itemId, 'testId3');
+    assert.equal(element._itemName, 'testName3');
+  });
+
+  test('_computeLoadingClass', () => {
+    assert.equal(element._computeLoadingClass(true), 'loading');
+
+    assert.equal(element._computeLoadingClass(false), '');
+  });
+
+  test('_computeGroupUrl', () => {
+    assert.isUndefined(element._computeGroupUrl(undefined));
+
+    assert.isUndefined(element._computeGroupUrl(false));
+
+    let url = '#/admin/groups/uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
+    assert.equal(element._computeGroupUrl(url),
+        'https://test/site/admin/groups/' +
+        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498');
+
+    url = 'https://gerrit.local/admin/groups/' +
+        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
+    assert.equal(element._computeGroupUrl(url), url);
+  });
+
+  test('fires page-error', done => {
+    groupStub.restore();
+
+    element.groupId = 1;
+
+    const response = {status: 404};
+    sandbox.stub(
+        element.$.restAPI, 'getGroupConfig', (group, errFn) => {
+          errFn(response);
+        });
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
+    });
+
+    element._loadGroupDetails();
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
index 42846f4..5127733 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
@@ -14,238 +14,253 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
 
-  const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
+import '../../../scripts/bundled-polymer.js';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-subpage-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-group_html.js';
 
-  const OPTIONS = {
-    submitFalse: {
-      value: false,
-      label: 'False',
-    },
-    submitTrue: {
-      value: true,
-      label: 'True',
-    },
-  };
+const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
+const OPTIONS = {
+  submitFalse: {
+    value: false,
+    label: 'False',
+  },
+  submitTrue: {
+    value: true,
+    label: 'True',
+  },
+};
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrGroup extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-group'; }
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the group name changes.
+   *
+   * @event name-changed
    */
-  class GrGroup extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-group'; }
-    /**
-     * Fired when the group name changes.
-     *
-     * @event name-changed
-     */
 
-    static get properties() {
-      return {
-        groupId: Number,
-        _rename: {
-          type: Boolean,
-          value: false,
+  static get properties() {
+    return {
+      groupId: Number,
+      _rename: {
+        type: Boolean,
+        value: false,
+      },
+      _groupIsInternal: Boolean,
+      _description: {
+        type: Boolean,
+        value: false,
+      },
+      _owner: {
+        type: Boolean,
+        value: false,
+      },
+      _options: {
+        type: Boolean,
+        value: false,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      /** @type {?} */
+      _groupConfig: Object,
+      _groupConfigOwner: String,
+      _groupName: Object,
+      _groupOwner: {
+        type: Boolean,
+        value: false,
+      },
+      _submitTypes: {
+        type: Array,
+        value() {
+          return Object.values(OPTIONS);
         },
-        _groupIsInternal: Boolean,
-        _description: {
-          type: Boolean,
-          value: false,
+      },
+      _query: {
+        type: Function,
+        value() {
+          return this._getGroupSuggestions.bind(this);
         },
-        _owner: {
-          type: Boolean,
-          value: false,
-        },
-        _options: {
-          type: Boolean,
-          value: false,
-        },
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-        /** @type {?} */
-        _groupConfig: Object,
-        _groupConfigOwner: String,
-        _groupName: Object,
-        _groupOwner: {
-          type: Boolean,
-          value: false,
-        },
-        _submitTypes: {
-          type: Array,
-          value() {
-            return Object.values(OPTIONS);
-          },
-        },
-        _query: {
-          type: Function,
-          value() {
-            return this._getGroupSuggestions.bind(this);
-          },
-        },
-        _isAdmin: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
-
-    static get observers() {
-      return [
-        '_handleConfigName(_groupConfig.name)',
-        '_handleConfigOwner(_groupConfig.owner, _groupConfigOwner)',
-        '_handleConfigDescription(_groupConfig.description)',
-        '_handleConfigOptions(_groupConfig.options.visible_to_all)',
-      ];
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this._loadGroup();
-    }
-
-    _loadGroup() {
-      if (!this.groupId) { return; }
-
-      const promises = [];
-
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
-
-      return this.$.restAPI.getGroupConfig(this.groupId, errFn)
-          .then(config => {
-            if (!config || !config.name) { return Promise.resolve(); }
-
-            this._groupName = config.name;
-            this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
-
-            promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
-              this._isAdmin = isAdmin ? true : false;
-            }));
-
-            promises.push(this.$.restAPI.getIsGroupOwner(config.name)
-                .then(isOwner => {
-                  this._groupOwner = isOwner ? true : false;
-                }));
-
-            // If visible to all is undefined, set to false. If it is defined
-            // as false, setting to false is fine. If any optional values
-            // are added with a default of true, then this would need to be an
-            // undefined check and not a truthy/falsy check.
-            if (!config.options.visible_to_all) {
-              config.options.visible_to_all = false;
-            }
-            this._groupConfig = config;
-
-            this.fire('title-change', {title: config.name});
-
-            return Promise.all(promises).then(() => {
-              this._loading = false;
-            });
-          });
-    }
-
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    }
-
-    _isLoading() {
-      return this._loading || this._loading === undefined;
-    }
-
-    _handleSaveName() {
-      return this.$.restAPI.saveGroupName(this.groupId, this._groupConfig.name)
-          .then(config => {
-            if (config.status === 200) {
-              this._groupName = this._groupConfig.name;
-              this.fire('name-changed', {name: this._groupConfig.name,
-                external: this._groupIsExtenral});
-              this._rename = false;
-            }
-          });
-    }
-
-    _handleSaveOwner() {
-      let owner = this._groupConfig.owner;
-      if (this._groupConfigOwner) {
-        owner = decodeURIComponent(this._groupConfigOwner);
-      }
-      return this.$.restAPI.saveGroupOwner(this.groupId,
-          owner).then(config => {
-        this._owner = false;
-      });
-    }
-
-    _handleSaveDescription() {
-      return this.$.restAPI.saveGroupDescription(this.groupId,
-          this._groupConfig.description).then(config => {
-        this._description = false;
-      });
-    }
-
-    _handleSaveOptions() {
-      const visible = this._groupConfig.options.visible_to_all;
-
-      const options = {visible_to_all: visible};
-
-      return this.$.restAPI.saveGroupOptions(this.groupId,
-          options).then(config => {
-        this._options = false;
-      });
-    }
-
-    _handleConfigName() {
-      if (this._isLoading()) { return; }
-      this._rename = true;
-    }
-
-    _handleConfigOwner() {
-      if (this._isLoading()) { return; }
-      this._owner = true;
-    }
-
-    _handleConfigDescription() {
-      if (this._isLoading()) { return; }
-      this._description = true;
-    }
-
-    _handleConfigOptions() {
-      if (this._isLoading()) { return; }
-      this._options = true;
-    }
-
-    _computeHeaderClass(configChanged) {
-      return configChanged ? 'edited' : '';
-    }
-
-    _getGroupSuggestions(input) {
-      return this.$.restAPI.getSuggestedGroups(input)
-          .then(response => {
-            const groups = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              groups.push({
-                name: key,
-                value: decodeURIComponent(response[key].id),
-              });
-            }
-            return groups;
-          });
-    }
-
-    _computeGroupDisabled(owner, admin, groupIsInternal) {
-      return groupIsInternal && (admin || owner) ? false : true;
-    }
+      },
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+    };
   }
 
-  customElements.define(GrGroup.is, GrGroup);
-})();
+  static get observers() {
+    return [
+      '_handleConfigName(_groupConfig.name)',
+      '_handleConfigOwner(_groupConfig.owner, _groupConfigOwner)',
+      '_handleConfigDescription(_groupConfig.description)',
+      '_handleConfigOptions(_groupConfig.options.visible_to_all)',
+    ];
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadGroup();
+  }
+
+  _loadGroup() {
+    if (!this.groupId) { return; }
+
+    const promises = [];
+
+    const errFn = response => {
+      this.fire('page-error', {response});
+    };
+
+    return this.$.restAPI.getGroupConfig(this.groupId, errFn)
+        .then(config => {
+          if (!config || !config.name) { return Promise.resolve(); }
+
+          this._groupName = config.name;
+          this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
+
+          promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
+            this._isAdmin = isAdmin ? true : false;
+          }));
+
+          promises.push(this.$.restAPI.getIsGroupOwner(config.name)
+              .then(isOwner => {
+                this._groupOwner = isOwner ? true : false;
+              }));
+
+          // If visible to all is undefined, set to false. If it is defined
+          // as false, setting to false is fine. If any optional values
+          // are added with a default of true, then this would need to be an
+          // undefined check and not a truthy/falsy check.
+          if (!config.options.visible_to_all) {
+            config.options.visible_to_all = false;
+          }
+          this._groupConfig = config;
+
+          this.fire('title-change', {title: config.name});
+
+          return Promise.all(promises).then(() => {
+            this._loading = false;
+          });
+        });
+  }
+
+  _computeLoadingClass(loading) {
+    return loading ? 'loading' : '';
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _handleSaveName() {
+    return this.$.restAPI.saveGroupName(this.groupId, this._groupConfig.name)
+        .then(config => {
+          if (config.status === 200) {
+            this._groupName = this._groupConfig.name;
+            this.fire('name-changed', {name: this._groupConfig.name,
+              external: this._groupIsExtenral});
+            this._rename = false;
+          }
+        });
+  }
+
+  _handleSaveOwner() {
+    let owner = this._groupConfig.owner;
+    if (this._groupConfigOwner) {
+      owner = decodeURIComponent(this._groupConfigOwner);
+    }
+    return this.$.restAPI.saveGroupOwner(this.groupId,
+        owner).then(config => {
+      this._owner = false;
+    });
+  }
+
+  _handleSaveDescription() {
+    return this.$.restAPI.saveGroupDescription(this.groupId,
+        this._groupConfig.description).then(config => {
+      this._description = false;
+    });
+  }
+
+  _handleSaveOptions() {
+    const visible = this._groupConfig.options.visible_to_all;
+
+    const options = {visible_to_all: visible};
+
+    return this.$.restAPI.saveGroupOptions(this.groupId,
+        options).then(config => {
+      this._options = false;
+    });
+  }
+
+  _handleConfigName() {
+    if (this._isLoading()) { return; }
+    this._rename = true;
+  }
+
+  _handleConfigOwner() {
+    if (this._isLoading()) { return; }
+    this._owner = true;
+  }
+
+  _handleConfigDescription() {
+    if (this._isLoading()) { return; }
+    this._description = true;
+  }
+
+  _handleConfigOptions() {
+    if (this._isLoading()) { return; }
+    this._options = true;
+  }
+
+  _computeHeaderClass(configChanged) {
+    return configChanged ? 'edited' : '';
+  }
+
+  _getGroupSuggestions(input) {
+    return this.$.restAPI.getSuggestedGroups(input)
+        .then(response => {
+          const groups = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            groups.push({
+              name: key,
+              value: decodeURIComponent(response[key].id),
+            });
+          }
+          return groups;
+        });
+  }
+
+  _computeGroupDisabled(owner, admin, groupIsInternal) {
+    return groupIsInternal && (admin || owner) ? false : true;
+  }
+}
+
+customElements.define(GrGroup.is, GrGroup);
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js
index faabe84..dc80235 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js
@@ -1,33 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-subpage-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-group">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -44,77 +33,57 @@
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
     <main class="gr-form-styles read-only">
-      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+      <div id="loading" class\$="[[_computeLoadingClass(_loading)]]">
         Loading...
       </div>
-      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <div id="loadedContent" class\$="[[_computeLoadingClass(_loading)]]">
         <h1 id="Title">[[_groupName]]</h1>
         <h2 id="configurations">General</h2>
         <div id="form">
           <fieldset>
             <h3 id="groupUUID">Group UUID</h3>
             <fieldset>
-              <gr-copy-clipboard
-                  text="[[groupId]]"></gr-copy-clipboard>
+              <gr-copy-clipboard text="[[groupId]]"></gr-copy-clipboard>
             </fieldset>
-            <h3 id="groupName" class$="[[_computeHeaderClass(_rename)]]">
+            <h3 id="groupName" class\$="[[_computeHeaderClass(_rename)]]">
               Group Name
             </h3>
             <fieldset>
               <span class="value">
-                <gr-autocomplete
-                    id="groupNameInput"
-                    text="{{_groupConfig.name}}"
-                    disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"></gr-autocomplete>
+                <gr-autocomplete id="groupNameInput" text="{{_groupConfig.name}}" disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"></gr-autocomplete>
               </span>
-              <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
-                <gr-button
-                    id="inputUpdateNameBtn"
-                    on-click="_handleSaveName"
-                    disabled="[[!_rename]]">
+              <span class="value" disabled\$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
+                <gr-button id="inputUpdateNameBtn" on-click="_handleSaveName" disabled="[[!_rename]]">
                   Rename Group</gr-button>
               </span>
             </fieldset>
-            <h3 class$="[[_computeHeaderClass(_owner)]]">
+            <h3 class\$="[[_computeHeaderClass(_owner)]]">
               Owners
             </h3>
             <fieldset>
               <span class="value">
-                <gr-autocomplete
-                    id="groupOwnerInput"
-                    text="{{_groupConfig.owner}}"
-                    value="{{_groupConfigOwner}}"
-                    query="[[_query]]"
-                    disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
+                <gr-autocomplete id="groupOwnerInput" text="{{_groupConfig.owner}}" value="{{_groupConfigOwner}}" query="[[_query]]" disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
                 </gr-autocomplete>
               </span>
-              <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
-                <gr-button
-                    on-click="_handleSaveOwner"
-                    disabled="[[!_owner]]">
+              <span class="value" disabled\$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
+                <gr-button on-click="_handleSaveOwner" disabled="[[!_owner]]">
                   Change Owners</gr-button>
               </span>
             </fieldset>
-            <h3 class$="[[_computeHeaderClass(_description)]]">
+            <h3 class\$="[[_computeHeaderClass(_description)]]">
               Description
             </h3>
             <fieldset>
               <div>
-                <iron-autogrow-textarea
-                    class="description"
-                    autocomplete="on"
-                    bind-value="{{_groupConfig.description}}"
-                    disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"></iron-autogrow-textarea>
+                <iron-autogrow-textarea class="description" autocomplete="on" bind-value="{{_groupConfig.description}}" disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"></iron-autogrow-textarea>
               </div>
-              <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
-                <gr-button
-                    on-click="_handleSaveDescription"
-                    disabled="[[!_description]]">
+              <span class="value" disabled\$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
+                <gr-button on-click="_handleSaveDescription" disabled="[[!_description]]">
                   Save Description
                 </gr-button>
               </span>
             </fieldset>
-            <h3 id="options" class$="[[_computeHeaderClass(_options)]]">
+            <h3 id="options" class\$="[[_computeHeaderClass(_options)]]">
               Group Options
             </h3>
             <fieldset id="visableToAll">
@@ -123,10 +92,8 @@
                   Make group visible to all registered users
                 </span>
                 <span class="value">
-                  <gr-select
-                      id="visibleToAll"
-                      bind-value="{{_groupConfig.options.visible_to_all}}">
-                    <select disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
+                  <gr-select id="visibleToAll" bind-value="{{_groupConfig.options.visible_to_all}}">
+                    <select disabled\$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
                       <template is="dom-repeat" items="[[_submitTypes]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
@@ -134,10 +101,8 @@
                   </gr-select>
                 </span>
               </section>
-              <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
-                <gr-button
-                    on-click="_handleSaveOptions"
-                    disabled="[[!_options]]">
+              <span class="value" disabled\$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
+                <gr-button on-click="_handleSaveOptions" disabled="[[!_options]]">
                   Save Group Options
                 </gr-button>
               </span>
@@ -147,6 +112,4 @@
       </div>
     </main>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-group.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
index a6aebbf..9f278c4 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-group.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-group.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-group.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,222 +40,224 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-group tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let groupStub;
-    const group = {
-      id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-      url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
-      options: {},
-      description: 'Gerrit Site Administrators',
-      group_id: 1,
-      owner: 'Administrators',
-      owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-      name: 'Administrators',
-    };
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-group.js';
+suite('gr-group tests', () => {
+  let element;
+  let sandbox;
+  let groupStub;
+  const group = {
+    id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    options: {},
+    description: 'Gerrit Site Administrators',
+    group_id: 1,
+    owner: 'Administrators',
+    owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    name: 'Administrators',
+  };
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-      });
-      element = fixture('basic');
-      groupStub = sandbox.stub(
-          element.$.restAPI,
-          'getGroupConfig',
-          () => Promise.resolve(group)
-      );
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
     });
+    element = fixture('basic');
+    groupStub = sandbox.stub(
+        element.$.restAPI,
+        'getGroupConfig',
+        () => Promise.resolve(group)
+    );
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('loading displays before group config is loaded', () => {
-      assert.isTrue(element.$.loading.classList.contains('loading'));
-      assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-      assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-      assert.isTrue(getComputedStyle(element.$.loadedContent)
-          .display === 'none');
-    });
+  test('loading displays before group config is loaded', () => {
+    assert.isTrue(element.$.loading.classList.contains('loading'));
+    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
+    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
+    assert.isTrue(getComputedStyle(element.$.loadedContent)
+        .display === 'none');
+  });
 
-    test('default values are populated with internal group', done => {
-      sandbox.stub(
-          element.$.restAPI,
-          'getIsGroupOwner',
-          () => Promise.resolve(true));
-      element.groupId = 1;
-      element._loadGroup().then(() => {
-        assert.isTrue(element._groupIsInternal);
-        assert.isFalse(element.$.visibleToAll.bindValue);
-        done();
-      });
-    });
-
-    test('default values with external group', done => {
-      const groupExternal = Object.assign({}, group);
-      groupExternal.id = 'external-group-id';
-      groupStub.restore();
-      groupStub = sandbox.stub(
-          element.$.restAPI,
-          'getGroupConfig',
-          () => Promise.resolve(groupExternal));
-      sandbox.stub(
-          element.$.restAPI,
-          'getIsGroupOwner',
-          () => Promise.resolve(true));
-      element.groupId = 1;
-      element._loadGroup().then(() => {
-        assert.isFalse(element._groupIsInternal);
-        assert.isFalse(element.$.visibleToAll.bindValue);
-        done();
-      });
-    });
-
-    test('rename group', done => {
-      const groupName = 'test-group';
-      const groupName2 = 'test-group2';
-      element.groupId = 1;
-      element._groupConfig = {
-        name: groupName,
-      };
-      element._groupConfigOwner = 'testId';
-      element._groupName = groupName;
-      element._groupOwner = true;
-
-      sandbox.stub(
-          element.$.restAPI,
-          'getIsGroupOwner',
-          () => Promise.resolve(true));
-
-      sandbox.stub(
-          element.$.restAPI,
-          'saveGroupName',
-          () => Promise.resolve({status: 200}));
-
-      const button = element.$.inputUpdateNameBtn;
-
-      element._loadGroup().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-
-        element.$.groupNameInput.text = groupName2;
-
-        element.$.groupOwnerInput.text = 'testId2';
-
-        assert.isFalse(button.hasAttribute('disabled'));
-        assert.isTrue(element.$.groupName.classList.contains('edited'));
-
-        element._handleSaveName().then(() => {
-          assert.isTrue(button.hasAttribute('disabled'));
-          assert.isFalse(element.$.Title.classList.contains('edited'));
-          assert.equal(element._groupName, groupName2);
-          done();
-        });
-
-        element._handleSaveOwner().then(() => {
-          assert.isTrue(button.hasAttribute('disabled'));
-          assert.isFalse(element.$.Title.classList.contains('edited'));
-          assert.equal(element._groupConfigOwner, 'testId2');
-          done();
-        });
-      });
-    });
-
-    test('test for undefined group name', done => {
-      groupStub.restore();
-
-      sandbox.stub(
-          element.$.restAPI,
-          'getGroupConfig',
-          () => Promise.resolve({}));
-
-      assert.isUndefined(element.groupId);
-
-      element.groupId = 1;
-
-      assert.isDefined(element.groupId);
-
-      // Test that loading shows instead of filling
-      // in group details
-      element._loadGroup().then(() => {
-        assert.isTrue(element.$.loading.classList.contains('loading'));
-
-        assert.isTrue(element._loading);
-
-        done();
-      });
-    });
-
-    test('test fire event', done => {
-      element._groupConfig = {
-        name: 'test-group',
-      };
-
-      sandbox.stub(element.$.restAPI, 'saveGroupName')
-          .returns(Promise.resolve({status: 200}));
-
-      const showStub = sandbox.stub(element, 'fire');
-      element._handleSaveName()
-          .then(() => {
-            assert.isTrue(showStub.called);
-            done();
-          });
-    });
-
-    test('_computeGroupDisabled', () => {
-      let admin = true;
-      let owner = false;
-      let groupIsInternal = true;
-      assert.equal(element._computeGroupDisabled(owner, admin,
-          groupIsInternal), false);
-
-      admin = false;
-      assert.equal(element._computeGroupDisabled(owner, admin,
-          groupIsInternal), true);
-
-      owner = true;
-      assert.equal(element._computeGroupDisabled(owner, admin,
-          groupIsInternal), false);
-
-      owner = false;
-      assert.equal(element._computeGroupDisabled(owner, admin,
-          groupIsInternal), true);
-
-      groupIsInternal = false;
-      assert.equal(element._computeGroupDisabled(owner, admin,
-          groupIsInternal), true);
-
-      admin = true;
-      assert.equal(element._computeGroupDisabled(owner, admin,
-          groupIsInternal), true);
-    });
-
-    test('_computeLoadingClass', () => {
-      assert.equal(element._computeLoadingClass(true), 'loading');
-      assert.equal(element._computeLoadingClass(false), '');
-    });
-
-    test('fires page-error', done => {
-      groupStub.restore();
-
-      element.groupId = 1;
-
-      const response = {status: 404};
-      sandbox.stub(
-          element.$.restAPI, 'getGroupConfig', (group, errFn) => {
-            errFn(response);
-          });
-
-      element.addEventListener('page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element._loadGroup();
+  test('default values are populated with internal group', done => {
+    sandbox.stub(
+        element.$.restAPI,
+        'getIsGroupOwner',
+        () => Promise.resolve(true));
+    element.groupId = 1;
+    element._loadGroup().then(() => {
+      assert.isTrue(element._groupIsInternal);
+      assert.isFalse(element.$.visibleToAll.bindValue);
+      done();
     });
   });
+
+  test('default values with external group', done => {
+    const groupExternal = Object.assign({}, group);
+    groupExternal.id = 'external-group-id';
+    groupStub.restore();
+    groupStub = sandbox.stub(
+        element.$.restAPI,
+        'getGroupConfig',
+        () => Promise.resolve(groupExternal));
+    sandbox.stub(
+        element.$.restAPI,
+        'getIsGroupOwner',
+        () => Promise.resolve(true));
+    element.groupId = 1;
+    element._loadGroup().then(() => {
+      assert.isFalse(element._groupIsInternal);
+      assert.isFalse(element.$.visibleToAll.bindValue);
+      done();
+    });
+  });
+
+  test('rename group', done => {
+    const groupName = 'test-group';
+    const groupName2 = 'test-group2';
+    element.groupId = 1;
+    element._groupConfig = {
+      name: groupName,
+    };
+    element._groupConfigOwner = 'testId';
+    element._groupName = groupName;
+    element._groupOwner = true;
+
+    sandbox.stub(
+        element.$.restAPI,
+        'getIsGroupOwner',
+        () => Promise.resolve(true));
+
+    sandbox.stub(
+        element.$.restAPI,
+        'saveGroupName',
+        () => Promise.resolve({status: 200}));
+
+    const button = element.$.inputUpdateNameBtn;
+
+    element._loadGroup().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+
+      element.$.groupNameInput.text = groupName2;
+
+      element.$.groupOwnerInput.text = 'testId2';
+
+      assert.isFalse(button.hasAttribute('disabled'));
+      assert.isTrue(element.$.groupName.classList.contains('edited'));
+
+      element._handleSaveName().then(() => {
+        assert.isTrue(button.hasAttribute('disabled'));
+        assert.isFalse(element.$.Title.classList.contains('edited'));
+        assert.equal(element._groupName, groupName2);
+        done();
+      });
+
+      element._handleSaveOwner().then(() => {
+        assert.isTrue(button.hasAttribute('disabled'));
+        assert.isFalse(element.$.Title.classList.contains('edited'));
+        assert.equal(element._groupConfigOwner, 'testId2');
+        done();
+      });
+    });
+  });
+
+  test('test for undefined group name', done => {
+    groupStub.restore();
+
+    sandbox.stub(
+        element.$.restAPI,
+        'getGroupConfig',
+        () => Promise.resolve({}));
+
+    assert.isUndefined(element.groupId);
+
+    element.groupId = 1;
+
+    assert.isDefined(element.groupId);
+
+    // Test that loading shows instead of filling
+    // in group details
+    element._loadGroup().then(() => {
+      assert.isTrue(element.$.loading.classList.contains('loading'));
+
+      assert.isTrue(element._loading);
+
+      done();
+    });
+  });
+
+  test('test fire event', done => {
+    element._groupConfig = {
+      name: 'test-group',
+    };
+
+    sandbox.stub(element.$.restAPI, 'saveGroupName')
+        .returns(Promise.resolve({status: 200}));
+
+    const showStub = sandbox.stub(element, 'fire');
+    element._handleSaveName()
+        .then(() => {
+          assert.isTrue(showStub.called);
+          done();
+        });
+  });
+
+  test('_computeGroupDisabled', () => {
+    let admin = true;
+    let owner = false;
+    let groupIsInternal = true;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), false);
+
+    admin = false;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), true);
+
+    owner = true;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), false);
+
+    owner = false;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), true);
+
+    groupIsInternal = false;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), true);
+
+    admin = true;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), true);
+  });
+
+  test('_computeLoadingClass', () => {
+    assert.equal(element._computeLoadingClass(true), 'loading');
+    assert.equal(element._computeLoadingClass(false), '');
+  });
+
+  test('fires page-error', done => {
+    groupStub.restore();
+
+    element.groupId = 1;
+
+    const response = {status: 404};
+    sandbox.stub(
+        element.$.restAPI, 'getGroupConfig', (group, errFn) => {
+          errFn(response);
+        });
+
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
+    });
+
+    element._loadGroup();
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
index 508c3a2..4c5bbb8 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -14,301 +14,318 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const MAX_AUTOCOMPLETE_RESULTS = 20;
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-menu-page-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-rule-editor/gr-rule-editor.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-permission_html.js';
 
-  const RANGE_NAMES = [
-    'QUERY LIMIT',
-    'BATCH CHANGES LIMIT',
-  ];
+const MAX_AUTOCOMPLETE_RESULTS = 20;
 
+const RANGE_NAMES = [
+  'QUERY LIMIT',
+  'BATCH CHANGES LIMIT',
+];
+
+/**
+ * @appliesMixin Gerrit.AccessMixin
+ * @appliesMixin Gerrit.FireMixin
+ */
+/**
+ * Fired when the permission has been modified or removed.
+ *
+ * @event access-modified
+ */
+/**
+ * Fired when a permission that was previously added was removed.
+ *
+ * @event added-permission-removed
+ * @extends Polymer.Element
+ */
+class GrPermission extends mixinBehaviors( [
+  Gerrit.AccessBehavior,
   /**
-   * @appliesMixin Gerrit.AccessMixin
-   * @appliesMixin Gerrit.FireMixin
+   * Unused in this element, but called by other elements in tests
+   * e.g gr-access-section_test.
    */
-  /**
-   * Fired when the permission has been modified or removed.
-   *
-   * @event access-modified
-   */
-  /**
-   * Fired when a permission that was previously added was removed.
-   *
-   * @event added-permission-removed
-   * @extends Polymer.Element
-   */
-  class GrPermission extends Polymer.mixinBehaviors( [
-    Gerrit.AccessBehavior,
-    /**
-     * Unused in this element, but called by other elements in tests
-     * e.g gr-access-section_test.
-     */
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-permission'; }
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    static get properties() {
-      return {
-        labels: Object,
-        name: String,
-        /** @type {?} */
-        permission: {
-          type: Object,
-          observer: '_sortPermission',
-          notify: true,
+  static get is() { return 'gr-permission'; }
+
+  static get properties() {
+    return {
+      labels: Object,
+      name: String,
+      /** @type {?} */
+      permission: {
+        type: Object,
+        observer: '_sortPermission',
+        notify: true,
+      },
+      groups: Object,
+      section: String,
+      editing: {
+        type: Boolean,
+        value: false,
+        observer: '_handleEditingChanged',
+      },
+      _label: {
+        type: Object,
+        computed: '_computeLabel(permission, labels)',
+      },
+      _groupFilter: String,
+      _query: {
+        type: Function,
+        value() {
+          return this._getGroupSuggestions.bind(this);
         },
-        groups: Object,
-        section: String,
-        editing: {
-          type: Boolean,
-          value: false,
-          observer: '_handleEditingChanged',
-        },
-        _label: {
-          type: Object,
-          computed: '_computeLabel(permission, labels)',
-        },
-        _groupFilter: String,
-        _query: {
-          type: Function,
-          value() {
-            return this._getGroupSuggestions.bind(this);
-          },
-        },
-        _rules: Array,
-        _groupsWithRules: Object,
-        _deleted: {
-          type: Boolean,
-          value: false,
-        },
-        _originalExclusiveValue: Boolean,
-      };
-    }
+      },
+      _rules: Array,
+      _groupsWithRules: Object,
+      _deleted: {
+        type: Boolean,
+        value: false,
+      },
+      _originalExclusiveValue: Boolean,
+    };
+  }
 
-    static get observers() {
-      return [
-        '_handleRulesChanged(_rules.splices)',
-      ];
-    }
+  static get observers() {
+    return [
+      '_handleRulesChanged(_rules.splices)',
+    ];
+  }
 
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('access-saved',
-          () => this._handleAccessSaved());
-    }
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('access-saved',
+        () => this._handleAccessSaved());
+  }
 
-    /** @override */
-    ready() {
-      super.ready();
-      this._setupValues();
-    }
+  /** @override */
+  ready() {
+    super.ready();
+    this._setupValues();
+  }
 
-    _setupValues() {
-      if (!this.permission) { return; }
-      this._originalExclusiveValue = !!this.permission.value.exclusive;
-      Polymer.dom.flush();
-    }
+  _setupValues() {
+    if (!this.permission) { return; }
+    this._originalExclusiveValue = !!this.permission.value.exclusive;
+    flush();
+  }
 
-    _handleAccessSaved() {
-      // Set a new 'original' value to keep track of after the value has been
-      // saved.
-      this._setupValues();
-    }
+  _handleAccessSaved() {
+    // Set a new 'original' value to keep track of after the value has been
+    // saved.
+    this._setupValues();
+  }
 
-    _permissionIsOwnerOrGlobal(permissionId, section) {
-      return permissionId === 'owner' || section === 'GLOBAL_CAPABILITIES';
-    }
+  _permissionIsOwnerOrGlobal(permissionId, section) {
+    return permissionId === 'owner' || section === 'GLOBAL_CAPABILITIES';
+  }
 
-    _handleEditingChanged(editing, editingOld) {
-      // Ignore when editing gets set initially.
-      if (!editingOld) { return; }
-      // Restore original values if no longer editing.
-      if (!editing) {
-        this._deleted = false;
-        delete this.permission.value.deleted;
-        this._groupFilter = '';
-        this._rules = this._rules.filter(rule => !rule.value.added);
-        for (const key of Object.keys(this.permission.value.rules)) {
-          if (this.permission.value.rules[key].added) {
-            delete this.permission.value.rules[key];
-          }
-        }
-
-        // Restore exclusive bit to original.
-        this.set(['permission', 'value', 'exclusive'],
-            this._originalExclusiveValue);
-      }
-    }
-
-    _handleAddedRuleRemoved(e) {
-      const index = e.model.index;
-      this._rules = this._rules.slice(0, index)
-          .concat(this._rules.slice(index + 1, this._rules.length));
-    }
-
-    _handleValueChange() {
-      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}));
-    }
-
-    _handleRemovePermission() {
-      if (this.permission.value.added) {
-        this.dispatchEvent(new CustomEvent(
-            'added-permission-removed', {bubbles: true, composed: true}));
-      }
-      this._deleted = true;
-      this.permission.value.deleted = true;
-      this.dispatchEvent(
-          new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    }
-
-    _handleRulesChanged(changeRecord) {
-      // Update the groups to exclude in the autocomplete.
-      this._groupsWithRules = this._computeGroupsWithRules(this._rules);
-    }
-
-    _sortPermission(permission) {
-      this._rules = this.toSortedArray(permission.value.rules);
-    }
-
-    _computeSectionClass(editing, deleted) {
-      const classList = [];
-      if (editing) {
-        classList.push('editing');
-      }
-      if (deleted) {
-        classList.push('deleted');
-      }
-      return classList.join(' ');
-    }
-
-    _handleUndoRemove() {
+  _handleEditingChanged(editing, editingOld) {
+    // Ignore when editing gets set initially.
+    if (!editingOld) { return; }
+    // Restore original values if no longer editing.
+    if (!editing) {
       this._deleted = false;
       delete this.permission.value.deleted;
-    }
-
-    _computeLabel(permission, labels) {
-      if (!labels || !permission ||
-          !permission.value || !permission.value.label) { return; }
-
-      const labelName = permission.value.label;
-
-      // It is possible to have a label name that is not included in the
-      // 'labels' object. In this case, treat it like anything else.
-      if (!labels[labelName]) { return; }
-      const label = {
-        name: labelName,
-        values: this._computeLabelValues(labels[labelName].values),
-      };
-      return label;
-    }
-
-    _computeLabelValues(values) {
-      const valuesArr = [];
-      const keys = Object.keys(values)
-          .sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
-
-      for (const key of keys) {
-        let text = values[key];
-        if (!text) { text = ''; }
-        // The value from the server being used to choose which item is
-        // selected is in integer form, so this must be converted.
-        valuesArr.push({value: parseInt(key, 10), text});
-      }
-      return valuesArr;
-    }
-
-    /**
-     * @param {!Array} rules
-     * @return {!Object} Object with groups with rues as keys, and true as
-     *    value.
-     */
-    _computeGroupsWithRules(rules) {
-      const groups = {};
-      for (const rule of rules) {
-        groups[rule.id] = true;
-      }
-      return groups;
-    }
-
-    _computeGroupName(groups, groupId) {
-      return groups && groups[groupId] && groups[groupId].name ?
-        groups[groupId].name : groupId;
-    }
-
-    _getGroupSuggestions() {
-      return this.$.restAPI.getSuggestedGroups(
-          this._groupFilter,
-          MAX_AUTOCOMPLETE_RESULTS)
-          .then(response => {
-            const groups = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              groups.push({
-                name: key,
-                value: response[key],
-              });
-            }
-            // Does not return groups in which we already have rules for.
-            return groups
-                .filter(group => !this._groupsWithRules[group.value.id]);
-          });
-    }
-
-    /**
-     * Handles adding a skeleton item to the dom-repeat.
-     * gr-rule-editor handles setting the default values.
-     */
-    _handleAddRuleItem(e) {
-      // The group id is encoded, but have to decode in order for the access
-      // API to work as expected.
-      const groupId = decodeURIComponent(e.detail.value.id)
-          .replace(/\+/g, ' ');
-      // We cannot use "this.set(...)" here, because groupId may contain dots,
-      // and dots in property path names are totally unsupported by Polymer.
-      // Apparently Polymer picks up this change anyway, otherwise we should
-      // have looked at using MutableData:
-      // https://polymer-library.polymer-project.org/2.0/docs/devguide/data-system#mutable-data
-      this.permission.value.rules[groupId] = {};
-
-      // Purposely don't recompute sorted array so that the newly added rule
-      // is the last item of the array.
-      this.push('_rules', {
-        id: groupId,
-      });
-
-      // Add the new group name to the groups object so the name renders
-      // correctly.
-      if (this.groups && !this.groups[groupId]) {
-        this.groups[groupId] = {name: this.$.groupAutocomplete.text};
+      this._groupFilter = '';
+      this._rules = this._rules.filter(rule => !rule.value.added);
+      for (const key of Object.keys(this.permission.value.rules)) {
+        if (this.permission.value.rules[key].added) {
+          delete this.permission.value.rules[key];
+        }
       }
 
-      // Wait for new rule to get value populated via gr-rule-editor, and then
-      // add to permission values as well, so that the change gets propogated
-      // back to the section. Since the rule is inside a dom-repeat, a flush
-      // is needed.
-      Polymer.dom.flush();
-      const value = this._rules[this._rules.length - 1].value;
-      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}));
-    }
-
-    _computeHasRange(name) {
-      if (!name) { return false; }
-
-      return RANGE_NAMES.includes(name.toUpperCase());
+      // Restore exclusive bit to original.
+      this.set(['permission', 'value', 'exclusive'],
+          this._originalExclusiveValue);
     }
   }
 
-  customElements.define(GrPermission.is, GrPermission);
-})();
+  _handleAddedRuleRemoved(e) {
+    const index = e.model.index;
+    this._rules = this._rules.slice(0, index)
+        .concat(this._rules.slice(index + 1, this._rules.length));
+  }
+
+  _handleValueChange() {
+    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}));
+  }
+
+  _handleRemovePermission() {
+    if (this.permission.value.added) {
+      this.dispatchEvent(new CustomEvent(
+          'added-permission-removed', {bubbles: true, composed: true}));
+    }
+    this._deleted = true;
+    this.permission.value.deleted = true;
+    this.dispatchEvent(
+        new CustomEvent('access-modified', {bubbles: true, composed: true}));
+  }
+
+  _handleRulesChanged(changeRecord) {
+    // Update the groups to exclude in the autocomplete.
+    this._groupsWithRules = this._computeGroupsWithRules(this._rules);
+  }
+
+  _sortPermission(permission) {
+    this._rules = this.toSortedArray(permission.value.rules);
+  }
+
+  _computeSectionClass(editing, deleted) {
+    const classList = [];
+    if (editing) {
+      classList.push('editing');
+    }
+    if (deleted) {
+      classList.push('deleted');
+    }
+    return classList.join(' ');
+  }
+
+  _handleUndoRemove() {
+    this._deleted = false;
+    delete this.permission.value.deleted;
+  }
+
+  _computeLabel(permission, labels) {
+    if (!labels || !permission ||
+        !permission.value || !permission.value.label) { return; }
+
+    const labelName = permission.value.label;
+
+    // It is possible to have a label name that is not included in the
+    // 'labels' object. In this case, treat it like anything else.
+    if (!labels[labelName]) { return; }
+    const label = {
+      name: labelName,
+      values: this._computeLabelValues(labels[labelName].values),
+    };
+    return label;
+  }
+
+  _computeLabelValues(values) {
+    const valuesArr = [];
+    const keys = Object.keys(values)
+        .sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
+
+    for (const key of keys) {
+      let text = values[key];
+      if (!text) { text = ''; }
+      // The value from the server being used to choose which item is
+      // selected is in integer form, so this must be converted.
+      valuesArr.push({value: parseInt(key, 10), text});
+    }
+    return valuesArr;
+  }
+
+  /**
+   * @param {!Array} rules
+   * @return {!Object} Object with groups with rues as keys, and true as
+   *    value.
+   */
+  _computeGroupsWithRules(rules) {
+    const groups = {};
+    for (const rule of rules) {
+      groups[rule.id] = true;
+    }
+    return groups;
+  }
+
+  _computeGroupName(groups, groupId) {
+    return groups && groups[groupId] && groups[groupId].name ?
+      groups[groupId].name : groupId;
+  }
+
+  _getGroupSuggestions() {
+    return this.$.restAPI.getSuggestedGroups(
+        this._groupFilter,
+        MAX_AUTOCOMPLETE_RESULTS)
+        .then(response => {
+          const groups = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            groups.push({
+              name: key,
+              value: response[key],
+            });
+          }
+          // Does not return groups in which we already have rules for.
+          return groups
+              .filter(group => !this._groupsWithRules[group.value.id]);
+        });
+  }
+
+  /**
+   * Handles adding a skeleton item to the dom-repeat.
+   * gr-rule-editor handles setting the default values.
+   */
+  _handleAddRuleItem(e) {
+    // The group id is encoded, but have to decode in order for the access
+    // API to work as expected.
+    const groupId = decodeURIComponent(e.detail.value.id)
+        .replace(/\+/g, ' ');
+    // We cannot use "this.set(...)" here, because groupId may contain dots,
+    // and dots in property path names are totally unsupported by Polymer.
+    // Apparently Polymer picks up this change anyway, otherwise we should
+    // have looked at using MutableData:
+    // https://polymer-library.polymer-project.org/2.0/docs/devguide/data-system#mutable-data
+    this.permission.value.rules[groupId] = {};
+
+    // Purposely don't recompute sorted array so that the newly added rule
+    // is the last item of the array.
+    this.push('_rules', {
+      id: groupId,
+    });
+
+    // Add the new group name to the groups object so the name renders
+    // correctly.
+    if (this.groups && !this.groups[groupId]) {
+      this.groups[groupId] = {name: this.$.groupAutocomplete.text};
+    }
+
+    // Wait for new rule to get value populated via gr-rule-editor, and then
+    // add to permission values as well, so that the change gets propogated
+    // back to the section. Since the rule is inside a dom-repeat, a flush
+    // is needed.
+    flush();
+    const value = this._rules[this._rules.length - 1].value;
+    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}));
+  }
+
+  _computeHasRange(name) {
+    if (!name) { return false; }
+
+    return RANGE_NAMES.includes(name.toUpperCase());
+  }
+}
+
+customElements.define(GrPermission.is, GrPermission);
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js
index e07f911..1b57336 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js
@@ -1,34 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-menu-page-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-rule-editor/gr-rule-editor.html">
-
-<dom-module id="gr-permission">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -88,49 +76,23 @@
     <style include="gr-menu-page-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
-    <section
-        id="permission"
-        class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
+    <section id="permission" class\$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
       <div id="mainContainer">
         <div class="header">
           <span class="title">[[name]]</span>
           <div class="right">
-            <template is=dom-if if="[[!_permissionIsOwnerOrGlobal(permission.id, section)]]">
-              <paper-toggle-button
-                  id="exclusiveToggle"
-                  checked="{{permission.value.exclusive}}"
-                  on-change="_handleValueChange"
-                  disabled$="[[!editing]]"></paper-toggle-button>Exclusive
+            <template is="dom-if" if="[[!_permissionIsOwnerOrGlobal(permission.id, section)]]">
+              <paper-toggle-button id="exclusiveToggle" checked="{{permission.value.exclusive}}" on-change="_handleValueChange" disabled\$="[[!editing]]"></paper-toggle-button>Exclusive
             </template>
-            <gr-button
-                link
-                id="removeBtn"
-                on-click="_handleRemovePermission">Remove</gr-button>
+            <gr-button link="" id="removeBtn" on-click="_handleRemovePermission">Remove</gr-button>
           </div>
         </div><!-- end header -->
         <div class="rules">
-          <template
-              is="dom-repeat"
-              items="{{_rules}}"
-              as="rule">
-            <gr-rule-editor
-                has-range="[[_computeHasRange(name)]]"
-                label="[[_label]]"
-                editing="[[editing]]"
-                group-id="[[rule.id]]"
-                group-name="[[_computeGroupName(groups, rule.id)]]"
-                permission="[[permission.id]]"
-                rule="{{rule}}"
-                section="[[section]]"
-                on-added-rule-removed="_handleAddedRuleRemoved"></gr-rule-editor>
+          <template is="dom-repeat" items="{{_rules}}" as="rule">
+            <gr-rule-editor has-range="[[_computeHasRange(name)]]" label="[[_label]]" editing="[[editing]]" group-id="[[rule.id]]" group-name="[[_computeGroupName(groups, rule.id)]]" permission="[[permission.id]]" rule="{{rule}}" section="[[section]]" on-added-rule-removed="_handleAddedRuleRemoved"></gr-rule-editor>
           </template>
           <div id="addRule">
-            <gr-autocomplete
-                id="groupAutocomplete"
-                text="{{_groupFilter}}"
-                query="[[_query]]"
-                placeholder="Add group"
-                on-commit="_handleAddRuleItem">
+            <gr-autocomplete id="groupAutocomplete" text="{{_groupFilter}}" query="[[_query]]" placeholder="Add group" on-commit="_handleAddRuleItem">
             </gr-autocomplete>
           </div>
           <!-- end addRule -->
@@ -138,13 +100,8 @@
       </div><!-- end mainContainer -->
       <div id="deletedContainer">
         <span>[[name]] was deleted</span>
-        <gr-button
-            link
-            id="undoRemoveBtn"
-            on-click="_handleUndoRemove">Undo</gr-button>
+        <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove">Undo</gr-button>
       </div><!-- end deletedContainer -->
     </section>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-permission.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
index f3c1e4f..6f05029 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-permission</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-permission.html">
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-permission.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-permission.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,399 +41,401 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-permission tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-permission.js';
+suite('gr-permission tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      sandbox.stub(element.$.restAPI, 'getSuggestedGroups').returns(
-          Promise.resolve({
-            'Administrators': {
-              id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    sandbox.stub(element.$.restAPI, 'getSuggestedGroups').returns(
+        Promise.resolve({
+          'Administrators': {
+            id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
+          },
+          'Anonymous Users': {
+            id: 'global%3AAnonymous-Users',
+          },
+        }));
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('unit tests', () => {
+    test('_sortPermission', () => {
+      const permission = {
+        id: 'submit',
+        value: {
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
             },
-            'Anonymous Users': {
-              id: 'global%3AAnonymous-Users',
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: 'ALLOW',
+              force: false,
             },
-          }));
+          },
+        },
+      };
+
+      const expectedRules = [
+        {
+          id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
+          value: {action: 'ALLOW', force: false},
+        },
+        {
+          id: 'global:Project-Owners',
+          value: {action: 'ALLOW', force: false},
+        },
+      ];
+
+      element._sortPermission(permission);
+      assert.deepEqual(element._rules, expectedRules);
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('_computeLabel and _computeLabelValues', () => {
+      const labels = {
+        'Code-Review': {
+          default_value: 0,
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not merged as is',
+            '-2': 'This shall not be merged',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+        },
+      };
+      let permission = {
+        id: 'label-Code-Review',
+        value: {
+          label: 'Code-Review',
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
+              min: -2,
+              max: 2,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: 'ALLOW',
+              force: false,
+              min: -2,
+              max: 2,
+            },
+          },
+        },
+      };
+
+      const expectedLabelValues = [
+        {value: -2, text: 'This shall not be merged'},
+        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: 0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ];
+
+      const expectedLabel = {
+        name: 'Code-Review',
+        values: expectedLabelValues,
+      };
+
+      assert.deepEqual(element._computeLabelValues(
+          labels['Code-Review'].values), expectedLabelValues);
+
+      assert.deepEqual(element._computeLabel(permission, labels),
+          expectedLabel);
+
+      permission = {
+        id: 'label-reviewDB',
+        value: {
+          label: 'reviewDB',
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: 'ALLOW',
+              force: false,
+            },
+          },
+        },
+      };
+
+      assert.isNotOk(element._computeLabel(permission, labels));
     });
 
-    suite('unit tests', () => {
-      test('_sortPermission', () => {
-        const permission = {
-          id: 'submit',
-          value: {
-            rules: {
-              'global:Project-Owners': {
-                action: 'ALLOW',
-                force: false,
-              },
-              '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-                action: 'ALLOW',
-                force: false,
-              },
-            },
-          },
-        };
+    test('_computeSectionClass', () => {
+      let deleted = true;
+      let editing = false;
+      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
 
-        const expectedRules = [
+      deleted = false;
+      assert.equal(element._computeSectionClass(editing, deleted), '');
+
+      editing = true;
+      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+
+      deleted = true;
+      assert.equal(element._computeSectionClass(editing, deleted),
+          'editing deleted');
+    });
+
+    test('_computeGroupName', () => {
+      const groups = {
+        abc123: {name: 'test group'},
+        bcd234: {},
+      };
+      assert.equal(element._computeGroupName(groups, 'abc123'), 'test group');
+      assert.equal(element._computeGroupName(groups, 'bcd234'), 'bcd234');
+    });
+
+    test('_computeGroupsWithRules', () => {
+      const rules = [
+        {
+          id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
+          value: {action: 'ALLOW', force: false},
+        },
+        {
+          id: 'global:Project-Owners',
+          value: {action: 'ALLOW', force: false},
+        },
+      ];
+      const groupsWithRules = {
+        '4c97682e6ce6b7247f3381b6f1789356666de7f': true,
+        'global:Project-Owners': true,
+      };
+      assert.deepEqual(element._computeGroupsWithRules(rules),
+          groupsWithRules);
+    });
+
+    test('_getGroupSuggestions without existing rules', done => {
+      element._groupsWithRules = {};
+
+      element._getGroupSuggestions().then(groups => {
+        assert.deepEqual(groups, [
           {
-            id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
-            value: {action: 'ALLOW', force: false},
-          },
-          {
-            id: 'global:Project-Owners',
-            value: {action: 'ALLOW', force: false},
-          },
-        ];
-
-        element._sortPermission(permission);
-        assert.deepEqual(element._rules, expectedRules);
-      });
-
-      test('_computeLabel and _computeLabelValues', () => {
-        const labels = {
-          'Code-Review': {
-            default_value: 0,
-            values: {
-              ' 0': 'No score',
-              '-1': 'I would prefer this is not merged as is',
-              '-2': 'This shall not be merged',
-              '+1': 'Looks good to me, but someone else must approve',
-              '+2': 'Looks good to me, approved',
-            },
-          },
-        };
-        let permission = {
-          id: 'label-Code-Review',
-          value: {
-            label: 'Code-Review',
-            rules: {
-              'global:Project-Owners': {
-                action: 'ALLOW',
-                force: false,
-                min: -2,
-                max: 2,
-              },
-              '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-                action: 'ALLOW',
-                force: false,
-                min: -2,
-                max: 2,
-              },
-            },
-          },
-        };
-
-        const expectedLabelValues = [
-          {value: -2, text: 'This shall not be merged'},
-          {value: -1, text: 'I would prefer this is not merged as is'},
-          {value: 0, text: 'No score'},
-          {value: 1, text: 'Looks good to me, but someone else must approve'},
-          {value: 2, text: 'Looks good to me, approved'},
-        ];
-
-        const expectedLabel = {
-          name: 'Code-Review',
-          values: expectedLabelValues,
-        };
-
-        assert.deepEqual(element._computeLabelValues(
-            labels['Code-Review'].values), expectedLabelValues);
-
-        assert.deepEqual(element._computeLabel(permission, labels),
-            expectedLabel);
-
-        permission = {
-          id: 'label-reviewDB',
-          value: {
-            label: 'reviewDB',
-            rules: {
-              'global:Project-Owners': {
-                action: 'ALLOW',
-                force: false,
-              },
-              '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-                action: 'ALLOW',
-                force: false,
-              },
-            },
-          },
-        };
-
-        assert.isNotOk(element._computeLabel(permission, labels));
-      });
-
-      test('_computeSectionClass', () => {
-        let deleted = true;
-        let editing = false;
-        assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
-        deleted = false;
-        assert.equal(element._computeSectionClass(editing, deleted), '');
-
-        editing = true;
-        assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
-        deleted = true;
-        assert.equal(element._computeSectionClass(editing, deleted),
-            'editing deleted');
-      });
-
-      test('_computeGroupName', () => {
-        const groups = {
-          abc123: {name: 'test group'},
-          bcd234: {},
-        };
-        assert.equal(element._computeGroupName(groups, 'abc123'), 'test group');
-        assert.equal(element._computeGroupName(groups, 'bcd234'), 'bcd234');
-      });
-
-      test('_computeGroupsWithRules', () => {
-        const rules = [
-          {
-            id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
-            value: {action: 'ALLOW', force: false},
-          },
-          {
-            id: 'global:Project-Owners',
-            value: {action: 'ALLOW', force: false},
-          },
-        ];
-        const groupsWithRules = {
-          '4c97682e6ce6b7247f3381b6f1789356666de7f': true,
-          'global:Project-Owners': true,
-        };
-        assert.deepEqual(element._computeGroupsWithRules(rules),
-            groupsWithRules);
-      });
-
-      test('_getGroupSuggestions without existing rules', done => {
-        element._groupsWithRules = {};
-
-        element._getGroupSuggestions().then(groups => {
-          assert.deepEqual(groups, [
-            {
-              name: 'Administrators',
-              value: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f'},
-            }, {
-              name: 'Anonymous Users',
-              value: {id: 'global%3AAnonymous-Users'},
-            },
-          ]);
-          done();
-        });
-      });
-
-      test('_getGroupSuggestions with existing rules filters them', done => {
-        element._groupsWithRules = {
-          '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
-        };
-
-        element._getGroupSuggestions().then(groups => {
-          assert.deepEqual(groups, [{
+            name: 'Administrators',
+            value: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f'},
+          }, {
             name: 'Anonymous Users',
             value: {id: 'global%3AAnonymous-Users'},
-          }]);
-          done();
-        });
-      });
-
-      test('_handleRemovePermission', () => {
-        element.editing = true;
-        element.permission = {value: {rules: {}}};
-        element._handleRemovePermission();
-        assert.isTrue(element._deleted);
-        assert.isTrue(element.permission.value.deleted);
-
-        element.editing = false;
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.permission.value.deleted);
-      });
-
-      test('_handleUndoRemove', () => {
-        element.permission = {value: {deleted: true, rules: {}}};
-        element._handleUndoRemove();
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.permission.value.deleted);
-      });
-
-      test('_computeHasRange', () => {
-        assert.isTrue(element._computeHasRange('Query Limit'));
-
-        assert.isTrue(element._computeHasRange('Batch Changes Limit'));
-
-        assert.isFalse(element._computeHasRange('test'));
+          },
+        ]);
+        done();
       });
     });
 
-    suite('interactions', () => {
-      setup(() => {
-        sandbox.spy(element, '_computeLabel');
-        element.name = 'Priority';
-        element.section = 'refs/*';
-        element.labels = {
-          'Code-Review': {
-            values: {
-              ' 0': 'No score',
-              '-1': 'I would prefer this is not merged as is',
-              '-2': 'This shall not be merged',
-              '+1': 'Looks good to me, but someone else must approve',
-              '+2': 'Looks good to me, approved',
-            },
-            default_value: 0,
-          },
-        };
-        element.permission = {
-          id: 'label-Code-Review',
-          value: {
-            label: 'Code-Review',
-            rules: {
-              'global:Project-Owners': {
-                action: 'ALLOW',
-                force: false,
-                min: -2,
-                max: 2,
-              },
-              '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-                action: 'ALLOW',
-                force: false,
-                min: -2,
-                max: 2,
-              },
-            },
-          },
-        };
-        element._setupValues();
-        flushAsynchronousOperations();
+    test('_getGroupSuggestions with existing rules filters them', done => {
+      element._groupsWithRules = {
+        '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
+      };
+
+      element._getGroupSuggestions().then(groups => {
+        assert.deepEqual(groups, [{
+          name: 'Anonymous Users',
+          value: {id: 'global%3AAnonymous-Users'},
+        }]);
+        done();
       });
+    });
 
-      test('adding a rule', () => {
-        element.name = 'Priority';
-        element.section = 'refs/*';
-        element.groups = {};
-        element.$.groupAutocomplete.text = 'ldap/tests te.st';
-        const e = {
-          detail: {
-            value: {
-              id: 'ldap:CN=test+te.st',
-            },
-          },
-        };
-        element.editing = true;
-        assert.equal(element._rules.length, 2);
-        assert.equal(Object.keys(element._groupsWithRules).length, 2);
-        element._handleAddRuleItem(e);
-        flushAsynchronousOperations();
-        assert.deepEqual(element.groups, {'ldap:CN=test te.st': {
-          name: 'ldap/tests te.st'}});
-        assert.equal(element._rules.length, 3);
-        assert.equal(Object.keys(element._groupsWithRules).length, 3);
-        assert.deepEqual(element.permission.value.rules['ldap:CN=test te.st'],
-            {action: 'ALLOW', min: -2, max: 2, added: true});
-        // New rule should be removed if cancel from editing.
-        element.editing = false;
-        assert.equal(element._rules.length, 2);
-        assert.equal(Object.keys(element.permission.value.rules).length, 2);
-      });
+    test('_handleRemovePermission', () => {
+      element.editing = true;
+      element.permission = {value: {rules: {}}};
+      element._handleRemovePermission();
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.permission.value.deleted);
 
-      test('removing an added rule', () => {
-        element.name = 'Priority';
-        element.section = 'refs/*';
-        element.groups = {};
-        element.$.groupAutocomplete.text = 'new group name';
-        assert.equal(element._rules.length, 2);
-        element.shadowRoot
-            .querySelector('gr-rule-editor').fire('added-rule-removed');
-        flushAsynchronousOperations();
-        assert.equal(element._rules.length, 1);
-      });
+      element.editing = false;
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.permission.value.deleted);
+    });
 
-      test('removing an added permission', () => {
-        const removeStub = sandbox.stub();
-        element.addEventListener('added-permission-removed', removeStub);
-        element.editing = true;
-        element.name = 'Priority';
-        element.section = 'refs/*';
-        element.permission.value.added = true;
-        MockInteractions.tap(element.$.removeBtn);
-        assert.isTrue(removeStub.called);
-      });
+    test('_handleUndoRemove', () => {
+      element.permission = {value: {deleted: true, rules: {}}};
+      element._handleUndoRemove();
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.permission.value.deleted);
+    });
 
-      test('removing the permission', () => {
-        element.editing = true;
-        element.name = 'Priority';
-        element.section = 'refs/*';
+    test('_computeHasRange', () => {
+      assert.isTrue(element._computeHasRange('Query Limit'));
 
-        const removeStub = sandbox.stub();
-        element.addEventListener('added-permission-removed', removeStub);
+      assert.isTrue(element._computeHasRange('Batch Changes Limit'));
 
-        assert.isFalse(element.$.permission.classList.contains('deleted'));
-        assert.isFalse(element._deleted);
-        MockInteractions.tap(element.$.removeBtn);
-        assert.isTrue(element.$.permission.classList.contains('deleted'));
-        assert.isTrue(element._deleted);
-        MockInteractions.tap(element.$.undoRemoveBtn);
-        assert.isFalse(element.$.permission.classList.contains('deleted'));
-        assert.isFalse(element._deleted);
-        assert.isFalse(removeStub.called);
-      });
-
-      test('modify a permission', () => {
-        element.editing = true;
-        element.name = 'Priority';
-        element.section = 'refs/*';
-
-        assert.isFalse(element._originalExclusiveValue);
-        assert.isNotOk(element.permission.value.modified);
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('#exclusiveToggle'));
-        flushAsynchronousOperations();
-        assert.isTrue(element.permission.value.exclusive);
-        assert.isTrue(element.permission.value.modified);
-        assert.isFalse(element._originalExclusiveValue);
-        element.editing = false;
-        assert.isFalse(element.permission.value.exclusive);
-      });
-
-      test('_handleValueChange', () => {
-        const modifiedHandler = sandbox.stub();
-        element.permission = {value: {rules: {}}};
-        element.addEventListener('access-modified', modifiedHandler);
-        assert.isNotOk(element.permission.value.modified);
-        element._handleValueChange();
-        assert.isTrue(element.permission.value.modified);
-        assert.isTrue(modifiedHandler.called);
-      });
-
-      test('Exclusive hidden for owner permission', () => {
-        assert.equal(getComputedStyle(element.shadowRoot
-            .querySelector('#exclusiveToggle')).display,
-        'flex');
-        element.set(['permission', 'id'], 'owner');
-        flushAsynchronousOperations();
-        assert.equal(getComputedStyle(element.shadowRoot
-            .querySelector('#exclusiveToggle')).display,
-        'none');
-      });
-
-      test('Exclusive hidden for any global permissions', () => {
-        assert.equal(getComputedStyle(element.shadowRoot
-            .querySelector('#exclusiveToggle')).display,
-        'flex');
-        element.section = 'GLOBAL_CAPABILITIES';
-        flushAsynchronousOperations();
-        assert.equal(getComputedStyle(element.shadowRoot
-            .querySelector('#exclusiveToggle')).display,
-        'none');
-      });
+      assert.isFalse(element._computeHasRange('test'));
     });
   });
+
+  suite('interactions', () => {
+    setup(() => {
+      sandbox.spy(element, '_computeLabel');
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.labels = {
+        'Code-Review': {
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not merged as is',
+            '-2': 'This shall not be merged',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      };
+      element.permission = {
+        id: 'label-Code-Review',
+        value: {
+          label: 'Code-Review',
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
+              min: -2,
+              max: 2,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: 'ALLOW',
+              force: false,
+              min: -2,
+              max: 2,
+            },
+          },
+        },
+      };
+      element._setupValues();
+      flushAsynchronousOperations();
+    });
+
+    test('adding a rule', () => {
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.groups = {};
+      element.$.groupAutocomplete.text = 'ldap/tests te.st';
+      const e = {
+        detail: {
+          value: {
+            id: 'ldap:CN=test+te.st',
+          },
+        },
+      };
+      element.editing = true;
+      assert.equal(element._rules.length, 2);
+      assert.equal(Object.keys(element._groupsWithRules).length, 2);
+      element._handleAddRuleItem(e);
+      flushAsynchronousOperations();
+      assert.deepEqual(element.groups, {'ldap:CN=test te.st': {
+        name: 'ldap/tests te.st'}});
+      assert.equal(element._rules.length, 3);
+      assert.equal(Object.keys(element._groupsWithRules).length, 3);
+      assert.deepEqual(element.permission.value.rules['ldap:CN=test te.st'],
+          {action: 'ALLOW', min: -2, max: 2, added: true});
+      // New rule should be removed if cancel from editing.
+      element.editing = false;
+      assert.equal(element._rules.length, 2);
+      assert.equal(Object.keys(element.permission.value.rules).length, 2);
+    });
+
+    test('removing an added rule', () => {
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.groups = {};
+      element.$.groupAutocomplete.text = 'new group name';
+      assert.equal(element._rules.length, 2);
+      element.shadowRoot
+          .querySelector('gr-rule-editor').fire('added-rule-removed');
+      flushAsynchronousOperations();
+      assert.equal(element._rules.length, 1);
+    });
+
+    test('removing an added permission', () => {
+      const removeStub = sandbox.stub();
+      element.addEventListener('added-permission-removed', removeStub);
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.permission.value.added = true;
+      MockInteractions.tap(element.$.removeBtn);
+      assert.isTrue(removeStub.called);
+    });
+
+    test('removing the permission', () => {
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*';
+
+      const removeStub = sandbox.stub();
+      element.addEventListener('added-permission-removed', removeStub);
+
+      assert.isFalse(element.$.permission.classList.contains('deleted'));
+      assert.isFalse(element._deleted);
+      MockInteractions.tap(element.$.removeBtn);
+      assert.isTrue(element.$.permission.classList.contains('deleted'));
+      assert.isTrue(element._deleted);
+      MockInteractions.tap(element.$.undoRemoveBtn);
+      assert.isFalse(element.$.permission.classList.contains('deleted'));
+      assert.isFalse(element._deleted);
+      assert.isFalse(removeStub.called);
+    });
+
+    test('modify a permission', () => {
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*';
+
+      assert.isFalse(element._originalExclusiveValue);
+      assert.isNotOk(element.permission.value.modified);
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#exclusiveToggle'));
+      flushAsynchronousOperations();
+      assert.isTrue(element.permission.value.exclusive);
+      assert.isTrue(element.permission.value.modified);
+      assert.isFalse(element._originalExclusiveValue);
+      element.editing = false;
+      assert.isFalse(element.permission.value.exclusive);
+    });
+
+    test('_handleValueChange', () => {
+      const modifiedHandler = sandbox.stub();
+      element.permission = {value: {rules: {}}};
+      element.addEventListener('access-modified', modifiedHandler);
+      assert.isNotOk(element.permission.value.modified);
+      element._handleValueChange();
+      assert.isTrue(element.permission.value.modified);
+      assert.isTrue(modifiedHandler.called);
+    });
+
+    test('Exclusive hidden for owner permission', () => {
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'flex');
+      element.set(['permission', 'id'], 'owner');
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'none');
+    });
+
+    test('Exclusive hidden for any global permissions', () => {
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'flex');
+      element.section = 'GLOBAL_CAPABILITIES';
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'none');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
index 92a8655..318c2c3 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
@@ -14,84 +14,95 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrPluginConfigArrayEditor extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-plugin-config-array-editor'; }
-    /**
-     * Fired when the plugin config option changes.
-     *
-     * @event plugin-config-option-changed
-     */
+import '@polymer/iron-input/iron-input.js';
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-plugin-config-array-editor_html.js';
 
-    static get properties() {
-      return {
+/** @extends Polymer.Element */
+class GrPluginConfigArrayEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-plugin-config-array-editor'; }
+  /**
+   * Fired when the plugin config option changes.
+   *
+   * @event plugin-config-option-changed
+   */
+
+  static get properties() {
+    return {
+    /** @type {?} */
+      pluginOption: Object,
+      /** @type {boolean} */
+      disabled: {
+        type: Boolean,
+        computed: '_computeDisabled(pluginOption.*)',
+      },
       /** @type {?} */
-        pluginOption: Object,
-        /** @type {boolean} */
-        disabled: {
-          type: Boolean,
-          computed: '_computeDisabled(pluginOption.*)',
-        },
-        /** @type {?} */
-        _newValue: {
-          type: String,
-          value: '',
-        },
-      };
-    }
+      _newValue: {
+        type: String,
+        value: '',
+      },
+    };
+  }
 
-    _computeDisabled(record) {
-      return !(record && record.base && record.base.info &&
-          record.base.info.editable);
-    }
+  _computeDisabled(record) {
+    return !(record && record.base && record.base.info &&
+        record.base.info.editable);
+  }
 
-    _handleAddTap(e) {
+  _handleAddTap(e) {
+    e.preventDefault();
+    this._handleAdd();
+  }
+
+  _handleInputKeydown(e) {
+    // Enter.
+    if (e.keyCode === 13) {
       e.preventDefault();
       this._handleAdd();
     }
-
-    _handleInputKeydown(e) {
-      // Enter.
-      if (e.keyCode === 13) {
-        e.preventDefault();
-        this._handleAdd();
-      }
-    }
-
-    _handleAdd() {
-      if (!this._newValue.length) { return; }
-      this._dispatchChanged(
-          this.pluginOption.info.values.concat([this._newValue]));
-      this._newValue = '';
-    }
-
-    _handleDelete(e) {
-      const value = Polymer.dom(e).localTarget.dataset.item;
-      this._dispatchChanged(
-          this.pluginOption.info.values.filter(str => str !== value));
-    }
-
-    _dispatchChanged(values) {
-      const {_key, info} = this.pluginOption;
-      const detail = {
-        _key,
-        info: Object.assign(info, {values}, {}),
-        notifyPath: `${_key}.values`,
-      };
-      this.dispatchEvent(
-          new CustomEvent('plugin-config-option-changed', {detail}));
-    }
-
-    _computeShowInputRow(disabled) {
-      return disabled ? 'hide' : '';
-    }
   }
 
-  customElements.define(GrPluginConfigArrayEditor.is,
-      GrPluginConfigArrayEditor);
-})();
+  _handleAdd() {
+    if (!this._newValue.length) { return; }
+    this._dispatchChanged(
+        this.pluginOption.info.values.concat([this._newValue]));
+    this._newValue = '';
+  }
+
+  _handleDelete(e) {
+    const value = dom(e).localTarget.dataset.item;
+    this._dispatchChanged(
+        this.pluginOption.info.values.filter(str => str !== value));
+  }
+
+  _dispatchChanged(values) {
+    const {_key, info} = this.pluginOption;
+    const detail = {
+      _key,
+      info: Object.assign(info, {values}, {}),
+      notifyPath: `${_key}.values`,
+    };
+    this.dispatchEvent(
+        new CustomEvent('plugin-config-option-changed', {detail}));
+  }
+
+  _computeShowInputRow(disabled) {
+    return disabled ? 'hide' : '';
+  }
+}
+
+customElements.define(GrPluginConfigArrayEditor.is,
+    GrPluginConfigArrayEditor);
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.js
index f6c744b..d97e2b37 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-
-<dom-module id="gr-plugin-config-array-editor">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -72,11 +64,7 @@
           <template is="dom-repeat" items="[[pluginOption.info.values]]">
             <div class="row">
               <span>[[item]]</span>
-              <gr-button
-                  link
-                  disabled$="[[disabled]]"
-                  data-item$="[[item]]"
-                  on-click="_handleDelete">Delete</gr-button>
+              <gr-button link="" disabled\$="[[disabled]]" data-item\$="[[item]]" on-click="_handleDelete">Delete</gr-button>
             </div>
           </template>
         </div>
@@ -84,23 +72,11 @@
       <template is="dom-if" if="[[!pluginOption.info.values.length]]">
         <div class="row placeholder">None configured.</div>
       </template>
-      <div class$="row [[_computeShowInputRow(disabled)]]">
-        <iron-input
-            on-keydown="_handleInputKeydown"
-            bind-value="{{_newValue}}">
-          <input
-              is="iron-input"
-              id="input"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newValue}}">
+      <div class\$="row [[_computeShowInputRow(disabled)]]">
+        <iron-input on-keydown="_handleInputKeydown" bind-value="{{_newValue}}">
+          <input is="iron-input" id="input" on-keydown="_handleInputKeydown" bind-value="{{_newValue}}">
         </iron-input>
-        <gr-button
-            id="addButton"
-            disabled$="[[!_newValue.length]]"
-            link
-            on-click="_handleAddTap">Add</gr-button>
+        <gr-button id="addButton" disabled\$="[[!_newValue.length]]" link="" on-click="_handleAddTap">Add</gr-button>
       </div>
     </div>
-  </template>
-  <script src="gr-plugin-config-array-editor.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
index 3342967..f66346e 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-config-array-editor</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-plugin-config-array-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-plugin-config-array-editor.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-plugin-config-array-editor.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,112 +40,115 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-plugin-config-array-editor tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let dispatchStub;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-plugin-config-array-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-plugin-config-array-editor tests', () => {
+  let element;
+  let sandbox;
+  let dispatchStub;
 
-    const getAll = str => Polymer.dom(element.root).querySelectorAll(str);
+  const getAll = str => dom(element.root).querySelectorAll(str);
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.pluginOption = {
+      _key: 'test-key',
+      info: {
+        values: [],
+      },
+    };
+  });
+
+  teardown(() => sandbox.restore());
+
+  test('_computeShowInputRow', () => {
+    assert.equal(element._computeShowInputRow(true), 'hide');
+    assert.equal(element._computeShowInputRow(false), '');
+  });
+
+  test('_computeDisabled', () => {
+    assert.isTrue(element._computeDisabled({}));
+    assert.isTrue(element._computeDisabled({base: {}}));
+    assert.isTrue(element._computeDisabled({base: {info: {}}}));
+    assert.isTrue(
+        element._computeDisabled({base: {info: {editable: false}}}));
+    assert.isFalse(
+        element._computeDisabled({base: {info: {editable: true}}}));
+  });
+
+  suite('adding', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.pluginOption = {
-        _key: 'test-key',
-        info: {
-          values: [],
-        },
-      };
-    });
-
-    teardown(() => sandbox.restore());
-
-    test('_computeShowInputRow', () => {
-      assert.equal(element._computeShowInputRow(true), 'hide');
-      assert.equal(element._computeShowInputRow(false), '');
-    });
-
-    test('_computeDisabled', () => {
-      assert.isTrue(element._computeDisabled({}));
-      assert.isTrue(element._computeDisabled({base: {}}));
-      assert.isTrue(element._computeDisabled({base: {info: {}}}));
-      assert.isTrue(
-          element._computeDisabled({base: {info: {editable: false}}}));
-      assert.isFalse(
-          element._computeDisabled({base: {info: {editable: true}}}));
-    });
-
-    suite('adding', () => {
-      setup(() => {
-        dispatchStub = sandbox.stub(element, '_dispatchChanged');
-      });
-
-      test('with enter', () => {
-        element._newValue = '';
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
-        flushAsynchronousOperations();
-
-        assert.isFalse(dispatchStub.called);
-        element._newValue = 'test';
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
-        flushAsynchronousOperations();
-
-        assert.isTrue(dispatchStub.called);
-        assert.equal(dispatchStub.lastCall.args[0], 'test');
-        assert.equal(element._newValue, '');
-      });
-
-      test('with add btn', () => {
-        element._newValue = '';
-        MockInteractions.tap(element.$.addButton);
-        flushAsynchronousOperations();
-
-        assert.isFalse(dispatchStub.called);
-        element._newValue = 'test';
-        MockInteractions.tap(element.$.addButton);
-        flushAsynchronousOperations();
-
-        assert.isTrue(dispatchStub.called);
-        assert.equal(dispatchStub.lastCall.args[0], 'test');
-        assert.equal(element._newValue, '');
-      });
-    });
-
-    test('deleting', () => {
       dispatchStub = sandbox.stub(element, '_dispatchChanged');
-      element.pluginOption = {info: {values: ['test', 'test2']}};
-      flushAsynchronousOperations();
+    });
 
-      const rows = getAll('.existingItems .row');
-      assert.equal(rows.length, 2);
-      const button = rows[0].querySelector('gr-button');
-
-      MockInteractions.tap(button);
+    test('with enter', () => {
+      element._newValue = '';
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
       flushAsynchronousOperations();
 
       assert.isFalse(dispatchStub.called);
-      element.pluginOption.info.editable = true;
-      element.notifyPath('pluginOption.info.editable');
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(button);
+      element._newValue = 'test';
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
       flushAsynchronousOperations();
 
       assert.isTrue(dispatchStub.called);
-      assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
+      assert.equal(dispatchStub.lastCall.args[0], 'test');
+      assert.equal(element._newValue, '');
     });
 
-    test('_dispatchChanged', () => {
-      const eventStub = sandbox.stub(element, 'dispatchEvent');
-      element._dispatchChanged(['new-test-value']);
+    test('with add btn', () => {
+      element._newValue = '';
+      MockInteractions.tap(element.$.addButton);
+      flushAsynchronousOperations();
 
-      assert.isTrue(eventStub.called);
-      const {detail} = eventStub.lastCall.args[0];
-      assert.equal(detail._key, 'test-key');
-      assert.deepEqual(detail.info, {values: ['new-test-value']});
-      assert.equal(detail.notifyPath, 'test-key.values');
+      assert.isFalse(dispatchStub.called);
+      element._newValue = 'test';
+      MockInteractions.tap(element.$.addButton);
+      flushAsynchronousOperations();
+
+      assert.isTrue(dispatchStub.called);
+      assert.equal(dispatchStub.lastCall.args[0], 'test');
+      assert.equal(element._newValue, '');
     });
   });
+
+  test('deleting', () => {
+    dispatchStub = sandbox.stub(element, '_dispatchChanged');
+    element.pluginOption = {info: {values: ['test', 'test2']}};
+    flushAsynchronousOperations();
+
+    const rows = getAll('.existingItems .row');
+    assert.equal(rows.length, 2);
+    const button = rows[0].querySelector('gr-button');
+
+    MockInteractions.tap(button);
+    flushAsynchronousOperations();
+
+    assert.isFalse(dispatchStub.called);
+    element.pluginOption.info.editable = true;
+    element.notifyPath('pluginOption.info.editable');
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(button);
+    flushAsynchronousOperations();
+
+    assert.isTrue(dispatchStub.called);
+    assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
+  });
+
+  test('_dispatchChanged', () => {
+    const eventStub = sandbox.stub(element, 'dispatchEvent');
+    element._dispatchChanged(['new-test-value']);
+
+    assert.isTrue(eventStub.called);
+    const {detail} = eventStub.lastCall.args[0];
+    assert.equal(detail._key, 'test-key');
+    assert.deepEqual(detail.info, {values: ['new-test-value']});
+    assert.equal(detail.notifyPath, 'test-key.values');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
index 5dd6ec2..d5a4e08 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
@@ -14,110 +14,122 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.ListViewMixin
-   * @extends Polymer.Element
-   */
-  class GrPluginList extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.ListViewBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-plugin-list'; }
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-list-view/gr-list-view.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-plugin-list_html.js';
 
-    static get properties() {
-      return {
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.ListViewMixin
+ * @extends Polymer.Element
+ */
+class GrPluginList extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.ListViewBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-plugin-list'; }
+
+  static get properties() {
+    return {
+    /**
+     * URL params passed from the router.
+     */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
       /**
-       * URL params passed from the router.
+       * Offset of currently visible query results.
        */
-        params: {
-          type: Object,
-          observer: '_paramsChanged',
-        },
-        /**
-         * Offset of currently visible query results.
-         */
-        _offset: {
-          type: Number,
-          value: 0,
-        },
-        _path: {
-          type: String,
-          readOnly: true,
-          value: '/admin/plugins',
-        },
-        _plugins: Array,
-        /**
-         * Because  we request one more than the pluginsPerPage, _shownPlugins
-         * maybe one less than _plugins.
-         * */
-        _shownPlugins: {
-          type: Array,
-          computed: 'computeShownItems(_plugins)',
-        },
-        _pluginsPerPage: {
-          type: Number,
-          value: 25,
-        },
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-        _filter: {
-          type: String,
-          value: '',
-        },
-      };
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this.fire('title-change', {title: 'Plugins'});
-    }
-
-    _paramsChanged(params) {
-      this._loading = true;
-      this._filter = this.getFilterValue(params);
-      this._offset = this.getOffsetValue(params);
-
-      return this._getPlugins(this._filter, this._pluginsPerPage,
-          this._offset);
-    }
-
-    _getPlugins(filter, pluginsPerPage, offset) {
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
-      return this.$.restAPI.getPlugins(filter, pluginsPerPage, offset, errFn)
-          .then(plugins => {
-            if (!plugins) {
-              this._plugins = [];
-              return;
-            }
-            this._plugins = Object.keys(plugins)
-                .map(key => {
-                  const plugin = plugins[key];
-                  plugin.name = key;
-                  return plugin;
-                });
-            this._loading = false;
-          });
-    }
-
-    _status(item) {
-      return item.disabled === true ? 'Disabled' : 'Enabled';
-    }
-
-    _computePluginUrl(id) {
-      return this.getUrl('/', id);
-    }
+      _offset: {
+        type: Number,
+        value: 0,
+      },
+      _path: {
+        type: String,
+        readOnly: true,
+        value: '/admin/plugins',
+      },
+      _plugins: Array,
+      /**
+       * Because  we request one more than the pluginsPerPage, _shownPlugins
+       * maybe one less than _plugins.
+       * */
+      _shownPlugins: {
+        type: Array,
+        computed: 'computeShownItems(_plugins)',
+      },
+      _pluginsPerPage: {
+        type: Number,
+        value: 25,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: {
+        type: String,
+        value: '',
+      },
+    };
   }
 
-  customElements.define(GrPluginList.is, GrPluginList);
-})();
+  /** @override */
+  attached() {
+    super.attached();
+    this.fire('title-change', {title: 'Plugins'});
+  }
+
+  _paramsChanged(params) {
+    this._loading = true;
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
+
+    return this._getPlugins(this._filter, this._pluginsPerPage,
+        this._offset);
+  }
+
+  _getPlugins(filter, pluginsPerPage, offset) {
+    const errFn = response => {
+      this.fire('page-error', {response});
+    };
+    return this.$.restAPI.getPlugins(filter, pluginsPerPage, offset, errFn)
+        .then(plugins => {
+          if (!plugins) {
+            this._plugins = [];
+            return;
+          }
+          this._plugins = Object.keys(plugins)
+              .map(key => {
+                const plugin = plugins[key];
+                plugin.name = key;
+                return plugin;
+              });
+          this._loading = false;
+        });
+  }
+
+  _status(item) {
+    return item.disabled === true ? 'Disabled' : 'Enabled';
+  }
+
+  _computePluginUrl(id) {
+    return this.getUrl('/', id);
+  }
+}
+
+customElements.define(GrPluginList.is, GrPluginList);
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.js
index b056f92..90192c4 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.js
@@ -1,58 +1,44 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-plugin-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
     <style include="gr-table-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
-    <gr-list-view
-        filter="[[_filter]]"
-        items-per-page="[[_pluginsPerPage]]"
-        items="[[_plugins]]"
-        loading="[[_loading]]"
-        offset="[[_offset]]"
-        path="[[_path]]">
+    <gr-list-view filter="[[_filter]]" items-per-page="[[_pluginsPerPage]]" items="[[_plugins]]" loading="[[_loading]]" offset="[[_offset]]" path="[[_path]]">
       <table id="list" class="genericList">
-        <tr class="headerRow">
+        <tbody><tr class="headerRow">
           <th class="name topHeader">Plugin Name</th>
           <th class="version topHeader">Version</th>
           <th class="status topHeader">Status</th>
         </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+        <tr id="loading" class\$="loadingMsg [[computeLoadingClass(_loading)]]">
           <td>Loading...</td>
         </tr>
-        <tbody class$="[[computeLoadingClass(_loading)]]">
+        </tbody><tbody class\$="[[computeLoadingClass(_loading)]]">
           <template is="dom-repeat" items="[[_shownPlugins]]">
             <tr class="table">
               <td class="name">
                 <template is="dom-if" if="[[item.index_url]]">
-                  <a href$="[[_computePluginUrl(item.index_url)]]">[[item.id]]</a>
+                  <a href\$="[[_computePluginUrl(item.index_url)]]">[[item.id]]</a>
                 </template>
                 <template is="dom-if" if="[[!item.index_url]]">
                   [[item.id]]
@@ -66,6 +52,4 @@
       </table>
     </gr-list-view>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-plugin-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
index 67a6c3f..f89eb8e 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-plugin-list.html">
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-plugin-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-plugin-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,151 +41,154 @@
   </template>
 </test-fixture>
 
-<script>
-  let counter;
-  const pluginGenerator = () => {
-    const plugin = {
-      id: `test${++counter}`,
-      version: '3.0-SNAPSHOT',
-      disabled: false,
-    };
-
-    if (counter !== 2) {
-      plugin.index_url = `plugins/test${counter}/`;
-    }
-    return plugin;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-plugin-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+let counter;
+const pluginGenerator = () => {
+  const plugin = {
+    id: `test${++counter}`,
+    version: '3.0-SNAPSHOT',
+    disabled: false,
   };
 
-  suite('gr-plugin-list tests', async () => {
-    await readyToTest();
-    let element;
-    let plugins;
-    let sandbox;
-    let value;
+  if (counter !== 2) {
+    plugin.index_url = `plugins/test${counter}/`;
+  }
+  return plugin;
+};
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      counter = 0;
+suite('gr-plugin-list tests', () => {
+  let element;
+  let plugins;
+  let sandbox;
+  let value;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    counter = 0;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('list with plugins', () => {
+    setup(done => {
+      plugins = _.times(26, pluginGenerator);
+
+      stub('gr-rest-api-interface', {
+        getPlugins(num, offset) {
+          return Promise.resolve(plugins);
+        },
+      });
+
+      element._paramsChanged(value).then(() => { flush(done); });
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('list with plugins', () => {
-      setup(done => {
-        plugins = _.times(26, pluginGenerator);
-
-        stub('gr-rest-api-interface', {
-          getPlugins(num, offset) {
-            return Promise.resolve(plugins);
-          },
-        });
-
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('plugin in the list is formatted correctly', done => {
-        flush(() => {
-          assert.equal(element._plugins[2].id, 'test3');
-          assert.equal(element._plugins[2].index_url, 'plugins/test3/');
-          assert.equal(element._plugins[2].version, '3.0-SNAPSHOT');
-          assert.equal(element._plugins[2].disabled, false);
-          done();
-        });
-      });
-
-      test('with and without urls', done => {
-        flush(() => {
-          const names = Polymer.dom(element.root).querySelectorAll('.name');
-          assert.isOk(names[1].querySelector('a'));
-          assert.equal(names[1].querySelector('a').innerText, 'test1');
-          assert.isNotOk(names[2].querySelector('a'));
-          assert.equal(names[2].innerText, 'test2');
-          done();
-        });
-      });
-
-      test('_shownPlugins', () => {
-        assert.equal(element._shownPlugins.length, 25);
+    test('plugin in the list is formatted correctly', done => {
+      flush(() => {
+        assert.equal(element._plugins[2].id, 'test3');
+        assert.equal(element._plugins[2].index_url, 'plugins/test3/');
+        assert.equal(element._plugins[2].version, '3.0-SNAPSHOT');
+        assert.equal(element._plugins[2].disabled, false);
+        done();
       });
     });
 
-    suite('list with less then 26 plugins', () => {
-      setup(done => {
-        plugins = _.times(25, pluginGenerator);
-
-        stub('gr-rest-api-interface', {
-          getPlugins(num, offset) {
-            return Promise.resolve(plugins);
-          },
-        });
-
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('_shownPlugins', () => {
-        assert.equal(element._shownPlugins.length, 25);
+    test('with and without urls', done => {
+      flush(() => {
+        const names = dom(element.root).querySelectorAll('.name');
+        assert.isOk(names[1].querySelector('a'));
+        assert.equal(names[1].querySelector('a').innerText, 'test1');
+        assert.isNotOk(names[2].querySelector('a'));
+        assert.equal(names[2].innerText, 'test2');
+        done();
       });
     });
 
-    suite('filter', () => {
-      test('_paramsChanged', done => {
-        sandbox.stub(
-            element.$.restAPI,
-            'getPlugins',
-            () => Promise.resolve(plugins));
-        const value = {
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(value).then(() => {
-          assert.equal(element.$.restAPI.getPlugins.lastCall.args[0],
-              'test');
-          assert.equal(element.$.restAPI.getPlugins.lastCall.args[1],
-              25);
-          assert.equal(element.$.restAPI.getPlugins.lastCall.args[2],
-              25);
-          done();
-        });
+    test('_shownPlugins', () => {
+      assert.equal(element._shownPlugins.length, 25);
+    });
+  });
+
+  suite('list with less then 26 plugins', () => {
+    setup(done => {
+      plugins = _.times(25, pluginGenerator);
+
+      stub('gr-rest-api-interface', {
+        getPlugins(num, offset) {
+          return Promise.resolve(plugins);
+        },
       });
+
+      element._paramsChanged(value).then(() => { flush(done); });
     });
 
-    suite('loading', () => {
-      test('correct contents are displayed', () => {
-        assert.isTrue(element._loading);
-        assert.equal(element.computeLoadingClass(element._loading), 'loading');
-        assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-        element._loading = false;
-        element._plugins = _.times(25, pluginGenerator);
-
-        flushAsynchronousOperations();
-        assert.equal(element.computeLoadingClass(element._loading), '');
-        assert.equal(getComputedStyle(element.$.loading).display, 'none');
-      });
+    test('_shownPlugins', () => {
+      assert.equal(element._shownPlugins.length, 25);
     });
+  });
 
-    suite('404', () => {
-      test('fires page-error', done => {
-        const response = {status: 404};
-        sandbox.stub(element.$.restAPI, 'getPlugins',
-            (filter, pluginsPerPage, opt_offset, errFn) => {
-              errFn(response);
-            });
-
-        element.addEventListener('page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          done();
-        });
-
-        const value = {
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(value);
+  suite('filter', () => {
+    test('_paramsChanged', done => {
+      sandbox.stub(
+          element.$.restAPI,
+          'getPlugins',
+          () => Promise.resolve(plugins));
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value).then(() => {
+        assert.equal(element.$.restAPI.getPlugins.lastCall.args[0],
+            'test');
+        assert.equal(element.$.restAPI.getPlugins.lastCall.args[1],
+            25);
+        assert.equal(element.$.restAPI.getPlugins.lastCall.args[2],
+            25);
+        done();
       });
     });
   });
+
+  suite('loading', () => {
+    test('correct contents are displayed', () => {
+      assert.isTrue(element._loading);
+      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+      element._loading = false;
+      element._plugins = _.times(25, pluginGenerator);
+
+      flushAsynchronousOperations();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      const response = {status: 404};
+      sandbox.stub(element.$.restAPI, 'getPlugins',
+          (filter, pluginsPerPage, opt_offset, errFn) => {
+            errFn(response);
+          });
+
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
index 02b62e0..6cfa7ff 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
@@ -14,497 +14,515 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const Defs = {};
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '../../../styles/gr-menu-page-styles.js';
+import '../../../styles/gr-subpage-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-access-section/gr-access-section.js';
+import '../../../scripts/util.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-access_html.js';
 
-  const NOTHING_TO_SAVE = 'No changes to save.';
+const Defs = {};
 
-  const MAX_AUTOCOMPLETE_RESULTS = 50;
+const NOTHING_TO_SAVE = 'No changes to save.';
 
-  /**
-   * Fired when save is a no-op
-   *
-   * @event show-alert
-   */
+const MAX_AUTOCOMPLETE_RESULTS = 50;
 
-  /**
-   * @typedef {{
-   *    value: !Object,
-   * }}
-   */
-  Defs.rule;
+/**
+ * Fired when save is a no-op
+ *
+ * @event show-alert
+ */
 
-  /**
-   * @typedef {{
-   *    rules: !Object<string, Defs.rule>
-   * }}
-   */
-  Defs.permission;
+/**
+ * @typedef {{
+ *    value: !Object,
+ * }}
+ */
+Defs.rule;
 
-  /**
-   * Can be an empty object or consist of permissions.
-   *
-   * @typedef {{
-   *    permissions: !Object<string, Defs.permission>
-   * }}
-   */
-  Defs.permissions;
+/**
+ * @typedef {{
+ *    rules: !Object<string, Defs.rule>
+ * }}
+ */
+Defs.permission;
 
-  /**
-   * Can be an empty object or consist of permissions.
-   *
-   * @typedef {!Object<string, Defs.permissions>}
-   */
-  Defs.sections;
+/**
+ * Can be an empty object or consist of permissions.
+ *
+ * @typedef {{
+ *    permissions: !Object<string, Defs.permission>
+ * }}
+ */
+Defs.permissions;
 
-  /**
-   * @typedef {{
-   *    remove: !Defs.sections,
-   *    add: !Defs.sections,
-   * }}
-   */
-  Defs.projectAccessInput;
+/**
+ * Can be an empty object or consist of permissions.
+ *
+ * @typedef {!Object<string, Defs.permissions>}
+ */
+Defs.sections;
 
-  /**
-   * @appliesMixin Gerrit.AccessMixin
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
-   */
-  class GrRepoAccess extends Polymer.mixinBehaviors( [
-    Gerrit.AccessBehavior,
-    Gerrit.BaseUrlBehavior,
-    Gerrit.FireBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-repo-access'; }
+/**
+ * @typedef {{
+ *    remove: !Defs.sections,
+ *    add: !Defs.sections,
+ * }}
+ */
+Defs.projectAccessInput;
 
-    static get properties() {
-      return {
-        repo: {
-          type: String,
-          observer: '_repoChanged',
+/**
+ * @appliesMixin Gerrit.AccessMixin
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrRepoAccess extends mixinBehaviors( [
+  Gerrit.AccessBehavior,
+  Gerrit.BaseUrlBehavior,
+  Gerrit.FireBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo-access'; }
+
+  static get properties() {
+    return {
+      repo: {
+        type: String,
+        observer: '_repoChanged',
+      },
+      // The current path
+      path: String,
+
+      _canUpload: {
+        type: Boolean,
+        value: false,
+      },
+      _inheritFromFilter: String,
+      _query: {
+        type: Function,
+        value() {
+          return this._getInheritFromSuggestions.bind(this);
         },
-        // The current path
-        path: String,
+      },
+      _ownerOf: Array,
+      _capabilities: Object,
+      _groups: Object,
+      /** @type {?} */
+      _inheritsFrom: Object,
+      _labels: Object,
+      _local: Object,
+      _editing: {
+        type: Boolean,
+        value: false,
+        observer: '_handleEditingChanged',
+      },
+      _modified: {
+        type: Boolean,
+        value: false,
+      },
+      _sections: Array,
+      _weblinks: Array,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+    };
+  }
 
-        _canUpload: {
-          type: Boolean,
-          value: false,
-        },
-        _inheritFromFilter: String,
-        _query: {
-          type: Function,
-          value() {
-            return this._getInheritFromSuggestions.bind(this);
-          },
-        },
-        _ownerOf: Array,
-        _capabilities: Object,
-        _groups: Object,
-        /** @type {?} */
-        _inheritsFrom: Object,
-        _labels: Object,
-        _local: Object,
-        _editing: {
-          type: Boolean,
-          value: false,
-          observer: '_handleEditingChanged',
-        },
-        _modified: {
-          type: Boolean,
-          value: false,
-        },
-        _sections: Array,
-        _weblinks: Array,
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-      };
-    }
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('access-modified',
+        () =>
+          this._handleAccessModified());
+  }
 
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('access-modified',
-          () =>
-            this._handleAccessModified());
-    }
+  _handleAccessModified() {
+    this._modified = true;
+  }
 
-    _handleAccessModified() {
-      this._modified = true;
-    }
+  /**
+   * @param {string} repo
+   * @return {!Promise}
+   */
+  _repoChanged(repo) {
+    this._loading = true;
 
-    /**
-     * @param {string} repo
-     * @return {!Promise}
-     */
-    _repoChanged(repo) {
-      this._loading = true;
+    if (!repo) { return Promise.resolve(); }
 
-      if (!repo) { return Promise.resolve(); }
+    return this._reload(repo);
+  }
 
-      return this._reload(repo);
-    }
+  _reload(repo) {
+    const promises = [];
 
-    _reload(repo) {
-      const promises = [];
+    const errFn = response => {
+      this.fire('page-error', {response});
+    };
 
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
+    this._editing = false;
 
-      this._editing = false;
+    // Always reset sections when a project changes.
+    this._sections = [];
+    promises.push(this.$.restAPI.getRepoAccessRights(repo, errFn)
+        .then(res => {
+          if (!res) { return Promise.resolve(); }
 
-      // Always reset sections when a project changes.
-      this._sections = [];
-      promises.push(this.$.restAPI.getRepoAccessRights(repo, errFn)
-          .then(res => {
-            if (!res) { return Promise.resolve(); }
-
-            // Keep a copy of the original inherit from values separate from
-            // the ones data bound to gr-autocomplete, so the original value
-            // can be restored if the user cancels.
-            this._inheritsFrom = res.inherits_from ? Object.assign({},
-                res.inherits_from) : null;
-            this._originalInheritsFrom = res.inherits_from ? Object.assign({},
-                res.inherits_from) : null;
-            // Initialize the filter value so when the user clicks edit, the
-            // current value appears. If there is no parent repo, it is
-            // initialized as an empty string.
-            this._inheritFromFilter = res.inherits_from ?
-              this._inheritsFrom.name : '';
-            this._local = res.local;
-            this._groups = res.groups;
-            this._weblinks = res.config_web_links || [];
-            this._canUpload = res.can_upload;
-            this._ownerOf = res.owner_of || [];
-            return this.toSortedArray(this._local);
-          }));
-
-      promises.push(this.$.restAPI.getCapabilities(errFn)
-          .then(res => {
-            if (!res) { return Promise.resolve(); }
-
-            return res;
-          }));
-
-      promises.push(this.$.restAPI.getRepo(repo, errFn)
-          .then(res => {
-            if (!res) { return Promise.resolve(); }
-
-            return res.labels;
-          }));
-
-      return Promise.all(promises).then(([sections, capabilities, labels]) => {
-        this._capabilities = capabilities;
-        this._labels = labels;
-        this._sections = sections;
-        this._loading = false;
-      });
-    }
-
-    _handleUpdateInheritFrom(e) {
-      if (!this._inheritsFrom) {
-        this._inheritsFrom = {};
-      }
-      this._inheritsFrom.id = e.detail.value;
-      this._inheritsFrom.name = this._inheritFromFilter;
-      this._handleAccessModified();
-    }
-
-    _getInheritFromSuggestions() {
-      return this.$.restAPI.getRepos(
-          this._inheritFromFilter,
-          MAX_AUTOCOMPLETE_RESULTS)
-          .then(response => {
-            const projects = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              projects.push({
-                name: response[key].name,
-                value: response[key].id,
-              });
-            }
-            return projects;
-          });
-    }
-
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    }
-
-    _handleEdit() {
-      this._editing = !this._editing;
-    }
-
-    _editOrCancel(editing) {
-      return editing ? 'Cancel' : 'Edit';
-    }
-
-    _computeWebLinkClass(weblinks) {
-      return weblinks && weblinks.length ? 'show' : '';
-    }
-
-    _computeShowInherit(inheritsFrom) {
-      return inheritsFrom ? 'show' : '';
-    }
-
-    _handleAddedSectionRemoved(e) {
-      const index = e.model.index;
-      this._sections = this._sections.slice(0, index)
-          .concat(this._sections.slice(index + 1, this._sections.length));
-    }
-
-    _handleEditingChanged(editing, editingOld) {
-      // Ignore when editing gets set initially.
-      if (!editingOld || editing) { return; }
-      // Remove any unsaved but added refs.
-      if (this._sections) {
-        this._sections = this._sections.filter(p => !p.value.added);
-      }
-      // Restore inheritFrom.
-      if (this._inheritsFrom) {
-        this._inheritsFrom = Object.assign({}, this._originalInheritsFrom);
-        this._inheritFromFilter = this._inheritsFrom.name;
-      }
-      for (const key of Object.keys(this._local)) {
-        if (this._local[key].added) {
-          delete this._local[key];
-        }
-      }
-    }
-
-    /**
-     * @param {!Defs.projectAccessInput} addRemoveObj
-     * @param {!Array} path
-     * @param {string} type add or remove
-     * @param {!Object=} opt_value value to add if the type is 'add'
-     * @return {!Defs.projectAccessInput}
-     */
-    _updateAddRemoveObj(addRemoveObj, path, type, opt_value) {
-      let curPos = addRemoveObj[type];
-      for (const item of path) {
-        if (!curPos[item]) {
-          if (item === path[path.length - 1] && type === 'remove') {
-            if (path[path.length - 2] === 'permissions') {
-              curPos[item] = {rules: {}};
-            } else if (path.length === 1) {
-              curPos[item] = {permissions: {}};
-            } else {
-              curPos[item] = {};
-            }
-          } else if (item === path[path.length - 1] && type === 'add') {
-            curPos[item] = opt_value;
-          } else {
-            curPos[item] = {};
-          }
-        }
-        curPos = curPos[item];
-      }
-      return addRemoveObj;
-    }
-
-    /**
-     * Used to recursively remove any objects with a 'deleted' bit.
-     */
-    _recursivelyRemoveDeleted(obj) {
-      for (const k in obj) {
-        if (!obj.hasOwnProperty(k)) { continue; }
-
-        if (typeof obj[k] == 'object') {
-          if (obj[k].deleted) {
-            delete obj[k];
-            return;
-          }
-          this._recursivelyRemoveDeleted(obj[k]);
-        }
-      }
-    }
-
-    _recursivelyUpdateAddRemoveObj(obj, addRemoveObj, path = []) {
-      for (const k in obj) {
-        if (!obj.hasOwnProperty(k)) { continue; }
-        if (typeof obj[k] == 'object') {
-          const updatedId = obj[k].updatedId;
-          const ref = updatedId ? updatedId : k;
-          if (obj[k].deleted) {
-            this._updateAddRemoveObj(addRemoveObj,
-                path.concat(k), 'remove');
-            continue;
-          } else if (obj[k].modified) {
-            this._updateAddRemoveObj(addRemoveObj,
-                path.concat(k), 'remove');
-            this._updateAddRemoveObj(addRemoveObj, path.concat(ref), 'add',
-                obj[k]);
-            /* Special case for ref changes because they need to be added and
-             removed in a different way. The new ref needs to include all
-             changes but also the initial state. To do this, instead of
-             continuing with the same recursion, just remove anything that is
-             deleted in the current state. */
-            if (updatedId && updatedId !== k) {
-              this._recursivelyRemoveDeleted(addRemoveObj.add[updatedId]);
-            }
-            continue;
-          } else if (obj[k].added) {
-            this._updateAddRemoveObj(addRemoveObj,
-                path.concat(ref), 'add', obj[k]);
-            /**
-             * As add / delete both can happen in the new section,
-             * so here to make sure it will remove the deleted ones.
-             *
-             * @see Issue 11339
-             */
-            this._recursivelyRemoveDeleted(addRemoveObj.add[k]);
-            continue;
-          }
-          this._recursivelyUpdateAddRemoveObj(obj[k], addRemoveObj,
-              path.concat(k));
-        }
-      }
-    }
-
-    /**
-     * Returns an object formatted for saving or submitting access changes for
-     * review
-     *
-     * @return {!Defs.projectAccessInput}
-     */
-    _computeAddAndRemove() {
-      const addRemoveObj = {
-        add: {},
-        remove: {},
-      };
-
-      const originalInheritsFromId = this._originalInheritsFrom ?
-        this.singleDecodeURL(this._originalInheritsFrom.id) :
-        null;
-      const inheritsFromId = this._inheritsFrom ?
-        this.singleDecodeURL(this._inheritsFrom.id) :
-        null;
-
-      const inheritFromChanged =
-          // Inherit from changed
-          (originalInheritsFromId &&
-              originalInheritsFromId !== inheritsFromId) ||
-          // Inherit from added (did not have one initially);
-          (!originalInheritsFromId && inheritsFromId);
-
-      this._recursivelyUpdateAddRemoveObj(this._local, addRemoveObj);
-
-      if (inheritFromChanged) {
-        addRemoveObj.parent = inheritsFromId;
-      }
-      return addRemoveObj;
-    }
-
-    _handleCreateSection() {
-      let newRef = 'refs/for/*';
-      // Avoid using an already used key for the placeholder, since it
-      // immediately gets added to an object.
-      while (this._local[newRef]) {
-        newRef = `${newRef}*`;
-      }
-      const section = {permissions: {}, added: true};
-      this.push('_sections', {id: newRef, value: section});
-      this.set(['_local', newRef], section);
-      Polymer.dom.flush();
-      Polymer.dom(this.root).querySelector('gr-access-section:last-of-type')
-          .editReference();
-    }
-
-    _getObjforSave() {
-      const addRemoveObj = this._computeAddAndRemove();
-      // If there are no changes, don't actually save.
-      if (!Object.keys(addRemoveObj.add).length &&
-          !Object.keys(addRemoveObj.remove).length &&
-          !addRemoveObj.parent) {
-        this.dispatchEvent(new CustomEvent('show-alert', {
-          detail: {message: NOTHING_TO_SAVE},
-          bubbles: true,
-          composed: true,
+          // Keep a copy of the original inherit from values separate from
+          // the ones data bound to gr-autocomplete, so the original value
+          // can be restored if the user cancels.
+          this._inheritsFrom = res.inherits_from ? Object.assign({},
+              res.inherits_from) : null;
+          this._originalInheritsFrom = res.inherits_from ? Object.assign({},
+              res.inherits_from) : null;
+          // Initialize the filter value so when the user clicks edit, the
+          // current value appears. If there is no parent repo, it is
+          // initialized as an empty string.
+          this._inheritFromFilter = res.inherits_from ?
+            this._inheritsFrom.name : '';
+          this._local = res.local;
+          this._groups = res.groups;
+          this._weblinks = res.config_web_links || [];
+          this._canUpload = res.can_upload;
+          this._ownerOf = res.owner_of || [];
+          return this.toSortedArray(this._local);
         }));
-        return;
-      }
-      const obj = {
-        add: addRemoveObj.add,
-        remove: addRemoveObj.remove,
-      };
-      if (addRemoveObj.parent) {
-        obj.parent = addRemoveObj.parent;
-      }
-      return obj;
-    }
 
-    _handleSave(e) {
-      const obj = this._getObjforSave();
-      if (!obj) { return; }
-      const button = e && e.target;
-      if (button) {
-        button.loading = true;
+    promises.push(this.$.restAPI.getCapabilities(errFn)
+        .then(res => {
+          if (!res) { return Promise.resolve(); }
+
+          return res;
+        }));
+
+    promises.push(this.$.restAPI.getRepo(repo, errFn)
+        .then(res => {
+          if (!res) { return Promise.resolve(); }
+
+          return res.labels;
+        }));
+
+    return Promise.all(promises).then(([sections, capabilities, labels]) => {
+      this._capabilities = capabilities;
+      this._labels = labels;
+      this._sections = sections;
+      this._loading = false;
+    });
+  }
+
+  _handleUpdateInheritFrom(e) {
+    if (!this._inheritsFrom) {
+      this._inheritsFrom = {};
+    }
+    this._inheritsFrom.id = e.detail.value;
+    this._inheritsFrom.name = this._inheritFromFilter;
+    this._handleAccessModified();
+  }
+
+  _getInheritFromSuggestions() {
+    return this.$.restAPI.getRepos(
+        this._inheritFromFilter,
+        MAX_AUTOCOMPLETE_RESULTS)
+        .then(response => {
+          const projects = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            projects.push({
+              name: response[key].name,
+              value: response[key].id,
+            });
+          }
+          return projects;
+        });
+  }
+
+  _computeLoadingClass(loading) {
+    return loading ? 'loading' : '';
+  }
+
+  _handleEdit() {
+    this._editing = !this._editing;
+  }
+
+  _editOrCancel(editing) {
+    return editing ? 'Cancel' : 'Edit';
+  }
+
+  _computeWebLinkClass(weblinks) {
+    return weblinks && weblinks.length ? 'show' : '';
+  }
+
+  _computeShowInherit(inheritsFrom) {
+    return inheritsFrom ? 'show' : '';
+  }
+
+  _handleAddedSectionRemoved(e) {
+    const index = e.model.index;
+    this._sections = this._sections.slice(0, index)
+        .concat(this._sections.slice(index + 1, this._sections.length));
+  }
+
+  _handleEditingChanged(editing, editingOld) {
+    // Ignore when editing gets set initially.
+    if (!editingOld || editing) { return; }
+    // Remove any unsaved but added refs.
+    if (this._sections) {
+      this._sections = this._sections.filter(p => !p.value.added);
+    }
+    // Restore inheritFrom.
+    if (this._inheritsFrom) {
+      this._inheritsFrom = Object.assign({}, this._originalInheritsFrom);
+      this._inheritFromFilter = this._inheritsFrom.name;
+    }
+    for (const key of Object.keys(this._local)) {
+      if (this._local[key].added) {
+        delete this._local[key];
       }
-      return this.$.restAPI.setRepoAccessRights(this.repo, obj)
-          .then(() => {
-            this._reload(this.repo);
-          })
-          .finally(() => {
-            this._modified = false;
-            if (button) {
-              button.loading = false;
-            }
-          });
-    }
-
-    _handleSaveForReview(e) {
-      const obj = this._getObjforSave();
-      if (!obj) { return; }
-      const button = e && e.target;
-      if (button) {
-        button.loading = true;
-      }
-      return this.$.restAPI
-          .setRepoAccessRightsForReview(this.repo, obj)
-          .then(change => {
-            Gerrit.Nav.navigateToChange(change);
-          })
-          .finally(() => {
-            this._modified = false;
-            if (button) {
-              button.loading = false;
-            }
-          });
-    }
-
-    _computeSaveReviewBtnClass(canUpload) {
-      return !canUpload ? 'invisible' : '';
-    }
-
-    _computeSaveBtnClass(ownerOf) {
-      return ownerOf && ownerOf.length === 0 ? 'invisible' : '';
-    }
-
-    _computeMainClass(ownerOf, canUpload, editing) {
-      const classList = [];
-      if (ownerOf && ownerOf.length > 0 || canUpload) {
-        classList.push('admin');
-      }
-      if (editing) {
-        classList.push('editing');
-      }
-      return classList.join(' ');
-    }
-
-    _computeParentHref(repoName) {
-      return this.getBaseUrl() +
-          `/admin/repos/${this.encodeURL(repoName, true)},access`;
     }
   }
 
-  customElements.define(GrRepoAccess.is, GrRepoAccess);
-})();
+  /**
+   * @param {!Defs.projectAccessInput} addRemoveObj
+   * @param {!Array} path
+   * @param {string} type add or remove
+   * @param {!Object=} opt_value value to add if the type is 'add'
+   * @return {!Defs.projectAccessInput}
+   */
+  _updateAddRemoveObj(addRemoveObj, path, type, opt_value) {
+    let curPos = addRemoveObj[type];
+    for (const item of path) {
+      if (!curPos[item]) {
+        if (item === path[path.length - 1] && type === 'remove') {
+          if (path[path.length - 2] === 'permissions') {
+            curPos[item] = {rules: {}};
+          } else if (path.length === 1) {
+            curPos[item] = {permissions: {}};
+          } else {
+            curPos[item] = {};
+          }
+        } else if (item === path[path.length - 1] && type === 'add') {
+          curPos[item] = opt_value;
+        } else {
+          curPos[item] = {};
+        }
+      }
+      curPos = curPos[item];
+    }
+    return addRemoveObj;
+  }
+
+  /**
+   * Used to recursively remove any objects with a 'deleted' bit.
+   */
+  _recursivelyRemoveDeleted(obj) {
+    for (const k in obj) {
+      if (!obj.hasOwnProperty(k)) { continue; }
+
+      if (typeof obj[k] == 'object') {
+        if (obj[k].deleted) {
+          delete obj[k];
+          return;
+        }
+        this._recursivelyRemoveDeleted(obj[k]);
+      }
+    }
+  }
+
+  _recursivelyUpdateAddRemoveObj(obj, addRemoveObj, path = []) {
+    for (const k in obj) {
+      if (!obj.hasOwnProperty(k)) { continue; }
+      if (typeof obj[k] == 'object') {
+        const updatedId = obj[k].updatedId;
+        const ref = updatedId ? updatedId : k;
+        if (obj[k].deleted) {
+          this._updateAddRemoveObj(addRemoveObj,
+              path.concat(k), 'remove');
+          continue;
+        } else if (obj[k].modified) {
+          this._updateAddRemoveObj(addRemoveObj,
+              path.concat(k), 'remove');
+          this._updateAddRemoveObj(addRemoveObj, path.concat(ref), 'add',
+              obj[k]);
+          /* Special case for ref changes because they need to be added and
+           removed in a different way. The new ref needs to include all
+           changes but also the initial state. To do this, instead of
+           continuing with the same recursion, just remove anything that is
+           deleted in the current state. */
+          if (updatedId && updatedId !== k) {
+            this._recursivelyRemoveDeleted(addRemoveObj.add[updatedId]);
+          }
+          continue;
+        } else if (obj[k].added) {
+          this._updateAddRemoveObj(addRemoveObj,
+              path.concat(ref), 'add', obj[k]);
+          /**
+           * As add / delete both can happen in the new section,
+           * so here to make sure it will remove the deleted ones.
+           *
+           * @see Issue 11339
+           */
+          this._recursivelyRemoveDeleted(addRemoveObj.add[k]);
+          continue;
+        }
+        this._recursivelyUpdateAddRemoveObj(obj[k], addRemoveObj,
+            path.concat(k));
+      }
+    }
+  }
+
+  /**
+   * Returns an object formatted for saving or submitting access changes for
+   * review
+   *
+   * @return {!Defs.projectAccessInput}
+   */
+  _computeAddAndRemove() {
+    const addRemoveObj = {
+      add: {},
+      remove: {},
+    };
+
+    const originalInheritsFromId = this._originalInheritsFrom ?
+      this.singleDecodeURL(this._originalInheritsFrom.id) :
+      null;
+    const inheritsFromId = this._inheritsFrom ?
+      this.singleDecodeURL(this._inheritsFrom.id) :
+      null;
+
+    const inheritFromChanged =
+        // Inherit from changed
+        (originalInheritsFromId &&
+            originalInheritsFromId !== inheritsFromId) ||
+        // Inherit from added (did not have one initially);
+        (!originalInheritsFromId && inheritsFromId);
+
+    this._recursivelyUpdateAddRemoveObj(this._local, addRemoveObj);
+
+    if (inheritFromChanged) {
+      addRemoveObj.parent = inheritsFromId;
+    }
+    return addRemoveObj;
+  }
+
+  _handleCreateSection() {
+    let newRef = 'refs/for/*';
+    // Avoid using an already used key for the placeholder, since it
+    // immediately gets added to an object.
+    while (this._local[newRef]) {
+      newRef = `${newRef}*`;
+    }
+    const section = {permissions: {}, added: true};
+    this.push('_sections', {id: newRef, value: section});
+    this.set(['_local', newRef], section);
+    flush();
+    dom(this.root).querySelector('gr-access-section:last-of-type')
+        .editReference();
+  }
+
+  _getObjforSave() {
+    const addRemoveObj = this._computeAddAndRemove();
+    // If there are no changes, don't actually save.
+    if (!Object.keys(addRemoveObj.add).length &&
+        !Object.keys(addRemoveObj.remove).length &&
+        !addRemoveObj.parent) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {message: NOTHING_TO_SAVE},
+        bubbles: true,
+        composed: true,
+      }));
+      return;
+    }
+    const obj = {
+      add: addRemoveObj.add,
+      remove: addRemoveObj.remove,
+    };
+    if (addRemoveObj.parent) {
+      obj.parent = addRemoveObj.parent;
+    }
+    return obj;
+  }
+
+  _handleSave(e) {
+    const obj = this._getObjforSave();
+    if (!obj) { return; }
+    const button = e && e.target;
+    if (button) {
+      button.loading = true;
+    }
+    return this.$.restAPI.setRepoAccessRights(this.repo, obj)
+        .then(() => {
+          this._reload(this.repo);
+        })
+        .finally(() => {
+          this._modified = false;
+          if (button) {
+            button.loading = false;
+          }
+        });
+  }
+
+  _handleSaveForReview(e) {
+    const obj = this._getObjforSave();
+    if (!obj) { return; }
+    const button = e && e.target;
+    if (button) {
+      button.loading = true;
+    }
+    return this.$.restAPI
+        .setRepoAccessRightsForReview(this.repo, obj)
+        .then(change => {
+          Gerrit.Nav.navigateToChange(change);
+        })
+        .finally(() => {
+          this._modified = false;
+          if (button) {
+            button.loading = false;
+          }
+        });
+  }
+
+  _computeSaveReviewBtnClass(canUpload) {
+    return !canUpload ? 'invisible' : '';
+  }
+
+  _computeSaveBtnClass(ownerOf) {
+    return ownerOf && ownerOf.length === 0 ? 'invisible' : '';
+  }
+
+  _computeMainClass(ownerOf, canUpload, editing) {
+    const classList = [];
+    if (ownerOf && ownerOf.length > 0 || canUpload) {
+      classList.push('admin');
+    }
+    if (editing) {
+      classList.push('editing');
+    }
+    return classList.join(' ');
+  }
+
+  _computeParentHref(repoName) {
+    return this.getBaseUrl() +
+        `/admin/repos/${this.encodeURL(repoName, true)},access`;
+  }
+}
+
+customElements.define(GrRepoAccess.is, GrRepoAccess);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js
index 54006b5..9a27371 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js
@@ -1,38 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-
-<link rel="import" href="../../../styles/gr-menu-page-styles.html">
-<link rel="import" href="../../../styles/gr-subpage-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-access-section/gr-access-section.html">
-
-<script src="../../../scripts/util.js"></script>
-
-<dom-module id="gr-repo-access">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -73,25 +57,18 @@
     <style include="gr-menu-page-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
-    <main class$="[[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
-      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+    <main class\$="[[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
+      <div id="loading" class\$="[[_computeLoadingClass(_loading)]]">
         Loading...
       </div>
-      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-        <h3 id="inheritsFrom" class$="[[_computeShowInherit(_inheritsFrom)]]">
+      <div id="loadedContent" class\$="[[_computeLoadingClass(_loading)]]">
+        <h3 id="inheritsFrom" class\$="[[_computeShowInherit(_inheritsFrom)]]">
           <span class="rightsText">Rights Inherit From</span>
-          <a
-              href$="[[_computeParentHref(_inheritsFrom.name)]]"
-              rel="noopener"
-              id="inheritFromName">
+          <a href\$="[[_computeParentHref(_inheritsFrom.name)]]" rel="noopener" id="inheritFromName">
             [[_inheritsFrom.name]]</a>
-          <gr-autocomplete
-              id="editInheritFromInput"
-              text="{{_inheritFromFilter}}"
-              query="[[_query]]"
-              on-commit="_handleUpdateInheritFrom"></gr-autocomplete>
+          <gr-autocomplete id="editInheritFromInput" text="{{_inheritFromFilter}}" query="[[_query]]" on-commit="_handleUpdateInheritFrom"></gr-autocomplete>
         </h3>
-        <div class$="weblinks [[_computeWebLinkClass(_weblinks)]]">
+        <div class\$="weblinks [[_computeWebLinkClass(_weblinks)]]">
           History:
           <template is="dom-repeat" items="[[_weblinks]]" as="link">
             <a href="[[link.url]]" class="weblink" rel="noopener" target="[[link.target]]">
@@ -99,41 +76,16 @@
             </a>
           </template>
         </div>
-        <gr-button id="editBtn"
-            on-click="_handleEdit">[[_editOrCancel(_editing)]]</gr-button>
-        <gr-button id="saveBtn"
-            primary
-            class$="[[_computeSaveBtnClass(_ownerOf)]]"
-            on-click="_handleSave"
-            disabled="[[!_modified]]">Save</gr-button>
-        <gr-button id="saveReviewBtn"
-            primary
-            class$="[[_computeSaveReviewBtnClass(_canUpload)]]"
-            on-click="_handleSaveForReview"
-            disabled="[[!_modified]]">Save for review</gr-button>
-        <template
-            is="dom-repeat"
-            items="{{_sections}}"
-            initial-count="5"
-            target-framerate="60"
-            as="section">
-          <gr-access-section
-              capabilities="[[_capabilities]]"
-              section="{{section}}"
-              labels="[[_labels]]"
-              can-upload="[[_canUpload]]"
-              editing="[[_editing]]"
-              owner-of="[[_ownerOf]]"
-              groups="[[_groups]]"
-              on-added-section-removed="_handleAddedSectionRemoved"></gr-access-section>
+        <gr-button id="editBtn" on-click="_handleEdit">[[_editOrCancel(_editing)]]</gr-button>
+        <gr-button id="saveBtn" primary="" class\$="[[_computeSaveBtnClass(_ownerOf)]]" on-click="_handleSave" disabled="[[!_modified]]">Save</gr-button>
+        <gr-button id="saveReviewBtn" primary="" class\$="[[_computeSaveReviewBtnClass(_canUpload)]]" on-click="_handleSaveForReview" disabled="[[!_modified]]">Save for review</gr-button>
+        <template is="dom-repeat" items="{{_sections}}" initial-count="5" target-framerate="60" as="section">
+          <gr-access-section capabilities="[[_capabilities]]" section="{{section}}" labels="[[_labels]]" can-upload="[[_canUpload]]" editing="[[_editing]]" owner-of="[[_ownerOf]]" groups="[[_groups]]" on-added-section-removed="_handleAddedSectionRemoved"></gr-access-section>
         </template>
         <div class="referenceContainer">
-          <gr-button id="addReferenceBtn"
-              on-click="_handleCreateSection">Add Reference</gr-button>
+          <gr-button id="addReferenceBtn" on-click="_handleCreateSection">Add Reference</gr-button>
         </div>
       </div>
     </main>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-access.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
index d89b5df..4a482db 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-access</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-access.html">
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-repo-access.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-access.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,395 +41,432 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-access tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let repoStub;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-access.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-repo-access tests', () => {
+  let element;
+  let sandbox;
+  let repoStub;
 
-    const accessRes = {
-      local: {
-        'refs/*': {
-          permissions: {
-            owner: {
-              rules: {
-                234: {action: 'ALLOW'},
-                123: {action: 'DENY'},
-              },
+  const accessRes = {
+    local: {
+      'refs/*': {
+        permissions: {
+          owner: {
+            rules: {
+              234: {action: 'ALLOW'},
+              123: {action: 'DENY'},
             },
-            read: {
-              rules: {
-                234: {action: 'ALLOW'},
+          },
+          read: {
+            rules: {
+              234: {action: 'ALLOW'},
+            },
+          },
+        },
+      },
+    },
+    groups: {
+      Administrators: {
+        name: 'Administrators',
+      },
+      Maintainers: {
+        name: 'Maintainers',
+      },
+    },
+    config_web_links: [{
+      name: 'gitiles',
+      target: '_blank',
+      url: 'https://my/site/+log/123/project.config',
+    }],
+    can_upload: true,
+  };
+  const accessRes2 = {
+    local: {
+      GLOBAL_CAPABILITIES: {
+        permissions: {
+          accessDatabase: {
+            rules: {
+              group1: {
+                action: 'ALLOW',
               },
             },
           },
         },
       },
-      groups: {
-        Administrators: {
-          name: 'Administrators',
-        },
-        Maintainers: {
-          name: 'Maintainers',
+    },
+  };
+  const repoRes = {
+    labels: {
+      'Code-Review': {
+        values: {
+          ' 0': 'No score',
+          '-1': 'I would prefer this is not merged as is',
+          '-2': 'This shall not be merged',
+          '+1': 'Looks good to me, but someone else must approve',
+          '+2': 'Looks good to me, approved',
         },
       },
-      config_web_links: [{
-        name: 'gitiles',
-        target: '_blank',
-        url: 'https://my/site/+log/123/project.config',
-      }],
-      can_upload: true,
-    };
-    const accessRes2 = {
-      local: {
-        GLOBAL_CAPABILITIES: {
-          permissions: {
-            accessDatabase: {
-              rules: {
-                group1: {
-                  action: 'ALLOW',
-                },
-              },
-            },
-          },
-        },
-      },
-    };
-    const repoRes = {
-      labels: {
-        'Code-Review': {
-          values: {
-            ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-        },
-      },
-    };
+    },
+  };
+  const capabilitiesRes = {
+    accessDatabase: {
+      id: 'accessDatabase',
+      name: 'Access Database',
+    },
+    createAccount: {
+      id: 'createAccount',
+      name: 'Create Account',
+    },
+  };
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(null); },
+    });
+    repoStub = sandbox.stub(element.$.restAPI, 'getRepo').returns(
+        Promise.resolve(repoRes));
+    element._loading = false;
+    element._ownerOf = [];
+    element._canUpload = false;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_repoChanged called when repo name changes', () => {
+    sandbox.stub(element, '_repoChanged');
+    element.repo = 'New Repo';
+    assert.isTrue(element._repoChanged.called);
+  });
+
+  test('_repoChanged', done => {
+    const accessStub = sandbox.stub(element.$.restAPI,
+        'getRepoAccessRights');
+
+    accessStub.withArgs('New Repo').returns(
+        Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+    accessStub.withArgs('Another New Repo')
+        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+    const capabilitiesStub = sandbox.stub(element.$.restAPI,
+        'getCapabilities');
+    capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
+
+    element._repoChanged('New Repo').then(() => {
+      assert.isTrue(accessStub.called);
+      assert.isTrue(capabilitiesStub.called);
+      assert.isTrue(repoStub.called);
+      assert.isNotOk(element._inheritsFrom);
+      assert.deepEqual(element._local, accessRes.local);
+      assert.deepEqual(element._sections,
+          element.toSortedArray(accessRes.local));
+      assert.deepEqual(element._labels, repoRes.labels);
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('.weblinks')).display,
+      'block');
+      return element._repoChanged('Another New Repo');
+    })
+        .then(() => {
+          assert.deepEqual(element._sections,
+              element.toSortedArray(accessRes2.local));
+          assert.equal(getComputedStyle(element.shadowRoot
+              .querySelector('.weblinks')).display,
+          'none');
+          done();
+        });
+  });
+
+  test('_repoChanged when repo changes to undefined returns', done => {
     const capabilitiesRes = {
       accessDatabase: {
         id: 'accessDatabase',
         name: 'Access Database',
       },
-      createAccount: {
-        id: 'createAccount',
-        name: 'Create Account',
-      },
     };
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-      });
-      repoStub = sandbox.stub(element.$.restAPI, 'getRepo').returns(
-          Promise.resolve(repoRes));
-      element._loading = false;
-      element._ownerOf = [];
-      element._canUpload = false;
+    const accessStub = sandbox.stub(element.$.restAPI, 'getRepoAccessRights')
+        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+    const capabilitiesStub = sandbox.stub(element.$.restAPI,
+        'getCapabilities').returns(Promise.resolve(capabilitiesRes));
+
+    element._repoChanged().then(() => {
+      assert.isFalse(accessStub.called);
+      assert.isFalse(capabilitiesStub.called);
+      assert.isFalse(repoStub.called);
+      done();
+    });
+  });
+
+  test('_computeParentHref', () => {
+    const repoName = 'test-repo';
+    assert.equal(element._computeParentHref(repoName),
+        '/admin/repos/test-repo,access');
+  });
+
+  test('_computeMainClass', () => {
+    let ownerOf = ['refs/*'];
+    const editing = true;
+    const canUpload = false;
+    assert.equal(element._computeMainClass(ownerOf, canUpload), 'admin');
+    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
+        'admin editing');
+    ownerOf = [];
+    assert.equal(element._computeMainClass(ownerOf, canUpload), '');
+    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
+        'editing');
+  });
+
+  test('inherit section', () => {
+    element._local = {};
+    element._ownerOf = [];
+    sandbox.stub(element, '_computeParentHref');
+    // Nothing should appear when no inherit from and not in edit mode.
+    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    // The autocomplete should be hidden, and the link should be  displayed.
+    assert.isFalse(element._computeParentHref.called);
+    // When it edit mode, the autocomplete should appear.
+    element._editing = true;
+    // When editing, the autocomplete should still not be shown.
+    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    element._editing = false;
+    element._inheritsFrom = {
+      name: 'another-repo',
+    };
+    // When there is a parent project, the link should be displayed.
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    assert.notEqual(getComputedStyle(element.$.inheritFromName).display,
+        'none');
+    assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
+        'none');
+    assert.isTrue(element._computeParentHref.called);
+    element._editing = true;
+    // When editing, the autocomplete should be shown.
+    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    assert.equal(getComputedStyle(element.$.inheritFromName).display, 'none');
+    assert.notEqual(getComputedStyle(element.$.editInheritFromInput).display,
+        'none');
+  });
+
+  test('_handleUpdateInheritFrom', () => {
+    element._inheritFromFilter = 'foo bar baz';
+    element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
+    assert.isOk(element._inheritsFrom);
+    assert.equal(element._inheritsFrom.id, 'abc+123');
+    assert.equal(element._inheritsFrom.name, 'foo bar baz');
+  });
+
+  test('_computeLoadingClass', () => {
+    assert.equal(element._computeLoadingClass(true), 'loading');
+    assert.equal(element._computeLoadingClass(false), '');
+  });
+
+  test('fires page-error', done => {
+    const response = {status: 404};
+
+    sandbox.stub(
+        element.$.restAPI, 'getRepoAccessRights', (repoName, errFn) => {
+          errFn(response);
+        });
+
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    element.repo = 'test';
+  });
 
-    test('_repoChanged called when repo name changes', () => {
-      sandbox.stub(element, '_repoChanged');
-      element.repo = 'New Repo';
-      assert.isTrue(element._repoChanged.called);
-    });
-
-    test('_repoChanged', done => {
-      const accessStub = sandbox.stub(element.$.restAPI,
-          'getRepoAccessRights');
-
-      accessStub.withArgs('New Repo').returns(
-          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-      accessStub.withArgs('Another New Repo')
-          .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-      const capabilitiesStub = sandbox.stub(element.$.restAPI,
-          'getCapabilities');
-      capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
-
-      element._repoChanged('New Repo').then(() => {
-        assert.isTrue(accessStub.called);
-        assert.isTrue(capabilitiesStub.called);
-        assert.isTrue(repoStub.called);
-        assert.isNotOk(element._inheritsFrom);
-        assert.deepEqual(element._local, accessRes.local);
-        assert.deepEqual(element._sections,
-            element.toSortedArray(accessRes.local));
-        assert.deepEqual(element._labels, repoRes.labels);
-        assert.equal(getComputedStyle(element.shadowRoot
-            .querySelector('.weblinks')).display,
-        'block');
-        return element._repoChanged('Another New Repo');
-      })
-          .then(() => {
-            assert.deepEqual(element._sections,
-                element.toSortedArray(accessRes2.local));
-            assert.equal(getComputedStyle(element.shadowRoot
-                .querySelector('.weblinks')).display,
-            'none');
-            done();
-          });
-    });
-
-    test('_repoChanged when repo changes to undefined returns', done => {
-      const capabilitiesRes = {
-        accessDatabase: {
-          id: 'accessDatabase',
-          name: 'Access Database',
-        },
-      };
-      const accessStub = sandbox.stub(element.$.restAPI, 'getRepoAccessRights')
-          .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-      const capabilitiesStub = sandbox.stub(element.$.restAPI,
-          'getCapabilities').returns(Promise.resolve(capabilitiesRes));
-
-      element._repoChanged().then(() => {
-        assert.isFalse(accessStub.called);
-        assert.isFalse(capabilitiesStub.called);
-        assert.isFalse(repoStub.called);
-        done();
-      });
-    });
-
-    test('_computeParentHref', () => {
-      const repoName = 'test-repo';
-      assert.equal(element._computeParentHref(repoName),
-          '/admin/repos/test-repo,access');
-    });
-
-    test('_computeMainClass', () => {
-      let ownerOf = ['refs/*'];
-      const editing = true;
-      const canUpload = false;
-      assert.equal(element._computeMainClass(ownerOf, canUpload), 'admin');
-      assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
-          'admin editing');
-      ownerOf = [];
-      assert.equal(element._computeMainClass(ownerOf, canUpload), '');
-      assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
-          'editing');
-    });
-
-    test('inherit section', () => {
-      element._local = {};
-      element._ownerOf = [];
-      sandbox.stub(element, '_computeParentHref');
-      // Nothing should appear when no inherit from and not in edit mode.
-      assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
-      // The autocomplete should be hidden, and the link should be  displayed.
-      assert.isFalse(element._computeParentHref.called);
-      // When it edit mode, the autocomplete should appear.
-      element._editing = true;
-      // When editing, the autocomplete should still not be shown.
-      assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
-      element._editing = false;
-      element._inheritsFrom = {
-        name: 'another-repo',
-      };
-      // When there is a parent project, the link should be displayed.
-      flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.inheritFromName).display,
-          'none');
+  suite('with defined sections', () => {
+    const testEditSaveCancelBtns = (shouldShowSave, shouldShowSaveReview) => {
+      // Edit button is visible and Save button is hidden.
+      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
+      assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
+      assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
+      assert.equal(element.$.editBtn.innerText, 'EDIT');
       assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
           'none');
-      assert.isTrue(element._computeParentHref.called);
-      element._editing = true;
-      // When editing, the autocomplete should be shown.
-      assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
-      assert.equal(getComputedStyle(element.$.inheritFromName).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.editInheritFromInput).display,
-          'none');
-    });
+      element._inheritsFrom = {
+        id: 'test-project',
+      };
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#editInheritFromInput'))
+          .display, 'none');
 
-    test('_handleUpdateInheritFrom', () => {
-      element._inheritFromFilter = 'foo bar baz';
-      element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
-      assert.isOk(element._inheritsFrom);
-      assert.equal(element._inheritsFrom.id, 'abc+123');
-      assert.equal(element._inheritsFrom.name, 'foo bar baz');
-    });
+      MockInteractions.tap(element.$.editBtn);
+      flushAsynchronousOperations();
 
-    test('_computeLoadingClass', () => {
-      assert.equal(element._computeLoadingClass(true), 'loading');
-      assert.equal(element._computeLoadingClass(false), '');
-    });
-
-    test('fires page-error', done => {
-      const response = {status: 404};
-
-      sandbox.stub(
-          element.$.restAPI, 'getRepoAccessRights', (repoName, errFn) => {
-            errFn(response);
-          });
-
-      element.addEventListener('page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element.repo = 'test';
-    });
-
-    suite('with defined sections', () => {
-      const testEditSaveCancelBtns = (shouldShowSave, shouldShowSaveReview) => {
-        // Edit button is visible and Save button is hidden.
-        assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
-        assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
-        assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
-        assert.equal(element.$.editBtn.innerText, 'EDIT');
-        assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
+      // Edit button changes to Cancel button, and Save button is visible but
+      // disabled.
+      assert.equal(element.$.editBtn.innerText, 'CANCEL');
+      if (shouldShowSaveReview) {
+        assert.notEqual(getComputedStyle(element.$.saveReviewBtn).display,
             'none');
-        element._inheritsFrom = {
-          id: 'test-project',
-        };
-        flushAsynchronousOperations();
-        assert.equal(getComputedStyle(element.shadowRoot
-            .querySelector('#editInheritFromInput'))
-            .display, 'none');
+        assert.isTrue(element.$.saveReviewBtn.disabled);
+      }
+      if (shouldShowSave) {
+        assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
+        assert.isTrue(element.$.saveBtn.disabled);
+      }
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('#editInheritFromInput'))
+          .display, 'none');
 
-        MockInteractions.tap(element.$.editBtn);
-        flushAsynchronousOperations();
+      // Save button should be enabled after access is modified
+      element.fire('access-modified');
+      if (shouldShowSaveReview) {
+        assert.isFalse(element.$.saveReviewBtn.disabled);
+      }
+      if (shouldShowSave) {
+        assert.isFalse(element.$.saveBtn.disabled);
+      }
+    };
 
-        // Edit button changes to Cancel button, and Save button is visible but
-        // disabled.
-        assert.equal(element.$.editBtn.innerText, 'CANCEL');
-        if (shouldShowSaveReview) {
-          assert.notEqual(getComputedStyle(element.$.saveReviewBtn).display,
-              'none');
-          assert.isTrue(element.$.saveReviewBtn.disabled);
-        }
-        if (shouldShowSave) {
-          assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
-          assert.isTrue(element.$.saveBtn.disabled);
-        }
-        assert.notEqual(getComputedStyle(element.shadowRoot
-            .querySelector('#editInheritFromInput'))
-            .display, 'none');
+    setup(() => {
+      // Create deep copies of these objects so the originals are not modified
+      // by any tests.
+      element._local = JSON.parse(JSON.stringify(accessRes.local));
+      element._ownerOf = [];
+      element._sections = element.toSortedArray(element._local);
+      element._groups = JSON.parse(JSON.stringify(accessRes.groups));
+      element._capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
+      element._labels = JSON.parse(JSON.stringify(repoRes.labels));
+      flushAsynchronousOperations();
+    });
 
-        // Save button should be enabled after access is modified
-        element.fire('access-modified');
-        if (shouldShowSaveReview) {
-          assert.isFalse(element.$.saveReviewBtn.disabled);
-        }
-        if (shouldShowSave) {
-          assert.isFalse(element.$.saveBtn.disabled);
-        }
+    test('removing an added section', () => {
+      element.editing = true;
+      assert.equal(element._sections.length, 1);
+      element.shadowRoot
+          .querySelector('gr-access-section').fire('added-section-removed');
+      flushAsynchronousOperations();
+      assert.equal(element._sections.length, 0);
+    });
+
+    test('button visibility for non ref owner', () => {
+      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
+      assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
+    });
+
+    test('button visibility for non ref owner with upload privilege', () => {
+      element._canUpload = true;
+      testEditSaveCancelBtns(false, true);
+    });
+
+    test('button visibility for ref owner', () => {
+      element._ownerOf = ['refs/for/*'];
+      testEditSaveCancelBtns(true, false);
+    });
+
+    test('button visibility for ref owner and upload', () => {
+      element._ownerOf = ['refs/for/*'];
+      element._canUpload = true;
+      testEditSaveCancelBtns(true, false);
+    });
+
+    test('_handleAccessModified called with event fired', () => {
+      sandbox.spy(element, '_handleAccessModified');
+      element.fire('access-modified');
+      assert.isTrue(element._handleAccessModified.called);
+    });
+
+    test('_handleAccessModified called when parent changes', () => {
+      element._inheritsFrom = {
+        id: 'test-project',
+      };
+      flushAsynchronousOperations();
+      element.shadowRoot.querySelector('#editInheritFromInput').fire('commit');
+      sandbox.spy(element, '_handleAccessModified');
+      element.fire('access-modified');
+      assert.isTrue(element._handleAccessModified.called);
+    });
+
+    test('_handleSaveForReview', () => {
+      const saveStub =
+          sandbox.stub(element.$.restAPI, 'setRepoAccessRightsForReview');
+      sandbox.stub(element, '_computeAddAndRemove').returns({
+        add: {},
+        remove: {},
+      });
+      element._handleSaveForReview();
+      assert.isFalse(saveStub.called);
+    });
+
+    test('_recursivelyRemoveDeleted', () => {
+      const obj = {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {
+                234: {action: 'ALLOW'},
+                123: {action: 'DENY', deleted: true},
+              },
+            },
+            read: {
+              deleted: true,
+              rules: {
+                234: {action: 'ALLOW'},
+              },
+            },
+          },
+        },
+      };
+      const expectedResult = {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {
+                234: {action: 'ALLOW'},
+              },
+            },
+          },
+        },
+      };
+      element._recursivelyRemoveDeleted(obj);
+      assert.deepEqual(obj, expectedResult);
+    });
+
+    test('_recursivelyUpdateAddRemoveObj on new added section', () => {
+      const obj = {
+        'refs/for/*': {
+          permissions: {
+            'label-Code-Review': {
+              rules: {
+                e798fed07afbc9173a587f876ef8760c78d240c1: {
+                  min: -2,
+                  max: 2,
+                  action: 'ALLOW',
+                  added: true,
+                },
+              },
+              added: true,
+              label: 'Code-Review',
+            },
+            'labelAs-Code-Review': {
+              rules: {
+                'ldap:gerritcodereview-eng': {
+                  min: -2,
+                  max: 2,
+                  action: 'ALLOW',
+                  added: true,
+                  deleted: true,
+                },
+              },
+              added: true,
+              label: 'Code-Review',
+            },
+          },
+          added: true,
+        },
       };
 
-      setup(() => {
-        // Create deep copies of these objects so the originals are not modified
-        // by any tests.
-        element._local = JSON.parse(JSON.stringify(accessRes.local));
-        element._ownerOf = [];
-        element._sections = element.toSortedArray(element._local);
-        element._groups = JSON.parse(JSON.stringify(accessRes.groups));
-        element._capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
-        element._labels = JSON.parse(JSON.stringify(repoRes.labels));
-        flushAsynchronousOperations();
-      });
-
-      test('removing an added section', () => {
-        element.editing = true;
-        assert.equal(element._sections.length, 1);
-        element.shadowRoot
-            .querySelector('gr-access-section').fire('added-section-removed');
-        flushAsynchronousOperations();
-        assert.equal(element._sections.length, 0);
-      });
-
-      test('button visibility for non ref owner', () => {
-        assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
-        assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
-      });
-
-      test('button visibility for non ref owner with upload privilege', () => {
-        element._canUpload = true;
-        testEditSaveCancelBtns(false, true);
-      });
-
-      test('button visibility for ref owner', () => {
-        element._ownerOf = ['refs/for/*'];
-        testEditSaveCancelBtns(true, false);
-      });
-
-      test('button visibility for ref owner and upload', () => {
-        element._ownerOf = ['refs/for/*'];
-        element._canUpload = true;
-        testEditSaveCancelBtns(true, false);
-      });
-
-      test('_handleAccessModified called with event fired', () => {
-        sandbox.spy(element, '_handleAccessModified');
-        element.fire('access-modified');
-        assert.isTrue(element._handleAccessModified.called);
-      });
-
-      test('_handleAccessModified called when parent changes', () => {
-        element._inheritsFrom = {
-          id: 'test-project',
-        };
-        flushAsynchronousOperations();
-        element.shadowRoot.querySelector('#editInheritFromInput').fire('commit');
-        sandbox.spy(element, '_handleAccessModified');
-        element.fire('access-modified');
-        assert.isTrue(element._handleAccessModified.called);
-      });
-
-      test('_handleSaveForReview', () => {
-        const saveStub =
-            sandbox.stub(element.$.restAPI, 'setRepoAccessRightsForReview');
-        sandbox.stub(element, '_computeAddAndRemove').returns({
-          add: {},
-          remove: {},
-        });
-        element._handleSaveForReview();
-        assert.isFalse(saveStub.called);
-      });
-
-      test('_recursivelyRemoveDeleted', () => {
-        const obj = {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  234: {action: 'ALLOW'},
-                  123: {action: 'DENY', deleted: true},
-                },
-              },
-              read: {
-                deleted: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-            },
-          },
-        };
-        const expectedResult = {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-            },
-          },
-        };
-        element._recursivelyRemoveDeleted(obj);
-        assert.deepEqual(obj, expectedResult);
-      });
-
-      test('_recursivelyUpdateAddRemoveObj on new added section', () => {
-        const obj = {
+      const expectedResult = {
+        add: {
           'refs/for/*': {
             permissions: {
               'label-Code-Review': {
@@ -440,798 +482,764 @@
                 label: 'Code-Review',
               },
               'labelAs-Code-Review': {
-                rules: {
-                  'ldap:gerritcodereview-eng': {
-                    min: -2,
-                    max: 2,
-                    action: 'ALLOW',
-                    added: true,
-                    deleted: true,
-                  },
-                },
+                rules: {},
                 added: true,
                 label: 'Code-Review',
               },
             },
             added: true,
           },
-        };
+        },
+        remove: {},
+      };
+      const updateObj = {add: {}, remove: {}};
+      element._recursivelyUpdateAddRemoveObj(obj, updateObj);
+      assert.deepEqual(updateObj, expectedResult);
+    });
 
-        const expectedResult = {
-          add: {
-            'refs/for/*': {
-              permissions: {
-                'label-Code-Review': {
-                  rules: {
-                    e798fed07afbc9173a587f876ef8760c78d240c1: {
-                      min: -2,
-                      max: 2,
-                      action: 'ALLOW',
-                      added: true,
-                    },
-                  },
-                  added: true,
-                  label: 'Code-Review',
-                },
-                'labelAs-Code-Review': {
-                  rules: {},
-                  added: true,
-                  label: 'Code-Review',
-                },
-              },
-              added: true,
-            },
-          },
-          remove: {},
-        };
-        const updateObj = {add: {}, remove: {}};
-        element._recursivelyUpdateAddRemoveObj(obj, updateObj);
-        assert.deepEqual(updateObj, expectedResult);
+    test('_handleSaveForReview with no changes', () => {
+      assert.deepEqual(element._computeAddAndRemove(), {add: {}, remove: {}});
+    });
+
+    test('_handleSaveForReview parent change', () => {
+      element._inheritsFrom = {
+        id: 'test-project',
+      };
+      element._originalInheritsFrom = {
+        id: 'test-project-original',
+      };
+      assert.deepEqual(element._computeAddAndRemove(), {
+        parent: 'test-project', add: {}, remove: {},
       });
+    });
 
-      test('_handleSaveForReview with no changes', () => {
-        assert.deepEqual(element._computeAddAndRemove(), {add: {}, remove: {}});
+    test('_handleSaveForReview new parent with spaces', () => {
+      element._inheritsFrom = {id: 'spaces+in+project+name'};
+      element._originalInheritsFrom = {id: 'old-project'};
+      assert.deepEqual(element._computeAddAndRemove(), {
+        parent: 'spaces in project name', add: {}, remove: {},
       });
+    });
 
-      test('_handleSaveForReview parent change', () => {
-        element._inheritsFrom = {
-          id: 'test-project',
-        };
-        element._originalInheritsFrom = {
-          id: 'test-project-original',
-        };
-        assert.deepEqual(element._computeAddAndRemove(), {
-          parent: 'test-project', add: {}, remove: {},
-        });
+    test('_handleSaveForReview rules', () => {
+      // Delete a rule.
+      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+      let expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Undo deleting a rule.
+      delete element._local['refs/*'].permissions.owner.rules[123].deleted;
+
+      // Modify a rule.
+      element._local['refs/*'].permissions.owner.rules[123].modified = true;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('_computeAddAndRemove permissions', () => {
+      // Add a new rule to a permission.
+      let expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+
+      element.shadowRoot
+          .querySelector('gr-access-section').shadowRoot
+          .querySelector('gr-permission')
+          ._handleAddRuleItem(
+              {detail: {value: {id: 'Maintainers'}}});
+
+      flushAsynchronousOperations();
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Remove the added rule.
+      delete element._local['refs/*'].permissions.owner.rules.Maintainers;
+
+      // Delete a permission.
+      element._local['refs/*'].permissions.owner.deleted = true;
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Undo delete permission.
+      delete element._local['refs/*'].permissions.owner.deleted;
+
+      // Modify a permission.
+      element._local['refs/*'].permissions.owner.modified = true;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  123: {action: 'DENY'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('_computeAddAndRemove sections', () => {
+      // Add a new permission to a section
+      let expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {},
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      element.shadowRoot
+          .querySelector('gr-access-section')._handleAddPermission();
+      flushAsynchronousOperations();
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add a new rule to the new permission.
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      const newPermission =
+          dom(element.shadowRoot
+              .querySelector('gr-access-section').root).querySelectorAll(
+              'gr-permission')[2];
+      newPermission._handleAddRuleItem(
+          {detail: {value: {id: 'Maintainers'}}});
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Modify a section reference.
+      element._local['refs/*'].updatedId = 'refs/for/bar';
+      element._local['refs/*'].modified = true;
+      expectedInput = {
+        add: {
+          'refs/for/bar': {
+            modified: true,
+            updatedId: 'refs/for/bar',
+            permissions: {
+              'owner': {
+                rules: {
+                  234: {action: 'ALLOW'},
+                  123: {action: 'DENY'},
+                },
+              },
+              'read': {
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Delete a section.
+      element._local['refs/*'].deleted = true;
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('_computeAddAndRemove new section', () => {
+      // Add a new permission to a section
+      let expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {},
+          },
+        },
+        remove: {},
+      };
+      MockInteractions.tap(element.$.addReferenceBtn);
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {},
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      const newSection = dom(element.root)
+          .querySelectorAll('gr-access-section')[1];
+      newSection._handleAddPermission();
+      flushAsynchronousOperations();
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add rule to the new permission.
+      expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+
+      newSection.shadowRoot
+          .querySelector('gr-permission')._handleAddRuleItem(
+              {detail: {value: {id: 'Maintainers'}}});
+
+      flushAsynchronousOperations();
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Modify a the reference from the default value.
+      element._local['refs/for/*'].updatedId = 'refs/for/new';
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('_computeAddAndRemove combinations', () => {
+      // Modify rule and delete permission that it is inside of.
+      element._local['refs/*'].permissions.owner.rules[123].modified = true;
+      element._local['refs/*'].permissions.owner.deleted = true;
+      let expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      // Delete rule and delete permission that it is inside of.
+      element._local['refs/*'].permissions.owner.rules[123].modified = false;
+      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Also modify a different rule inside of another permission.
+      element._local['refs/*'].permissions.read.modified = true;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      // Modify both permissions with an exclusive bit. Owner is still
+      // deleted.
+      element._local['refs/*'].permissions.owner.exclusive = true;
+      element._local['refs/*'].permissions.owner.modified = true;
+      element._local['refs/*'].permissions.read.exclusive = true;
+      element._local['refs/*'].permissions.read.modified = true;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add a rule to the existing permission;
+      const readPermission =
+          dom(element.shadowRoot
+              .querySelector('gr-access-section').root).querySelectorAll(
+              'gr-permission')[1];
+      readPermission._handleAddRuleItem(
+          {detail: {value: {id: 'Maintainers'}}});
+
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  Maintainers: {action: 'ALLOW', added: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Change one of the refs
+      element._local['refs/*'].updatedId = 'refs/for/bar';
+      element._local['refs/*'].modified = true;
+
+      expectedInput = {
+        add: {
+          'refs/for/bar': {
+            modified: true,
+            updatedId: 'refs/for/bar',
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  Maintainers: {action: 'ALLOW', added: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      element._local['refs/*'].deleted = true;
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add a new section.
+      MockInteractions.tap(element.$.addReferenceBtn);
+      let newSection = dom(element.root)
+          .querySelectorAll('gr-access-section')[1];
+      newSection._handleAddPermission();
+      flushAsynchronousOperations();
+      newSection.shadowRoot
+          .querySelector('gr-permission')._handleAddRuleItem(
+              {detail: {value: {id: 'Maintainers'}}});
+      // Modify a the reference from the default value.
+      element._local['refs/for/*'].updatedId = 'refs/for/new';
+
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Modify newly added rule inside new ref.
+      element._local['refs/for/*'].permissions['label-Code-Review'].
+          rules['Maintainers'].modified = true;
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    modified: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add a second new section.
+      MockInteractions.tap(element.$.addReferenceBtn);
+      newSection = dom(element.root)
+          .querySelectorAll('gr-access-section')[2];
+      newSection._handleAddPermission();
+      flushAsynchronousOperations();
+      newSection.shadowRoot
+          .querySelector('gr-permission')._handleAddRuleItem(
+              {detail: {value: {id: 'Maintainers'}}});
+      // Modify a the reference from the default value.
+      element._local['refs/for/**'].updatedId = 'refs/for/new2';
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    modified: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+          'refs/for/new2': {
+            added: true,
+            updatedId: 'refs/for/new2',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('Unsaved added refs are discarded when edit cancelled', () => {
+      // Unsaved changes are discarded when editing is cancelled.
+      MockInteractions.tap(element.$.editBtn);
+      assert.equal(element._sections.length, 1);
+      assert.equal(Object.keys(element._local).length, 1);
+      MockInteractions.tap(element.$.addReferenceBtn);
+      assert.equal(element._sections.length, 2);
+      assert.equal(Object.keys(element._local).length, 2);
+      MockInteractions.tap(element.$.editBtn);
+      assert.equal(element._sections.length, 1);
+      assert.equal(Object.keys(element._local).length, 1);
+    });
+
+    test('_handleSave', done => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      sandbox.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+      sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      let resolver;
+      const saveStub = sandbox.stub(element.$.restAPI,
+          'setRepoAccessRights')
+          .returns(new Promise(r => resolver = r));
+
+      element.repo = 'test-repo';
+      sandbox.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
+
+      element._modified = true;
+      MockInteractions.tap(element.$.saveBtn);
+      assert.equal(element.$.saveBtn.hasAttribute('loading'), true);
+      resolver({_number: 1});
+      flush(() => {
+        assert.isTrue(saveStub.called);
+        assert.isTrue(Gerrit.Nav.navigateToChange.notCalled);
+        done();
       });
+    });
 
-      test('_handleSaveForReview new parent with spaces', () => {
-        element._inheritsFrom = {id: 'spaces+in+project+name'};
-        element._originalInheritsFrom = {id: 'old-project'};
-        assert.deepEqual(element._computeAddAndRemove(), {
-          parent: 'spaces in project name', add: {}, remove: {},
-        });
-      });
-
-      test('_handleSaveForReview rules', () => {
-        // Delete a rule.
-        element._local['refs/*'].permissions.owner.rules[123].deleted = true;
-        let expectedInput = {
-          add: {},
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    123: {},
-                  },
+    test('_handleSaveForReview', done => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
                 },
               },
             },
           },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Undo deleting a rule.
-        delete element._local['refs/*'].permissions.owner.rules[123].deleted;
-
-        // Modify a rule.
-        element._local['refs/*'].permissions.owner.rules[123].modified = true;
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    123: {action: 'DENY', modified: true},
-                  },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
                 },
               },
             },
           },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    123: {},
-                  },
-                },
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      });
+        },
+      };
+      sandbox.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+      sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      let resolver;
+      const saveForReviewStub = sandbox.stub(element.$.restAPI,
+          'setRepoAccessRightsForReview')
+          .returns(new Promise(r => resolver = r));
 
-      test('_computeAddAndRemove permissions', () => {
-        // Add a new rule to a permission.
-        let expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                    },
-                  },
-                },
-              },
-            },
-          },
-          remove: {},
-        };
+      element.repo = 'test-repo';
+      sandbox.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
 
-        element.shadowRoot
-            .querySelector('gr-access-section').shadowRoot
-            .querySelector('gr-permission')
-            ._handleAddRuleItem(
-                {detail: {value: {id: 'Maintainers'}}});
-
-        flushAsynchronousOperations();
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Remove the added rule.
-        delete element._local['refs/*'].permissions.owner.rules.Maintainers;
-
-        // Delete a permission.
-        element._local['refs/*'].permissions.owner.deleted = true;
-        expectedInput = {
-          add: {},
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {rules: {}},
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Undo delete permission.
-        delete element._local['refs/*'].permissions.owner.deleted;
-
-        // Modify a permission.
-        element._local['refs/*'].permissions.owner.modified = true;
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  modified: true,
-                  rules: {
-                    234: {action: 'ALLOW'},
-                    123: {action: 'DENY'},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {rules: {}},
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      });
-
-      test('_computeAddAndRemove sections', () => {
-        // Add a new permission to a section
-        let expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {},
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {},
-        };
-        element.shadowRoot
-            .querySelector('gr-access-section')._handleAddPermission();
-        flushAsynchronousOperations();
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Add a new rule to the new permission.
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      min: -2,
-                      max: 2,
-                      action: 'ALLOW',
-                      added: true,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {},
-        };
-        const newPermission =
-            Polymer.dom(element.shadowRoot
-                .querySelector('gr-access-section').root).querySelectorAll(
-                'gr-permission')[2];
-        newPermission._handleAddRuleItem(
-            {detail: {value: {id: 'Maintainers'}}});
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Modify a section reference.
-        element._local['refs/*'].updatedId = 'refs/for/bar';
-        element._local['refs/*'].modified = true;
-        expectedInput = {
-          add: {
-            'refs/for/bar': {
-              modified: true,
-              updatedId: 'refs/for/bar',
-              permissions: {
-                'owner': {
-                  rules: {
-                    234: {action: 'ALLOW'},
-                    123: {action: 'DENY'},
-                  },
-                },
-                'read': {
-                  rules: {
-                    234: {action: 'ALLOW'},
-                  },
-                },
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      min: -2,
-                      max: 2,
-                      action: 'ALLOW',
-                      added: true,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Delete a section.
-        element._local['refs/*'].deleted = true;
-        expectedInput = {
-          add: {},
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      });
-
-      test('_computeAddAndRemove new section', () => {
-        // Add a new permission to a section
-        let expectedInput = {
-          add: {
-            'refs/for/*': {
-              added: true,
-              permissions: {},
-            },
-          },
-          remove: {},
-        };
-        MockInteractions.tap(element.$.addReferenceBtn);
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        expectedInput = {
-          add: {
-            'refs/for/*': {
-              added: true,
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {},
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {},
-        };
-        const newSection = Polymer.dom(element.root)
-            .querySelectorAll('gr-access-section')[1];
-        newSection._handleAddPermission();
-        flushAsynchronousOperations();
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Add rule to the new permission.
-        expectedInput = {
-          add: {
-            'refs/for/*': {
-              added: true,
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                      max: 2,
-                      min: -2,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {},
-        };
-
-        newSection.shadowRoot
-            .querySelector('gr-permission')._handleAddRuleItem(
-                {detail: {value: {id: 'Maintainers'}}});
-
-        flushAsynchronousOperations();
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Modify a the reference from the default value.
-        element._local['refs/for/*'].updatedId = 'refs/for/new';
-        expectedInput = {
-          add: {
-            'refs/for/new': {
-              added: true,
-              updatedId: 'refs/for/new',
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                      max: 2,
-                      min: -2,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {},
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      });
-
-      test('_computeAddAndRemove combinations', () => {
-        // Modify rule and delete permission that it is inside of.
-        element._local['refs/*'].permissions.owner.rules[123].modified = true;
-        element._local['refs/*'].permissions.owner.deleted = true;
-        let expectedInput = {
-          add: {},
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {rules: {}},
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-        // Delete rule and delete permission that it is inside of.
-        element._local['refs/*'].permissions.owner.rules[123].modified = false;
-        element._local['refs/*'].permissions.owner.rules[123].deleted = true;
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Also modify a different rule inside of another permission.
-        element._local['refs/*'].permissions.read.modified = true;
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                read: {
-                  modified: true,
-                  rules: {
-                    234: {action: 'ALLOW'},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {rules: {}},
-                read: {rules: {}},
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-        // Modify both permissions with an exclusive bit. Owner is still
-        // deleted.
-        element._local['refs/*'].permissions.owner.exclusive = true;
-        element._local['refs/*'].permissions.owner.modified = true;
-        element._local['refs/*'].permissions.read.exclusive = true;
-        element._local['refs/*'].permissions.read.modified = true;
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                read: {
-                  exclusive: true,
-                  modified: true,
-                  rules: {
-                    234: {action: 'ALLOW'},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {rules: {}},
-                read: {rules: {}},
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Add a rule to the existing permission;
-        const readPermission =
-            Polymer.dom(element.shadowRoot
-                .querySelector('gr-access-section').root).querySelectorAll(
-                'gr-permission')[1];
-        readPermission._handleAddRuleItem(
-            {detail: {value: {id: 'Maintainers'}}});
-
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                read: {
-                  exclusive: true,
-                  modified: true,
-                  rules: {
-                    234: {action: 'ALLOW'},
-                    Maintainers: {action: 'ALLOW', added: true},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {rules: {}},
-                read: {rules: {}},
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Change one of the refs
-        element._local['refs/*'].updatedId = 'refs/for/bar';
-        element._local['refs/*'].modified = true;
-
-        expectedInput = {
-          add: {
-            'refs/for/bar': {
-              modified: true,
-              updatedId: 'refs/for/bar',
-              permissions: {
-                read: {
-                  exclusive: true,
-                  modified: true,
-                  rules: {
-                    234: {action: 'ALLOW'},
-                    Maintainers: {action: 'ALLOW', added: true},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        expectedInput = {
-          add: {},
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        element._local['refs/*'].deleted = true;
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Add a new section.
-        MockInteractions.tap(element.$.addReferenceBtn);
-        let newSection = Polymer.dom(element.root)
-            .querySelectorAll('gr-access-section')[1];
-        newSection._handleAddPermission();
-        flushAsynchronousOperations();
-        newSection.shadowRoot
-            .querySelector('gr-permission')._handleAddRuleItem(
-                {detail: {value: {id: 'Maintainers'}}});
-        // Modify a the reference from the default value.
-        element._local['refs/for/*'].updatedId = 'refs/for/new';
-
-        expectedInput = {
-          add: {
-            'refs/for/new': {
-              added: true,
-              updatedId: 'refs/for/new',
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                      max: 2,
-                      min: -2,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Modify newly added rule inside new ref.
-        element._local['refs/for/*'].permissions['label-Code-Review'].
-            rules['Maintainers'].modified = true;
-        expectedInput = {
-          add: {
-            'refs/for/new': {
-              added: true,
-              updatedId: 'refs/for/new',
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                      modified: true,
-                      max: 2,
-                      min: -2,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Add a second new section.
-        MockInteractions.tap(element.$.addReferenceBtn);
-        newSection = Polymer.dom(element.root)
-            .querySelectorAll('gr-access-section')[2];
-        newSection._handleAddPermission();
-        flushAsynchronousOperations();
-        newSection.shadowRoot
-            .querySelector('gr-permission')._handleAddRuleItem(
-                {detail: {value: {id: 'Maintainers'}}});
-        // Modify a the reference from the default value.
-        element._local['refs/for/**'].updatedId = 'refs/for/new2';
-        expectedInput = {
-          add: {
-            'refs/for/new': {
-              added: true,
-              updatedId: 'refs/for/new',
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                      modified: true,
-                      max: 2,
-                      min: -2,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-            'refs/for/new2': {
-              added: true,
-              updatedId: 'refs/for/new2',
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                      max: 2,
-                      min: -2,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      });
-
-      test('Unsaved added refs are discarded when edit cancelled', () => {
-        // Unsaved changes are discarded when editing is cancelled.
-        MockInteractions.tap(element.$.editBtn);
-        assert.equal(element._sections.length, 1);
-        assert.equal(Object.keys(element._local).length, 1);
-        MockInteractions.tap(element.$.addReferenceBtn);
-        assert.equal(element._sections.length, 2);
-        assert.equal(Object.keys(element._local).length, 2);
-        MockInteractions.tap(element.$.editBtn);
-        assert.equal(element._sections.length, 1);
-        assert.equal(Object.keys(element._local).length, 1);
-      });
-
-      test('_handleSave', done => {
-        const repoAccessInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    123: {action: 'DENY', modified: true},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    123: {},
-                  },
-                },
-              },
-            },
-          },
-        };
-        sandbox.stub(element.$.restAPI, 'getRepoAccessRights').returns(
-            Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-        sandbox.stub(Gerrit.Nav, 'navigateToChange');
-        let resolver;
-        const saveStub = sandbox.stub(element.$.restAPI,
-            'setRepoAccessRights')
-            .returns(new Promise(r => resolver = r));
-
-        element.repo = 'test-repo';
-        sandbox.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
-
-        element._modified = true;
-        MockInteractions.tap(element.$.saveBtn);
-        assert.equal(element.$.saveBtn.hasAttribute('loading'), true);
-        resolver({_number: 1});
-        flush(() => {
-          assert.isTrue(saveStub.called);
-          assert.isTrue(Gerrit.Nav.navigateToChange.notCalled);
-          done();
-        });
-      });
-
-      test('_handleSaveForReview', done => {
-        const repoAccessInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    123: {action: 'DENY', modified: true},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    123: {},
-                  },
-                },
-              },
-            },
-          },
-        };
-        sandbox.stub(element.$.restAPI, 'getRepoAccessRights').returns(
-            Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-        sandbox.stub(Gerrit.Nav, 'navigateToChange');
-        let resolver;
-        const saveForReviewStub = sandbox.stub(element.$.restAPI,
-            'setRepoAccessRightsForReview')
-            .returns(new Promise(r => resolver = r));
-
-        element.repo = 'test-repo';
-        sandbox.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
-
-        element._modified = true;
-        MockInteractions.tap(element.$.saveReviewBtn);
-        assert.equal(element.$.saveReviewBtn.hasAttribute('loading'), true);
-        resolver({_number: 1});
-        flush(() => {
-          assert.isTrue(saveForReviewStub.called);
-          assert.isTrue(Gerrit.Nav.navigateToChange
-              .lastCall.calledWithExactly({_number: 1}));
-          done();
-        });
+      element._modified = true;
+      MockInteractions.tap(element.$.saveReviewBtn);
+      assert.equal(element.$.saveReviewBtn.hasAttribute('loading'), true);
+      resolver({_number: 1});
+      flush(() => {
+        assert.isTrue(saveForReviewStub.called);
+        assert.isTrue(Gerrit.Nav.navigateToChange
+            .lastCall.calledWithExactly({_number: 1}));
+        done();
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
index 622bfe4..53b4989 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
@@ -14,34 +14,41 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrRepoCommand extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-repo-command'; }
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-command_html.js';
 
-    static get properties() {
-      return {
-        title: String,
-        disabled: Boolean,
-        tooltip: String,
-      };
-    }
+/** @extends Polymer.Element */
+class GrRepoCommand extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when command button is tapped.
-     *
-     * @event command-tap
-     */
+  static get is() { return 'gr-repo-command'; }
 
-    _onCommandTap() {
-      this.dispatchEvent(
-          new CustomEvent('command-tap', {bubbles: true, composed: true}));
-    }
+  static get properties() {
+    return {
+      title: String,
+      disabled: Boolean,
+      tooltip: String,
+    };
   }
 
-  customElements.define(GrRepoCommand.is, GrRepoCommand);
-})();
+  /**
+   * Fired when command button is tapped.
+   *
+   * @event command-tap
+   */
+
+  _onCommandTap() {
+    this.dispatchEvent(
+        new CustomEvent('command-tap', {bubbles: true, composed: true}));
+  }
+}
+
+customElements.define(GrRepoCommand.is, GrRepoCommand);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
index 29bc02d..10d22fc 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
@@ -1,25 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-
-<dom-module id="gr-repo-command">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -27,13 +24,7 @@
       }
     </style>
     <h3>[[title]]</h3>
-    <gr-button
-        title$="[[tooltip]]"
-        disabled$="[[disabled]]"
-        on-click
-        ="_onCommandTap">
+    <gr-button title\$="[[tooltip]]" disabled\$="[[disabled]]" on-click="_onCommandTap">
       [[title]]
     </gr-button>
-  </template>
-  <script src="gr-repo-command.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
index f4988a5..a3f507b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-command</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-command.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-repo-command.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-command.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,21 +40,24 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-command tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-command.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-repo-command tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('dispatched command-tap on button tap', done => {
-      element.addEventListener('command-tap', () => {
-        done();
-      });
-      MockInteractions.tap(
-          Polymer.dom(element.root).querySelector('gr-button'));
-    });
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('dispatched command-tap on button tap', done => {
+    element.addEventListener('command-tap', () => {
+      done();
+    });
+    MockInteractions.tap(
+        dom(element.root).querySelector('gr-button'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
index 80b187a..de9d8e2 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
@@ -14,113 +14,131 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const GC_MESSAGE = 'Garbage collection completed successfully.';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-subpage-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-create-change-dialog/gr-create-change-dialog.js';
+import '../gr-repo-command/gr-repo-command.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-commands_html.js';
 
-  const CONFIG_BRANCH = 'refs/meta/config';
-  const CONFIG_PATH = 'project.config';
-  const EDIT_CONFIG_SUBJECT = 'Edit Repo Config';
-  const INITIAL_PATCHSET = 1;
-  const CREATE_CHANGE_FAILED_MESSAGE = 'Failed to create change.';
-  const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
+const GC_MESSAGE = 'Garbage collection completed successfully.';
 
-  /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
-   */
-  class GrRepoCommands extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-repo-commands'; }
+const CONFIG_BRANCH = 'refs/meta/config';
+const CONFIG_PATH = 'project.config';
+const EDIT_CONFIG_SUBJECT = 'Edit Repo Config';
+const INITIAL_PATCHSET = 1;
+const CREATE_CHANGE_FAILED_MESSAGE = 'Failed to create change.';
+const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
 
-    static get properties() {
-      return {
-        params: Object,
-        repo: String,
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-        /** @type {?} */
-        _repoConfig: Object,
-        _canCreate: Boolean,
-      };
-    }
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrRepoCommands extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this._loadRepo();
+  static get is() { return 'gr-repo-commands'; }
 
-      this.fire('title-change', {title: 'Repo Commands'});
-    }
-
-    _loadRepo() {
-      if (!this.repo) { return Promise.resolve(); }
-
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
-
-      return this.$.restAPI.getProjectConfig(this.repo, errFn)
-          .then(config => {
-            if (!config) { return Promise.resolve(); }
-
-            this._repoConfig = config;
-            this._loading = false;
-          });
-    }
-
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    }
-
-    _isLoading() {
-      return this._loading || this._loading === undefined;
-    }
-
-    _handleRunningGC() {
-      return this.$.restAPI.runRepoGC(this.repo).then(response => {
-        if (response.status === 200) {
-          this.dispatchEvent(new CustomEvent(
-              'show-alert',
-              {detail: {message: GC_MESSAGE}, bubbles: true, composed: true}));
-        }
-      });
-    }
-
-    _createNewChange() {
-      this.$.createChangeOverlay.open();
-    }
-
-    _handleCreateChange() {
-      this.$.createNewChangeModal.handleCreateChange();
-      this._handleCloseCreateChange();
-    }
-
-    _handleCloseCreateChange() {
-      this.$.createChangeOverlay.close();
-    }
-
-    _handleEditRepoConfig() {
-      return this.$.restAPI.createChange(this.repo, CONFIG_BRANCH,
-          EDIT_CONFIG_SUBJECT, undefined, false, true).then(change => {
-        const message = change ?
-          CREATE_CHANGE_SUCCEEDED_MESSAGE :
-          CREATE_CHANGE_FAILED_MESSAGE;
-        this.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message}, bubbles: true, composed: true}));
-        if (!change) { return; }
-
-        Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getEditUrlForDiff(
-            change, CONFIG_PATH, INITIAL_PATCHSET));
-      });
-    }
+  static get properties() {
+    return {
+      params: Object,
+      repo: String,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      /** @type {?} */
+      _repoConfig: Object,
+      _canCreate: Boolean,
+    };
   }
 
-  customElements.define(GrRepoCommands.is, GrRepoCommands);
-})();
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadRepo();
+
+    this.fire('title-change', {title: 'Repo Commands'});
+  }
+
+  _loadRepo() {
+    if (!this.repo) { return Promise.resolve(); }
+
+    const errFn = response => {
+      this.fire('page-error', {response});
+    };
+
+    return this.$.restAPI.getProjectConfig(this.repo, errFn)
+        .then(config => {
+          if (!config) { return Promise.resolve(); }
+
+          this._repoConfig = config;
+          this._loading = false;
+        });
+  }
+
+  _computeLoadingClass(loading) {
+    return loading ? 'loading' : '';
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _handleRunningGC() {
+    return this.$.restAPI.runRepoGC(this.repo).then(response => {
+      if (response.status === 200) {
+        this.dispatchEvent(new CustomEvent(
+            'show-alert',
+            {detail: {message: GC_MESSAGE}, bubbles: true, composed: true}));
+      }
+    });
+  }
+
+  _createNewChange() {
+    this.$.createChangeOverlay.open();
+  }
+
+  _handleCreateChange() {
+    this.$.createNewChangeModal.handleCreateChange();
+    this._handleCloseCreateChange();
+  }
+
+  _handleCloseCreateChange() {
+    this.$.createChangeOverlay.close();
+  }
+
+  _handleEditRepoConfig() {
+    return this.$.restAPI.createChange(this.repo, CONFIG_BRANCH,
+        EDIT_CONFIG_SUBJECT, undefined, false, true).then(change => {
+      const message = change ?
+        CREATE_CHANGE_SUCCEEDED_MESSAGE :
+        CREATE_CHANGE_FAILED_MESSAGE;
+      this.dispatchEvent(new CustomEvent('show-alert',
+          {detail: {message}, bubbles: true, composed: true}));
+      if (!change) { return; }
+
+      Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getEditUrlForDiff(
+          change, CONFIG_PATH, INITIAL_PATCHSET));
+    });
+  }
+}
+
+customElements.define(GrRepoCommands.is, GrRepoCommands);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js
index b610460..ce19555 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js
@@ -1,36 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-subpage-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-create-change-dialog/gr-create-change-dialog.html">
-<link rel="import" href="../gr-repo-command/gr-repo-command.html">
-
-<dom-module id="gr-repo-commands">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -42,24 +28,15 @@
     </style>
     <main class="gr-form-styles read-only">
       <h1 id="Title">Repository Commands</h1>
-      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
-      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <div id="loading" class\$="[[_computeLoadingClass(_loading)]]">Loading...</div>
+      <div id="loadedContent" class\$="[[_computeLoadingClass(_loading)]]">
         <h2 id="options">Command</h2>
         <div id="form">
-          <gr-repo-command
-              title="Create change"
-              on-command-tap="_createNewChange">
+          <gr-repo-command title="Create change" on-command-tap="_createNewChange">
           </gr-repo-command>
-          <gr-repo-command
-              id="editRepoConfig"
-              title="Edit repo config"
-              on-command-tap="_handleEditRepoConfig">
+          <gr-repo-command id="editRepoConfig" title="Edit repo config" on-command-tap="_handleEditRepoConfig">
           </gr-repo-command>
-          <gr-repo-command
-              title="[[_repoConfig.actions.gc.label]]"
-              tooltip="[[_repoConfig.actions.gc.title]]"
-              hidden$="[[!_repoConfig.actions.gc.enabled]]"
-              on-command-tap="_handleRunningGC">
+          <gr-repo-command title="[[_repoConfig.actions.gc.label]]" tooltip="[[_repoConfig.actions.gc.title]]" hidden\$="[[!_repoConfig.actions.gc.enabled]]" on-command-tap="_handleRunningGC">
           </gr-repo-command>
           <gr-endpoint-decorator name="repo-command">
             <gr-endpoint-param name="config" value="[[_repoConfig]]">
@@ -70,25 +47,15 @@
         </div>
       </div>
     </main>
-    <gr-overlay id="createChangeOverlay" with-backdrop>
-      <gr-dialog
-          id="createChangeDialog"
-          confirm-label="Create"
-          disabled="[[!_canCreate]]"
-          on-confirm="_handleCreateChange"
-          on-cancel="_handleCloseCreateChange">
+    <gr-overlay id="createChangeOverlay" with-backdrop="">
+      <gr-dialog id="createChangeDialog" confirm-label="Create" disabled="[[!_canCreate]]" on-confirm="_handleCreateChange" on-cancel="_handleCloseCreateChange">
         <div class="header" slot="header">
           Create Change
         </div>
         <div class="main" slot="main">
-          <gr-create-change-dialog
-              id="createNewChangeModal"
-              can-create="{{_canCreate}}"
-              repo-name="[[repo]]"></gr-create-change-dialog>
+          <gr-create-change-dialog id="createNewChangeModal" can-create="{{_canCreate}}" repo-name="[[repo]]"></gr-create-change-dialog>
         </div>
       </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-commands.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
index da8b57f..c2f71e7 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-commands</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-commands.html">
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-repo-commands.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-commands.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,111 +41,113 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-commands tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let repoStub;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-commands.js';
+suite('gr-repo-commands tests', () => {
+  let element;
+  let sandbox;
+  let repoStub;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    repoStub = sandbox.stub(
+        element.$.restAPI,
+        'getProjectConfig',
+        () => Promise.resolve({}));
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('create new change dialog', () => {
+    test('_createNewChange opens modal', () => {
+      const openStub = sandbox.stub(element.$.createChangeOverlay, 'open');
+      element._createNewChange();
+      assert.isTrue(openStub.called);
+    });
+
+    test('_handleCreateChange called when confirm fired', () => {
+      sandbox.stub(element, '_handleCreateChange');
+      element.$.createChangeDialog.fire('confirm');
+      assert.isTrue(element._handleCreateChange.called);
+    });
+
+    test('_handleCloseCreateChange called when cancel fired', () => {
+      sandbox.stub(element, '_handleCloseCreateChange');
+      element.$.createChangeDialog.fire('cancel');
+      assert.isTrue(element._handleCloseCreateChange.called);
+    });
+  });
+
+  suite('edit repo config', () => {
+    let createChangeStub;
+    let urlStub;
+    let handleSpy;
+    let alertStub;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      repoStub = sandbox.stub(
-          element.$.restAPI,
-          'getProjectConfig',
-          () => Promise.resolve({}));
+      createChangeStub = sandbox.stub(element.$.restAPI, 'createChange');
+      urlStub = sandbox.stub(Gerrit.Nav, 'getEditUrlForDiff');
+      sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
+      handleSpy = sandbox.spy(element, '_handleEditRepoConfig');
+      alertStub = sandbox.stub();
+      element.addEventListener('show-alert', alertStub);
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    test('successful creation of change', () => {
+      const change = {_number: '1'};
+      createChangeStub.returns(Promise.resolve(change));
+      MockInteractions.tap(element.$.editRepoConfig.shadowRoot
+          .querySelector('gr-button'));
+      return handleSpy.lastCall.returnValue.then(() => {
+        flushAsynchronousOperations();
 
-    suite('create new change dialog', () => {
-      test('_createNewChange opens modal', () => {
-        const openStub = sandbox.stub(element.$.createChangeOverlay, 'open');
-        element._createNewChange();
-        assert.isTrue(openStub.called);
-      });
-
-      test('_handleCreateChange called when confirm fired', () => {
-        sandbox.stub(element, '_handleCreateChange');
-        element.$.createChangeDialog.fire('confirm');
-        assert.isTrue(element._handleCreateChange.called);
-      });
-
-      test('_handleCloseCreateChange called when cancel fired', () => {
-        sandbox.stub(element, '_handleCloseCreateChange');
-        element.$.createChangeDialog.fire('cancel');
-        assert.isTrue(element._handleCloseCreateChange.called);
+        assert.isTrue(alertStub.called);
+        assert.equal(alertStub.lastCall.args[0].detail.message,
+            'Navigating to change');
+        assert.isTrue(urlStub.called);
+        assert.deepEqual(urlStub.lastCall.args,
+            [change, 'project.config', 1]);
       });
     });
 
-    suite('edit repo config', () => {
-      let createChangeStub;
-      let urlStub;
-      let handleSpy;
-      let alertStub;
+    test('unsuccessful creation of change', () => {
+      createChangeStub.returns(Promise.resolve(null));
+      MockInteractions.tap(element.$.editRepoConfig.shadowRoot
+          .querySelector('gr-button'));
+      return handleSpy.lastCall.returnValue.then(() => {
+        flushAsynchronousOperations();
 
-      setup(() => {
-        createChangeStub = sandbox.stub(element.$.restAPI, 'createChange');
-        urlStub = sandbox.stub(Gerrit.Nav, 'getEditUrlForDiff');
-        sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
-        handleSpy = sandbox.spy(element, '_handleEditRepoConfig');
-        alertStub = sandbox.stub();
-        element.addEventListener('show-alert', alertStub);
-      });
-
-      test('successful creation of change', () => {
-        const change = {_number: '1'};
-        createChangeStub.returns(Promise.resolve(change));
-        MockInteractions.tap(element.$.editRepoConfig.shadowRoot
-            .querySelector('gr-button'));
-        return handleSpy.lastCall.returnValue.then(() => {
-          flushAsynchronousOperations();
-
-          assert.isTrue(alertStub.called);
-          assert.equal(alertStub.lastCall.args[0].detail.message,
-              'Navigating to change');
-          assert.isTrue(urlStub.called);
-          assert.deepEqual(urlStub.lastCall.args,
-              [change, 'project.config', 1]);
-        });
-      });
-
-      test('unsuccessful creation of change', () => {
-        createChangeStub.returns(Promise.resolve(null));
-        MockInteractions.tap(element.$.editRepoConfig.shadowRoot
-            .querySelector('gr-button'));
-        return handleSpy.lastCall.returnValue.then(() => {
-          flushAsynchronousOperations();
-
-          assert.isTrue(alertStub.called);
-          assert.equal(alertStub.lastCall.args[0].detail.message,
-              'Failed to create change.');
-          assert.isFalse(urlStub.called);
-        });
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', done => {
-        repoStub.restore();
-
-        element.repo = 'test';
-
-        const response = {status: 404};
-        sandbox.stub(
-            element.$.restAPI, 'getProjectConfig', (repo, errFn) => {
-              errFn(response);
-            });
-        element.addEventListener('page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          done();
-        });
-
-        element._loadRepo();
+        assert.isTrue(alertStub.called);
+        assert.equal(alertStub.lastCall.args[0].detail.message,
+            'Failed to create change.');
+        assert.isFalse(urlStub.called);
       });
     });
   });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      repoStub.restore();
+
+      element.repo = 'test';
+
+      const response = {status: 404};
+      sandbox.stub(
+          element.$.restAPI, 'getProjectConfig', (repo, errFn) => {
+            errFn(response);
+          });
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      element._loadRepo();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
index 8e09263..e1f38c9 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
+ * Copyright (C) 2018 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.
@@ -14,89 +14,100 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
-   */
-  class GrRepoDashboards extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-repo-dashboards'; }
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-dashboards_html.js';
 
-    static get properties() {
-      return {
-        repo: {
-          type: String,
-          observer: '_repoChanged',
-        },
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-        _dashboards: Array,
-      };
-    }
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrRepoDashboards extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    _repoChanged(repo) {
-      this._loading = true;
-      if (!repo) { return Promise.resolve(); }
+  static get is() { return 'gr-repo-dashboards'; }
 
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
-
-      this.$.restAPI.getRepoDashboards(this.repo, errFn).then(res => {
-        if (!res) { return Promise.resolve(); }
-
-        // Group by ref and sort by id.
-        const dashboards = res.concat.apply([], res).sort((a, b) =>
-          (a.id < b.id ? -1 : 1));
-        const dashboardsByRef = {};
-        dashboards.forEach(d => {
-          if (!dashboardsByRef[d.ref]) {
-            dashboardsByRef[d.ref] = [];
-          }
-          dashboardsByRef[d.ref].push(d);
-        });
-
-        const dashboardBuilder = [];
-        Object.keys(dashboardsByRef).sort()
-            .forEach(ref => {
-              dashboardBuilder.push({
-                section: ref,
-                dashboards: dashboardsByRef[ref],
-              });
-            });
-
-        this._dashboards = dashboardBuilder;
-        this._loading = false;
-        Polymer.dom.flush();
-      });
-    }
-
-    _getUrl(project, id) {
-      if (!project || !id) { return ''; }
-
-      return Gerrit.Nav.getUrlForRepoDashboard(project, id);
-    }
-
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    }
-
-    _computeInheritedFrom(project, definingProject) {
-      return project === definingProject ? '' : definingProject;
-    }
-
-    _computeIsDefault(isDefault) {
-      return isDefault ? '✓' : '';
-    }
+  static get properties() {
+    return {
+      repo: {
+        type: String,
+        observer: '_repoChanged',
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _dashboards: Array,
+    };
   }
 
-  customElements.define(GrRepoDashboards.is, GrRepoDashboards);
-})();
+  _repoChanged(repo) {
+    this._loading = true;
+    if (!repo) { return Promise.resolve(); }
+
+    const errFn = response => {
+      this.fire('page-error', {response});
+    };
+
+    this.$.restAPI.getRepoDashboards(this.repo, errFn).then(res => {
+      if (!res) { return Promise.resolve(); }
+
+      // Group by ref and sort by id.
+      const dashboards = res.concat.apply([], res).sort((a, b) =>
+        (a.id < b.id ? -1 : 1));
+      const dashboardsByRef = {};
+      dashboards.forEach(d => {
+        if (!dashboardsByRef[d.ref]) {
+          dashboardsByRef[d.ref] = [];
+        }
+        dashboardsByRef[d.ref].push(d);
+      });
+
+      const dashboardBuilder = [];
+      Object.keys(dashboardsByRef).sort()
+          .forEach(ref => {
+            dashboardBuilder.push({
+              section: ref,
+              dashboards: dashboardsByRef[ref],
+            });
+          });
+
+      this._dashboards = dashboardBuilder;
+      this._loading = false;
+      flush();
+    });
+  }
+
+  _getUrl(project, id) {
+    if (!project || !id) { return ''; }
+
+    return Gerrit.Nav.getUrlForRepoDashboard(project, id);
+  }
+
+  _computeLoadingClass(loading) {
+    return loading ? 'loading' : '';
+  }
+
+  _computeInheritedFrom(project, definingProject) {
+    return project === definingProject ? '' : definingProject;
+  }
+
+  _computeIsDefault(isDefault) {
+    return isDefault ? '✓' : '';
+  }
+}
+
+customElements.define(GrRepoDashboards.is, GrRepoDashboards);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.js
index f74f705..3bac16c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.js
@@ -1,27 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-repo-dashboards">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -38,8 +33,8 @@
     <style include="gr-table-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
-    <table id="list" class$="genericList [[_computeLoadingClass(_loading)]]">
-      <tr class="headerRow">
+    <table id="list" class\$="genericList [[_computeLoadingClass(_loading)]]">
+      <tbody><tr class="headerRow">
         <th class="topHeader">Dashboard name</th>
         <th class="topHeader">Dashboard title</th>
         <th class="topHeader">Dashboard description</th>
@@ -49,14 +44,14 @@
       <tr id="loadingContainer">
         <td>Loading...</td>
       </tr>
-      <tbody id="dashboards">
+      </tbody><tbody id="dashboards">
         <template is="dom-repeat" items="[[_dashboards]]">
           <tr class="groupHeader">
             <td colspan="5">[[item.section]]</td>
           </tr>
           <template is="dom-repeat" items="[[item.dashboards]]">
             <tr class="table">
-              <td class="name"><a href$="[[_getUrl(item.project, item.id)]]">[[item.path]]</a></td>
+              <td class="name"><a href\$="[[_getUrl(item.project, item.id)]]">[[item.path]]</a></td>
               <td class="title">[[item.title]]</td>
               <td class="desc">[[item.description]]</td>
               <td class="inherited">[[_computeInheritedFrom(item.project, item.defining_project)]]</td>
@@ -67,6 +62,4 @@
       </tbody>
     </table>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-dashboards.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
index 681ee19..1d6f05e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-dashboards</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-dashboards.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-repo-dashboards.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-dashboards.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,128 +40,130 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-dashboards tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-dashboards.js';
+suite('gr-repo-dashboards tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('dashboard table', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+      sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
+          Promise.resolve([
+            {
+              id: 'default:contributor',
+              project: 'gerrit',
+              defining_project: 'gerrit',
+              ref: 'default',
+              path: 'contributor',
+              description: 'Own contributions.',
+              foreach: 'owner:self',
+              url: '/dashboard/?params',
+              title: 'Contributor Dashboard',
+              sections: [
+                {
+                  name: 'Mine To Rebase',
+                  query: 'is:open -is:mergeable',
+                },
+                {
+                  name: 'My Recently Merged',
+                  query: 'is:merged limit:10',
+                },
+              ],
+            },
+            {
+              id: 'custom:custom2',
+              project: 'gerrit',
+              defining_project: 'Public-Projects',
+              ref: 'custom',
+              path: 'open',
+              description: 'Recent open changes.',
+              url: '/dashboard/?params',
+              title: 'Open Changes',
+              sections: [
+                {
+                  name: 'Open Changes',
+                  query: 'status:open project:${project} -age:7w',
+                },
+              ],
+            },
+            {
+              id: 'default:abc',
+              project: 'gerrit',
+              ref: 'default',
+            },
+            {
+              id: 'custom:custom1',
+              project: 'gerrit',
+              ref: 'custom',
+            },
+          ]));
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('dashboard table', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
-            Promise.resolve([
-              {
-                id: 'default:contributor',
-                project: 'gerrit',
-                defining_project: 'gerrit',
-                ref: 'default',
-                path: 'contributor',
-                description: 'Own contributions.',
-                foreach: 'owner:self',
-                url: '/dashboard/?params',
-                title: 'Contributor Dashboard',
-                sections: [
-                  {
-                    name: 'Mine To Rebase',
-                    query: 'is:open -is:mergeable',
-                  },
-                  {
-                    name: 'My Recently Merged',
-                    query: 'is:merged limit:10',
-                  },
-                ],
-              },
-              {
-                id: 'custom:custom2',
-                project: 'gerrit',
-                defining_project: 'Public-Projects',
-                ref: 'custom',
-                path: 'open',
-                description: 'Recent open changes.',
-                url: '/dashboard/?params',
-                title: 'Open Changes',
-                sections: [
-                  {
-                    name: 'Open Changes',
-                    query: 'status:open project:${project} -age:7w',
-                  },
-                ],
-              },
-              {
-                id: 'default:abc',
-                project: 'gerrit',
-                ref: 'default',
-              },
-              {
-                id: 'custom:custom1',
-                project: 'gerrit',
-                ref: 'custom',
-              },
-            ]));
-      });
-
-      test('loading, sections, and ordering', done => {
-        assert.isTrue(element._loading);
-        assert.notEqual(getComputedStyle(element.$.loadingContainer).display,
+    test('loading, sections, and ordering', done => {
+      assert.isTrue(element._loading);
+      assert.notEqual(getComputedStyle(element.$.loadingContainer).display,
+          'none');
+      assert.equal(getComputedStyle(element.$.dashboards).display,
+          'none');
+      element.repo = 'test';
+      flush(() => {
+        assert.equal(getComputedStyle(element.$.loadingContainer).display,
             'none');
-        assert.equal(getComputedStyle(element.$.dashboards).display,
+        assert.notEqual(getComputedStyle(element.$.dashboards).display,
             'none');
-        element.repo = 'test';
-        flush(() => {
-          assert.equal(getComputedStyle(element.$.loadingContainer).display,
-              'none');
-          assert.notEqual(getComputedStyle(element.$.dashboards).display,
-              'none');
 
-          assert.equal(element._dashboards.length, 2);
-          assert.equal(element._dashboards[0].section, 'custom');
-          assert.equal(element._dashboards[1].section, 'default');
+        assert.equal(element._dashboards.length, 2);
+        assert.equal(element._dashboards[0].section, 'custom');
+        assert.equal(element._dashboards[1].section, 'default');
 
-          const dashboards = element._dashboards[0].dashboards;
-          assert.equal(dashboards.length, 2);
-          assert.equal(dashboards[0].id, 'custom:custom1');
-          assert.equal(dashboards[1].id, 'custom:custom2');
+        const dashboards = element._dashboards[0].dashboards;
+        assert.equal(dashboards.length, 2);
+        assert.equal(dashboards[0].id, 'custom:custom1');
+        assert.equal(dashboards[1].id, 'custom:custom2');
 
-          done();
-        });
-      });
-    });
-
-    suite('test url', () => {
-      test('_getUrl', () => {
-        sandbox.stub(Gerrit.Nav, 'getUrlForRepoDashboard',
-            () => '/r/dashboard/test');
-
-        assert.equal(element._getUrl('/dashboard/test', {}), '/r/dashboard/test');
-
-        assert.equal(element._getUrl(undefined, undefined), '');
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', done => {
-        const response = {status: 404};
-        sandbox.stub(
-            element.$.restAPI, 'getRepoDashboards', (repo, errFn) => {
-              errFn(response);
-            });
-
-        element.addEventListener('page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          done();
-        });
-
-        element.repo = 'test';
+        done();
       });
     });
   });
+
+  suite('test url', () => {
+    test('_getUrl', () => {
+      sandbox.stub(Gerrit.Nav, 'getUrlForRepoDashboard',
+          () => '/r/dashboard/test');
+
+      assert.equal(element._getUrl('/dashboard/test', {}), '/r/dashboard/test');
+
+      assert.equal(element._getUrl(undefined, undefined), '');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      const response = {status: 404};
+      sandbox.stub(
+          element.$.restAPI, 'getRepoDashboards', (repo, errFn) => {
+            errFn(response);
+          });
+
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      element.repo = 'test';
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
index ccfdfc6..82a6a4c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
@@ -14,279 +14,302 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
 
-  const DETAIL_TYPES = {
-    BRANCHES: 'branches',
-    TAGS: 'tags',
-  };
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-account-link/gr-account-link.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-list-view/gr-list-view.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-create-pointer-dialog/gr-create-pointer-dialog.js';
+import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-detail-list_html.js';
 
-  const PGP_START = '-----BEGIN PGP SIGNATURE-----';
+const DETAIL_TYPES = {
+  BRANCHES: 'branches',
+  TAGS: 'tags',
+};
 
-  /**
-   * @appliesMixin Gerrit.ListViewMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
-   */
-  class GrRepoDetailList extends Polymer.mixinBehaviors( [
-    Gerrit.ListViewBehavior,
-    Gerrit.FireBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-repo-detail-list'; }
+const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
-    static get properties() {
-      return {
+/**
+ * @appliesMixin Gerrit.ListViewMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrRepoDetailList extends mixinBehaviors( [
+  Gerrit.ListViewBehavior,
+  Gerrit.FireBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo-detail-list'; }
+
+  static get properties() {
+    return {
+    /**
+     * URL params passed from the router.
+     */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
       /**
-       * URL params passed from the router.
+       * The kind of detail we are displaying, possibilities are determined by
+       * the const DETAIL_TYPES.
        */
-        params: {
-          type: Object,
-          observer: '_paramsChanged',
-        },
-        /**
-         * The kind of detail we are displaying, possibilities are determined by
-         * the const DETAIL_TYPES.
-         */
-        detailType: String,
+      detailType: String,
 
-        _editing: {
-          type: Boolean,
-          value: false,
-        },
-        _isOwner: {
-          type: Boolean,
-          value: false,
-        },
-        _loggedIn: {
-          type: Boolean,
-          value: false,
-        },
-        /**
-         * Offset of currently visible query results.
-         */
-        _offset: Number,
-        _repo: Object,
-        _items: Array,
-        /**
-         * Because  we request one more than the projectsPerPage, _shownProjects
-         * maybe one less than _projects.
-         */
-        _shownItems: {
-          type: Array,
-          computed: 'computeShownItems(_items)',
-        },
-        _itemsPerPage: {
-          type: Number,
-          value: 25,
-        },
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-        _filter: String,
-        _refName: String,
-        _hasNewItemName: Boolean,
-        _isEditing: Boolean,
-        _revisedRef: String,
-      };
-    }
+      _editing: {
+        type: Boolean,
+        value: false,
+      },
+      _isOwner: {
+        type: Boolean,
+        value: false,
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: Number,
+      _repo: Object,
+      _items: Array,
+      /**
+       * Because  we request one more than the projectsPerPage, _shownProjects
+       * maybe one less than _projects.
+       */
+      _shownItems: {
+        type: Array,
+        computed: 'computeShownItems(_items)',
+      },
+      _itemsPerPage: {
+        type: Number,
+        value: 25,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: String,
+      _refName: String,
+      _hasNewItemName: Boolean,
+      _isEditing: Boolean,
+      _revisedRef: String,
+    };
+  }
 
-    _determineIfOwner(repo) {
-      return this.$.restAPI.getRepoAccess(repo)
-          .then(access =>
-            this._isOwner = access && !!access[repo].is_owner);
-    }
+  _determineIfOwner(repo) {
+    return this.$.restAPI.getRepoAccess(repo)
+        .then(access =>
+          this._isOwner = access && !!access[repo].is_owner);
+  }
 
-    _paramsChanged(params) {
-      if (!params || !params.repo) { return; }
+  _paramsChanged(params) {
+    if (!params || !params.repo) { return; }
 
-      this._repo = params.repo;
+    this._repo = params.repo;
 
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-        if (loggedIn) {
-          this._determineIfOwner(this._repo);
-        }
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this._determineIfOwner(this._repo);
+      }
+    });
+
+    this.detailType = params.detail;
+
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
+
+    return this._getItems(this._filter, this._repo,
+        this._itemsPerPage, this._offset, this.detailType);
+  }
+
+  _getItems(filter, repo, itemsPerPage, offset, detailType) {
+    this._loading = true;
+    this._items = [];
+    flush();
+    const errFn = response => {
+      this.fire('page-error', {response});
+    };
+    if (detailType === DETAIL_TYPES.BRANCHES) {
+      return this.$.restAPI.getRepoBranches(
+          filter, repo, itemsPerPage, offset, errFn).then(items => {
+        if (!items) { return; }
+        this._items = items;
+        this._loading = false;
       });
-
-      this.detailType = params.detail;
-
-      this._filter = this.getFilterValue(params);
-      this._offset = this.getOffsetValue(params);
-
-      return this._getItems(this._filter, this._repo,
-          this._itemsPerPage, this._offset, this.detailType);
-    }
-
-    _getItems(filter, repo, itemsPerPage, offset, detailType) {
-      this._loading = true;
-      this._items = [];
-      Polymer.dom.flush();
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
-      if (detailType === DETAIL_TYPES.BRANCHES) {
-        return this.$.restAPI.getRepoBranches(
-            filter, repo, itemsPerPage, offset, errFn).then(items => {
-          if (!items) { return; }
-          this._items = items;
-          this._loading = false;
-        });
-      } else if (detailType === DETAIL_TYPES.TAGS) {
-        return this.$.restAPI.getRepoTags(
-            filter, repo, itemsPerPage, offset, errFn).then(items => {
-          if (!items) { return; }
-          this._items = items;
-          this._loading = false;
-        });
-      }
-    }
-
-    _getPath(repo) {
-      return `/admin/repos/${this.encodeURL(repo, false)},` +
-          `${this.detailType}`;
-    }
-
-    _computeWeblink(repo) {
-      if (!repo.web_links) { return ''; }
-      const webLinks = repo.web_links;
-      return webLinks.length ? webLinks : null;
-    }
-
-    _computeMessage(message) {
-      if (!message) { return; }
-      // Strip PGP info.
-      return message.split(PGP_START)[0];
-    }
-
-    _stripRefs(item, detailType) {
-      if (detailType === DETAIL_TYPES.BRANCHES) {
-        return item.replace('refs/heads/', '');
-      } else if (detailType === DETAIL_TYPES.TAGS) {
-        return item.replace('refs/tags/', '');
-      }
-    }
-
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    }
-
-    _computeEditingClass(isEditing) {
-      return isEditing ? 'editing' : '';
-    }
-
-    _computeCanEditClass(ref, detailType, isOwner) {
-      return isOwner && this._stripRefs(ref, detailType) === 'HEAD' ?
-        'canEdit' : '';
-    }
-
-    _handleEditRevision(e) {
-      this._revisedRef = e.model.get('item.revision');
-      this._isEditing = true;
-    }
-
-    _handleCancelRevision() {
-      this._isEditing = false;
-    }
-
-    _handleSaveRevision(e) {
-      this._setRepoHead(this._repo, this._revisedRef, e);
-    }
-
-    _setRepoHead(repo, ref, e) {
-      return this.$.restAPI.setRepoHead(repo, ref).then(res => {
-        if (res.status < 400) {
-          this._isEditing = false;
-          e.model.set('item.revision', ref);
-          // This is needed to refresh _items property with fresh data,
-          // specifically can_delete from the json response.
-          this._getItems(
-              this._filter, this._repo, this._itemsPerPage,
-              this._offset, this.detailType);
-        }
+    } else if (detailType === DETAIL_TYPES.TAGS) {
+      return this.$.restAPI.getRepoTags(
+          filter, repo, itemsPerPage, offset, errFn).then(items => {
+        if (!items) { return; }
+        this._items = items;
+        this._loading = false;
       });
     }
-
-    _computeItemName(detailType) {
-      if (detailType === DETAIL_TYPES.BRANCHES) {
-        return 'Branch';
-      } else if (detailType === DETAIL_TYPES.TAGS) {
-        return 'Tag';
-      }
-    }
-
-    _handleDeleteItemConfirm() {
-      this.$.overlay.close();
-      if (this.detailType === DETAIL_TYPES.BRANCHES) {
-        return this.$.restAPI.deleteRepoBranches(this._repo, this._refName)
-            .then(itemDeleted => {
-              if (itemDeleted.status === 204) {
-                this._getItems(
-                    this._filter, this._repo, this._itemsPerPage,
-                    this._offset, this.detailType);
-              }
-            });
-      } else if (this.detailType === DETAIL_TYPES.TAGS) {
-        return this.$.restAPI.deleteRepoTags(this._repo, this._refName)
-            .then(itemDeleted => {
-              if (itemDeleted.status === 204) {
-                this._getItems(
-                    this._filter, this._repo, this._itemsPerPage,
-                    this._offset, this.detailType);
-              }
-            });
-      }
-    }
-
-    _handleConfirmDialogCancel() {
-      this.$.overlay.close();
-    }
-
-    _handleDeleteItem(e) {
-      const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
-      if (!name) { return; }
-      this._refName = name;
-      this.$.overlay.open();
-    }
-
-    _computeHideDeleteClass(owner, canDelete) {
-      if (canDelete || owner) {
-        return 'show';
-      }
-
-      return '';
-    }
-
-    _handleCreateItem() {
-      this.$.createNewModal.handleCreateItem();
-      this._handleCloseCreate();
-    }
-
-    _handleCloseCreate() {
-      this.$.createOverlay.close();
-    }
-
-    _handleCreateClicked() {
-      this.$.createOverlay.open();
-    }
-
-    _hideIfBranch(type) {
-      if (type === DETAIL_TYPES.BRANCHES) {
-        return 'hideItem';
-      }
-
-      return '';
-    }
-
-    _computeHideTagger(tagger) {
-      return tagger ? '' : 'hide';
-    }
   }
 
-  customElements.define(GrRepoDetailList.is, GrRepoDetailList);
-})();
+  _getPath(repo) {
+    return `/admin/repos/${this.encodeURL(repo, false)},` +
+        `${this.detailType}`;
+  }
+
+  _computeWeblink(repo) {
+    if (!repo.web_links) { return ''; }
+    const webLinks = repo.web_links;
+    return webLinks.length ? webLinks : null;
+  }
+
+  _computeMessage(message) {
+    if (!message) { return; }
+    // Strip PGP info.
+    return message.split(PGP_START)[0];
+  }
+
+  _stripRefs(item, detailType) {
+    if (detailType === DETAIL_TYPES.BRANCHES) {
+      return item.replace('refs/heads/', '');
+    } else if (detailType === DETAIL_TYPES.TAGS) {
+      return item.replace('refs/tags/', '');
+    }
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _computeEditingClass(isEditing) {
+    return isEditing ? 'editing' : '';
+  }
+
+  _computeCanEditClass(ref, detailType, isOwner) {
+    return isOwner && this._stripRefs(ref, detailType) === 'HEAD' ?
+      'canEdit' : '';
+  }
+
+  _handleEditRevision(e) {
+    this._revisedRef = e.model.get('item.revision');
+    this._isEditing = true;
+  }
+
+  _handleCancelRevision() {
+    this._isEditing = false;
+  }
+
+  _handleSaveRevision(e) {
+    this._setRepoHead(this._repo, this._revisedRef, e);
+  }
+
+  _setRepoHead(repo, ref, e) {
+    return this.$.restAPI.setRepoHead(repo, ref).then(res => {
+      if (res.status < 400) {
+        this._isEditing = false;
+        e.model.set('item.revision', ref);
+        // This is needed to refresh _items property with fresh data,
+        // specifically can_delete from the json response.
+        this._getItems(
+            this._filter, this._repo, this._itemsPerPage,
+            this._offset, this.detailType);
+      }
+    });
+  }
+
+  _computeItemName(detailType) {
+    if (detailType === DETAIL_TYPES.BRANCHES) {
+      return 'Branch';
+    } else if (detailType === DETAIL_TYPES.TAGS) {
+      return 'Tag';
+    }
+  }
+
+  _handleDeleteItemConfirm() {
+    this.$.overlay.close();
+    if (this.detailType === DETAIL_TYPES.BRANCHES) {
+      return this.$.restAPI.deleteRepoBranches(this._repo, this._refName)
+          .then(itemDeleted => {
+            if (itemDeleted.status === 204) {
+              this._getItems(
+                  this._filter, this._repo, this._itemsPerPage,
+                  this._offset, this.detailType);
+            }
+          });
+    } else if (this.detailType === DETAIL_TYPES.TAGS) {
+      return this.$.restAPI.deleteRepoTags(this._repo, this._refName)
+          .then(itemDeleted => {
+            if (itemDeleted.status === 204) {
+              this._getItems(
+                  this._filter, this._repo, this._itemsPerPage,
+                  this._offset, this.detailType);
+            }
+          });
+    }
+  }
+
+  _handleConfirmDialogCancel() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteItem(e) {
+    const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
+    if (!name) { return; }
+    this._refName = name;
+    this.$.overlay.open();
+  }
+
+  _computeHideDeleteClass(owner, canDelete) {
+    if (canDelete || owner) {
+      return 'show';
+    }
+
+    return '';
+  }
+
+  _handleCreateItem() {
+    this.$.createNewModal.handleCreateItem();
+    this._handleCloseCreate();
+  }
+
+  _handleCloseCreate() {
+    this.$.createOverlay.close();
+  }
+
+  _handleCreateClicked() {
+    this.$.createOverlay.open();
+  }
+
+  _hideIfBranch(type) {
+    if (type === DETAIL_TYPES.BRANCHES) {
+      return 'hideItem';
+    }
+
+    return '';
+  }
+
+  _computeHideTagger(tagger) {
+    return tagger ? '' : 'hide';
+  }
+}
+
+customElements.define(GrRepoDetailList.is, GrRepoDetailList);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.js
index 467cef0..0d232f2 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.js
@@ -1,40 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-create-pointer-dialog/gr-create-pointer-dialog.html">
-<link rel="import" href="../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
-
-<dom-module id="gr-repo-detail-list">
-  <template>
+export const htmlTemplate = html`
     <style include="gr-form-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -88,100 +70,68 @@
     <style include="gr-table-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
-    <gr-list-view
-        create-new="[[_loggedIn]]"
-        filter="[[_filter]]"
-        items-per-page="[[_itemsPerPage]]"
-        items="[[_items]]"
-        loading="[[_loading]]"
-        offset="[[_offset]]"
-        on-create-clicked="_handleCreateClicked"
-        path="[[_getPath(_repo, detailType)]]">
+    <gr-list-view create-new="[[_loggedIn]]" filter="[[_filter]]" items-per-page="[[_itemsPerPage]]" items="[[_items]]" loading="[[_loading]]" offset="[[_offset]]" on-create-clicked="_handleCreateClicked" path="[[_getPath(_repo, detailType)]]">
       <table id="list" class="genericList gr-form-styles">
-        <tr class="headerRow">
+        <tbody><tr class="headerRow">
           <th class="name topHeader">Name</th>
           <th class="revision topHeader">Revision</th>
-          <th class$="message topHeader [[_hideIfBranch(detailType)]]">
+          <th class\$="message topHeader [[_hideIfBranch(detailType)]]">
             Message</th>
-          <th class$="tagger topHeader [[_hideIfBranch(detailType)]]">
+          <th class\$="tagger topHeader [[_hideIfBranch(detailType)]]">
             Tagger</th>
           <th class="repositoryBrowser topHeader">
             Repository Browser</th>
           <th class="delete topHeader"></th>
         </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+        <tr id="loading" class\$="loadingMsg [[computeLoadingClass(_loading)]]">
           <td>Loading...</td>
         </tr>
-        <tbody class$="[[computeLoadingClass(_loading)]]">
+        </tbody><tbody class\$="[[computeLoadingClass(_loading)]]">
           <template is="dom-repeat" items="[[_shownItems]]">
             <tr class="table">
-              <td class$="[[detailType]] name">[[_stripRefs(item.ref, detailType)]]</td>
-              <td class$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]">
+              <td class\$="[[detailType]] name">[[_stripRefs(item.ref, detailType)]]</td>
+              <td class\$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]">
                 <span class="revisionNoEditing">
                   [[item.revision]]
                 </span>
-                <span class$="revisionEdit [[_computeEditingClass(_isEditing)]]">
+                <span class\$="revisionEdit [[_computeEditingClass(_isEditing)]]">
                   <span class="revisionWithEditing">
                     [[item.revision]]
                   </span>
-                  <gr-button
-                      link
-                      on-click="_handleEditRevision"
-                      class="editBtn">
+                  <gr-button link="" on-click="_handleEditRevision" class="editBtn">
                     edit
                   </gr-button>
-                  <iron-input
-                      bind-value="{{_revisedRef}}"
-                      class="editItem">
-                    <input
-                        is="iron-input"
-                        bind-value="{{_revisedRef}}">
+                  <iron-input bind-value="{{_revisedRef}}" class="editItem">
+                    <input is="iron-input" bind-value="{{_revisedRef}}">
                   </iron-input>
-                  <gr-button
-                      link
-                      on-click="_handleCancelRevision"
-                      class="cancelBtn editItem">
+                  <gr-button link="" on-click="_handleCancelRevision" class="cancelBtn editItem">
                     Cancel
                   </gr-button>
-                  <gr-button
-                      link
-                      on-click="_handleSaveRevision"
-                      class="saveBtn editItem"
-                      disabled="[[!_revisedRef]]">
+                  <gr-button link="" on-click="_handleSaveRevision" class="saveBtn editItem" disabled="[[!_revisedRef]]">
                     Save
                   </gr-button>
                 </span>
               </td>
-              <td class$="message [[_hideIfBranch(detailType)]]">
+              <td class\$="message [[_hideIfBranch(detailType)]]">
                 [[_computeMessage(item.message)]]
               </td>
-              <td class$="tagger [[_hideIfBranch(detailType)]]">
-                <div class$="tagger [[_computeHideTagger(item.tagger)]]">
-                  <gr-account-link
-                      account="[[item.tagger]]">
+              <td class\$="tagger [[_hideIfBranch(detailType)]]">
+                <div class\$="tagger [[_computeHideTagger(item.tagger)]]">
+                  <gr-account-link account="[[item.tagger]]">
                   </gr-account-link>
-                  (<gr-date-formatter
-                      has-tooltip
-                      date-str="[[item.tagger.date]]">
+                  (<gr-date-formatter has-tooltip="" date-str="[[item.tagger.date]]">
                   </gr-date-formatter>)
                 </div>
               </td>
               <td class="repositoryBrowser">
-                <template is="dom-repeat"
-                    items="[[_computeWeblink(item)]]" as="link">
-                  <a href$="[[link.url]]"
-                      class="webLink"
-                      rel="noopener"
-                      target="_blank">
+                <template is="dom-repeat" items="[[_computeWeblink(item)]]" as="link">
+                  <a href\$="[[link.url]]" class="webLink" rel="noopener" target="_blank">
                     ([[link.name]])
                   </a>
                 </template>
               </td>
               <td class="delete">
-                <gr-button
-                    link
-                    class$="deleteButton [[_computeHideDeleteClass(_isOwner, item.can_delete)]]"
-                    on-click="_handleDeleteItem">
+                <gr-button link="" class\$="deleteButton [[_computeHideDeleteClass(_isOwner, item.can_delete)]]" on-click="_handleDeleteItem">
                   Delete
                 </gr-button>
               </td>
@@ -189,36 +139,19 @@
           </template>
         </tbody>
       </table>
-      <gr-overlay id="overlay" with-backdrop>
-        <gr-confirm-delete-item-dialog
-            class="confirmDialog"
-            on-confirm="_handleDeleteItemConfirm"
-            on-cancel="_handleConfirmDialogCancel"
-            item="[[_refName]]"
-            item-type="[[detailType]]"></gr-confirm-delete-item-dialog>
+      <gr-overlay id="overlay" with-backdrop="">
+        <gr-confirm-delete-item-dialog class="confirmDialog" on-confirm="_handleDeleteItemConfirm" on-cancel="_handleConfirmDialogCancel" item="[[_refName]]" item-type="[[detailType]]"></gr-confirm-delete-item-dialog>
       </gr-overlay>
     </gr-list-view>
-    <gr-overlay id="createOverlay" with-backdrop>
-      <gr-dialog
-          id="createDialog"
-          disabled="[[!_hasNewItemName]]"
-          confirm-label="Create"
-          on-confirm="_handleCreateItem"
-          on-cancel="_handleCloseCreate">
+    <gr-overlay id="createOverlay" with-backdrop="">
+      <gr-dialog id="createDialog" disabled="[[!_hasNewItemName]]" confirm-label="Create" on-confirm="_handleCreateItem" on-cancel="_handleCloseCreate">
         <div class="header" slot="header">
           Create [[_computeItemName(detailType)]]
         </div>
         <div class="main" slot="main">
-          <gr-create-pointer-dialog
-              id="createNewModal"
-              detail-type="[[_computeItemName(detailType)]]"
-              has-new-item-name="{{_hasNewItemName}}"
-              item-detail="[[detailType]]"
-              repo-name="[[_repo]]"></gr-create-pointer-dialog>
+          <gr-create-pointer-dialog id="createNewModal" detail-type="[[_computeItemName(detailType)]]" has-new-item-name="{{_hasNewItemName}}" item-detail="[[detailType]]" repo-name="[[_repo]]"></gr-create-pointer-dialog>
         </div>
       </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-detail-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
index d466f28..13510d8 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-detail-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-detail-list.html">
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-repo-detail-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-detail-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,533 +41,536 @@
   </template>
 </test-fixture>
 
-<script>
-  let counter;
-  const branchGenerator = () => {
-    return {
-      ref: `refs/heads/test${++counter}`,
-      revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
-      web_links: [
-        {
-          name: 'diffusion',
-          url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
-        },
-      ],
-    };
-  };
-  const tagGenerator = () => {
-    return {
-      ref: `refs/tags/test${++counter}`,
-      revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
-      web_links: [
-        {
-          name: 'diffusion',
-          url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
-        },
-      ],
-      message: 'Annotated tag',
-      tagger: {
-        name: 'Test User',
-        email: 'test.user@gmail.com',
-        date: '2017-09-19 14:54:00.000000000',
-        tz: 540,
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-detail-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+let counter;
+const branchGenerator = () => {
+  return {
+    ref: `refs/heads/test${++counter}`,
+    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
       },
-    };
+    ],
   };
+};
+const tagGenerator = () => {
+  return {
+    ref: `refs/tags/test${++counter}`,
+    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
+      },
+    ],
+    message: 'Annotated tag',
+    tagger: {
+      name: 'Test User',
+      email: 'test.user@gmail.com',
+      date: '2017-09-19 14:54:00.000000000',
+      tz: 540,
+    },
+  };
+};
 
-  suite('gr-repo-detail-list', async () => {
-    await readyToTest();
-    suite('Branches', () => {
-      let element;
-      let branches;
-      let sandbox;
+suite('gr-repo-detail-list', () => {
+  suite('Branches', () => {
+    let element;
+    let branches;
+    let sandbox;
 
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-        element = fixture('basic');
-        element.detailType = 'branches';
-        counter = 0;
-        sandbox.stub(page, 'show');
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.detailType = 'branches';
+      counter = 0;
+      sandbox.stub(page, 'show');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('list of repo branches', () => {
+      setup(done => {
+        branches = [{
+          ref: 'HEAD',
+          revision: 'master',
+        }].concat(_.times(25, branchGenerator));
+
+        stub('gr-rest-api-interface', {
+          getRepoBranches(num, project, offset) {
+            return Promise.resolve(branches);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'branches',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
       });
 
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      suite('list of repo branches', () => {
-        setup(done => {
-          branches = [{
-            ref: 'HEAD',
-            revision: 'master',
-          }].concat(_.times(25, branchGenerator));
-
-          stub('gr-rest-api-interface', {
-            getRepoBranches(num, project, offset) {
-              return Promise.resolve(branches);
-            },
-          });
-
-          const params = {
-            repo: 'test',
-            detail: 'branches',
-          };
-
-          element._paramsChanged(params).then(() => { flush(done); });
-        });
-
-        test('test for branch in the list', done => {
-          flush(() => {
-            assert.equal(element._items[2].ref, 'refs/heads/test2');
-            done();
-          });
-        });
-
-        test('test for web links in the branches list', done => {
-          flush(() => {
-            assert.equal(element._items[2].web_links[0].url,
-                'https://git.example.org/branch/test;refs/heads/test2');
-            done();
-          });
-        });
-
-        test('test for refs/heads/ being striped from ref', done => {
-          flush(() => {
-            assert.equal(element._stripRefs(element._items[2].ref,
-                element.detailType), 'test2');
-            done();
-          });
-        });
-
-        test('_shownItems', () => {
-          assert.equal(element._shownItems.length, 25);
-        });
-
-        test('Edit HEAD button not admin', done => {
-          sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-          sandbox.stub(element.$.restAPI, 'getRepoAccess').returns(
-              Promise.resolve({
-                test: {is_owner: false},
-              }));
-          element._determineIfOwner('test').then(() => {
-            assert.equal(element._isOwner, false);
-            assert.equal(getComputedStyle(Polymer.dom(element.root)
-                .querySelector('.revisionNoEditing')).display, 'inline');
-            assert.equal(getComputedStyle(Polymer.dom(element.root)
-                .querySelector('.revisionEdit')).display, 'none');
-            done();
-          });
-        });
-
-        test('Edit HEAD button admin', done => {
-          const saveBtn = Polymer.dom(element.root).querySelector('.saveBtn');
-          const cancelBtn = Polymer.dom(element.root).querySelector('.cancelBtn');
-          const editBtn = Polymer.dom(element.root).querySelector('.editBtn');
-          const revisionNoEditing = Polymer.dom(element.root)
-              .querySelector('.revisionNoEditing');
-          const revisionWithEditing = Polymer.dom(element.root)
-              .querySelector('.revisionWithEditing');
-
-          sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-          sandbox.stub(element.$.restAPI, 'getRepoAccess').returns(
-              Promise.resolve({
-                test: {is_owner: true},
-              }));
-          sandbox.stub(element, '_handleSaveRevision');
-          element._determineIfOwner('test').then(() => {
-            assert.equal(element._isOwner, true);
-            // The revision container for non-editing enabled row is not visible.
-            assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
-
-            // The revision container for editing enabled row is visible.
-            assert.notEqual(getComputedStyle(Polymer.dom(element.root)
-                .querySelector('.revisionEdit')).display, 'none');
-
-            // The revision and edit button are visible.
-            assert.notEqual(getComputedStyle(revisionWithEditing).display,
-                'none');
-            assert.notEqual(getComputedStyle(editBtn).display, 'none');
-
-            // The input, cancel, and save buttons are not visible.
-            const hiddenElements = Polymer.dom(element.root)
-                .querySelectorAll('.canEdit .editItem');
-
-            for (const item of hiddenElements) {
-              assert.equal(getComputedStyle(item).display, 'none');
-            }
-
-            MockInteractions.tap(editBtn);
-            flushAsynchronousOperations();
-            // The revision and edit button are not visible.
-            assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
-            assert.equal(getComputedStyle(editBtn).display, 'none');
-
-            // The input, cancel, and save buttons are not visible.
-            for (const item of hiddenElements) {
-              assert.notEqual(getComputedStyle(item).display, 'none');
-            }
-
-            // The revised ref was set correctly
-            assert.equal(element._revisedRef, 'master');
-
-            assert.isFalse(saveBtn.disabled);
-
-            // Delete the ref.
-            element._revisedRef = '';
-            assert.isTrue(saveBtn.disabled);
-
-            // Change the ref to something else
-            element._revisedRef = 'newRef';
-            element._repo = 'test';
-            assert.isFalse(saveBtn.disabled);
-
-            // Save button calls handleSave. since this is stubbed, the edit
-            // section remains open.
-            MockInteractions.tap(saveBtn);
-            assert.isTrue(element._handleSaveRevision.called);
-
-            // When cancel is tapped, the edit secion closes.
-            MockInteractions.tap(cancelBtn);
-            flushAsynchronousOperations();
-
-            // The revision and edit button are visible.
-            assert.notEqual(getComputedStyle(revisionWithEditing).display,
-                'none');
-            assert.notEqual(getComputedStyle(editBtn).display, 'none');
-
-            // The input, cancel, and save buttons are not visible.
-            for (const item of hiddenElements) {
-              assert.equal(getComputedStyle(item).display, 'none');
-            }
-            done();
-          });
-        });
-
-        test('_handleSaveRevision with invalid rev', done => {
-          const event = {model: {set: sandbox.stub()}};
-          element._isEditing = true;
-          sandbox.stub(element.$.restAPI, 'setRepoHead').returns(
-              Promise.resolve({
-                status: 400,
-              })
-          );
-
-          element._setRepoHead('test', 'newRef', event).then(() => {
-            assert.isTrue(element._isEditing);
-            assert.isFalse(event.model.set.called);
-            done();
-          });
-        });
-
-        test('_handleSaveRevision with valid rev', done => {
-          const event = {model: {set: sandbox.stub()}};
-          element._isEditing = true;
-          sandbox.stub(element.$.restAPI, 'setRepoHead').returns(
-              Promise.resolve({
-                status: 200,
-              })
-          );
-
-          element._setRepoHead('test', 'newRef', event).then(() => {
-            assert.isFalse(element._isEditing);
-            assert.isTrue(event.model.set.called);
-            done();
-          });
-        });
-
-        test('test _computeItemName', () => {
-          assert.deepEqual(element._computeItemName('branches'), 'Branch');
-          assert.deepEqual(element._computeItemName('tags'), 'Tag');
+      test('test for branch in the list', done => {
+        flush(() => {
+          assert.equal(element._items[2].ref, 'refs/heads/test2');
+          done();
         });
       });
 
-      suite('list with less then 25 branches', () => {
-        setup(done => {
-          branches = _.times(25, branchGenerator);
-
-          stub('gr-rest-api-interface', {
-            getRepoBranches(num, repo, offset) {
-              return Promise.resolve(branches);
-            },
-          });
-
-          const params = {
-            repo: 'test',
-            detail: 'branches',
-          };
-
-          element._paramsChanged(params).then(() => { flush(done); });
-        });
-
-        test('_shownItems', () => {
-          assert.equal(element._shownItems.length, 25);
+      test('test for web links in the branches list', done => {
+        flush(() => {
+          assert.equal(element._items[2].web_links[0].url,
+              'https://git.example.org/branch/test;refs/heads/test2');
+          done();
         });
       });
 
-      suite('filter', () => {
-        test('_paramsChanged', done => {
-          sandbox.stub(
-              element.$.restAPI,
-              'getRepoBranches',
-              () => Promise.resolve(branches));
-          const params = {
-            detail: 'branches',
-            repo: 'test',
-            filter: 'test',
-            offset: 25,
-          };
-          element._paramsChanged(params).then(() => {
-            assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[0],
-                'test');
-            assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[1],
-                'test');
-            assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[2],
-                25);
-            assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[3],
-                25);
-            done();
-          });
+      test('test for refs/heads/ being striped from ref', done => {
+        flush(() => {
+          assert.equal(element._stripRefs(element._items[2].ref,
+              element.detailType), 'test2');
+          done();
         });
       });
 
-      suite('404', () => {
-        test('fires page-error', done => {
-          const response = {status: 404};
-          sandbox.stub(element.$.restAPI, 'getRepoBranches',
-              (filter, repo, reposBranchesPerPage, opt_offset, errFn) => {
-                errFn(response);
-              });
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
 
-          element.addEventListener('page-error', e => {
-            assert.deepEqual(e.detail.response, response);
-            done();
-          });
+      test('Edit HEAD button not admin', done => {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        sandbox.stub(element.$.restAPI, 'getRepoAccess').returns(
+            Promise.resolve({
+              test: {is_owner: false},
+            }));
+        element._determineIfOwner('test').then(() => {
+          assert.equal(element._isOwner, false);
+          assert.equal(getComputedStyle(dom(element.root)
+              .querySelector('.revisionNoEditing')).display, 'inline');
+          assert.equal(getComputedStyle(dom(element.root)
+              .querySelector('.revisionEdit')).display, 'none');
+          done();
+        });
+      });
 
-          const params = {
-            detail: 'branches',
-            repo: 'test',
-            filter: 'test',
-            offset: 25,
-          };
-          element._paramsChanged(params);
+      test('Edit HEAD button admin', done => {
+        const saveBtn = dom(element.root).querySelector('.saveBtn');
+        const cancelBtn = dom(element.root).querySelector('.cancelBtn');
+        const editBtn = dom(element.root).querySelector('.editBtn');
+        const revisionNoEditing = dom(element.root)
+            .querySelector('.revisionNoEditing');
+        const revisionWithEditing = dom(element.root)
+            .querySelector('.revisionWithEditing');
+
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        sandbox.stub(element.$.restAPI, 'getRepoAccess').returns(
+            Promise.resolve({
+              test: {is_owner: true},
+            }));
+        sandbox.stub(element, '_handleSaveRevision');
+        element._determineIfOwner('test').then(() => {
+          assert.equal(element._isOwner, true);
+          // The revision container for non-editing enabled row is not visible.
+          assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
+
+          // The revision container for editing enabled row is visible.
+          assert.notEqual(getComputedStyle(dom(element.root)
+              .querySelector('.revisionEdit')).display, 'none');
+
+          // The revision and edit button are visible.
+          assert.notEqual(getComputedStyle(revisionWithEditing).display,
+              'none');
+          assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          const hiddenElements = dom(element.root)
+              .querySelectorAll('.canEdit .editItem');
+
+          for (const item of hiddenElements) {
+            assert.equal(getComputedStyle(item).display, 'none');
+          }
+
+          MockInteractions.tap(editBtn);
+          flushAsynchronousOperations();
+          // The revision and edit button are not visible.
+          assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
+          assert.equal(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          for (const item of hiddenElements) {
+            assert.notEqual(getComputedStyle(item).display, 'none');
+          }
+
+          // The revised ref was set correctly
+          assert.equal(element._revisedRef, 'master');
+
+          assert.isFalse(saveBtn.disabled);
+
+          // Delete the ref.
+          element._revisedRef = '';
+          assert.isTrue(saveBtn.disabled);
+
+          // Change the ref to something else
+          element._revisedRef = 'newRef';
+          element._repo = 'test';
+          assert.isFalse(saveBtn.disabled);
+
+          // Save button calls handleSave. since this is stubbed, the edit
+          // section remains open.
+          MockInteractions.tap(saveBtn);
+          assert.isTrue(element._handleSaveRevision.called);
+
+          // When cancel is tapped, the edit secion closes.
+          MockInteractions.tap(cancelBtn);
+          flushAsynchronousOperations();
+
+          // The revision and edit button are visible.
+          assert.notEqual(getComputedStyle(revisionWithEditing).display,
+              'none');
+          assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          for (const item of hiddenElements) {
+            assert.equal(getComputedStyle(item).display, 'none');
+          }
+          done();
+        });
+      });
+
+      test('_handleSaveRevision with invalid rev', done => {
+        const event = {model: {set: sandbox.stub()}};
+        element._isEditing = true;
+        sandbox.stub(element.$.restAPI, 'setRepoHead').returns(
+            Promise.resolve({
+              status: 400,
+            })
+        );
+
+        element._setRepoHead('test', 'newRef', event).then(() => {
+          assert.isTrue(element._isEditing);
+          assert.isFalse(event.model.set.called);
+          done();
+        });
+      });
+
+      test('_handleSaveRevision with valid rev', done => {
+        const event = {model: {set: sandbox.stub()}};
+        element._isEditing = true;
+        sandbox.stub(element.$.restAPI, 'setRepoHead').returns(
+            Promise.resolve({
+              status: 200,
+            })
+        );
+
+        element._setRepoHead('test', 'newRef', event).then(() => {
+          assert.isFalse(element._isEditing);
+          assert.isTrue(event.model.set.called);
+          done();
+        });
+      });
+
+      test('test _computeItemName', () => {
+        assert.deepEqual(element._computeItemName('branches'), 'Branch');
+        assert.deepEqual(element._computeItemName('tags'), 'Tag');
+      });
+    });
+
+    suite('list with less then 25 branches', () => {
+      setup(done => {
+        branches = _.times(25, branchGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepoBranches(num, repo, offset) {
+            return Promise.resolve(branches);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'branches',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sandbox.stub(
+            element.$.restAPI,
+            'getRepoBranches',
+            () => Promise.resolve(branches));
+        const params = {
+          detail: 'branches',
+          repo: 'test',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params).then(() => {
+          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[0],
+              'test');
+          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[1],
+              'test');
+          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[2],
+              25);
+          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[3],
+              25);
+          done();
         });
       });
     });
 
-    suite('Tags', () => {
-      let element;
-      let tags;
-      let sandbox;
+    suite('404', () => {
+      test('fires page-error', done => {
+        const response = {status: 404};
+        sandbox.stub(element.$.restAPI, 'getRepoBranches',
+            (filter, repo, reposBranchesPerPage, opt_offset, errFn) => {
+              errFn(response);
+            });
 
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-        element = fixture('basic');
-        element.detailType = 'tags';
-        counter = 0;
-        sandbox.stub(page, 'show');
-      });
-
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      test('_computeMessage', () => {
-        let message = 'v2.15-rc1↵-----BEGIN PGP SIGNATURE-----↵Version: GnuPG v' +
-        '1↵↵iQIcBAABAgAGBQJZ27O7AAoJEF/XxZqaEoiMy6kQAMoQCpGr3J6JITI4BVWsr7QM↵xy' +
-        'EcWH5YPUko5EPTbkABHmaVyFmKGkuIQdn6c+NIbqJOk+5XT4oUyRSo1T569HPJ↵3kyxEJi' +
-        'T1ryvp5BIHwdvHx58fjw1+YkiWLZuZq1FFkUYqnWTYCrkv7Fok98pdOmV↵CL1Hgugi5uK8' +
-        '/kxf1M7+Nv6piaZ140pwSb1h6QdAjaZVfaBCnoxlG4LRUqHvEYay↵f4QYgFT67auHIGkZ4' +
-        'moUcsp2Du/1jSsCWL/CPwjPFGbbckVAjLCMT9yD3NKwpEZF↵pfsiZyHI9dL0M+QjVrM+RD' +
-        'HwIIJwra8R0IMkDlQ6MDrFlKNqNBbo588S6UPrm71L↵YuiwWlcrK9ZIybxT6LzbR65Rvez' +
-        'DSitQ+xeIfpZE19/X6BCnvlARLE8k/tC2JksI↵lEZi7Lf3FQdIcwwyt98tJkS9HX9v9jbC' +
-        '5QXifnoj3Li8tHSLuQ1dJCxHQiis6ojI↵OWUFkm0IHBXVNHA2dqYBdM+pL12mlI3wp6Ica' +
-        '4cdEVDwzu+j1xnVSFUa+d+Y2xJF↵7mytuyhHiKG4hm+zbhMv6WD8Q3FoDsJZeLY99l0hYQ' +
-        'SnnkMduFVroIs45pAs8gUA↵RvYla8mm9w/543IJAPzzFarPVLSsSyQ7tJl3UBzjKRNH/rX' +
-        'W+F22qyWD1zyHPUIR↵C00ItmwlAvveImYKpQAH↵=L+K9↵-----END PGP SIGNATURE---' +
-        '--';
-        assert.equal(element._computeMessage(message), 'v2.15-rc1↵');
-        message = 'v2.15-rc1';
-        assert.equal(element._computeMessage(message), 'v2.15-rc1');
-      });
-
-      suite('list of repo tags', () => {
-        setup(done => {
-          tags = _.times(26, tagGenerator);
-
-          stub('gr-rest-api-interface', {
-            getRepoTags(num, repo, offset) {
-              return Promise.resolve(tags);
-            },
-          });
-
-          const params = {
-            repo: 'test',
-            detail: 'tags',
-          };
-
-          element._paramsChanged(params).then(() => { flush(done); });
+        element.addEventListener('page-error', e => {
+          assert.deepEqual(e.detail.response, response);
+          done();
         });
 
-        test('test for tag in the list', done => {
-          flush(() => {
-            assert.equal(element._items[1].ref, 'refs/tags/test2');
-            done();
-          });
-        });
-
-        test('test for tag message in the list', done => {
-          flush(() => {
-            assert.equal(element._items[1].message, 'Annotated tag');
-            done();
-          });
-        });
-
-        test('test for tagger in the tag list', done => {
-          const tagger = {
-            name: 'Test User',
-            email: 'test.user@gmail.com',
-            date: '2017-09-19 14:54:00.000000000',
-            tz: 540,
-          };
-          flush(() => {
-            assert.deepEqual(element._items[1].tagger, tagger);
-            done();
-          });
-        });
-
-        test('test for web links in the tags list', done => {
-          flush(() => {
-            assert.equal(element._items[1].web_links[0].url,
-                'https://git.example.org/tag/test;refs/tags/test2');
-            done();
-          });
-        });
-
-        test('test for refs/tags/ being striped from ref', done => {
-          flush(() => {
-            assert.equal(element._stripRefs(element._items[1].ref,
-                element.detailType), 'test2');
-            done();
-          });
-        });
-
-        test('_shownItems', () => {
-          assert.equal(element._shownItems.length, 25);
-        });
-
-        test('_computeHideTagger', () => {
-          const testObject1 = {
-            tagger: 'test',
-          };
-          assert.equal(element._computeHideTagger(testObject1), '');
-
-          assert.equal(element._computeHideTagger(undefined), 'hide');
-        });
-      });
-
-      suite('list with less then 25 tags', () => {
-        setup(done => {
-          tags = _.times(25, tagGenerator);
-
-          stub('gr-rest-api-interface', {
-            getRepoTags(num, project, offset) {
-              return Promise.resolve(tags);
-            },
-          });
-
-          const params = {
-            repo: 'test',
-            detail: 'tags',
-          };
-
-          element._paramsChanged(params).then(() => { flush(done); });
-        });
-
-        test('_shownItems', () => {
-          assert.equal(element._shownItems.length, 25);
-        });
-      });
-
-      suite('filter', () => {
-        test('_paramsChanged', done => {
-          sandbox.stub(
-              element.$.restAPI,
-              'getRepoTags',
-              () => Promise.resolve(tags));
-          const params = {
-            repo: 'test',
-            detail: 'tags',
-            filter: 'test',
-            offset: 25,
-          };
-          element._paramsChanged(params).then(() => {
-            assert.equal(element.$.restAPI.getRepoTags.lastCall.args[0],
-                'test');
-            assert.equal(element.$.restAPI.getRepoTags.lastCall.args[1],
-                'test');
-            assert.equal(element.$.restAPI.getRepoTags.lastCall.args[2],
-                25);
-            assert.equal(element.$.restAPI.getRepoTags.lastCall.args[3],
-                25);
-            done();
-          });
-        });
-      });
-
-      suite('create new', () => {
-        test('_handleCreateClicked called when create-click fired', () => {
-          sandbox.stub(element, '_handleCreateClicked');
-          element.shadowRoot
-              .querySelector('gr-list-view').fire('create-clicked');
-          assert.isTrue(element._handleCreateClicked.called);
-        });
-
-        test('_handleCreateClicked opens modal', () => {
-          const openStub = sandbox.stub(element.$.createOverlay, 'open');
-          element._handleCreateClicked();
-          assert.isTrue(openStub.called);
-        });
-
-        test('_handleCreateItem called when confirm fired', () => {
-          sandbox.stub(element, '_handleCreateItem');
-          element.$.createDialog.fire('confirm');
-          assert.isTrue(element._handleCreateItem.called);
-        });
-
-        test('_handleCloseCreate called when cancel fired', () => {
-          sandbox.stub(element, '_handleCloseCreate');
-          element.$.createDialog.fire('cancel');
-          assert.isTrue(element._handleCloseCreate.called);
-        });
-      });
-
-      suite('404', () => {
-        test('fires page-error', done => {
-          const response = {status: 404};
-          sandbox.stub(element.$.restAPI, 'getRepoTags',
-              (filter, repo, reposTagsPerPage, opt_offset, errFn) => {
-                errFn(response);
-              });
-
-          element.addEventListener('page-error', e => {
-            assert.deepEqual(e.detail.response, response);
-            done();
-          });
-
-          const params = {
-            repo: 'test',
-            detail: 'tags',
-            filter: 'test',
-            offset: 25,
-          };
-          element._paramsChanged(params);
-        });
-      });
-
-      test('test _computeHideDeleteClass', () => {
-        assert.deepEqual(element._computeHideDeleteClass(true, false), 'show');
-        assert.deepEqual(element._computeHideDeleteClass(false, true), 'show');
-        assert.deepEqual(element._computeHideDeleteClass(false, false), '');
+        const params = {
+          detail: 'branches',
+          repo: 'test',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params);
       });
     });
   });
+
+  suite('Tags', () => {
+    let element;
+    let tags;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.detailType = 'tags';
+      counter = 0;
+      sandbox.stub(page, 'show');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_computeMessage', () => {
+      let message = 'v2.15-rc1↵-----BEGIN PGP SIGNATURE-----↵Version: GnuPG v' +
+      '1↵↵iQIcBAABAgAGBQJZ27O7AAoJEF/XxZqaEoiMy6kQAMoQCpGr3J6JITI4BVWsr7QM↵xy' +
+      'EcWH5YPUko5EPTbkABHmaVyFmKGkuIQdn6c+NIbqJOk+5XT4oUyRSo1T569HPJ↵3kyxEJi' +
+      'T1ryvp5BIHwdvHx58fjw1+YkiWLZuZq1FFkUYqnWTYCrkv7Fok98pdOmV↵CL1Hgugi5uK8' +
+      '/kxf1M7+Nv6piaZ140pwSb1h6QdAjaZVfaBCnoxlG4LRUqHvEYay↵f4QYgFT67auHIGkZ4' +
+      'moUcsp2Du/1jSsCWL/CPwjPFGbbckVAjLCMT9yD3NKwpEZF↵pfsiZyHI9dL0M+QjVrM+RD' +
+      'HwIIJwra8R0IMkDlQ6MDrFlKNqNBbo588S6UPrm71L↵YuiwWlcrK9ZIybxT6LzbR65Rvez' +
+      'DSitQ+xeIfpZE19/X6BCnvlARLE8k/tC2JksI↵lEZi7Lf3FQdIcwwyt98tJkS9HX9v9jbC' +
+      '5QXifnoj3Li8tHSLuQ1dJCxHQiis6ojI↵OWUFkm0IHBXVNHA2dqYBdM+pL12mlI3wp6Ica' +
+      '4cdEVDwzu+j1xnVSFUa+d+Y2xJF↵7mytuyhHiKG4hm+zbhMv6WD8Q3FoDsJZeLY99l0hYQ' +
+      'SnnkMduFVroIs45pAs8gUA↵RvYla8mm9w/543IJAPzzFarPVLSsSyQ7tJl3UBzjKRNH/rX' +
+      'W+F22qyWD1zyHPUIR↵C00ItmwlAvveImYKpQAH↵=L+K9↵-----END PGP SIGNATURE---' +
+      '--';
+      assert.equal(element._computeMessage(message), 'v2.15-rc1↵');
+      message = 'v2.15-rc1';
+      assert.equal(element._computeMessage(message), 'v2.15-rc1');
+    });
+
+    suite('list of repo tags', () => {
+      setup(done => {
+        tags = _.times(26, tagGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepoTags(num, repo, offset) {
+            return Promise.resolve(tags);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('test for tag in the list', done => {
+        flush(() => {
+          assert.equal(element._items[1].ref, 'refs/tags/test2');
+          done();
+        });
+      });
+
+      test('test for tag message in the list', done => {
+        flush(() => {
+          assert.equal(element._items[1].message, 'Annotated tag');
+          done();
+        });
+      });
+
+      test('test for tagger in the tag list', done => {
+        const tagger = {
+          name: 'Test User',
+          email: 'test.user@gmail.com',
+          date: '2017-09-19 14:54:00.000000000',
+          tz: 540,
+        };
+        flush(() => {
+          assert.deepEqual(element._items[1].tagger, tagger);
+          done();
+        });
+      });
+
+      test('test for web links in the tags list', done => {
+        flush(() => {
+          assert.equal(element._items[1].web_links[0].url,
+              'https://git.example.org/tag/test;refs/tags/test2');
+          done();
+        });
+      });
+
+      test('test for refs/tags/ being striped from ref', done => {
+        flush(() => {
+          assert.equal(element._stripRefs(element._items[1].ref,
+              element.detailType), 'test2');
+          done();
+        });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+
+      test('_computeHideTagger', () => {
+        const testObject1 = {
+          tagger: 'test',
+        };
+        assert.equal(element._computeHideTagger(testObject1), '');
+
+        assert.equal(element._computeHideTagger(undefined), 'hide');
+      });
+    });
+
+    suite('list with less then 25 tags', () => {
+      setup(done => {
+        tags = _.times(25, tagGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepoTags(num, project, offset) {
+            return Promise.resolve(tags);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sandbox.stub(
+            element.$.restAPI,
+            'getRepoTags',
+            () => Promise.resolve(tags));
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params).then(() => {
+          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[0],
+              'test');
+          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[1],
+              'test');
+          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[2],
+              25);
+          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[3],
+              25);
+          done();
+        });
+      });
+    });
+
+    suite('create new', () => {
+      test('_handleCreateClicked called when create-click fired', () => {
+        sandbox.stub(element, '_handleCreateClicked');
+        element.shadowRoot
+            .querySelector('gr-list-view').fire('create-clicked');
+        assert.isTrue(element._handleCreateClicked.called);
+      });
+
+      test('_handleCreateClicked opens modal', () => {
+        const openStub = sandbox.stub(element.$.createOverlay, 'open');
+        element._handleCreateClicked();
+        assert.isTrue(openStub.called);
+      });
+
+      test('_handleCreateItem called when confirm fired', () => {
+        sandbox.stub(element, '_handleCreateItem');
+        element.$.createDialog.fire('confirm');
+        assert.isTrue(element._handleCreateItem.called);
+      });
+
+      test('_handleCloseCreate called when cancel fired', () => {
+        sandbox.stub(element, '_handleCloseCreate');
+        element.$.createDialog.fire('cancel');
+        assert.isTrue(element._handleCloseCreate.called);
+      });
+    });
+
+    suite('404', () => {
+      test('fires page-error', done => {
+        const response = {status: 404};
+        sandbox.stub(element.$.restAPI, 'getRepoTags',
+            (filter, repo, reposTagsPerPage, opt_offset, errFn) => {
+              errFn(response);
+            });
+
+        element.addEventListener('page-error', e => {
+          assert.deepEqual(e.detail.response, response);
+          done();
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params);
+      });
+    });
+
+    test('test _computeHideDeleteClass', () => {
+      assert.deepEqual(element._computeHideDeleteClass(true, false), 'show');
+      assert.deepEqual(element._computeHideDeleteClass(false, true), 'show');
+      assert.deepEqual(element._computeHideDeleteClass(false, false), '');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
index c509717..24cc9a8 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
@@ -14,160 +14,174 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-list-view/gr-list-view.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-create-repo-dialog/gr-create-repo-dialog.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-list_html.js';
+
+/**
+ * @appliesMixin Gerrit.ListViewMixin
+ * @extends Polymer.Element
+ */
+class GrRepoList extends mixinBehaviors( [
+  Gerrit.ListViewBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo-list'; }
+
+  static get properties() {
+    return {
+    /**
+     * URL params passed from the router.
+     */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: Number,
+      _path: {
+        type: String,
+        readOnly: true,
+        value: '/admin/repos',
+      },
+      _hasNewRepoName: Boolean,
+      _createNewCapability: {
+        type: Boolean,
+        value: false,
+      },
+      _repos: Array,
+
+      /**
+       * Because  we request one more than the projectsPerPage, _shownProjects
+       * maybe one less than _projects.
+       * */
+      _shownRepos: {
+        type: Array,
+        computed: 'computeShownItems(_repos)',
+      },
+
+      _reposPerPage: {
+        type: Number,
+        value: 25,
+      },
+
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: {
+        type: String,
+        value: '',
+      },
+    };
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getCreateRepoCapability();
+    this.fire('title-change', {title: 'Repos'});
+    this._maybeOpenCreateOverlay(this.params);
+  }
+
+  _paramsChanged(params) {
+    this._loading = true;
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
+
+    return this._getRepos(this._filter, this._reposPerPage,
+        this._offset);
+  }
 
   /**
-   * @appliesMixin Gerrit.ListViewMixin
-   * @extends Polymer.Element
+   * Opens the create overlay if the route has a hash 'create'
+   *
+   * @param {!Object} params
    */
-  class GrRepoList extends Polymer.mixinBehaviors( [
-    Gerrit.ListViewBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-repo-list'; }
-
-    static get properties() {
-      return {
-      /**
-       * URL params passed from the router.
-       */
-        params: {
-          type: Object,
-          observer: '_paramsChanged',
-        },
-
-        /**
-         * Offset of currently visible query results.
-         */
-        _offset: Number,
-        _path: {
-          type: String,
-          readOnly: true,
-          value: '/admin/repos',
-        },
-        _hasNewRepoName: Boolean,
-        _createNewCapability: {
-          type: Boolean,
-          value: false,
-        },
-        _repos: Array,
-
-        /**
-         * Because  we request one more than the projectsPerPage, _shownProjects
-         * maybe one less than _projects.
-         * */
-        _shownRepos: {
-          type: Array,
-          computed: 'computeShownItems(_repos)',
-        },
-
-        _reposPerPage: {
-          type: Number,
-          value: 25,
-        },
-
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-        _filter: {
-          type: String,
-          value: '',
-        },
-      };
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this._getCreateRepoCapability();
-      this.fire('title-change', {title: 'Repos'});
-      this._maybeOpenCreateOverlay(this.params);
-    }
-
-    _paramsChanged(params) {
-      this._loading = true;
-      this._filter = this.getFilterValue(params);
-      this._offset = this.getOffsetValue(params);
-
-      return this._getRepos(this._filter, this._reposPerPage,
-          this._offset);
-    }
-
-    /**
-     * Opens the create overlay if the route has a hash 'create'
-     *
-     * @param {!Object} params
-     */
-    _maybeOpenCreateOverlay(params) {
-      if (params && params.openCreateModal) {
-        this.$.createOverlay.open();
-      }
-    }
-
-    _computeRepoUrl(name) {
-      return this.getUrl(this._path + '/', name);
-    }
-
-    _computeChangesLink(name) {
-      return Gerrit.Nav.getUrlForProjectChanges(name);
-    }
-
-    _getCreateRepoCapability() {
-      return this.$.restAPI.getAccount().then(account => {
-        if (!account) { return; }
-        return this.$.restAPI.getAccountCapabilities(['createProject'])
-            .then(capabilities => {
-              if (capabilities.createProject) {
-                this._createNewCapability = true;
-              }
-            });
-      });
-    }
-
-    _getRepos(filter, reposPerPage, offset) {
-      this._repos = [];
-      return this.$.restAPI.getRepos(filter, reposPerPage, offset)
-          .then(repos => {
-            // Late response.
-            if (filter !== this._filter || !repos) { return; }
-            this._repos = repos;
-            this._loading = false;
-          });
-    }
-
-    _refreshReposList() {
-      this.$.restAPI.invalidateReposCache();
-      return this._getRepos(this._filter, this._reposPerPage,
-          this._offset);
-    }
-
-    _handleCreateRepo() {
-      this.$.createNewModal.handleCreateRepo().then(() => {
-        this._refreshReposList();
-      });
-    }
-
-    _handleCloseCreate() {
-      this.$.createOverlay.close();
-    }
-
-    _handleCreateClicked() {
+  _maybeOpenCreateOverlay(params) {
+    if (params && params.openCreateModal) {
       this.$.createOverlay.open();
     }
-
-    _readOnly(item) {
-      return item.state === 'READ_ONLY' ? 'Y' : '';
-    }
-
-    _computeWeblink(repo) {
-      if (!repo.web_links) { return ''; }
-      const webLinks = repo.web_links;
-      return webLinks.length ? webLinks : null;
-    }
   }
 
-  customElements.define(GrRepoList.is, GrRepoList);
-})();
+  _computeRepoUrl(name) {
+    return this.getUrl(this._path + '/', name);
+  }
+
+  _computeChangesLink(name) {
+    return Gerrit.Nav.getUrlForProjectChanges(name);
+  }
+
+  _getCreateRepoCapability() {
+    return this.$.restAPI.getAccount().then(account => {
+      if (!account) { return; }
+      return this.$.restAPI.getAccountCapabilities(['createProject'])
+          .then(capabilities => {
+            if (capabilities.createProject) {
+              this._createNewCapability = true;
+            }
+          });
+    });
+  }
+
+  _getRepos(filter, reposPerPage, offset) {
+    this._repos = [];
+    return this.$.restAPI.getRepos(filter, reposPerPage, offset)
+        .then(repos => {
+          // Late response.
+          if (filter !== this._filter || !repos) { return; }
+          this._repos = repos;
+          this._loading = false;
+        });
+  }
+
+  _refreshReposList() {
+    this.$.restAPI.invalidateReposCache();
+    return this._getRepos(this._filter, this._reposPerPage,
+        this._offset);
+  }
+
+  _handleCreateRepo() {
+    this.$.createNewModal.handleCreateRepo().then(() => {
+      this._refreshReposList();
+    });
+  }
+
+  _handleCloseCreate() {
+    this.$.createOverlay.close();
+  }
+
+  _handleCreateClicked() {
+    this.$.createOverlay.open();
+  }
+
+  _readOnly(item) {
+    return item.state === 'READ_ONLY' ? 'Y' : '';
+  }
+
+  _computeWeblink(repo) {
+    if (!repo.web_links) { return ''; }
+    const webLinks = repo.web_links;
+    return webLinks.length ? webLinks : null;
+  }
+}
+
+customElements.define(GrRepoList.is, GrRepoList);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.js
index 08fd45c..d498869 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.js
@@ -1,32 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-create-repo-dialog/gr-create-repo-dialog.html">
-
-<dom-module id="gr-repo-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -47,44 +37,32 @@
         white-space:nowrap;
       }
     </style>
-    <gr-list-view
-        create-new=[[_createNewCapability]]
-        filter="[[_filter]]"
-        items-per-page="[[_reposPerPage]]"
-        items="[[_repos]]"
-        loading="[[_loading]]"
-        offset="[[_offset]]"
-        on-create-clicked="_handleCreateClicked"
-        path="[[_path]]">
+    <gr-list-view create-new="[[_createNewCapability]]" filter="[[_filter]]" items-per-page="[[_reposPerPage]]" items="[[_repos]]" loading="[[_loading]]" offset="[[_offset]]" on-create-clicked="_handleCreateClicked" path="[[_path]]">
       <table id="list" class="genericList">
-        <tr class="headerRow">
+        <tbody><tr class="headerRow">
           <th class="name topHeader">Repository Name</th>
           <th class="repositoryBrowser topHeader">Repository Browser</th>
           <th class="changesLink topHeader">Changes</th>
           <th class="topHeader readOnly">Read only</th>
           <th class="description topHeader">Repository Description</th>
         </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+        <tr id="loading" class\$="loadingMsg [[computeLoadingClass(_loading)]]">
           <td>Loading...</td>
         </tr>
-        <tbody class$="[[computeLoadingClass(_loading)]]">
+        </tbody><tbody class\$="[[computeLoadingClass(_loading)]]">
           <template is="dom-repeat" items="[[_shownRepos]]">
             <tr class="table">
               <td class="name">
-                <a href$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a>
+                <a href\$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a>
               </td>
               <td class="repositoryBrowser">
-                <template is="dom-repeat"
-                    items="[[_computeWeblink(item)]]" as="link">
-                  <a href$="[[link.url]]"
-                      class="webLink"
-                      rel="noopener"
-                      target="_blank">
+                <template is="dom-repeat" items="[[_computeWeblink(item)]]" as="link">
+                  <a href\$="[[link.url]]" class="webLink" rel="noopener" target="_blank">
                     [[link.name]]
                   </a>
                 </template>
               </td>
-              <td class="changesLink"><a href$="[[_computeChangesLink(item.name)]]">view all</a></td>
+              <td class="changesLink"><a href\$="[[_computeChangesLink(item.name)]]">view all</a></td>
               <td class="readOnly">[[_readOnly(item)]]</td>
               <td class="description">[[item.description]]</td>
             </tr>
@@ -92,26 +70,15 @@
         </tbody>
       </table>
     </gr-list-view>
-    <gr-overlay id="createOverlay" with-backdrop>
-      <gr-dialog
-          id="createDialog"
-          class="confirmDialog"
-          disabled="[[!_hasNewRepoName]]"
-          confirm-label="Create"
-          on-confirm="_handleCreateRepo"
-          on-cancel="_handleCloseCreate">
+    <gr-overlay id="createOverlay" with-backdrop="">
+      <gr-dialog id="createDialog" class="confirmDialog" disabled="[[!_hasNewRepoName]]" confirm-label="Create" on-confirm="_handleCreateRepo" on-cancel="_handleCloseCreate">
         <div class="header" slot="header">
           Create Repository
         </div>
         <div class="main" slot="main">
-          <gr-create-repo-dialog
-              has-new-repo-name="{{_hasNewRepoName}}"
-              params="[[params]]"
-              id="createNewModal"></gr-create-repo-dialog>
+          <gr-create-repo-dialog has-new-repo-name="{{_hasNewRepoName}}" params="[[params]]" id="createNewModal"></gr-create-repo-dialog>
         </div>
       </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
index 4003b15..fbd1099 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-list.html">
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-repo-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,166 +41,168 @@
   </template>
 </test-fixture>
 
-<script>
-  let counter;
-  const repoGenerator = () => {
-    return {
-      id: `test${++counter}`,
-      state: 'ACTIVE',
-      web_links: [
-        {
-          name: 'diffusion',
-          url: `https://phabricator.example.org/r/project/test${counter}`,
-        },
-      ],
-    };
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-list.js';
+let counter;
+const repoGenerator = () => {
+  return {
+    id: `test${++counter}`,
+    state: 'ACTIVE',
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://phabricator.example.org/r/project/test${counter}`,
+      },
+    ],
   };
+};
 
-  suite('gr-repo-list tests', async () => {
-    await readyToTest();
-    let element;
-    let repos;
-    let sandbox;
-    let value;
+suite('gr-repo-list tests', () => {
+  let element;
+  let repos;
+  let sandbox;
+  let value;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(page, 'show');
+    element = fixture('basic');
+    counter = 0;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('list with repos', () => {
+    setup(done => {
+      repos = _.times(26, repoGenerator);
+      stub('gr-rest-api-interface', {
+        getRepos(num, offset) {
+          return Promise.resolve(repos);
+        },
+      });
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('test for test repo in the list', done => {
+      flush(() => {
+        assert.equal(element._repos[1].id, 'test2');
+        done();
+      });
+    });
+
+    test('_shownRepos', () => {
+      assert.equal(element._shownRepos.length, 25);
+    });
+
+    test('_maybeOpenCreateOverlay', () => {
+      const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
+      element._maybeOpenCreateOverlay();
+      assert.isFalse(overlayOpen.called);
+      const params = {};
+      element._maybeOpenCreateOverlay(params);
+      assert.isFalse(overlayOpen.called);
+      params.openCreateModal = true;
+      element._maybeOpenCreateOverlay(params);
+      assert.isTrue(overlayOpen.called);
+    });
+  });
+
+  suite('list with less then 25 repos', () => {
+    setup(done => {
+      repos = _.times(25, repoGenerator);
+
+      stub('gr-rest-api-interface', {
+        getRepos(num, offset) {
+          return Promise.resolve(repos);
+        },
+      });
+
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('_shownRepos', () => {
+      assert.equal(element._shownRepos.length, 25);
+    });
+  });
+
+  suite('filter', () => {
+    let reposFiltered;
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(page, 'show');
-      element = fixture('basic');
-      counter = 0;
+      repos = _.times(25, repoGenerator);
+      reposFiltered = _.times(1, repoGenerator);
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('list with repos', () => {
-      setup(done => {
-        repos = _.times(26, repoGenerator);
-        stub('gr-rest-api-interface', {
-          getRepos(num, offset) {
-            return Promise.resolve(repos);
-          },
-        });
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('test for test repo in the list', done => {
-        flush(() => {
-          assert.equal(element._repos[1].id, 'test2');
-          done();
-        });
-      });
-
-      test('_shownRepos', () => {
-        assert.equal(element._shownRepos.length, 25);
-      });
-
-      test('_maybeOpenCreateOverlay', () => {
-        const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
-        element._maybeOpenCreateOverlay();
-        assert.isFalse(overlayOpen.called);
-        const params = {};
-        element._maybeOpenCreateOverlay(params);
-        assert.isFalse(overlayOpen.called);
-        params.openCreateModal = true;
-        element._maybeOpenCreateOverlay(params);
-        assert.isTrue(overlayOpen.called);
+    test('_paramsChanged', done => {
+      sandbox.stub(element.$.restAPI, 'getRepos', () => Promise.resolve(repos));
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value).then(() => {
+        assert.isTrue(element.$.restAPI.getRepos.lastCall
+            .calledWithExactly('test', 25, 25));
+        done();
       });
     });
 
-    suite('list with less then 25 repos', () => {
-      setup(done => {
-        repos = _.times(25, repoGenerator);
+    test('latest repos requested are always set', done => {
+      const repoStub = sandbox.stub(element.$.restAPI, 'getRepos');
+      repoStub.withArgs('test').returns(Promise.resolve(repos));
+      repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
+      element._filter = 'test';
 
-        stub('gr-rest-api-interface', {
-          getRepos(num, offset) {
-            return Promise.resolve(repos);
-          },
-        });
-
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('_shownRepos', () => {
-        assert.equal(element._shownRepos.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      let reposFiltered;
-      setup(() => {
-        repos = _.times(25, repoGenerator);
-        reposFiltered = _.times(1, repoGenerator);
-      });
-
-      test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getRepos', () => Promise.resolve(repos));
-        const value = {
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(value).then(() => {
-          assert.isTrue(element.$.restAPI.getRepos.lastCall
-              .calledWithExactly('test', 25, 25));
-          done();
-        });
-      });
-
-      test('latest repos requested are always set', done => {
-        const repoStub = sandbox.stub(element.$.restAPI, 'getRepos');
-        repoStub.withArgs('test').returns(Promise.resolve(repos));
-        repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
-        element._filter = 'test';
-
-        // Repos are not set because the element._filter differs.
-        element._getRepos('filter', 25, 0).then(() => {
-          assert.deepEqual(element._repos, []);
-          done();
-        });
-      });
-    });
-
-    suite('loading', () => {
-      test('correct contents are displayed', () => {
-        assert.isTrue(element._loading);
-        assert.equal(element.computeLoadingClass(element._loading), 'loading');
-        assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-        element._loading = false;
-        element._repos = _.times(25, repoGenerator);
-
-        flushAsynchronousOperations();
-        assert.equal(element.computeLoadingClass(element._loading), '');
-        assert.equal(getComputedStyle(element.$.loading).display, 'none');
-      });
-    });
-
-    suite('create new', () => {
-      test('_handleCreateClicked called when create-click fired', () => {
-        sandbox.stub(element, '_handleCreateClicked');
-        element.shadowRoot
-            .querySelector('gr-list-view').fire('create-clicked');
-        assert.isTrue(element._handleCreateClicked.called);
-      });
-
-      test('_handleCreateClicked opens modal', () => {
-        const openStub = sandbox.stub(element.$.createOverlay, 'open');
-        element._handleCreateClicked();
-        assert.isTrue(openStub.called);
-      });
-
-      test('_handleCreateRepo called when confirm fired', () => {
-        sandbox.stub(element, '_handleCreateRepo');
-        element.$.createDialog.fire('confirm');
-        assert.isTrue(element._handleCreateRepo.called);
-      });
-
-      test('_handleCloseCreate called when cancel fired', () => {
-        sandbox.stub(element, '_handleCloseCreate');
-        element.$.createDialog.fire('cancel');
-        assert.isTrue(element._handleCloseCreate.called);
+      // Repos are not set because the element._filter differs.
+      element._getRepos('filter', 25, 0).then(() => {
+        assert.deepEqual(element._repos, []);
+        done();
       });
     });
   });
+
+  suite('loading', () => {
+    test('correct contents are displayed', () => {
+      assert.isTrue(element._loading);
+      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+      element._loading = false;
+      element._repos = _.times(25, repoGenerator);
+
+      flushAsynchronousOperations();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+
+  suite('create new', () => {
+    test('_handleCreateClicked called when create-click fired', () => {
+      sandbox.stub(element, '_handleCreateClicked');
+      element.shadowRoot
+          .querySelector('gr-list-view').fire('create-clicked');
+      assert.isTrue(element._handleCreateClicked.called);
+    });
+
+    test('_handleCreateClicked opens modal', () => {
+      const openStub = sandbox.stub(element.$.createOverlay, 'open');
+      element._handleCreateClicked();
+      assert.isTrue(openStub.called);
+    });
+
+    test('_handleCreateRepo called when confirm fired', () => {
+      sandbox.stub(element, '_handleCreateRepo');
+      element.$.createDialog.fire('confirm');
+      assert.isTrue(element._handleCreateRepo.called);
+    });
+
+    test('_handleCloseCreate called when cancel fired', () => {
+      sandbox.stub(element, '_handleCloseCreate');
+      element.$.createDialog.fire('cancel');
+      assert.isTrue(element._handleCloseCreate.called);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
index 7368eb8..826bf93 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
@@ -14,128 +14,145 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
+import '@polymer/iron-input/iron-input.js';
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../../behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-subpage-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-select/gr-select.js';
+import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
+import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-plugin-config_html.js';
+
+/**
+ * @appliesMixin Gerrit.RepoPluginConfigMixin
+ * @extends Polymer.Element
+ */
+class GrRepoPluginConfig extends mixinBehaviors( [
+  Gerrit.RepoPluginConfig,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo-plugin-config'; }
   /**
-   * @appliesMixin Gerrit.RepoPluginConfigMixin
-   * @extends Polymer.Element
+   * Fired when the plugin config changes.
+   *
+   * @event plugin-config-changed
    */
-  class GrRepoPluginConfig extends Polymer.mixinBehaviors( [
-    Gerrit.RepoPluginConfig,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-repo-plugin-config'; }
-    /**
-     * Fired when the plugin config changes.
-     *
-     * @event plugin-config-changed
-     */
 
-    static get properties() {
-      return {
-      /** @type {?} */
-        pluginData: Object,
-        /** @type {Array} */
-        _pluginConfigOptions: {
-          type: Array,
-          computed: '_computePluginConfigOptions(pluginData.*)',
-        },
-      };
-    }
-
-    _computePluginConfigOptions(dataRecord) {
-      if (!dataRecord || !dataRecord.base || !dataRecord.base.config) {
-        return [];
-      }
-      const {config} = dataRecord.base;
-      return Object.keys(config)
-          .map(_key => { return {_key, info: config[_key]}; });
-    }
-
-    _isArray(type) {
-      return type === this.ENTRY_TYPES.ARRAY;
-    }
-
-    _isBoolean(type) {
-      return type === this.ENTRY_TYPES.BOOLEAN;
-    }
-
-    _isList(type) {
-      return type === this.ENTRY_TYPES.LIST;
-    }
-
-    _isString(type) {
-      // Treat numbers like strings for simplicity.
-      return type === this.ENTRY_TYPES.STRING ||
-          type === this.ENTRY_TYPES.INT ||
-          type === this.ENTRY_TYPES.LONG;
-    }
-
-    _computeDisabled(editable) {
-      return editable === 'false';
-    }
-
-    /**
-     * @param {string} value - fallback to 'false' if undefined
-     */
-    _computeChecked(value = 'false') {
-      return JSON.parse(value);
-    }
-
-    _handleStringChange(e) {
-      const el = Polymer.dom(e).localTarget;
-      const _key = el.getAttribute('data-option-key');
-      const configChangeInfo =
-          this._buildConfigChangeInfo(el.value, _key);
-      this._handleChange(configChangeInfo);
-    }
-
-    _handleListChange(e) {
-      const el = Polymer.dom(e).localTarget;
-      const _key = el.getAttribute('data-option-key');
-      const configChangeInfo =
-          this._buildConfigChangeInfo(el.value, _key);
-      this._handleChange(configChangeInfo);
-    }
-
-    _handleBooleanChange(e) {
-      const el = Polymer.dom(e).localTarget;
-      const _key = el.getAttribute('data-option-key');
-      const configChangeInfo =
-          this._buildConfigChangeInfo(JSON.stringify(el.checked), _key);
-      this._handleChange(configChangeInfo);
-    }
-
-    _buildConfigChangeInfo(value, _key) {
-      const info = this.pluginData.config[_key];
-      info.value = value;
-      return {
-        _key,
-        info,
-        notifyPath: `${_key}.value`,
-      };
-    }
-
-    _handleArrayChange({detail}) {
-      this._handleChange(detail);
-    }
-
-    _handleChange({_key, info, notifyPath}) {
-      const {name, config} = this.pluginData;
-
-      /** @type {Object} */
-      const detail = {
-        name,
-        config: Object.assign(config, {[_key]: info}, {}),
-        notifyPath: `${name}.${notifyPath}`,
-      };
-
-      this.dispatchEvent(new CustomEvent(
-          this.PLUGIN_CONFIG_CHANGED, {detail, bubbles: true, composed: true}));
-    }
+  static get properties() {
+    return {
+    /** @type {?} */
+      pluginData: Object,
+      /** @type {Array} */
+      _pluginConfigOptions: {
+        type: Array,
+        computed: '_computePluginConfigOptions(pluginData.*)',
+      },
+    };
   }
 
-  customElements.define(GrRepoPluginConfig.is, GrRepoPluginConfig);
-})();
+  _computePluginConfigOptions(dataRecord) {
+    if (!dataRecord || !dataRecord.base || !dataRecord.base.config) {
+      return [];
+    }
+    const {config} = dataRecord.base;
+    return Object.keys(config)
+        .map(_key => { return {_key, info: config[_key]}; });
+  }
+
+  _isArray(type) {
+    return type === this.ENTRY_TYPES.ARRAY;
+  }
+
+  _isBoolean(type) {
+    return type === this.ENTRY_TYPES.BOOLEAN;
+  }
+
+  _isList(type) {
+    return type === this.ENTRY_TYPES.LIST;
+  }
+
+  _isString(type) {
+    // Treat numbers like strings for simplicity.
+    return type === this.ENTRY_TYPES.STRING ||
+        type === this.ENTRY_TYPES.INT ||
+        type === this.ENTRY_TYPES.LONG;
+  }
+
+  _computeDisabled(editable) {
+    return editable === 'false';
+  }
+
+  /**
+   * @param {string} value - fallback to 'false' if undefined
+   */
+  _computeChecked(value = 'false') {
+    return JSON.parse(value);
+  }
+
+  _handleStringChange(e) {
+    const el = dom(e).localTarget;
+    const _key = el.getAttribute('data-option-key');
+    const configChangeInfo =
+        this._buildConfigChangeInfo(el.value, _key);
+    this._handleChange(configChangeInfo);
+  }
+
+  _handleListChange(e) {
+    const el = dom(e).localTarget;
+    const _key = el.getAttribute('data-option-key');
+    const configChangeInfo =
+        this._buildConfigChangeInfo(el.value, _key);
+    this._handleChange(configChangeInfo);
+  }
+
+  _handleBooleanChange(e) {
+    const el = dom(e).localTarget;
+    const _key = el.getAttribute('data-option-key');
+    const configChangeInfo =
+        this._buildConfigChangeInfo(JSON.stringify(el.checked), _key);
+    this._handleChange(configChangeInfo);
+  }
+
+  _buildConfigChangeInfo(value, _key) {
+    const info = this.pluginData.config[_key];
+    info.value = value;
+    return {
+      _key,
+      info,
+      notifyPath: `${_key}.value`,
+    };
+  }
+
+  _handleArrayChange({detail}) {
+    this._handleChange(detail);
+  }
+
+  _handleChange({_key, info, notifyPath}) {
+    const {name, config} = this.pluginData;
+
+    /** @type {Object} */
+    const detail = {
+      name,
+      config: Object.assign(config, {[_key]: info}, {}),
+      notifyPath: `${name}.${notifyPath}`,
+    };
+
+    this.dispatchEvent(new CustomEvent(
+        this.PLUGIN_CONFIG_CHANGED, {detail, bubbles: true, composed: true}));
+  }
+}
+
+customElements.define(GrRepoPluginConfig.is, GrRepoPluginConfig);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js
index ef5b755..fa4617d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js
@@ -1,35 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
-
-<link rel="import" href="../../../behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-subpage-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../gr-plugin-config-array-editor/gr-plugin-config-array-editor.html">
-
-<dom-module id="gr-repo-plugin-config">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -53,55 +40,31 @@
       <fieldset>
         <h4>[[pluginData.name]]</h4>
         <template is="dom-repeat" items="[[_pluginConfigOptions]]" as="option">
-          <section class$="section [[option.info.type]]">
+          <section class\$="section [[option.info.type]]">
             <span class="title">
-              <gr-tooltip-content
-                  has-tooltip="[[option.info.description]]"
-                  show-icon="[[option.info.description]]"
-                  title="[[option.info.description]]">
+              <gr-tooltip-content has-tooltip="[[option.info.description]]" show-icon="[[option.info.description]]" title="[[option.info.description]]">
                 <span>[[option.info.display_name]]</span>
               </gr-tooltip-content>
             </span>
             <span class="value">
               <template is="dom-if" if="[[_isArray(option.info.type)]]">
-                <gr-plugin-config-array-editor
-                    on-plugin-config-option-changed="_handleArrayChange"
-                    plugin-option="[[option]]"></gr-plugin-config-array-editor>
+                <gr-plugin-config-array-editor on-plugin-config-option-changed="_handleArrayChange" plugin-option="[[option]]"></gr-plugin-config-array-editor>
               </template>
               <template is="dom-if" if="[[_isBoolean(option.info.type)]]">
-                <paper-toggle-button
-                    checked="[[_computeChecked(option.info.value)]]"
-                    on-change="_handleBooleanChange"
-                    data-option-key$="[[option._key]]"
-                    disabled$="[[_computeDisabled(option.info.editable)]]"></paper-toggle-button>
+                <paper-toggle-button checked="[[_computeChecked(option.info.value)]]" on-change="_handleBooleanChange" data-option-key\$="[[option._key]]" disabled\$="[[_computeDisabled(option.info.editable)]]"></paper-toggle-button>
               </template>
               <template is="dom-if" if="[[_isList(option.info.type)]]">
-                <gr-select
-                    bind-value$="[[option.info.value]]"
-                    on-change="_handleListChange">
-                  <select
-                      data-option-key$="[[option._key]]"
-                      disabled$="[[_computeDisabled(option.info.editable)]]">
-                    <template is="dom-repeat"
-                        items="[[option.info.permitted_values]]"
-                        as="value">
-                      <option value$="[[value]]">[[value]]</option>
+                <gr-select bind-value\$="[[option.info.value]]" on-change="_handleListChange">
+                  <select data-option-key\$="[[option._key]]" disabled\$="[[_computeDisabled(option.info.editable)]]">
+                    <template is="dom-repeat" items="[[option.info.permitted_values]]" as="value">
+                      <option value\$="[[value]]">[[value]]</option>
                     </template>
                   </select>
                 </gr-select>
               </template>
               <template is="dom-if" if="[[_isString(option.info.type)]]">
-                <iron-input
-                    bind-value="[[option.info.value]]"
-                    on-input="_handleStringChange"
-                    data-option-key$="[[option._key]]"
-                    disabled$="[[_computeDisabled(option.info.editable)]]">
-                  <input
-                      is="iron-input"
-                      value="[[option.info.value]]"
-                      on-input="_handleStringChange"
-                      data-option-key$="[[option._key]]"
-                      disabled$="[[_computeDisabled(option.info.editable)]]">
+                <iron-input bind-value="[[option.info.value]]" on-input="_handleStringChange" data-option-key\$="[[option._key]]" disabled\$="[[_computeDisabled(option.info.editable)]]">
+                  <input is="iron-input" value="[[option.info.value]]" on-input="_handleStringChange" data-option-key\$="[[option._key]]" disabled\$="[[_computeDisabled(option.info.editable)]]">
                 </iron-input>
               </template>
               <template is="dom-if" if="[[option.info.inherited_value]]">
@@ -114,6 +77,4 @@
         </template>
       </fieldset>
     </div>
-  </template>
-  <script src="gr-repo-plugin-config.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
index 8313edf..f70d3ea 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-plugin-config</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-plugin-config.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-repo-plugin-config.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-plugin-config.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,150 +40,152 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-plugin-config tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-plugin-config.js';
+suite('gr-repo-plugin-config tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => sandbox.restore());
+
+  test('_computePluginConfigOptions', () => {
+    assert.deepEqual(element._computePluginConfigOptions(), []);
+    assert.deepEqual(element._computePluginConfigOptions({}), []);
+    assert.deepEqual(element._computePluginConfigOptions({base: {}}), []);
+    assert.deepEqual(element._computePluginConfigOptions(
+        {base: {config: {}}}), []);
+    assert.deepEqual(element._computePluginConfigOptions(
+        {base: {config: {testKey: 'testInfo'}}}),
+    [{_key: 'testKey', info: 'testInfo'}]);
+  });
+
+  test('_computeDisabled', () => {
+    assert.isFalse(element._computeDisabled('true'));
+    assert.isTrue(element._computeDisabled('false'));
+  });
+
+  test('_handleChange', () => {
+    const eventStub = sandbox.stub(element, 'dispatchEvent');
+    element.pluginData = {
+      name: 'testName',
+      config: {plugin: {value: 'test'}},
+    };
+    element._handleChange({
+      _key: 'plugin',
+      info: {value: 'newTest'},
+      notifyPath: 'plugin.value',
+    });
+
+    assert.isTrue(eventStub.called);
+
+    const {detail} = eventStub.lastCall.args[0];
+    assert.equal(detail.name, 'testName');
+    assert.deepEqual(detail.config, {plugin: {value: 'newTest'}});
+    assert.equal(detail.notifyPath, 'testName.plugin.value');
+  });
+
+  suite('option types', () => {
+    let changeStub;
+    let buildStub;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+      changeStub = sandbox.stub(element, '_handleChange');
+      buildStub = sandbox.stub(element, '_buildConfigChangeInfo');
     });
 
-    teardown(() => sandbox.restore());
-
-    test('_computePluginConfigOptions', () => {
-      assert.deepEqual(element._computePluginConfigOptions(), []);
-      assert.deepEqual(element._computePluginConfigOptions({}), []);
-      assert.deepEqual(element._computePluginConfigOptions({base: {}}), []);
-      assert.deepEqual(element._computePluginConfigOptions(
-          {base: {config: {}}}), []);
-      assert.deepEqual(element._computePluginConfigOptions(
-          {base: {config: {testKey: 'testInfo'}}}),
-      [{_key: 'testKey', info: 'testInfo'}]);
-    });
-
-    test('_computeDisabled', () => {
-      assert.isFalse(element._computeDisabled('true'));
-      assert.isTrue(element._computeDisabled('false'));
-    });
-
-    test('_handleChange', () => {
-      const eventStub = sandbox.stub(element, 'dispatchEvent');
+    test('ARRAY type option', () => {
       element.pluginData = {
         name: 'testName',
-        config: {plugin: {value: 'test'}},
+        config: {plugin: {value: 'test', type: 'ARRAY'}},
       };
-      element._handleChange({
-        _key: 'plugin',
-        info: {value: 'newTest'},
-        notifyPath: 'plugin.value',
-      });
+      flushAsynchronousOperations();
 
-      assert.isTrue(eventStub.called);
-
-      const {detail} = eventStub.lastCall.args[0];
-      assert.equal(detail.name, 'testName');
-      assert.deepEqual(detail.config, {plugin: {value: 'newTest'}});
-      assert.equal(detail.notifyPath, 'testName.plugin.value');
+      const editor = element.shadowRoot
+          .querySelector('gr-plugin-config-array-editor');
+      assert.ok(editor);
+      element._handleArrayChange({detail: 'test'});
+      assert.isTrue(changeStub.called);
+      assert.equal(changeStub.lastCall.args[0], 'test');
     });
 
-    suite('option types', () => {
-      let changeStub;
-      let buildStub;
-
-      setup(() => {
-        changeStub = sandbox.stub(element, '_handleChange');
-        buildStub = sandbox.stub(element, '_buildConfigChangeInfo');
-      });
-
-      test('ARRAY type option', () => {
-        element.pluginData = {
-          name: 'testName',
-          config: {plugin: {value: 'test', type: 'ARRAY'}},
-        };
-        flushAsynchronousOperations();
-
-        const editor = element.shadowRoot
-            .querySelector('gr-plugin-config-array-editor');
-        assert.ok(editor);
-        element._handleArrayChange({detail: 'test'});
-        assert.isTrue(changeStub.called);
-        assert.equal(changeStub.lastCall.args[0], 'test');
-      });
-
-      test('BOOLEAN type option', () => {
-        element.pluginData = {
-          name: 'testName',
-          config: {plugin: {value: 'true', type: 'BOOLEAN'}},
-        };
-        flushAsynchronousOperations();
-
-        const toggle = element.shadowRoot
-            .querySelector('paper-toggle-button');
-        assert.ok(toggle);
-        toggle.click();
-        flushAsynchronousOperations();
-
-        assert.isTrue(buildStub.called);
-        assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
-
-        assert.isTrue(changeStub.called);
-      });
-
-      test('INT/LONG/STRING type option', () => {
-        element.pluginData = {
-          name: 'testName',
-          config: {plugin: {value: 'test', type: 'STRING'}},
-        };
-        flushAsynchronousOperations();
-
-        const input = element.shadowRoot
-            .querySelector('input');
-        assert.ok(input);
-        input.value = 'newTest';
-        input.dispatchEvent(new Event('input'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(buildStub.called);
-        assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
-
-        assert.isTrue(changeStub.called);
-      });
-
-      test('LIST type option', () => {
-        const permitted_values = ['test', 'newTest'];
-        element.pluginData = {
-          name: 'testName',
-          config: {plugin: {value: 'test', type: 'LIST', permitted_values}},
-        };
-        flushAsynchronousOperations();
-
-        const select = element.shadowRoot
-            .querySelector('select');
-        assert.ok(select);
-        select.value = 'newTest';
-        select.dispatchEvent(new Event(
-            'change', {bubbles: true, composed: true}));
-        flushAsynchronousOperations();
-
-        assert.isTrue(buildStub.called);
-        assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
-
-        assert.isTrue(changeStub.called);
-      });
-    });
-
-    test('_buildConfigChangeInfo', () => {
+    test('BOOLEAN type option', () => {
       element.pluginData = {
         name: 'testName',
-        config: {plugin: {value: 'test'}},
+        config: {plugin: {value: 'true', type: 'BOOLEAN'}},
       };
-      const detail = element._buildConfigChangeInfo('newTest', 'plugin');
-      assert.equal(detail._key, 'plugin');
-      assert.deepEqual(detail.info, {value: 'newTest'});
-      assert.equal(detail.notifyPath, 'plugin.value');
+      flushAsynchronousOperations();
+
+      const toggle = element.shadowRoot
+          .querySelector('paper-toggle-button');
+      assert.ok(toggle);
+      toggle.click();
+      flushAsynchronousOperations();
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+
+    test('INT/LONG/STRING type option', () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'test', type: 'STRING'}},
+      };
+      flushAsynchronousOperations();
+
+      const input = element.shadowRoot
+          .querySelector('input');
+      assert.ok(input);
+      input.value = 'newTest';
+      input.dispatchEvent(new Event('input'));
+      flushAsynchronousOperations();
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+
+    test('LIST type option', () => {
+      const permitted_values = ['test', 'newTest'];
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'test', type: 'LIST', permitted_values}},
+      };
+      flushAsynchronousOperations();
+
+      const select = element.shadowRoot
+          .querySelector('select');
+      assert.ok(select);
+      select.value = 'newTest';
+      select.dispatchEvent(new Event(
+          'change', {bubbles: true, composed: true}));
+      flushAsynchronousOperations();
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+      assert.isTrue(changeStub.called);
     });
   });
+
+  test('_buildConfigChangeInfo', () => {
+    element.pluginData = {
+      name: 'testName',
+      config: {plugin: {value: 'test'}},
+    };
+    const detail = element._buildConfigChangeInfo('newTest', 'plugin');
+    assert.equal(detail._key, 'plugin');
+    assert.deepEqual(detail.info, {value: 'newTest'});
+    assert.equal(detail.notifyPath, 'plugin.value');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
index f6328de..fd85f1a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
@@ -14,348 +14,366 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const STATES = {
-    active: {value: 'ACTIVE', label: 'Active'},
-    readOnly: {value: 'READ_ONLY', label: 'Read Only'},
-    hidden: {value: 'HIDDEN', label: 'Hidden'},
-  };
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../shared/gr-download-commands/gr-download-commands.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-subpage-styles.js';
+import '../../../styles/shared-styles.js';
+import '../gr-repo-plugin-config/gr-repo-plugin-config.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo_html.js';
 
-  const SUBMIT_TYPES = {
-    // Exclude INHERIT, which is handled specially.
-    mergeIfNecessary: {
-      value: 'MERGE_IF_NECESSARY',
-      label: 'Merge if necessary',
-    },
-    fastForwardOnly: {
-      value: 'FAST_FORWARD_ONLY',
-      label: 'Fast forward only',
-    },
-    rebaseAlways: {
-      value: 'REBASE_ALWAYS',
-      label: 'Rebase Always',
-    },
-    rebaseIfNecessary: {
-      value: 'REBASE_IF_NECESSARY',
-      label: 'Rebase if necessary',
-    },
-    mergeAlways: {
-      value: 'MERGE_ALWAYS',
-      label: 'Merge always',
-    },
-    cherryPick: {
-      value: 'CHERRY_PICK',
-      label: 'Cherry pick',
-    },
-  };
+const STATES = {
+  active: {value: 'ACTIVE', label: 'Active'},
+  readOnly: {value: 'READ_ONLY', label: 'Read Only'},
+  hidden: {value: 'HIDDEN', label: 'Hidden'},
+};
 
-  /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
-   */
-  class GrRepo extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-repo'; }
+const SUBMIT_TYPES = {
+  // Exclude INHERIT, which is handled specially.
+  mergeIfNecessary: {
+    value: 'MERGE_IF_NECESSARY',
+    label: 'Merge if necessary',
+  },
+  fastForwardOnly: {
+    value: 'FAST_FORWARD_ONLY',
+    label: 'Fast forward only',
+  },
+  rebaseAlways: {
+    value: 'REBASE_ALWAYS',
+    label: 'Rebase Always',
+  },
+  rebaseIfNecessary: {
+    value: 'REBASE_IF_NECESSARY',
+    label: 'Rebase if necessary',
+  },
+  mergeAlways: {
+    value: 'MERGE_ALWAYS',
+    label: 'Merge always',
+  },
+  cherryPick: {
+    value: 'CHERRY_PICK',
+    label: 'Cherry pick',
+  },
+};
 
-    static get properties() {
-      return {
-        params: Object,
-        repo: String,
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrRepo extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-        _configChanged: {
-          type: Boolean,
-          value: false,
+  static get is() { return 'gr-repo'; }
+
+  static get properties() {
+    return {
+      params: Object,
+      repo: String,
+
+      _configChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+        observer: '_loggedInChanged',
+      },
+      /** @type {?} */
+      _repoConfig: Object,
+      /** @type {?} */
+      _pluginData: {
+        type: Array,
+        computed: '_computePluginData(_repoConfig.plugin_config.*)',
+      },
+      _readOnly: {
+        type: Boolean,
+        value: true,
+      },
+      _states: {
+        type: Array,
+        value() {
+          return Object.values(STATES);
         },
-        _loading: {
-          type: Boolean,
-          value: true,
+      },
+      _submitTypes: {
+        type: Array,
+        value() {
+          return Object.values(SUBMIT_TYPES);
         },
-        _loggedIn: {
-          type: Boolean,
-          value: false,
-          observer: '_loggedInChanged',
-        },
-        /** @type {?} */
-        _repoConfig: Object,
-        /** @type {?} */
-        _pluginData: {
-          type: Array,
-          computed: '_computePluginData(_repoConfig.plugin_config.*)',
-        },
-        _readOnly: {
-          type: Boolean,
-          value: true,
-        },
-        _states: {
-          type: Array,
-          value() {
-            return Object.values(STATES);
-          },
-        },
-        _submitTypes: {
-          type: Array,
-          value() {
-            return Object.values(SUBMIT_TYPES);
-          },
-        },
-        _schemes: {
-          type: Array,
-          value() { return []; },
-          computed: '_computeSchemes(_schemesObj)',
-          observer: '_schemesChanged',
-        },
-        _selectedCommand: {
-          type: String,
-          value: 'Clone',
-        },
-        _selectedScheme: String,
-        _schemesObj: Object,
-      };
-    }
+      },
+      _schemes: {
+        type: Array,
+        value() { return []; },
+        computed: '_computeSchemes(_schemesObj)',
+        observer: '_schemesChanged',
+      },
+      _selectedCommand: {
+        type: String,
+        value: 'Clone',
+      },
+      _selectedScheme: String,
+      _schemesObj: Object,
+    };
+  }
 
-    static get observers() {
-      return [
-        '_handleConfigChanged(_repoConfig.*)',
-      ];
-    }
+  static get observers() {
+    return [
+      '_handleConfigChanged(_repoConfig.*)',
+    ];
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this._loadRepo();
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadRepo();
 
-      this.fire('title-change', {title: this.repo});
-    }
+    this.fire('title-change', {title: this.repo});
+  }
 
-    _computePluginData(configRecord) {
-      if (!configRecord ||
-          !configRecord.base) { return []; }
+  _computePluginData(configRecord) {
+    if (!configRecord ||
+        !configRecord.base) { return []; }
 
-      const pluginConfig = configRecord.base;
-      return Object.keys(pluginConfig)
-          .map(name => { return {name, config: pluginConfig[name]}; });
-    }
+    const pluginConfig = configRecord.base;
+    return Object.keys(pluginConfig)
+        .map(name => { return {name, config: pluginConfig[name]}; });
+  }
 
-    _loadRepo() {
-      if (!this.repo) { return Promise.resolve(); }
+  _loadRepo() {
+    if (!this.repo) { return Promise.resolve(); }
 
-      const promises = [];
+    const promises = [];
 
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
+    const errFn = response => {
+      this.fire('page-error', {response});
+    };
 
-      promises.push(this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-        if (loggedIn) {
-          this.$.restAPI.getRepoAccess(this.repo).then(access => {
-            if (!access) { return Promise.resolve(); }
+    promises.push(this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this.$.restAPI.getRepoAccess(this.repo).then(access => {
+          if (!access) { return Promise.resolve(); }
 
-            // If the user is not an owner, is_owner is not a property.
-            this._readOnly = !access[this.repo].is_owner;
-          });
-        }
-      }));
-
-      promises.push(this.$.restAPI.getProjectConfig(this.repo, errFn)
-          .then(config => {
-            if (!config) { return Promise.resolve(); }
-
-            if (config.default_submit_type) {
-              // The gr-select is bound to submit_type, which needs to be the
-              // *configured* submit type. When default_submit_type is
-              // present, the server reports the *effective* submit type in
-              // submit_type, so we need to overwrite it before storing the
-              // config in this.
-              config.submit_type =
-                  config.default_submit_type.configured_value;
-            }
-            if (!config.state) {
-              config.state = STATES.active.value;
-            }
-            this._repoConfig = config;
-            this._loading = false;
-          }));
-
-      promises.push(this.$.restAPI.getConfig().then(config => {
-        if (!config) { return Promise.resolve(); }
-
-        this._schemesObj = config.download.schemes;
-      }));
-
-      return Promise.all(promises);
-    }
-
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    }
-
-    _computeHideClass(arr) {
-      return !arr || !arr.length ? 'hide' : '';
-    }
-
-    _loggedInChanged(_loggedIn) {
-      if (!_loggedIn) { return; }
-      this.$.restAPI.getPreferences().then(prefs => {
-        if (prefs.download_scheme) {
-          // Note (issue 5180): normalize the download scheme with lower-case.
-          this._selectedScheme = prefs.download_scheme.toLowerCase();
-        }
-      });
-    }
-
-    _formatBooleanSelect(item) {
-      if (!item) { return; }
-      let inheritLabel = 'Inherit';
-      if (!(item.inherited_value === undefined)) {
-        inheritLabel = `Inherit (${item.inherited_value})`;
-      }
-      return [
-        {
-          label: inheritLabel,
-          value: 'INHERIT',
-        },
-        {
-          label: 'True',
-          value: 'TRUE',
-        }, {
-          label: 'False',
-          value: 'FALSE',
-        },
-      ];
-    }
-
-    _formatSubmitTypeSelect(projectConfig) {
-      if (!projectConfig) { return; }
-      const allValues = Object.values(SUBMIT_TYPES);
-      const type = projectConfig.default_submit_type;
-      if (!type) {
-        // Server is too old to report default_submit_type, so assume INHERIT
-        // is not a valid value.
-        return allValues;
-      }
-
-      let inheritLabel = 'Inherit';
-      if (type.inherited_value) {
-        let inherited = type.inherited_value;
-        for (const val of allValues) {
-          if (val.value === type.inherited_value) {
-            inherited = val.label;
-            break;
-          }
-        }
-        inheritLabel = `Inherit (${inherited})`;
-      }
-      return [
-        {
-          label: inheritLabel,
-          value: 'INHERIT',
-        },
-        ...allValues,
-      ];
-    }
-
-    _isLoading() {
-      return this._loading || this._loading === undefined;
-    }
-
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    }
-
-    _formatRepoConfigForSave(repoConfig) {
-      const configInputObj = {};
-      for (const key in repoConfig) {
-        if (repoConfig.hasOwnProperty(key)) {
-          if (key === 'default_submit_type') {
-            // default_submit_type is not in the input type, and the
-            // configured value was already copied to submit_type by
-            // _loadProject. Omit this property when saving.
-            continue;
-          }
-          if (key === 'plugin_config') {
-            configInputObj.plugin_config_values = repoConfig[key];
-          } else if (typeof repoConfig[key] === 'object') {
-            configInputObj[key] = repoConfig[key].configured_value;
-          } else {
-            configInputObj[key] = repoConfig[key];
-          }
-        }
-      }
-      return configInputObj;
-    }
-
-    _handleSaveRepoConfig() {
-      return this.$.restAPI.saveRepoConfig(this.repo,
-          this._formatRepoConfigForSave(this._repoConfig)).then(() => {
-        this._configChanged = false;
-      });
-    }
-
-    _handleConfigChanged() {
-      if (this._isLoading()) { return; }
-      this._configChanged = true;
-    }
-
-    _computeButtonDisabled(readOnly, configChanged) {
-      return readOnly || !configChanged;
-    }
-
-    _computeHeaderClass(configChanged) {
-      return configChanged ? 'edited' : '';
-    }
-
-    _computeSchemes(schemesObj) {
-      return Object.keys(schemesObj);
-    }
-
-    _schemesChanged(schemes) {
-      if (schemes.length === 0) { return; }
-      if (!schemes.includes(this._selectedScheme)) {
-        this._selectedScheme = schemes.sort()[0];
-      }
-    }
-
-    _computeCommands(repo, schemesObj, _selectedScheme) {
-      if (!schemesObj || !repo || !_selectedScheme) {
-        return [];
-      }
-      const commands = [];
-      let commandObj;
-      if (schemesObj.hasOwnProperty(_selectedScheme)) {
-        commandObj = schemesObj[_selectedScheme].clone_commands;
-      }
-      for (const title in commandObj) {
-        if (!commandObj.hasOwnProperty(title)) { continue; }
-        commands.push({
-          title,
-          command: commandObj[title]
-              .replace(/\$\{project\}/gi, encodeURI(repo))
-              .replace(/\$\{project-base-name\}/gi,
-                  encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
+          // If the user is not an owner, is_owner is not a property.
+          this._readOnly = !access[this.repo].is_owner;
         });
       }
-      return commands;
+    }));
+
+    promises.push(this.$.restAPI.getProjectConfig(this.repo, errFn)
+        .then(config => {
+          if (!config) { return Promise.resolve(); }
+
+          if (config.default_submit_type) {
+            // The gr-select is bound to submit_type, which needs to be the
+            // *configured* submit type. When default_submit_type is
+            // present, the server reports the *effective* submit type in
+            // submit_type, so we need to overwrite it before storing the
+            // config in this.
+            config.submit_type =
+                config.default_submit_type.configured_value;
+          }
+          if (!config.state) {
+            config.state = STATES.active.value;
+          }
+          this._repoConfig = config;
+          this._loading = false;
+        }));
+
+    promises.push(this.$.restAPI.getConfig().then(config => {
+      if (!config) { return Promise.resolve(); }
+
+      this._schemesObj = config.download.schemes;
+    }));
+
+    return Promise.all(promises);
+  }
+
+  _computeLoadingClass(loading) {
+    return loading ? 'loading' : '';
+  }
+
+  _computeHideClass(arr) {
+    return !arr || !arr.length ? 'hide' : '';
+  }
+
+  _loggedInChanged(_loggedIn) {
+    if (!_loggedIn) { return; }
+    this.$.restAPI.getPreferences().then(prefs => {
+      if (prefs.download_scheme) {
+        // Note (issue 5180): normalize the download scheme with lower-case.
+        this._selectedScheme = prefs.download_scheme.toLowerCase();
+      }
+    });
+  }
+
+  _formatBooleanSelect(item) {
+    if (!item) { return; }
+    let inheritLabel = 'Inherit';
+    if (!(item.inherited_value === undefined)) {
+      inheritLabel = `Inherit (${item.inherited_value})`;
+    }
+    return [
+      {
+        label: inheritLabel,
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      }, {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ];
+  }
+
+  _formatSubmitTypeSelect(projectConfig) {
+    if (!projectConfig) { return; }
+    const allValues = Object.values(SUBMIT_TYPES);
+    const type = projectConfig.default_submit_type;
+    if (!type) {
+      // Server is too old to report default_submit_type, so assume INHERIT
+      // is not a valid value.
+      return allValues;
     }
 
-    _computeRepositoriesClass(config) {
-      return config ? 'showConfig': '';
+    let inheritLabel = 'Inherit';
+    if (type.inherited_value) {
+      let inherited = type.inherited_value;
+      for (const val of allValues) {
+        if (val.value === type.inherited_value) {
+          inherited = val.label;
+          break;
+        }
+      }
+      inheritLabel = `Inherit (${inherited})`;
     }
+    return [
+      {
+        label: inheritLabel,
+        value: 'INHERIT',
+      },
+      ...allValues,
+    ];
+  }
 
-    _computeChangesUrl(name) {
-      return Gerrit.Nav.getUrlForProjectChanges(name);
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _formatRepoConfigForSave(repoConfig) {
+    const configInputObj = {};
+    for (const key in repoConfig) {
+      if (repoConfig.hasOwnProperty(key)) {
+        if (key === 'default_submit_type') {
+          // default_submit_type is not in the input type, and the
+          // configured value was already copied to submit_type by
+          // _loadProject. Omit this property when saving.
+          continue;
+        }
+        if (key === 'plugin_config') {
+          configInputObj.plugin_config_values = repoConfig[key];
+        } else if (typeof repoConfig[key] === 'object') {
+          configInputObj[key] = repoConfig[key].configured_value;
+        } else {
+          configInputObj[key] = repoConfig[key];
+        }
+      }
     }
+    return configInputObj;
+  }
 
-    _handlePluginConfigChanged({detail: {name, config, notifyPath}}) {
-      this._repoConfig.plugin_config[name] = config;
-      this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+  _handleSaveRepoConfig() {
+    return this.$.restAPI.saveRepoConfig(this.repo,
+        this._formatRepoConfigForSave(this._repoConfig)).then(() => {
+      this._configChanged = false;
+    });
+  }
+
+  _handleConfigChanged() {
+    if (this._isLoading()) { return; }
+    this._configChanged = true;
+  }
+
+  _computeButtonDisabled(readOnly, configChanged) {
+    return readOnly || !configChanged;
+  }
+
+  _computeHeaderClass(configChanged) {
+    return configChanged ? 'edited' : '';
+  }
+
+  _computeSchemes(schemesObj) {
+    return Object.keys(schemesObj);
+  }
+
+  _schemesChanged(schemes) {
+    if (schemes.length === 0) { return; }
+    if (!schemes.includes(this._selectedScheme)) {
+      this._selectedScheme = schemes.sort()[0];
     }
   }
 
-  customElements.define(GrRepo.is, GrRepo);
-})();
+  _computeCommands(repo, schemesObj, _selectedScheme) {
+    if (!schemesObj || !repo || !_selectedScheme) {
+      return [];
+    }
+    const commands = [];
+    let commandObj;
+    if (schemesObj.hasOwnProperty(_selectedScheme)) {
+      commandObj = schemesObj[_selectedScheme].clone_commands;
+    }
+    for (const title in commandObj) {
+      if (!commandObj.hasOwnProperty(title)) { continue; }
+      commands.push({
+        title,
+        command: commandObj[title]
+            .replace(/\$\{project\}/gi, encodeURI(repo))
+            .replace(/\$\{project-base-name\}/gi,
+                encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
+      });
+    }
+    return commands;
+  }
+
+  _computeRepositoriesClass(config) {
+    return config ? 'showConfig': '';
+  }
+
+  _computeChangesUrl(name) {
+    return Gerrit.Nav.getUrlForProjectChanges(name);
+  }
+
+  _handlePluginConfigChanged({detail: {name, config, notifyPath}}) {
+    this._repoConfig.plugin_config[name] = config;
+    this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+  }
+}
+
+customElements.define(GrRepo.is, GrRepo);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js
index 5e37261..2c7540f 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js
@@ -1,37 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-subpage-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-repo-plugin-config/gr-repo-plugin-config.html">
-
-<dom-module id="gr-repo">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -65,50 +50,37 @@
         /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
       </style>
       <div class="info">
-        <h1 id="Title" class$="name">
+        <h1 id="Title" class\$="name">
           [[repo]]
-          <hr/>
+          <hr>
         </h1>
         <div>
-          <a href$="[[_computeChangesUrl(repo)]]">(view changes)</a>
+          <a href\$="[[_computeChangesUrl(repo)]]">(view changes)</a>
         </div>
       </div>
-      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
-      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-        <div id="downloadContent" class$="[[_computeHideClass(_schemes)]]">
+      <div id="loading" class\$="[[_computeLoadingClass(_loading)]]">Loading...</div>
+      <div id="loadedContent" class\$="[[_computeLoadingClass(_loading)]]">
+        <div id="downloadContent" class\$="[[_computeHideClass(_schemes)]]">
           <h2 id="download">Download</h2>
           <fieldset>
-            <gr-download-commands
-                id="downloadCommands"
-                commands="[[_computeCommands(repo, _schemesObj, _selectedScheme)]]"
-                schemes="[[_schemes]]"
-                selected-scheme="{{_selectedScheme}}"></gr-download-commands>
+            <gr-download-commands id="downloadCommands" commands="[[_computeCommands(repo, _schemesObj, _selectedScheme)]]" schemes="[[_schemes]]" selected-scheme="{{_selectedScheme}}"></gr-download-commands>
           </fieldset>
         </div>
-        <h2 id="configurations"
-            class$="[[_computeHeaderClass(_configChanged)]]">Configurations</h2>
+        <h2 id="configurations" class\$="[[_computeHeaderClass(_configChanged)]]">Configurations</h2>
         <div id="form">
           <fieldset>
             <h3 id="Description">Description</h3>
             <fieldset>
-              <iron-autogrow-textarea
-                  id="descriptionInput"
-                  class="description"
-                  autocomplete="on"
-                  placeholder="<Insert repo description here>"
-                  bind-value="{{_repoConfig.description}}"
-                  disabled$="[[_readOnly]]"></iron-autogrow-textarea>
+              <iron-autogrow-textarea id="descriptionInput" class="description" autocomplete="on" placeholder="<Insert repo description here>" bind-value="{{_repoConfig.description}}" disabled\$="[[_readOnly]]"></iron-autogrow-textarea>
             </fieldset>
             <h3 id="Options">Repository Options</h3>
             <fieldset id="options">
               <section>
                 <span class="title">State</span>
                 <span class="value">
-                  <gr-select
-                      id="stateSelect"
-                      bind-value="{{_repoConfig.state}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat" items=[[_states]]>
+                  <gr-select id="stateSelect" bind-value="{{_repoConfig.state}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_states]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                     </select>
@@ -118,12 +90,9 @@
               <section>
                 <span class="title">Submit type</span>
                 <span class="value">
-                  <gr-select
-                      id="submitTypeSelect"
-                      bind-value="{{_repoConfig.submit_type}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatSubmitTypeSelect(_repoConfig)]]">
+                  <gr-select id="submitTypeSelect" bind-value="{{_repoConfig.submit_type}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatSubmitTypeSelect(_repoConfig)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                     </select>
@@ -133,12 +102,9 @@
               <section>
                 <span class="title">Allow content merges</span>
                 <span class="value">
-                  <gr-select
-                      id="contentMergeSelect"
-                      bind-value="{{_repoConfig.use_content_merge.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.use_content_merge)]]">
+                  <gr-select id="contentMergeSelect" bind-value="{{_repoConfig.use_content_merge.configured_value}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.use_content_merge)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                     </select>
@@ -150,12 +116,9 @@
                   Create a new change for every commit not in the target branch
                 </span>
                 <span class="value">
-                  <gr-select
-                      id="newChangeSelect"
-                      bind-value="{{_repoConfig.create_new_change_for_all_not_in_target.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.create_new_change_for_all_not_in_target)]]">
+                  <gr-select id="newChangeSelect" bind-value="{{_repoConfig.create_new_change_for_all_not_in_target.configured_value}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.create_new_change_for_all_not_in_target)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                     </select>
@@ -165,46 +128,33 @@
               <section>
                 <span class="title">Require Change-Id in commit message</span>
                 <span class="value">
-                  <gr-select
-                      id="requireChangeIdSelect"
-                      bind-value="{{_repoConfig.require_change_id.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.require_change_id)]]">
+                  <gr-select id="requireChangeIdSelect" bind-value="{{_repoConfig.require_change_id.configured_value}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.require_change_id)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                     </select>
                   </gr-select>
                 </span>
               </section>
-              <section
-                   id="enableSignedPushSettings"
-                   class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.enable_signed_push)]]">
+              <section id="enableSignedPushSettings" class\$="repositorySettings [[_computeRepositoriesClass(_repoConfig.enable_signed_push)]]">
                 <span class="title">Enable signed push</span>
                 <span class="value">
-                  <gr-select
-                      id="enableSignedPush"
-                      bind-value="{{_repoConfig.enable_signed_push.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.enable_signed_push)]]">
+                  <gr-select id="enableSignedPush" bind-value="{{_repoConfig.enable_signed_push.configured_value}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.enable_signed_push)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                     </select>
                   </gr-select>
                 </span>
               </section>
-              <section
-                   id="requireSignedPushSettings"
-                   class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.require_signed_push)]]">
+              <section id="requireSignedPushSettings" class\$="repositorySettings [[_computeRepositoriesClass(_repoConfig.require_signed_push)]]">
                 <span class="title">Require signed push</span>
                 <span class="value">
-                  <gr-select
-                      id="requireSignedPush"
-                      bind-value="{{_repoConfig.require_signed_push.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.require_signed_push)]]">
+                  <gr-select id="requireSignedPush" bind-value="{{_repoConfig.require_signed_push.configured_value}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.require_signed_push)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                     </select>
@@ -215,12 +165,9 @@
                 <span class="title">
                   Reject implicit merges when changes are pushed for review</span>
                 <span class="value">
-                  <gr-select
-                      id="rejectImplicitMergesSelect"
-                      bind-value="{{_repoConfig.reject_implicit_merges.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.reject_implicit_merges)]]">
+                  <gr-select id="rejectImplicitMergesSelect" bind-value="{{_repoConfig.reject_implicit_merges.configured_value}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.reject_implicit_merges)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                     </select>
@@ -231,12 +178,9 @@
                 <span class="title">
                   Enable adding unregistered users as reviewers and CCs on changes</span>
                 <span class="value">
-                  <gr-select
-                      id="unRegisteredCcSelect"
-                      bind-value="{{_repoConfig.enable_reviewer_by_email.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.enable_reviewer_by_email)]]">
+                  <gr-select id="unRegisteredCcSelect" bind-value="{{_repoConfig.enable_reviewer_by_email.configured_value}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.enable_reviewer_by_email)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                   </select>
@@ -247,12 +191,9 @@
                 <span class="title">
                   Set all new changes private by default</span>
                 <span class="value">
-                  <gr-select
-                      id="setAllnewChangesPrivateByDefaultSelect"
-                      bind-value="{{_repoConfig.private_by_default.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.private_by_default)]]">
+                  <gr-select id="setAllnewChangesPrivateByDefaultSelect" bind-value="{{_repoConfig.private_by_default.configured_value}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.private_by_default)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                     </select>
@@ -263,12 +204,9 @@
                 <span class="title">
                   Set new changes to "work in progress" by default</span>
                 <span class="value">
-                  <gr-select
-                      id="setAllNewChangesWorkInProgressByDefaultSelect"
-                      bind-value="{{_repoConfig.work_in_progress_by_default.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.work_in_progress_by_default)]]">
+                  <gr-select id="setAllNewChangesWorkInProgressByDefaultSelect" bind-value="{{_repoConfig.work_in_progress_by_default.configured_value}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.work_in_progress_by_default)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                     </select>
@@ -278,17 +216,8 @@
               <section>
                 <span class="title">Maximum Git object size limit</span>
                 <span class="value">
-                  <iron-input
-                      id="maxGitObjSizeIronInput"
-                      bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                      type="text"
-                      disabled$="[[_readOnly]]">
-                    <input
-                        id="maxGitObjSizeInput"
-                        bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                        is="iron-input"
-                        type="text"
-                        disabled$="[[_readOnly]]">
+                  <iron-input id="maxGitObjSizeIronInput" bind-value="{{_repoConfig.max_object_size_limit.configured_value}}" type="text" disabled\$="[[_readOnly]]">
+                    <input id="maxGitObjSizeInput" bind-value="{{_repoConfig.max_object_size_limit.configured_value}}" is="iron-input" type="text" disabled\$="[[_readOnly]]">
                   </iron-input>
                   <template is="dom-if" if="[[_repoConfig.max_object_size_limit.value]]">
                     effective: [[_repoConfig.max_object_size_limit.value]] bytes
@@ -298,12 +227,9 @@
               <section>
                 <span class="title">Match authored date with committer date upon submit</span>
                 <span class="value">
-                  <gr-select
-                      id="matchAuthoredDateWithCommitterDateSelect"
-                      bind-value="{{_repoConfig.match_author_to_committer_date.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.match_author_to_committer_date)]]">
+                  <gr-select id="matchAuthoredDateWithCommitterDateSelect" bind-value="{{_repoConfig.match_author_to_committer_date.configured_value}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.match_author_to_committer_date)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                   </select>
@@ -313,12 +239,9 @@
               <section>
                 <span class="title">Reject empty commit upon submit</span>
                 <span class="value">
-                  <gr-select
-                      id="rejectEmptyCommitSelect"
-                      bind-value="{{_repoConfig.reject_empty_commit.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                                items="[[_formatBooleanSelect(_repoConfig.reject_empty_commit)]]">
+                  <gr-select id="rejectEmptyCommitSelect" bind-value="{{_repoConfig.reject_empty_commit.configured_value}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.reject_empty_commit)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                   </select>
@@ -332,12 +255,9 @@
                 <span class="title">
                   Require a valid contributor agreement to upload</span>
                 <span class="value">
-                  <gr-select
-                      id="contributorAgreementSelect"
-                      bind-value="{{_repoConfig.use_contributor_agreements.configured_value}}">
-                  <select disabled$="[[_readOnly]]">
-                    <template is="dom-repeat"
-                        items="[[_formatBooleanSelect(_repoConfig.use_contributor_agreements)]]">
+                  <gr-select id="contributorAgreementSelect" bind-value="{{_repoConfig.use_contributor_agreements.configured_value}}">
+                  <select disabled\$="[[_readOnly]]">
+                    <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.use_contributor_agreements)]]">
                       <option value="[[item.value]]">[[item.label]]</option>
                     </template>
                     </select>
@@ -347,12 +267,9 @@
               <section>
                 <span class="title">Require Signed-off-by in commit message</span>
                 <span class="value">
-                  <gr-select
-                        id="useSignedOffBySelect"
-                        bind-value="{{_repoConfig.use_signed_off_by.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.use_signed_off_by)]]">
+                  <gr-select id="useSignedOffBySelect" bind-value="{{_repoConfig.use_signed_off_by.configured_value}}">
+                    <select disabled\$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_formatBooleanSelect(_repoConfig.use_signed_off_by)]]">
                         <option value="[[item.value]]">[[item.label]]</option>
                       </template>
                     </select>
@@ -360,18 +277,13 @@
                 </span>
               </section>
             </fieldset>
-            <div
-                class$="pluginConfig [[_computeHideClass(_pluginData)]]"
-                on-plugin-config-changed="_handlePluginConfigChanged">
+            <div class\$="pluginConfig [[_computeHideClass(_pluginData)]]" on-plugin-config-changed="_handlePluginConfigChanged">
               <h3>Plugins</h3>
               <template is="dom-repeat" items="[[_pluginData]]" as="data">
-                <gr-repo-plugin-config
-                    plugin-data="[[data]]"></gr-repo-plugin-config>
+                <gr-repo-plugin-config plugin-data="[[data]]"></gr-repo-plugin-config>
               </template>
             </div>
-            <gr-button
-                on-click="_handleSaveRepoConfig"
-                disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]">Save changes</gr-button>
+            <gr-button on-click="_handleSaveRepoConfig" disabled\$="[[_computeButtonDisabled(_readOnly, _configChanged)]]">Save changes</gr-button>
           </fieldset>
           <gr-endpoint-decorator name="repo-config">
             <gr-endpoint-param name="repoName" value="[[repo]]"></gr-endpoint-param>
@@ -381,6 +293,4 @@
       </div>
     </main>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
index da0c271..b9d5d56 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-repo.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,367 +40,371 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let repoStub;
-    const repoConf = {
-      description: 'Access inherited by all other projects.',
-      use_contributor_agreements: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      use_content_merge: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      use_signed_off_by: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      create_new_change_for_all_not_in_target: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      require_change_id: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      enable_signed_push: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      require_signed_push: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      reject_implicit_merges: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      private_by_default: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      match_author_to_committer_date: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      reject_empty_commit: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      enable_reviewer_by_email: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      max_object_size_limit: {},
-      submit_type: 'MERGE_IF_NECESSARY',
-      default_submit_type: {
-        value: 'MERGE_IF_NECESSARY',
-        configured_value: 'INHERIT',
-        inherited_value: 'MERGE_IF_NECESSARY',
-      },
-    };
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+suite('gr-repo tests', () => {
+  let element;
+  let sandbox;
+  let repoStub;
+  const repoConf = {
+    description: 'Access inherited by all other projects.',
+    use_contributor_agreements: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    use_content_merge: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    use_signed_off_by: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    create_new_change_for_all_not_in_target: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    require_change_id: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    enable_signed_push: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    require_signed_push: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    reject_implicit_merges: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    private_by_default: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    match_author_to_committer_date: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    reject_empty_commit: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    enable_reviewer_by_email: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    max_object_size_limit: {},
+    submit_type: 'MERGE_IF_NECESSARY',
+    default_submit_type: {
+      value: 'MERGE_IF_NECESSARY',
+      configured_value: 'INHERIT',
+      inherited_value: 'MERGE_IF_NECESSARY',
+    },
+  };
 
-    const REPO = 'test-repo';
-    const SCHEMES = {http: {}, repo: {}, ssh: {}};
+  const REPO = 'test-repo';
+  const SCHEMES = {http: {}, repo: {}, ssh: {}};
 
-    function getFormFields() {
-      const selects = Array.from(
-          Polymer.dom(element.root).querySelectorAll('select'));
-      const textareas = Array.from(
-          Polymer.dom(element.root).querySelectorAll('iron-autogrow-textarea'));
-      const inputs = Array.from(
-          Polymer.dom(element.root).querySelectorAll('input'));
-      return inputs.concat(textareas).concat(selects);
-    }
+  function getFormFields() {
+    const selects = Array.from(
+        dom(element.root).querySelectorAll('select'));
+    const textareas = Array.from(
+        dom(element.root).querySelectorAll('iron-autogrow-textarea'));
+    const inputs = Array.from(
+        dom(element.root).querySelectorAll('input'));
+    return inputs.concat(textareas).concat(selects);
+  }
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        getConfig() {
-          return Promise.resolve({download: {}});
-        },
-      });
-      element = fixture('basic');
-      repoStub = sandbox.stub(
-          element.$.restAPI,
-          'getProjectConfig',
-          () => Promise.resolve(repoConf));
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getConfig() {
+        return Promise.resolve({download: {}});
+      },
     });
+    element = fixture('basic');
+    repoStub = sandbox.stub(
+        element.$.restAPI,
+        'getProjectConfig',
+        () => Promise.resolve(repoConf));
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('_computePluginData', () => {
-      assert.deepEqual(element._computePluginData(), []);
-      assert.deepEqual(element._computePluginData({}), []);
-      assert.deepEqual(element._computePluginData({base: {}}), []);
-      assert.deepEqual(element._computePluginData({base: {plugin: 'data'}}),
-          [{name: 'plugin', config: 'data'}]);
-    });
+  test('_computePluginData', () => {
+    assert.deepEqual(element._computePluginData(), []);
+    assert.deepEqual(element._computePluginData({}), []);
+    assert.deepEqual(element._computePluginData({base: {}}), []);
+    assert.deepEqual(element._computePluginData({base: {plugin: 'data'}}),
+        [{name: 'plugin', config: 'data'}]);
+  });
 
-    test('_handlePluginConfigChanged', () => {
-      const notifyStub = sandbox.stub(element, 'notifyPath');
-      element._repoConfig = {plugin_config: {}};
-      element._handlePluginConfigChanged({detail: {
-        name: 'test',
-        config: 'data',
-        notifyPath: 'path',
-      }});
-      flushAsynchronousOperations();
+  test('_handlePluginConfigChanged', () => {
+    const notifyStub = sandbox.stub(element, 'notifyPath');
+    element._repoConfig = {plugin_config: {}};
+    element._handlePluginConfigChanged({detail: {
+      name: 'test',
+      config: 'data',
+      notifyPath: 'path',
+    }});
+    flushAsynchronousOperations();
 
-      assert.equal(element._repoConfig.plugin_config.test, 'data');
-      assert.equal(notifyStub.lastCall.args[0],
-          '_repoConfig.plugin_config.path');
-    });
+    assert.equal(element._repoConfig.plugin_config.test, 'data');
+    assert.equal(notifyStub.lastCall.args[0],
+        '_repoConfig.plugin_config.path');
+  });
 
-    test('loading displays before repo config is loaded', () => {
-      assert.isTrue(element.$.loading.classList.contains('loading'));
-      assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-      assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-      assert.isTrue(getComputedStyle(element.$.loadedContent)
-          .display === 'none');
-    });
+  test('loading displays before repo config is loaded', () => {
+    assert.isTrue(element.$.loading.classList.contains('loading'));
+    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
+    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
+    assert.isTrue(getComputedStyle(element.$.loadedContent)
+        .display === 'none');
+  });
 
-    test('download commands visibility', () => {
-      element._loading = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.downloadContent.classList.contains('hide'));
-      assert.isTrue(getComputedStyle(element.$.downloadContent)
-          .display == 'none');
-      element._schemesObj = SCHEMES;
-      flushAsynchronousOperations();
-      assert.isFalse(element.$.downloadContent.classList.contains('hide'));
-      assert.isFalse(getComputedStyle(element.$.downloadContent)
-          .display == 'none');
-    });
+  test('download commands visibility', () => {
+    element._loading = false;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.downloadContent.classList.contains('hide'));
+    assert.isTrue(getComputedStyle(element.$.downloadContent)
+        .display == 'none');
+    element._schemesObj = SCHEMES;
+    flushAsynchronousOperations();
+    assert.isFalse(element.$.downloadContent.classList.contains('hide'));
+    assert.isFalse(getComputedStyle(element.$.downloadContent)
+        .display == 'none');
+  });
 
-    test('form defaults to read only', () => {
+  test('form defaults to read only', () => {
+    assert.isTrue(element._readOnly);
+  });
+
+  test('form defaults to read only when not logged in', done => {
+    element.repo = REPO;
+    element._loadRepo().then(() => {
       assert.isTrue(element._readOnly);
+      done();
+    });
+  });
+
+  test('form defaults to read only when logged in and not admin', done => {
+    element.repo = REPO;
+    sandbox.stub(element, '_getLoggedIn', () => Promise.resolve(true));
+    sandbox.stub(
+        element.$.restAPI,
+        'getRepoAccess',
+        () => Promise.resolve({'test-repo': {}}));
+    element._loadRepo().then(() => {
+      assert.isTrue(element._readOnly);
+      done();
+    });
+  });
+
+  test('all form elements are disabled when not admin', done => {
+    element.repo = REPO;
+    element._loadRepo().then(() => {
+      flushAsynchronousOperations();
+      const formFields = getFormFields();
+      for (const field of formFields) {
+        assert.isTrue(field.hasAttribute('disabled'));
+      }
+      done();
+    });
+  });
+
+  test('_formatBooleanSelect', () => {
+    let item = {inherited_value: true};
+    assert.deepEqual(element._formatBooleanSelect(item), [
+      {
+        label: 'Inherit (true)',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      }, {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+
+    item = {inherited_value: false};
+    assert.deepEqual(element._formatBooleanSelect(item), [
+      {
+        label: 'Inherit (false)',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      }, {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+
+    // For items without inherited values
+    item = {};
+    assert.deepEqual(element._formatBooleanSelect(item), [
+      {
+        label: 'Inherit',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      }, {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+  });
+
+  test('fires page-error', done => {
+    repoStub.restore();
+
+    element.repo = 'test';
+
+    const response = {status: 404};
+    sandbox.stub(
+        element.$.restAPI, 'getProjectConfig', (repo, errFn) => {
+          errFn(response);
+        });
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
     });
 
-    test('form defaults to read only when not logged in', done => {
-      element.repo = REPO;
-      element._loadRepo().then(() => {
-        assert.isTrue(element._readOnly);
-        done();
-      });
-    });
+    element._loadRepo();
+  });
 
-    test('form defaults to read only when logged in and not admin', done => {
+  suite('admin', () => {
+    setup(() => {
       element.repo = REPO;
       sandbox.stub(element, '_getLoggedIn', () => Promise.resolve(true));
       sandbox.stub(
           element.$.restAPI,
           'getRepoAccess',
-          () => Promise.resolve({'test-repo': {}}));
-      element._loadRepo().then(() => {
-        assert.isTrue(element._readOnly);
-        done();
-      });
+          () => Promise.resolve({'test-repo': {is_owner: true}}));
     });
 
-    test('all form elements are disabled when not admin', done => {
-      element.repo = REPO;
+    test('all form elements are enabled', done => {
       element._loadRepo().then(() => {
         flushAsynchronousOperations();
         const formFields = getFormFields();
         for (const field of formFields) {
-          assert.isTrue(field.hasAttribute('disabled'));
+          assert.isFalse(field.hasAttribute('disabled'));
         }
+        assert.isFalse(element._loading);
         done();
       });
     });
 
-    test('_formatBooleanSelect', () => {
-      let item = {inherited_value: true};
-      assert.deepEqual(element._formatBooleanSelect(item), [
-        {
-          label: 'Inherit (true)',
-          value: 'INHERIT',
-        },
-        {
-          label: 'True',
-          value: 'TRUE',
-        }, {
-          label: 'False',
-          value: 'FALSE',
-        },
-      ]);
-
-      item = {inherited_value: false};
-      assert.deepEqual(element._formatBooleanSelect(item), [
-        {
-          label: 'Inherit (false)',
-          value: 'INHERIT',
-        },
-        {
-          label: 'True',
-          value: 'TRUE',
-        }, {
-          label: 'False',
-          value: 'FALSE',
-        },
-      ]);
-
-      // For items without inherited values
-      item = {};
-      assert.deepEqual(element._formatBooleanSelect(item), [
-        {
-          label: 'Inherit',
-          value: 'INHERIT',
-        },
-        {
-          label: 'True',
-          value: 'TRUE',
-        }, {
-          label: 'False',
-          value: 'FALSE',
-        },
-      ]);
+    test('state gets set correctly', done => {
+      element._loadRepo().then(() => {
+        assert.equal(element._repoConfig.state, 'ACTIVE');
+        assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
+        done();
+      });
     });
 
-    test('fires page-error', done => {
-      repoStub.restore();
-
-      element.repo = 'test';
-
-      const response = {status: 404};
-      sandbox.stub(
-          element.$.restAPI, 'getProjectConfig', (repo, errFn) => {
-            errFn(response);
+    test('inherited submit type value is calculated correctly', done => {
+      element
+          ._loadRepo().then(() => {
+            const sel = element.$.submitTypeSelect;
+            assert.equal(sel.bindValue, 'INHERIT');
+            assert.equal(
+                sel.nativeSelect.options[0].text,
+                'Inherit (Merge if necessary)'
+            );
+            done();
           });
-      element.addEventListener('page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element._loadRepo();
     });
 
-    suite('admin', () => {
-      setup(() => {
-        element.repo = REPO;
-        sandbox.stub(element, '_getLoggedIn', () => Promise.resolve(true));
-        sandbox.stub(
-            element.$.restAPI,
-            'getRepoAccess',
-            () => Promise.resolve({'test-repo': {is_owner: true}}));
-      });
+    test('fields update and save correctly', () => {
+      const configInputObj = {
+        description: 'new description',
+        use_contributor_agreements: 'TRUE',
+        use_content_merge: 'TRUE',
+        use_signed_off_by: 'TRUE',
+        create_new_change_for_all_not_in_target: 'TRUE',
+        require_change_id: 'TRUE',
+        enable_signed_push: 'TRUE',
+        require_signed_push: 'TRUE',
+        reject_implicit_merges: 'TRUE',
+        private_by_default: 'TRUE',
+        match_author_to_committer_date: 'TRUE',
+        reject_empty_commit: 'TRUE',
+        max_object_size_limit: 10,
+        submit_type: 'FAST_FORWARD_ONLY',
+        state: 'READ_ONLY',
+        enable_reviewer_by_email: 'TRUE',
+      };
 
-      test('all form elements are enabled', done => {
-        element._loadRepo().then(() => {
-          flushAsynchronousOperations();
-          const formFields = getFormFields();
-          for (const field of formFields) {
-            assert.isFalse(field.hasAttribute('disabled'));
-          }
-          assert.isFalse(element._loading);
-          done();
-        });
-      });
+      const saveStub = sandbox.stub(element.$.restAPI, 'saveRepoConfig'
+          , () => Promise.resolve({}));
 
-      test('state gets set correctly', done => {
-        element._loadRepo().then(() => {
-          assert.equal(element._repoConfig.state, 'ACTIVE');
-          assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
-          done();
-        });
-      });
+      const button = dom(element.root).querySelector('gr-button');
 
-      test('inherited submit type value is calculated correctly', done => {
-        element
-            ._loadRepo().then(() => {
-              const sel = element.$.submitTypeSelect;
-              assert.equal(sel.bindValue, 'INHERIT');
-              assert.equal(
-                  sel.nativeSelect.options[0].text,
-                  'Inherit (Merge if necessary)'
-              );
-              done();
-            });
-      });
+      return element._loadRepo().then(() => {
+        assert.isTrue(button.hasAttribute('disabled'));
+        assert.isFalse(element.$.Title.classList.contains('edited'));
+        element.$.descriptionInput.bindValue = configInputObj.description;
+        element.$.stateSelect.bindValue = configInputObj.state;
+        element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
+        element.$.contentMergeSelect.bindValue =
+            configInputObj.use_content_merge;
+        element.$.newChangeSelect.bindValue =
+            configInputObj.create_new_change_for_all_not_in_target;
+        element.$.requireChangeIdSelect.bindValue =
+            configInputObj.require_change_id;
+        element.$.enableSignedPush.bindValue =
+            configInputObj.enable_signed_push;
+        element.$.requireSignedPush.bindValue =
+            configInputObj.require_signed_push;
+        element.$.rejectImplicitMergesSelect.bindValue =
+            configInputObj.reject_implicit_merges;
+        element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
+            configInputObj.private_by_default;
+        element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
+            configInputObj.match_author_to_committer_date;
+        const inputElement = PolymerElement ?
+          element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
+        inputElement.bindValue = configInputObj.max_object_size_limit;
+        element.$.contributorAgreementSelect.bindValue =
+            configInputObj.use_contributor_agreements;
+        element.$.useSignedOffBySelect.bindValue =
+            configInputObj.use_signed_off_by;
+        element.$.rejectEmptyCommitSelect.bindValue =
+            configInputObj.reject_empty_commit;
+        element.$.unRegisteredCcSelect.bindValue =
+            configInputObj.enable_reviewer_by_email;
 
-      test('fields update and save correctly', () => {
-        const configInputObj = {
-          description: 'new description',
-          use_contributor_agreements: 'TRUE',
-          use_content_merge: 'TRUE',
-          use_signed_off_by: 'TRUE',
-          create_new_change_for_all_not_in_target: 'TRUE',
-          require_change_id: 'TRUE',
-          enable_signed_push: 'TRUE',
-          require_signed_push: 'TRUE',
-          reject_implicit_merges: 'TRUE',
-          private_by_default: 'TRUE',
-          match_author_to_committer_date: 'TRUE',
-          reject_empty_commit: 'TRUE',
-          max_object_size_limit: 10,
-          submit_type: 'FAST_FORWARD_ONLY',
-          state: 'READ_ONLY',
-          enable_reviewer_by_email: 'TRUE',
-        };
+        assert.isFalse(button.hasAttribute('disabled'));
+        assert.isTrue(element.$.configurations.classList.contains('edited'));
 
-        const saveStub = sandbox.stub(element.$.restAPI, 'saveRepoConfig'
-            , () => Promise.resolve({}));
+        const formattedObj =
+            element._formatRepoConfigForSave(element._repoConfig);
+        assert.deepEqual(formattedObj, configInputObj);
 
-        const button = Polymer.dom(element.root).querySelector('gr-button');
-
-        return element._loadRepo().then(() => {
+        return element._handleSaveRepoConfig().then(() => {
           assert.isTrue(button.hasAttribute('disabled'));
           assert.isFalse(element.$.Title.classList.contains('edited'));
-          element.$.descriptionInput.bindValue = configInputObj.description;
-          element.$.stateSelect.bindValue = configInputObj.state;
-          element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
-          element.$.contentMergeSelect.bindValue =
-              configInputObj.use_content_merge;
-          element.$.newChangeSelect.bindValue =
-              configInputObj.create_new_change_for_all_not_in_target;
-          element.$.requireChangeIdSelect.bindValue =
-              configInputObj.require_change_id;
-          element.$.enableSignedPush.bindValue =
-              configInputObj.enable_signed_push;
-          element.$.requireSignedPush.bindValue =
-              configInputObj.require_signed_push;
-          element.$.rejectImplicitMergesSelect.bindValue =
-              configInputObj.reject_implicit_merges;
-          element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
-              configInputObj.private_by_default;
-          element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
-              configInputObj.match_author_to_committer_date;
-          const inputElement = Polymer.Element ?
-            element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
-          inputElement.bindValue = configInputObj.max_object_size_limit;
-          element.$.contributorAgreementSelect.bindValue =
-              configInputObj.use_contributor_agreements;
-          element.$.useSignedOffBySelect.bindValue =
-              configInputObj.use_signed_off_by;
-          element.$.rejectEmptyCommitSelect.bindValue =
-              configInputObj.reject_empty_commit;
-          element.$.unRegisteredCcSelect.bindValue =
-              configInputObj.enable_reviewer_by_email;
-
-          assert.isFalse(button.hasAttribute('disabled'));
-          assert.isTrue(element.$.configurations.classList.contains('edited'));
-
-          const formattedObj =
-              element._formatRepoConfigForSave(element._repoConfig);
-          assert.deepEqual(formattedObj, configInputObj);
-
-          return element._handleSaveRepoConfig().then(() => {
-            assert.isTrue(button.hasAttribute('disabled'));
-            assert.isFalse(element.$.Title.classList.contains('edited'));
-            assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
-                configInputObj));
-          });
+          assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
+              configInputObj));
         });
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
index ac98d33..2421c46 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -14,270 +14,286 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-select/gr-select.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-rule-editor_html.js';
+
+/**
+ * Fired when the rule has been modified or removed.
+ *
+ * @event access-modified
+ */
+
+/**
+ * Fired when a rule that was previously added was removed.
+ *
+ * @event added-rule-removed
+ */
+
+const PRIORITY_OPTIONS = [
+  'BATCH',
+  'INTERACTIVE',
+];
+
+const Action = {
+  ALLOW: 'ALLOW',
+  DENY: 'DENY',
+  BLOCK: 'BLOCK',
+};
+
+const DROPDOWN_OPTIONS = [Action.ALLOW, Action.DENY, Action.BLOCK];
+
+const ForcePushOptions = {
+  ALLOW: [
+    {name: 'Allow pushing (but not force pushing)', value: false},
+    {name: 'Allow pushing with or without force', value: true},
+  ],
+  BLOCK: [
+    {name: 'Block pushing with or without force', value: false},
+    {name: 'Block force pushing', value: true},
+  ],
+};
+
+const FORCE_EDIT_OPTIONS = [
+  {
+    name: 'No Force Edit',
+    value: false,
+  },
+  {
+    name: 'Force Edit',
+    value: true,
+  },
+];
+
+/**
+ * @appliesMixin Gerrit.AccessMixin
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrRuleEditor extends mixinBehaviors( [
+  Gerrit.AccessBehavior,
+  Gerrit.BaseUrlBehavior,
   /**
-   * Fired when the rule has been modified or removed.
-   *
-   * @event access-modified
+   * Unused in this element, but called by other elements in tests
+   * e.g gr-permission_test.
    */
+  Gerrit.FireBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-  /**
-   * Fired when a rule that was previously added was removed.
-   *
-   * @event added-rule-removed
-   */
+  static get is() { return 'gr-rule-editor'; }
 
-  const PRIORITY_OPTIONS = [
-    'BATCH',
-    'INTERACTIVE',
-  ];
+  static get properties() {
+    return {
+      hasRange: Boolean,
+      /** @type {?} */
+      label: Object,
+      editing: {
+        type: Boolean,
+        value: false,
+        observer: '_handleEditingChanged',
+      },
+      groupId: String,
+      groupName: String,
+      permission: String,
+      /** @type {?} */
+      rule: {
+        type: Object,
+        notify: true,
+      },
+      section: String,
 
-  const Action = {
-    ALLOW: 'ALLOW',
-    DENY: 'DENY',
-    BLOCK: 'BLOCK',
-  };
+      _deleted: {
+        type: Boolean,
+        value: false,
+      },
+      _originalRuleValues: Object,
+    };
+  }
 
-  const DROPDOWN_OPTIONS = [Action.ALLOW, Action.DENY, Action.BLOCK];
+  static get observers() {
+    return [
+      '_handleValueChange(rule.value.*)',
+    ];
+  }
 
-  const ForcePushOptions = {
-    ALLOW: [
-      {name: 'Allow pushing (but not force pushing)', value: false},
-      {name: 'Allow pushing with or without force', value: true},
-    ],
-    BLOCK: [
-      {name: 'Block pushing with or without force', value: false},
-      {name: 'Block force pushing', value: true},
-    ],
-  };
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('access-saved',
+        () => this._handleAccessSaved());
+  }
 
-  const FORCE_EDIT_OPTIONS = [
-    {
-      name: 'No Force Edit',
-      value: false,
-    },
-    {
-      name: 'Force Edit',
-      value: true,
-    },
-  ];
+  /** @override */
+  ready() {
+    super.ready();
+    // Called on ready rather than the observer because when new rules are
+    // added, the observer is triggered prior to being ready.
+    if (!this.rule) { return; } // Check needed for test purposes.
+    this._setupValues(this.rule);
+  }
 
-  /**
-   * @appliesMixin Gerrit.AccessMixin
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
-   */
-  class GrRuleEditor extends Polymer.mixinBehaviors( [
-    Gerrit.AccessBehavior,
-    Gerrit.BaseUrlBehavior,
-    /**
-     * Unused in this element, but called by other elements in tests
-     * e.g gr-permission_test.
-     */
-    Gerrit.FireBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-rule-editor'; }
-
-    static get properties() {
-      return {
-        hasRange: Boolean,
-        /** @type {?} */
-        label: Object,
-        editing: {
-          type: Boolean,
-          value: false,
-          observer: '_handleEditingChanged',
-        },
-        groupId: String,
-        groupName: String,
-        permission: String,
-        /** @type {?} */
-        rule: {
-          type: Object,
-          notify: true,
-        },
-        section: String,
-
-        _deleted: {
-          type: Boolean,
-          value: false,
-        },
-        _originalRuleValues: Object,
-      };
-    }
-
-    static get observers() {
-      return [
-        '_handleValueChange(rule.value.*)',
-      ];
-    }
-
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('access-saved',
-          () => this._handleAccessSaved());
-    }
-
-    /** @override */
-    ready() {
-      super.ready();
-      // Called on ready rather than the observer because when new rules are
-      // added, the observer is triggered prior to being ready.
-      if (!this.rule) { return; } // Check needed for test purposes.
-      this._setupValues(this.rule);
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      if (!this.rule) { return; } // Check needed for test purposes.
-      if (!this._originalRuleValues) {
-        // Observer _handleValueChange is called after the ready()
-        // method finishes. Original values must be set later to
-        // avoid set .modified flag to true
-        this._setOriginalRuleValues(this.rule.value);
-      }
-    }
-
-    _setupValues(rule) {
-      if (!rule.value) {
-        this._setDefaultRuleValues();
-      }
-    }
-
-    _computeForce(permission, action) {
-      if (this.permissionValues.push.id === permission &&
-          action !== Action.DENY) {
-        return true;
-      }
-
-      return this.permissionValues.editTopicName.id === permission;
-    }
-
-    _computeForceClass(permission, action) {
-      return this._computeForce(permission, action) ? 'force' : '';
-    }
-
-    _computeGroupPath(group) {
-      return `${this.getBaseUrl()}/admin/groups/${this.encodeURL(group, true)}`;
-    }
-
-    _handleAccessSaved() {
-      // Set a new 'original' value to keep track of after the value has been
-      // saved.
+  /** @override */
+  attached() {
+    super.attached();
+    if (!this.rule) { return; } // Check needed for test purposes.
+    if (!this._originalRuleValues) {
+      // Observer _handleValueChange is called after the ready()
+      // method finishes. Original values must be set later to
+      // avoid set .modified flag to true
       this._setOriginalRuleValues(this.rule.value);
     }
-
-    _handleEditingChanged(editing, editingOld) {
-      // Ignore when editing gets set initially.
-      if (!editingOld) { return; }
-      // Restore original values if no longer editing.
-      if (!editing) {
-        this._handleUndoChange();
-      }
-    }
-
-    _computeSectionClass(editing, deleted) {
-      const classList = [];
-      if (editing) {
-        classList.push('editing');
-      }
-      if (deleted) {
-        classList.push('deleted');
-      }
-      return classList.join(' ');
-    }
-
-    _computeForceOptions(permission, action) {
-      if (permission === this.permissionValues.push.id) {
-        if (action === Action.ALLOW) {
-          return ForcePushOptions.ALLOW;
-        } else if (action === Action.BLOCK) {
-          return ForcePushOptions.BLOCK;
-        } else {
-          return [];
-        }
-      } else if (permission === this.permissionValues.editTopicName.id) {
-        return FORCE_EDIT_OPTIONS;
-      }
-      return [];
-    }
-
-    _getDefaultRuleValues(permission, label) {
-      const ruleAction = Action.ALLOW;
-      const value = {};
-      if (permission === 'priority') {
-        value.action = PRIORITY_OPTIONS[0];
-        return value;
-      } else if (label) {
-        value.min = label.values[0].value;
-        value.max = label.values[label.values.length - 1].value;
-      } else if (this._computeForce(permission, ruleAction)) {
-        value.force =
-            this._computeForceOptions(permission, ruleAction)[0].value;
-      }
-      value.action = DROPDOWN_OPTIONS[0];
-      return value;
-    }
-
-    _setDefaultRuleValues() {
-      this.set('rule.value', this._getDefaultRuleValues(this.permission,
-          this.label));
-    }
-
-    _computeOptions(permission) {
-      if (permission === 'priority') {
-        return PRIORITY_OPTIONS;
-      }
-      return DROPDOWN_OPTIONS;
-    }
-
-    _handleRemoveRule() {
-      if (this.rule.value.added) {
-        this.dispatchEvent(new CustomEvent(
-            'added-rule-removed', {bubbles: true, composed: true}));
-      }
-      this._deleted = true;
-      this.rule.value.deleted = true;
-      this.dispatchEvent(
-          new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    }
-
-    _handleUndoRemove() {
-      this._deleted = false;
-      delete this.rule.value.deleted;
-    }
-
-    _handleUndoChange() {
-      // gr-permission will take care of removing rules that were added but
-      // unsaved. We need to keep the added bit for the filter.
-      if (this.rule.value.added) { return; }
-      this.set('rule.value', Object.assign({}, this._originalRuleValues));
-      this._deleted = false;
-      delete this.rule.value.deleted;
-      delete this.rule.value.modified;
-    }
-
-    _handleValueChange() {
-      if (!this._originalRuleValues) { return; }
-      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}));
-    }
-
-    _setOriginalRuleValues(value) {
-      this._originalRuleValues = Object.assign({}, value);
-    }
   }
 
-  customElements.define(GrRuleEditor.is, GrRuleEditor);
-})();
+  _setupValues(rule) {
+    if (!rule.value) {
+      this._setDefaultRuleValues();
+    }
+  }
+
+  _computeForce(permission, action) {
+    if (this.permissionValues.push.id === permission &&
+        action !== Action.DENY) {
+      return true;
+    }
+
+    return this.permissionValues.editTopicName.id === permission;
+  }
+
+  _computeForceClass(permission, action) {
+    return this._computeForce(permission, action) ? 'force' : '';
+  }
+
+  _computeGroupPath(group) {
+    return `${this.getBaseUrl()}/admin/groups/${this.encodeURL(group, true)}`;
+  }
+
+  _handleAccessSaved() {
+    // Set a new 'original' value to keep track of after the value has been
+    // saved.
+    this._setOriginalRuleValues(this.rule.value);
+  }
+
+  _handleEditingChanged(editing, editingOld) {
+    // Ignore when editing gets set initially.
+    if (!editingOld) { return; }
+    // Restore original values if no longer editing.
+    if (!editing) {
+      this._handleUndoChange();
+    }
+  }
+
+  _computeSectionClass(editing, deleted) {
+    const classList = [];
+    if (editing) {
+      classList.push('editing');
+    }
+    if (deleted) {
+      classList.push('deleted');
+    }
+    return classList.join(' ');
+  }
+
+  _computeForceOptions(permission, action) {
+    if (permission === this.permissionValues.push.id) {
+      if (action === Action.ALLOW) {
+        return ForcePushOptions.ALLOW;
+      } else if (action === Action.BLOCK) {
+        return ForcePushOptions.BLOCK;
+      } else {
+        return [];
+      }
+    } else if (permission === this.permissionValues.editTopicName.id) {
+      return FORCE_EDIT_OPTIONS;
+    }
+    return [];
+  }
+
+  _getDefaultRuleValues(permission, label) {
+    const ruleAction = Action.ALLOW;
+    const value = {};
+    if (permission === 'priority') {
+      value.action = PRIORITY_OPTIONS[0];
+      return value;
+    } else if (label) {
+      value.min = label.values[0].value;
+      value.max = label.values[label.values.length - 1].value;
+    } else if (this._computeForce(permission, ruleAction)) {
+      value.force =
+          this._computeForceOptions(permission, ruleAction)[0].value;
+    }
+    value.action = DROPDOWN_OPTIONS[0];
+    return value;
+  }
+
+  _setDefaultRuleValues() {
+    this.set('rule.value', this._getDefaultRuleValues(this.permission,
+        this.label));
+  }
+
+  _computeOptions(permission) {
+    if (permission === 'priority') {
+      return PRIORITY_OPTIONS;
+    }
+    return DROPDOWN_OPTIONS;
+  }
+
+  _handleRemoveRule() {
+    if (this.rule.value.added) {
+      this.dispatchEvent(new CustomEvent(
+          'added-rule-removed', {bubbles: true, composed: true}));
+    }
+    this._deleted = true;
+    this.rule.value.deleted = true;
+    this.dispatchEvent(
+        new CustomEvent('access-modified', {bubbles: true, composed: true}));
+  }
+
+  _handleUndoRemove() {
+    this._deleted = false;
+    delete this.rule.value.deleted;
+  }
+
+  _handleUndoChange() {
+    // gr-permission will take care of removing rules that were added but
+    // unsaved. We need to keep the added bit for the filter.
+    if (this.rule.value.added) { return; }
+    this.set('rule.value', Object.assign({}, this._originalRuleValues));
+    this._deleted = false;
+    delete this.rule.value.deleted;
+    delete this.rule.value.modified;
+  }
+
+  _handleValueChange() {
+    if (!this._originalRuleValues) { return; }
+    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}));
+  }
+
+  _setOriginalRuleValues(value) {
+    this._originalRuleValues = Object.assign({}, value);
+  }
+}
+
+customElements.define(GrRuleEditor.is, GrRuleEditor);
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.js
index 9820e31..4ea13b1 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.js
@@ -1,35 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-rule-editor">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         border-bottom: 1px solid var(--border-color);
@@ -79,34 +66,25 @@
         width: 14em;
       }
     </style>
-    <div id="mainContainer"
-        class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
+    <div id="mainContainer" class\$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
       <div id="options">
-        <gr-select id="action"
-            bind-value="{{rule.value.action}}"
-            on-change="_handleValueChange">
-          <select disabled$="[[!editing]]">
+        <gr-select id="action" bind-value="{{rule.value.action}}" on-change="_handleValueChange">
+          <select disabled\$="[[!editing]]">
             <template is="dom-repeat" items="[[_computeOptions(permission)]]">
               <option value="[[item]]">[[item]]</option>
             </template>
           </select>
         </gr-select>
         <template is="dom-if" if="[[label]]">
-          <gr-select
-              id="labelMin"
-              bind-value="{{rule.value.min}}"
-              on-change="_handleValueChange">
-            <select disabled$="[[!editing]]">
+          <gr-select id="labelMin" bind-value="{{rule.value.min}}" on-change="_handleValueChange">
+            <select disabled\$="[[!editing]]">
               <template is="dom-repeat" items="[[label.values]]">
                 <option value="[[item.value]]">[[item.value]]</option>
               </template>
             </select>
           </gr-select>
-          <gr-select
-              id="labelMax"
-              bind-value="{{rule.value.max}}"
-              on-change="_handleValueChange">
-            <select disabled$="[[!editing]]">
+          <gr-select id="labelMax" bind-value="{{rule.value.max}}" on-change="_handleValueChange">
+            <select disabled\$="[[!editing]]">
               <template is="dom-repeat" items="[[label.values]]">
                 <option value="[[item.value]]">[[item.value]]</option>
               </template>
@@ -114,51 +92,25 @@
           </gr-select>
         </template>
         <template is="dom-if" if="[[hasRange]]">
-          <iron-autogrow-textarea
-              id="minInput"
-              class="min"
-              autocomplete="on"
-              placeholder="Min value"
-              bind-value="{{rule.value.min}}"
-              disabled$="[[!editing]]"></iron-autogrow-textarea>
-          <iron-autogrow-textarea
-              id="maxInput"
-              class="max"
-              autocomplete="on"
-              placeholder="Max value"
-              bind-value="{{rule.value.max}}"
-              disabled$="[[!editing]]"></iron-autogrow-textarea>
+          <iron-autogrow-textarea id="minInput" class="min" autocomplete="on" placeholder="Min value" bind-value="{{rule.value.min}}" disabled\$="[[!editing]]"></iron-autogrow-textarea>
+          <iron-autogrow-textarea id="maxInput" class="max" autocomplete="on" placeholder="Max value" bind-value="{{rule.value.max}}" disabled\$="[[!editing]]"></iron-autogrow-textarea>
         </template>
-        <a class="groupPath" href$="[[_computeGroupPath(groupId)]]">
+        <a class="groupPath" href\$="[[_computeGroupPath(groupId)]]">
           [[groupName]]
         </a>
-        <gr-select
-            id="force"
-            class$="[[_computeForceClass(permission, rule.value.action)]]"
-            bind-value="{{rule.value.force}}"
-            on-change="_handleValueChange">
-          <select disabled$="[[!editing]]">
-            <template
-                is="dom-repeat"
-                items="[[_computeForceOptions(permission, rule.value.action)]]">
+        <gr-select id="force" class\$="[[_computeForceClass(permission, rule.value.action)]]" bind-value="{{rule.value.force}}" on-change="_handleValueChange">
+          <select disabled\$="[[!editing]]">
+            <template is="dom-repeat" items="[[_computeForceOptions(permission, rule.value.action)]]">
               <option value="[[item.value]]">[[item.name]]</option>
             </template>
           </select>
         </gr-select>
       </div>
-      <gr-button
-          link
-          id="removeBtn"
-          on-click="_handleRemoveRule">Remove</gr-button>
+      <gr-button link="" id="removeBtn" on-click="_handleRemoveRule">Remove</gr-button>
     </div>
-    <div
-        id="deletedContainer"
-        class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
+    <div id="deletedContainer" class\$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
       [[groupName]] was deleted
-      <gr-button link
-          id="undoRemoveBtn" on-click="_handleUndoRemove">Undo</gr-button>
+      <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove">Undo</gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-rule-editor.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
index 9f02ddc..42e5f32 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-rule-editor</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-rule-editor.html">
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-rule-editor.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-rule-editor.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,593 +41,596 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-rule-editor tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-rule-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-rule-editor tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    suite('unit tests', () => {
-      test('_computeForce, _computeForceClass, and _computeForceOptions',
-          () => {
-            const ForcePushOptions = {
-              ALLOW: [
-                {name: 'Allow pushing (but not force pushing)', value: false},
-                {name: 'Allow pushing with or without force', value: true},
-              ],
-              BLOCK: [
-                {name: 'Block pushing with or without force', value: false},
-                {name: 'Block force pushing', value: true},
-              ],
-            };
+  suite('unit tests', () => {
+    test('_computeForce, _computeForceClass, and _computeForceOptions',
+        () => {
+          const ForcePushOptions = {
+            ALLOW: [
+              {name: 'Allow pushing (but not force pushing)', value: false},
+              {name: 'Allow pushing with or without force', value: true},
+            ],
+            BLOCK: [
+              {name: 'Block pushing with or without force', value: false},
+              {name: 'Block force pushing', value: true},
+            ],
+          };
 
-            const FORCE_EDIT_OPTIONS = [
-              {
-                name: 'No Force Edit',
-                value: false,
-              },
-              {
-                name: 'Force Edit',
-                value: true,
-              },
-            ];
-            let permission = 'push';
-            let action = 'ALLOW';
-            assert.isTrue(element._computeForce(permission, action));
-            assert.equal(element._computeForceClass(permission, action),
-                'force');
-            assert.deepEqual(element._computeForceOptions(permission, action),
-                ForcePushOptions.ALLOW);
+          const FORCE_EDIT_OPTIONS = [
+            {
+              name: 'No Force Edit',
+              value: false,
+            },
+            {
+              name: 'Force Edit',
+              value: true,
+            },
+          ];
+          let permission = 'push';
+          let action = 'ALLOW';
+          assert.isTrue(element._computeForce(permission, action));
+          assert.equal(element._computeForceClass(permission, action),
+              'force');
+          assert.deepEqual(element._computeForceOptions(permission, action),
+              ForcePushOptions.ALLOW);
 
-            action = 'BLOCK';
-            assert.isTrue(element._computeForce(permission, action));
-            assert.equal(element._computeForceClass(permission, action),
-                'force');
-            assert.deepEqual(element._computeForceOptions(permission, action),
-                ForcePushOptions.BLOCK);
+          action = 'BLOCK';
+          assert.isTrue(element._computeForce(permission, action));
+          assert.equal(element._computeForceClass(permission, action),
+              'force');
+          assert.deepEqual(element._computeForceOptions(permission, action),
+              ForcePushOptions.BLOCK);
 
-            action = 'DENY';
-            assert.isFalse(element._computeForce(permission, action));
-            assert.equal(element._computeForceClass(permission, action), '');
-            assert.equal(
-                element._computeForceOptions(permission, action).length, 0);
-
-            permission = 'editTopicName';
-            assert.isTrue(element._computeForce(permission));
-            assert.equal(element._computeForceClass(permission), 'force');
-            assert.deepEqual(element._computeForceOptions(permission),
-                FORCE_EDIT_OPTIONS);
-            permission = 'submit';
-            assert.isFalse(element._computeForce(permission));
-            assert.equal(element._computeForceClass(permission), '');
-            assert.deepEqual(element._computeForceOptions(permission), []);
-          });
-
-      test('_computeSectionClass', () => {
-        let deleted = true;
-        let editing = false;
-        assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
-        deleted = false;
-        assert.equal(element._computeSectionClass(editing, deleted), '');
-
-        editing = true;
-        assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
-        deleted = true;
-        assert.equal(element._computeSectionClass(editing, deleted),
-            'editing deleted');
-      });
-
-      test('_getDefaultRuleValues', () => {
-        let permission = 'priority';
-        let label;
-        assert.deepEqual(element._getDefaultRuleValues(permission, label),
-            {action: 'BATCH'});
-        permission = 'label-Code-Review';
-        label = {values: [
-          {value: -2, text: 'This shall not be merged'},
-          {value: -1, text: 'I would prefer this is not merged as is'},
-          {value: -0, text: 'No score'},
-          {value: 1, text: 'Looks good to me, but someone else must approve'},
-          {value: 2, text: 'Looks good to me, approved'},
-        ]};
-        assert.deepEqual(element._getDefaultRuleValues(permission, label),
-            {action: 'ALLOW', max: 2, min: -2});
-        permission = 'push';
-        label = undefined;
-        assert.deepEqual(element._getDefaultRuleValues(permission, label),
-            {action: 'ALLOW', force: false});
-        permission = 'submit';
-        assert.deepEqual(element._getDefaultRuleValues(permission, label),
-            {action: 'ALLOW'});
-      });
-
-      test('_setDefaultRuleValues', () => {
-        element.rule = {id: 123};
-        const defaultValue = {action: 'ALLOW'};
-        sandbox.stub(element, '_getDefaultRuleValues').returns(defaultValue);
-        element._setDefaultRuleValues();
-        assert.isTrue(element._getDefaultRuleValues.called);
-        assert.equal(element.rule.value, defaultValue);
-      });
-
-      test('_computeOptions', () => {
-        const PRIORITY_OPTIONS = [
-          'BATCH',
-          'INTERACTIVE',
-        ];
-        const DROPDOWN_OPTIONS = [
-          'ALLOW',
-          'DENY',
-          'BLOCK',
-        ];
-        let permission = 'priority';
-        assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
-        permission = 'submit';
-        assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
-      });
-
-      test('_handleValueChange', () => {
-        const modifiedHandler = sandbox.stub();
-        element.rule = {value: {}};
-        element.addEventListener('access-modified', modifiedHandler);
-        element._handleValueChange();
-        assert.isNotOk(element.rule.value.modified);
-        element._originalRuleValues = {};
-        element._handleValueChange();
-        assert.isTrue(element.rule.value.modified);
-        assert.isTrue(modifiedHandler.called);
-      });
-
-      test('_handleAccessSaved', () => {
-        const originalValue = {action: 'DENY'};
-        const newValue = {action: 'ALLOW'};
-        element._originalRuleValues = originalValue;
-        element.rule = {value: newValue};
-        element._handleAccessSaved();
-        assert.deepEqual(element._originalRuleValues, newValue);
-      });
-
-      test('_setOriginalRuleValues', () => {
-        const value = {
-          action: 'ALLOW',
-          force: false,
-        };
-        element._setOriginalRuleValues(value);
-        assert.deepEqual(element._originalRuleValues, value);
-      });
-    });
-
-    suite('already existing generic rule', () => {
-      setup(done => {
-        element.group = 'Group Name';
-        element.permission = 'submit';
-        element.rule = {
-          id: '123',
-          value: {
-            action: 'ALLOW',
-            force: false,
-          },
-        };
-        element.section = 'refs/*';
-
-        // Typically called on ready since elements will have properies defined
-        // by the parent element.
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-      });
-
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, element.rule.value.action);
-        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMin'));
-        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
-        assert.isFalse(element.$.force.classList.contains('force'));
-      });
-
-      test('modify and cancel restores original values', () => {
-        element.editing = true;
-        assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-        assert.isNotOk(element.rule.value.modified);
-        element.$.action.bindValue = 'DENY';
-        assert.isTrue(element.rule.value.modified);
-        element.editing = false;
-        assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-        assert.equal(element.$.action.bindValue, 'ALLOW');
-        assert.isNotOk(element.rule.value.modified);
-      });
-
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        element.$.action.bindValue = 'DENY';
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
-
-      test('all selects are disabled when not in edit mode', () => {
-        const selects = Polymer.dom(element.root).querySelectorAll('select');
-        for (const select of selects) {
-          assert.isTrue(select.disabled);
-        }
-        element.editing = true;
-        for (const select of selects) {
-          assert.isFalse(select.disabled);
-        }
-      });
-
-      test('remove rule and undo remove', () => {
-        element.editing = true;
-        element.rule = {id: 123, value: {action: 'ALLOW'}};
-        assert.isFalse(
-            element.$.deletedContainer.classList.contains('deleted'));
-        MockInteractions.tap(element.$.removeBtn);
-        assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
-        assert.isTrue(element._deleted);
-        assert.isTrue(element.rule.value.deleted);
-
-        MockInteractions.tap(element.$.undoRemoveBtn);
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.rule.value.deleted);
-      });
-
-      test('remove rule and cancel', () => {
-        element.editing = true;
-        assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-        assert.equal(getComputedStyle(element.$.deletedContainer).display,
-            'none');
-
-        element.rule = {id: 123, value: {action: 'ALLOW'}};
-        MockInteractions.tap(element.$.removeBtn);
-        assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-        assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
-            'none');
-        assert.isTrue(element._deleted);
-        assert.isTrue(element.rule.value.deleted);
-
-        element.editing = false;
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.rule.value.deleted);
-        assert.isNotOk(element.rule.value.modified);
-
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-        assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-        assert.equal(getComputedStyle(element.$.deletedContainer).display,
-            'none');
-      });
-
-      test('_computeGroupPath', () => {
-        const group = '123';
-        assert.equal(element._computeGroupPath(group),
-            `/admin/groups/123`);
-      });
-    });
-
-    suite('new edit rule', () => {
-      setup(done => {
-        element.group = 'Group Name';
-        element.permission = 'editTopicName';
-        element.rule = {
-          id: '123',
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        element.rule.value.added = true;
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        // Since the element does not already have default values, they should
-        // be set. The original values should be set to those too.
-        assert.isNotOk(element.rule.value.modified);
-        const expectedRuleValue = {
-          action: 'ALLOW',
-          force: false,
-          added: true,
-        };
-        assert.deepEqual(element.rule.value, expectedRuleValue);
-        test('values are set correctly', () => {
-          assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-          assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-        });
-      });
-
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        element.$.force.bindValue = true;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
-
-      test('remove value', () => {
-        element.editing = true;
-        const removeStub = sandbox.stub();
-        element.addEventListener('added-rule-removed', removeStub);
-        MockInteractions.tap(element.$.removeBtn);
-        flushAsynchronousOperations();
-        assert.isTrue(removeStub.called);
-      });
-    });
-
-    suite('already existing rule with labels', () => {
-      setup(done => {
-        element.label = {values: [
-          {value: -2, text: 'This shall not be merged'},
-          {value: -1, text: 'I would prefer this is not merged as is'},
-          {value: -0, text: 'No score'},
-          {value: 1, text: 'Looks good to me, but someone else must approve'},
-          {value: 2, text: 'Looks good to me, approved'},
-        ]};
-        element.group = 'Group Name';
-        element.permission = 'label-Code-Review';
-        element.rule = {
-          id: '123',
-          value: {
-            action: 'ALLOW',
-            force: false,
-            max: 2,
-            min: -2,
-          },
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-      });
-
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, element.rule.value.action);
-        assert.equal(
-            Polymer.dom(element.root).querySelector('#labelMin').bindValue,
-            element.rule.value.min);
-        assert.equal(
-            Polymer.dom(element.root).querySelector('#labelMax').bindValue,
-            element.rule.value.max);
-        assert.isFalse(element.$.force.classList.contains('force'));
-      });
-
-      test('modify value', () => {
-        const removeStub = sandbox.stub();
-        element.addEventListener('added-rule-removed', removeStub);
-        assert.isNotOk(element.rule.value.modified);
-        Polymer.dom(element.root).querySelector('#labelMin').bindValue = 1;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-        assert.isFalse(removeStub.called);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
-    });
-
-    suite('new rule with labels', () => {
-      setup(done => {
-        sandbox.spy(element, '_setDefaultRuleValues');
-        element.label = {values: [
-          {value: -2, text: 'This shall not be merged'},
-          {value: -1, text: 'I would prefer this is not merged as is'},
-          {value: -0, text: 'No score'},
-          {value: 1, text: 'Looks good to me, but someone else must approve'},
-          {value: 2, text: 'Looks good to me, approved'},
-        ]};
-        element.group = 'Group Name';
-        element.permission = 'label-Code-Review';
-        element.rule = {
-          id: '123',
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        element.rule.value.added = true;
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        // Since the element does not already have default values, they should
-        // be set. The original values should be set to those too.
-        assert.isNotOk(element.rule.value.modified);
-        assert.isTrue(element._setDefaultRuleValues.called);
-
-        const expectedRuleValue = {
-          max: element.label.values[element.label.values.length - 1].value,
-          min: element.label.values[0].value,
-          action: 'ALLOW',
-          added: true,
-        };
-        assert.deepEqual(element.rule.value, expectedRuleValue);
-        test('values are set correctly', () => {
+          action = 'DENY';
+          assert.isFalse(element._computeForce(permission, action));
+          assert.equal(element._computeForceClass(permission, action), '');
           assert.equal(
-              element.$.action.bindValue,
-              expectedRuleValue.action);
-          assert.equal(
-              Polymer.dom(element.root).querySelector('#labelMin').bindValue,
-              expectedRuleValue.min);
-          assert.equal(
-              Polymer.dom(element.root).querySelector('#labelMax').bindValue,
-              expectedRuleValue.max);
+              element._computeForceOptions(permission, action).length, 0);
+
+          permission = 'editTopicName';
+          assert.isTrue(element._computeForce(permission));
+          assert.equal(element._computeForceClass(permission), 'force');
+          assert.deepEqual(element._computeForceOptions(permission),
+              FORCE_EDIT_OPTIONS);
+          permission = 'submit';
+          assert.isFalse(element._computeForce(permission));
+          assert.equal(element._computeForceClass(permission), '');
+          assert.deepEqual(element._computeForceOptions(permission), []);
         });
-      });
 
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        Polymer.dom(element.root).querySelector('#labelMin').bindValue = 1;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
+    test('_computeSectionClass', () => {
+      let deleted = true;
+      let editing = false;
+      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
 
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
+      deleted = false;
+      assert.equal(element._computeSectionClass(editing, deleted), '');
+
+      editing = true;
+      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+
+      deleted = true;
+      assert.equal(element._computeSectionClass(editing, deleted),
+          'editing deleted');
     });
 
-    suite('already existing push rule', () => {
-      setup(done => {
-        element.group = 'Group Name';
-        element.permission = 'push';
-        element.rule = {
-          id: '123',
-          value: {
-            action: 'ALLOW',
-            force: true,
-          },
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-      });
-
-      test('values are set correctly', () => {
-        assert.isTrue(element.$.force.classList.contains('force'));
-        assert.equal(element.$.action.bindValue, element.rule.value.action);
-        assert.equal(
-            Polymer.dom(element.root).querySelector('#force').bindValue,
-            element.rule.value.force);
-        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMin'));
-        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
-      });
-
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        element.$.action.bindValue = false;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
+    test('_getDefaultRuleValues', () => {
+      let permission = 'priority';
+      let label;
+      assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'BATCH'});
+      permission = 'label-Code-Review';
+      label = {values: [
+        {value: -2, text: 'This shall not be merged'},
+        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: -0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ]};
+      assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'ALLOW', max: 2, min: -2});
+      permission = 'push';
+      label = undefined;
+      assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'ALLOW', force: false});
+      permission = 'submit';
+      assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'ALLOW'});
     });
 
-    suite('new push rule', () => {
-      setup(done => {
-        element.group = 'Group Name';
-        element.permission = 'push';
-        element.rule = {
-          id: '123',
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        element.rule.value.added = true;
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        // Since the element does not already have default values, they should
-        // be set. The original values should be set to those too.
-        assert.isNotOk(element.rule.value.modified);
-        const expectedRuleValue = {
-          action: 'ALLOW',
-          force: false,
-          added: true,
-        };
-        assert.deepEqual(element.rule.value, expectedRuleValue);
-        test('values are set correctly', () => {
-          assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-          assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-        });
-      });
-
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        element.$.force.bindValue = true;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
+    test('_setDefaultRuleValues', () => {
+      element.rule = {id: 123};
+      const defaultValue = {action: 'ALLOW'};
+      sandbox.stub(element, '_getDefaultRuleValues').returns(defaultValue);
+      element._setDefaultRuleValues();
+      assert.isTrue(element._getDefaultRuleValues.called);
+      assert.equal(element.rule.value, defaultValue);
     });
 
-    suite('already existing edit rule', () => {
-      setup(done => {
-        element.group = 'Group Name';
-        element.permission = 'editTopicName';
-        element.rule = {
-          id: '123',
-          value: {
-            action: 'ALLOW',
-            force: true,
-          },
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
+    test('_computeOptions', () => {
+      const PRIORITY_OPTIONS = [
+        'BATCH',
+        'INTERACTIVE',
+      ];
+      const DROPDOWN_OPTIONS = [
+        'ALLOW',
+        'DENY',
+        'BLOCK',
+      ];
+      let permission = 'priority';
+      assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
+      permission = 'submit';
+      assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
+    });
 
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-      });
+    test('_handleValueChange', () => {
+      const modifiedHandler = sandbox.stub();
+      element.rule = {value: {}};
+      element.addEventListener('access-modified', modifiedHandler);
+      element._handleValueChange();
+      assert.isNotOk(element.rule.value.modified);
+      element._originalRuleValues = {};
+      element._handleValueChange();
+      assert.isTrue(element.rule.value.modified);
+      assert.isTrue(modifiedHandler.called);
+    });
 
-      test('values are set correctly', () => {
-        assert.isTrue(element.$.force.classList.contains('force'));
-        assert.equal(element.$.action.bindValue, element.rule.value.action);
-        assert.equal(
-            Polymer.dom(element.root).querySelector('#force').bindValue,
-            element.rule.value.force);
-        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMin'));
-        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
-      });
+    test('_handleAccessSaved', () => {
+      const originalValue = {action: 'DENY'};
+      const newValue = {action: 'ALLOW'};
+      element._originalRuleValues = originalValue;
+      element.rule = {value: newValue};
+      element._handleAccessSaved();
+      assert.deepEqual(element._originalRuleValues, newValue);
+    });
 
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        element.$.action.bindValue = false;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
+    test('_setOriginalRuleValues', () => {
+      const value = {
+        action: 'ALLOW',
+        force: false,
+      };
+      element._setOriginalRuleValues(value);
+      assert.deepEqual(element._originalRuleValues, value);
     });
   });
+
+  suite('already existing generic rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'submit';
+      element.rule = {
+        id: '123',
+        value: {
+          action: 'ALLOW',
+          force: false,
+        },
+      };
+      element.section = 'refs/*';
+
+      // Typically called on ready since elements will have properies defined
+      // by the parent element.
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(element.$.action.bindValue, element.rule.value.action);
+      assert.isNotOk(dom(element.root).querySelector('#labelMin'));
+      assert.isNotOk(dom(element.root).querySelector('#labelMax'));
+      assert.isFalse(element.$.force.classList.contains('force'));
+    });
+
+    test('modify and cancel restores original values', () => {
+      element.editing = true;
+      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = 'DENY';
+      assert.isTrue(element.rule.value.modified);
+      element.editing = false;
+      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+      assert.equal(element.$.action.bindValue, 'ALLOW');
+      assert.isNotOk(element.rule.value.modified);
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = 'DENY';
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('all selects are disabled when not in edit mode', () => {
+      const selects = dom(element.root).querySelectorAll('select');
+      for (const select of selects) {
+        assert.isTrue(select.disabled);
+      }
+      element.editing = true;
+      for (const select of selects) {
+        assert.isFalse(select.disabled);
+      }
+    });
+
+    test('remove rule and undo remove', () => {
+      element.editing = true;
+      element.rule = {id: 123, value: {action: 'ALLOW'}};
+      assert.isFalse(
+          element.$.deletedContainer.classList.contains('deleted'));
+      MockInteractions.tap(element.$.removeBtn);
+      assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.rule.value.deleted);
+
+      MockInteractions.tap(element.$.undoRemoveBtn);
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.rule.value.deleted);
+    });
+
+    test('remove rule and cancel', () => {
+      element.editing = true;
+      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.equal(getComputedStyle(element.$.deletedContainer).display,
+          'none');
+
+      element.rule = {id: 123, value: {action: 'ALLOW'}};
+      MockInteractions.tap(element.$.removeBtn);
+      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
+          'none');
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.rule.value.deleted);
+
+      element.editing = false;
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.rule.value.deleted);
+      assert.isNotOk(element.rule.value.modified);
+
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.equal(getComputedStyle(element.$.deletedContainer).display,
+          'none');
+    });
+
+    test('_computeGroupPath', () => {
+      const group = '123';
+      assert.equal(element._computeGroupPath(group),
+          `/admin/groups/123`);
+    });
+  });
+
+  suite('new edit rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'editTopicName';
+      element.rule = {
+        id: '123',
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      element.rule.value.added = true;
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule.value.modified);
+      const expectedRuleValue = {
+        action: 'ALLOW',
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
+        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.force.bindValue = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('remove value', () => {
+      element.editing = true;
+      const removeStub = sandbox.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      MockInteractions.tap(element.$.removeBtn);
+      flushAsynchronousOperations();
+      assert.isTrue(removeStub.called);
+    });
+  });
+
+  suite('already existing rule with labels', () => {
+    setup(done => {
+      element.label = {values: [
+        {value: -2, text: 'This shall not be merged'},
+        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: -0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ]};
+      element.group = 'Group Name';
+      element.permission = 'label-Code-Review';
+      element.rule = {
+        id: '123',
+        value: {
+          action: 'ALLOW',
+          force: false,
+          max: 2,
+          min: -2,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(element.$.action.bindValue, element.rule.value.action);
+      assert.equal(
+          dom(element.root).querySelector('#labelMin').bindValue,
+          element.rule.value.min);
+      assert.equal(
+          dom(element.root).querySelector('#labelMax').bindValue,
+          element.rule.value.max);
+      assert.isFalse(element.$.force.classList.contains('force'));
+    });
+
+    test('modify value', () => {
+      const removeStub = sandbox.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      assert.isNotOk(element.rule.value.modified);
+      dom(element.root).querySelector('#labelMin').bindValue = 1;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+      assert.isFalse(removeStub.called);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('new rule with labels', () => {
+    setup(done => {
+      sandbox.spy(element, '_setDefaultRuleValues');
+      element.label = {values: [
+        {value: -2, text: 'This shall not be merged'},
+        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: -0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ]};
+      element.group = 'Group Name';
+      element.permission = 'label-Code-Review';
+      element.rule = {
+        id: '123',
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      element.rule.value.added = true;
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule.value.modified);
+      assert.isTrue(element._setDefaultRuleValues.called);
+
+      const expectedRuleValue = {
+        max: element.label.values[element.label.values.length - 1].value,
+        min: element.label.values[0].value,
+        action: 'ALLOW',
+        added: true,
+      };
+      assert.deepEqual(element.rule.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+            element.$.action.bindValue,
+            expectedRuleValue.action);
+        assert.equal(
+            dom(element.root).querySelector('#labelMin').bindValue,
+            expectedRuleValue.min);
+        assert.equal(
+            dom(element.root).querySelector('#labelMax').bindValue,
+            expectedRuleValue.max);
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      dom(element.root).querySelector('#labelMin').bindValue = 1;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('already existing push rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'push';
+      element.rule = {
+        id: '123',
+        value: {
+          action: 'ALLOW',
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(element.$.force.classList.contains('force'));
+      assert.equal(element.$.action.bindValue, element.rule.value.action);
+      assert.equal(
+          dom(element.root).querySelector('#force').bindValue,
+          element.rule.value.force);
+      assert.isNotOk(dom(element.root).querySelector('#labelMin'));
+      assert.isNotOk(dom(element.root).querySelector('#labelMax'));
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('new push rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'push';
+      element.rule = {
+        id: '123',
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      element.rule.value.added = true;
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule.value.modified);
+      const expectedRuleValue = {
+        action: 'ALLOW',
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
+        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.force.bindValue = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('already existing edit rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'editTopicName';
+      element.rule = {
+        id: '123',
+        value: {
+          action: 'ALLOW',
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(element.$.force.classList.contains('force'));
+      assert.equal(element.$.action.bindValue, element.rule.value.action);
+      assert.equal(
+          dom(element.root).querySelector('#force').bindValue,
+          element.rule.value.force);
+      assert.isNotOk(dom(element.root).querySelector('#labelMin'));
+      assert.isNotOk(dom(element.root).querySelector('#labelMax'));
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index 013b44b..6e2f11c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,210 +14,232 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  const CHANGE_SIZE = {
-    XS: 10,
-    SMALL: 50,
-    MEDIUM: 250,
-    LARGE: 1000,
-  };
+import '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
+import '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/gr-change-list-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-account-link/gr-account-link.js';
+import '../../shared/gr-change-star/gr-change-star.js';
+import '../../shared/gr-change-status/gr-change-status.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-limited-text/gr-limited-text.js';
+import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
+import '../../../styles/shared-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-list-item_html.js';
 
-  /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.ChangeTableMixin
-   * @appliesMixin Gerrit.PathListMixin
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
-   */
-  class GrChangeListItem extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.ChangeTableBehavior,
-    Gerrit.PathListBehavior,
-    Gerrit.RESTClientBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-change-list-item'; }
+const CHANGE_SIZE = {
+  XS: 10,
+  SMALL: 50,
+  MEDIUM: 250,
+  LARGE: 1000,
+};
 
-    static get properties() {
-      return {
-        visibleChangeTableColumns: Array,
-        labelNames: {
-          type: Array,
-        },
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.ChangeTableMixin
+ * @appliesMixin Gerrit.PathListMixin
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrChangeListItem extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.ChangeTableBehavior,
+  Gerrit.PathListBehavior,
+  Gerrit.RESTClientBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-        /** @type {?} */
-        change: Object,
-        changeURL: {
-          type: String,
-          computed: '_computeChangeURL(change)',
-        },
-        statuses: {
-          type: Array,
-          computed: 'changeStatuses(change)',
-        },
-        showStar: {
-          type: Boolean,
-          value: false,
-        },
-        showNumber: Boolean,
-        _changeSize: {
-          type: String,
-          computed: '_computeChangeSize(change)',
-        },
-        _dynamicCellEndpoints: {
-          type: Array,
-        },
-      };
+  static get is() { return 'gr-change-list-item'; }
+
+  static get properties() {
+    return {
+      visibleChangeTableColumns: Array,
+      labelNames: {
+        type: Array,
+      },
+
+      /** @type {?} */
+      change: Object,
+      changeURL: {
+        type: String,
+        computed: '_computeChangeURL(change)',
+      },
+      statuses: {
+        type: Array,
+        computed: 'changeStatuses(change)',
+      },
+      showStar: {
+        type: Boolean,
+        value: false,
+      },
+      showNumber: Boolean,
+      _changeSize: {
+        type: String,
+        computed: '_computeChangeSize(change)',
+      },
+      _dynamicCellEndpoints: {
+        type: Array,
+      },
+    };
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    Gerrit.awaitPluginsLoaded().then(() => {
+      this._dynamicCellEndpoints = Gerrit._endpoints.getDynamicEndpoints(
+          'change-list-item-cell');
+    });
+  }
+
+  _computeChangeURL(change) {
+    return Gerrit.Nav.getUrlForChange(change);
+  }
+
+  _computeLabelTitle(change, labelName) {
+    const label = change.labels[labelName];
+    if (!label) { return 'Label not applicable'; }
+    const significantLabel = label.rejected || label.approved ||
+        label.disliked || label.recommended;
+    if (significantLabel && significantLabel.name) {
+      return labelName + '\nby ' + significantLabel.name;
     }
+    return labelName;
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      Gerrit.awaitPluginsLoaded().then(() => {
-        this._dynamicCellEndpoints = Gerrit._endpoints.getDynamicEndpoints(
-            'change-list-item-cell');
-      });
-    }
-
-    _computeChangeURL(change) {
-      return Gerrit.Nav.getUrlForChange(change);
-    }
-
-    _computeLabelTitle(change, labelName) {
-      const label = change.labels[labelName];
-      if (!label) { return 'Label not applicable'; }
-      const significantLabel = label.rejected || label.approved ||
-          label.disliked || label.recommended;
-      if (significantLabel && significantLabel.name) {
-        return labelName + '\nby ' + significantLabel.name;
-      }
-      return labelName;
-    }
-
-    _computeLabelClass(change, labelName) {
-      const label = change.labels[labelName];
-      // Mimic a Set.
-      const classes = {
-        cell: true,
-        label: true,
-      };
-      if (label) {
-        if (label.approved) {
-          classes['u-green'] = true;
-        }
-        if (label.value == 1) {
-          classes['u-monospace'] = true;
-          classes['u-green'] = true;
-        } else if (label.value == -1) {
-          classes['u-monospace'] = true;
-          classes['u-red'] = true;
-        }
-        if (label.rejected) {
-          classes['u-red'] = true;
-        }
-      } else {
-        classes['u-gray-background'] = true;
-      }
-      return Object.keys(classes).sort()
-          .join(' ');
-    }
-
-    _computeLabelValue(change, labelName) {
-      const label = change.labels[labelName];
-      if (!label) { return ''; }
+  _computeLabelClass(change, labelName) {
+    const label = change.labels[labelName];
+    // Mimic a Set.
+    const classes = {
+      cell: true,
+      label: true,
+    };
+    if (label) {
       if (label.approved) {
-        return '✓';
+        classes['u-green'] = true;
+      }
+      if (label.value == 1) {
+        classes['u-monospace'] = true;
+        classes['u-green'] = true;
+      } else if (label.value == -1) {
+        classes['u-monospace'] = true;
+        classes['u-red'] = true;
       }
       if (label.rejected) {
-        return '✕';
+        classes['u-red'] = true;
       }
-      if (label.value > 0) {
-        return '+' + label.value;
-      }
-      if (label.value < 0) {
-        return label.value;
-      }
-      return '';
+    } else {
+      classes['u-gray-background'] = true;
     }
+    return Object.keys(classes).sort()
+        .join(' ');
+  }
 
-    _computeRepoUrl(change) {
-      return Gerrit.Nav.getUrlForProjectChanges(change.project, true,
-          change.internalHost);
+  _computeLabelValue(change, labelName) {
+    const label = change.labels[labelName];
+    if (!label) { return ''; }
+    if (label.approved) {
+      return '✓';
     }
-
-    _computeRepoBranchURL(change) {
-      return Gerrit.Nav.getUrlForBranch(change.branch, change.project, null,
-          change.internalHost);
+    if (label.rejected) {
+      return '✕';
     }
-
-    _computeTopicURL(change) {
-      if (!change.topic) { return ''; }
-      return Gerrit.Nav.getUrlForTopic(change.topic, change.internalHost);
+    if (label.value > 0) {
+      return '+' + label.value;
     }
-
-    /**
-     * Computes the display string for the project column. If there is a host
-     * specified in the change detail, the string will be prefixed with it.
-     *
-     * @param {!Object} change
-     * @param {string=} truncate whether or not the project name should be
-     *     truncated. If this value is truthy, the name will be truncated.
-     * @return {string}
-     */
-    _computeRepoDisplay(change, truncate) {
-      if (!change || !change.project) { return ''; }
-      let str = '';
-      if (change.internalHost) { str += change.internalHost + '/'; }
-      str += truncate ? this.truncatePath(change.project, 2) : change.project;
-      return str;
+    if (label.value < 0) {
+      return label.value;
     }
+    return '';
+  }
 
-    _computeSizeTooltip(change) {
-      if (change.insertions + change.deletions === 0 ||
-          isNaN(change.insertions + change.deletions)) {
-        return 'Size unknown';
-      } else {
-        return `+${change.insertions}, -${change.deletions}`;
-      }
-    }
+  _computeRepoUrl(change) {
+    return Gerrit.Nav.getUrlForProjectChanges(change.project, true,
+        change.internalHost);
+  }
 
-    /**
-     * TShirt sizing is based on the following paper:
-     * http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf
-     */
-    _computeChangeSize(change) {
-      const delta = change.insertions + change.deletions;
-      if (isNaN(delta) || delta === 0) {
-        return null; // Unknown
-      }
-      if (delta < CHANGE_SIZE.XS) {
-        return 'XS';
-      } else if (delta < CHANGE_SIZE.SMALL) {
-        return 'S';
-      } else if (delta < CHANGE_SIZE.MEDIUM) {
-        return 'M';
-      } else if (delta < CHANGE_SIZE.LARGE) {
-        return 'L';
-      } else {
-        return 'XL';
-      }
-    }
+  _computeRepoBranchURL(change) {
+    return Gerrit.Nav.getUrlForBranch(change.branch, change.project, null,
+        change.internalHost);
+  }
 
-    toggleReviewed() {
-      const newVal = !this.change.reviewed;
-      this.set('change.reviewed', newVal);
-      this.dispatchEvent(new CustomEvent('toggle-reviewed', {
-        bubbles: true,
-        composed: true,
-        detail: {change: this.change, reviewed: newVal},
-      }));
+  _computeTopicURL(change) {
+    if (!change.topic) { return ''; }
+    return Gerrit.Nav.getUrlForTopic(change.topic, change.internalHost);
+  }
+
+  /**
+   * Computes the display string for the project column. If there is a host
+   * specified in the change detail, the string will be prefixed with it.
+   *
+   * @param {!Object} change
+   * @param {string=} truncate whether or not the project name should be
+   *     truncated. If this value is truthy, the name will be truncated.
+   * @return {string}
+   */
+  _computeRepoDisplay(change, truncate) {
+    if (!change || !change.project) { return ''; }
+    let str = '';
+    if (change.internalHost) { str += change.internalHost + '/'; }
+    str += truncate ? this.truncatePath(change.project, 2) : change.project;
+    return str;
+  }
+
+  _computeSizeTooltip(change) {
+    if (change.insertions + change.deletions === 0 ||
+        isNaN(change.insertions + change.deletions)) {
+      return 'Size unknown';
+    } else {
+      return `+${change.insertions}, -${change.deletions}`;
     }
   }
 
-  customElements.define(GrChangeListItem.is, GrChangeListItem);
-})();
+  /**
+   * TShirt sizing is based on the following paper:
+   * http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf
+   */
+  _computeChangeSize(change) {
+    const delta = change.insertions + change.deletions;
+    if (isNaN(delta) || delta === 0) {
+      return null; // Unknown
+    }
+    if (delta < CHANGE_SIZE.XS) {
+      return 'XS';
+    } else if (delta < CHANGE_SIZE.SMALL) {
+      return 'S';
+    } else if (delta < CHANGE_SIZE.MEDIUM) {
+      return 'M';
+    } else if (delta < CHANGE_SIZE.LARGE) {
+      return 'L';
+    } else {
+      return 'XL';
+    }
+  }
+
+  toggleReviewed() {
+    const newVal = !this.change.reviewed;
+    this.set('change.reviewed', newVal);
+    this.dispatchEvent(new CustomEvent('toggle-reviewed', {
+      bubbles: true,
+      composed: true,
+      detail: {change: this.change, reviewed: newVal},
+    }));
+  }
+}
+
+customElements.define(GrChangeListItem.is, GrChangeListItem);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
index 9022cf2..f76189c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
@@ -1,39 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-change-list-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-<link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
-<link rel="import" href="../../shared/gr-change-status/gr-change-status.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-
-<dom-module id="gr-change-list-item">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: table-row;
@@ -128,17 +111,16 @@
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
     <td class="cell leftPadding"></td>
-    <td class="cell star" hidden$="[[!showStar]]" hidden>
+    <td class="cell star" hidden\$="[[!showStar]]" hidden="">
       <gr-change-star change="{{change}}"></gr-change-star>
     </td>
-    <td class="cell number" hidden$="[[!showNumber]]" hidden>
-      <a href$="[[changeURL]]">[[change._number]]</a>
+    <td class="cell number" hidden\$="[[!showNumber]]" hidden="">
+      <a href\$="[[changeURL]]">[[change._number]]</a>
     </td>
-    <td class="cell subject"
-        hidden$="[[isColumnHidden('Subject', visibleChangeTableColumns)]]">
+    <td class="cell subject" hidden\$="[[isColumnHidden('Subject', visibleChangeTableColumns)]]">
       <div class="container">
         <div class="content">
-          <a title$="[[change.subject]]" href$="[[changeURL]]">
+          <a title\$="[[change.subject]]" href\$="[[changeURL]]">
             [[change.subject]]
           </a>
         </div>
@@ -148,67 +130,50 @@
         <span>&nbsp;</span>
       </div>
     </td>
-    <td class="cell status"
-        hidden$="[[isColumnHidden('Status', visibleChangeTableColumns)]]">
+    <td class="cell status" hidden\$="[[isColumnHidden('Status', visibleChangeTableColumns)]]">
       <template is="dom-repeat" items="[[statuses]]" as="status">
         <div class="comma">,</div>
-        <gr-change-status flat status="[[status]]"></gr-change-status>
+        <gr-change-status flat="" status="[[status]]"></gr-change-status>
       </template>
       <template is="dom-if" if="[[!statuses.length]]">
         <span class="placeholder">--</span>
       </template>
     </td>
-    <td class="cell owner"
-        hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]">
-      <gr-account-link
-          account="[[change.owner]]"></gr-account-link>
+    <td class="cell owner" hidden\$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]">
+      <gr-account-link account="[[change.owner]]"></gr-account-link>
     </td>
-    <td class="cell assignee"
-        hidden$="[[isColumnHidden('Assignee', visibleChangeTableColumns)]]">
+    <td class="cell assignee" hidden\$="[[isColumnHidden('Assignee', visibleChangeTableColumns)]]">
       <template is="dom-if" if="[[change.assignee]]">
-        <gr-account-link
-            id="assigneeAccountLink"
-            account="[[change.assignee]]"></gr-account-link>
+        <gr-account-link id="assigneeAccountLink" account="[[change.assignee]]"></gr-account-link>
       </template>
       <template is="dom-if" if="[[!change.assignee]]">
         <span class="placeholder">--</span>
       </template>
     </td>
-    <td class="cell repo"
-        hidden$="[[isColumnHidden('Repo', visibleChangeTableColumns)]]">
-      <a class="fullRepo" href$="[[_computeRepoUrl(change)]]">
+    <td class="cell repo" hidden\$="[[isColumnHidden('Repo', visibleChangeTableColumns)]]">
+      <a class="fullRepo" href\$="[[_computeRepoUrl(change)]]">
         [[_computeRepoDisplay(change)]]
       </a>
-      <a
-          class="truncatedRepo"
-          href$="[[_computeRepoUrl(change)]]"
-          title$="[[_computeRepoDisplay(change)]]">
+      <a class="truncatedRepo" href\$="[[_computeRepoUrl(change)]]" title\$="[[_computeRepoDisplay(change)]]">
         [[_computeRepoDisplay(change, 'true')]]
       </a>
     </td>
-    <td class="cell branch"
-        hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]">
-      <a href$="[[_computeRepoBranchURL(change)]]">
+    <td class="cell branch" hidden\$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]">
+      <a href\$="[[_computeRepoBranchURL(change)]]">
         [[change.branch]]
       </a>
       <template is="dom-if" if="[[change.topic]]">
-        (<a href$="[[_computeTopicURL(change)]]"><!--
+        (<a href\$="[[_computeTopicURL(change)]]"><!--
        --><gr-limited-text limit="50" text="[[change.topic]]">
           </gr-limited-text><!--
      --></a>)
       </template>
     </td>
-    <td class="cell updated"
-        hidden$="[[isColumnHidden('Updated', visibleChangeTableColumns)]]">
-      <gr-date-formatter
-          has-tooltip
-          date-str="[[change.updated]]"></gr-date-formatter>
+    <td class="cell updated" hidden\$="[[isColumnHidden('Updated', visibleChangeTableColumns)]]">
+      <gr-date-formatter has-tooltip="" date-str="[[change.updated]]"></gr-date-formatter>
     </td>
-    <td class="cell size"
-        hidden$="[[isColumnHidden('Size', visibleChangeTableColumns)]]">
-      <gr-tooltip-content
-          has-tooltip
-          title="[[_computeSizeTooltip(change)]]">
+    <td class="cell size" hidden\$="[[isColumnHidden('Size', visibleChangeTableColumns)]]">
+      <gr-tooltip-content has-tooltip="" title="[[_computeSizeTooltip(change)]]">
         <template is="dom-if" if="[[_changeSize]]">
             <span>[[_changeSize]]</span>
         </template>
@@ -218,20 +183,16 @@
       </gr-tooltip-content>
     </td>
     <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-      <td title$="[[_computeLabelTitle(change, labelName)]]"
-          class$="[[_computeLabelClass(change, labelName)]]">
+      <td title\$="[[_computeLabelTitle(change, labelName)]]" class\$="[[_computeLabelClass(change, labelName)]]">
         [[_computeLabelValue(change, labelName)]]
       </td>
     </template>
-    <template is="dom-repeat" items="[[_dynamicCellEndpoints]]"
-      as="pluginEndpointName">
+    <template is="dom-repeat" items="[[_dynamicCellEndpoints]]" as="pluginEndpointName">
       <td class="cell endpoint">
-        <gr-endpoint-decorator name$="[[pluginEndpointName]]">
+        <gr-endpoint-decorator name\$="[[pluginEndpointName]]">
           <gr-endpoint-param name="change" value="[[change]]">
           </gr-endpoint-param>
         </gr-endpoint-decorator>
       </td>
     </template>
-  </template>
-  <script src="gr-change-list-item.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
index 076c478..9bfec19 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-list-item</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-change-list-item.html">
+<script type="module" src="./gr-change-list-item.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-change-list-item.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,244 +43,247 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-list-item tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-change-list-item.js';
+suite('gr-change-list-item tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
     });
+    element = fixture('basic');
+  });
 
-    teardown(() => { sandbox.restore(); });
+  teardown(() => { sandbox.restore(); });
 
-    test('computed fields', () => {
-      assert.equal(element._computeLabelClass({labels: {}}),
-          'cell label u-gray-background');
-      assert.equal(element._computeLabelClass(
-          {labels: {}}, 'Verified'), 'cell label u-gray-background');
-      assert.equal(element._computeLabelClass(
-          {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
-      'cell label u-green u-monospace');
-      assert.equal(element._computeLabelClass(
-          {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
-      'cell label u-monospace u-red');
-      assert.equal(element._computeLabelClass(
-          {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
-      'cell label u-green u-monospace');
-      assert.equal(element._computeLabelClass(
-          {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
-      'cell label u-monospace u-red');
-      assert.equal(element._computeLabelClass(
-          {labels: {'Code-Review': {value: -1}}}, 'Verified'),
-      'cell label u-gray-background');
+  test('computed fields', () => {
+    assert.equal(element._computeLabelClass({labels: {}}),
+        'cell label u-gray-background');
+    assert.equal(element._computeLabelClass(
+        {labels: {}}, 'Verified'), 'cell label u-gray-background');
+    assert.equal(element._computeLabelClass(
+        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
+    'cell label u-green u-monospace');
+    assert.equal(element._computeLabelClass(
+        {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
+    'cell label u-monospace u-red');
+    assert.equal(element._computeLabelClass(
+        {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
+    'cell label u-green u-monospace');
+    assert.equal(element._computeLabelClass(
+        {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
+    'cell label u-monospace u-red');
+    assert.equal(element._computeLabelClass(
+        {labels: {'Code-Review': {value: -1}}}, 'Verified'),
+    'cell label u-gray-background');
 
-      assert.equal(element._computeLabelTitle({labels: {}}, 'Verified'),
-          'Label not applicable');
-      assert.equal(element._computeLabelTitle(
-          {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Verified'),
-      'Verified\nby Diffy');
-      assert.equal(element._computeLabelTitle(
-          {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Code-Review'),
-      'Label not applicable');
-      assert.equal(element._computeLabelTitle(
-          {labels: {Verified: {rejected: {name: 'Diffy'}}}}, 'Verified'),
-      'Verified\nby Diffy');
-      assert.equal(element._computeLabelTitle(
-          {labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}}},
-          'Code-Review'), 'Code-Review\nby Diffy');
-      assert.equal(element._computeLabelTitle(
-          {labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}}},
-          'Code-Review'), 'Code-Review\nby Diffy');
-      assert.equal(element._computeLabelTitle(
-          {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-            rejected: {name: 'Admin'}}}}, 'Code-Review'),
-      'Code-Review\nby Admin');
-      assert.equal(element._computeLabelTitle(
-          {labels: {'Code-Review': {approved: {name: 'Diffy'},
-            rejected: {name: 'Admin'}}}}, 'Code-Review'),
-      'Code-Review\nby Admin');
-      assert.equal(element._computeLabelTitle(
-          {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-            disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-      'Code-Review\nby Admin');
-      assert.equal(element._computeLabelTitle(
-          {labels: {'Code-Review': {approved: {name: 'Diffy'},
-            disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-      'Code-Review\nby Diffy');
+    assert.equal(element._computeLabelTitle({labels: {}}, 'Verified'),
+        'Label not applicable');
+    assert.equal(element._computeLabelTitle(
+        {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Verified'),
+    'Verified\nby Diffy');
+    assert.equal(element._computeLabelTitle(
+        {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Code-Review'),
+    'Label not applicable');
+    assert.equal(element._computeLabelTitle(
+        {labels: {Verified: {rejected: {name: 'Diffy'}}}}, 'Verified'),
+    'Verified\nby Diffy');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}}},
+        'Code-Review'), 'Code-Review\nby Diffy');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}}},
+        'Code-Review'), 'Code-Review\nby Diffy');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {recommended: {name: 'Diffy'},
+          rejected: {name: 'Admin'}}}}, 'Code-Review'),
+    'Code-Review\nby Admin');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {approved: {name: 'Diffy'},
+          rejected: {name: 'Admin'}}}}, 'Code-Review'),
+    'Code-Review\nby Admin');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {recommended: {name: 'Diffy'},
+          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
+    'Code-Review\nby Admin');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {approved: {name: 'Diffy'},
+          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
+    'Code-Review\nby Diffy');
 
-      assert.equal(element._computeLabelValue({labels: {}}), '');
-      assert.equal(element._computeLabelValue({labels: {}}, 'Verified'), '');
-      assert.equal(element._computeLabelValue(
-          {labels: {Verified: {approved: true, value: 1}}}, 'Verified'), '✓');
-      assert.equal(element._computeLabelValue(
-          {labels: {Verified: {value: 1}}}, 'Verified'), '+1');
-      assert.equal(element._computeLabelValue(
-          {labels: {Verified: {value: -1}}}, 'Verified'), '-1');
-      assert.equal(element._computeLabelValue(
-          {labels: {Verified: {approved: true}}}, 'Verified'), '✓');
-      assert.equal(element._computeLabelValue(
-          {labels: {Verified: {rejected: true}}}, 'Verified'), '✕');
-    });
+    assert.equal(element._computeLabelValue({labels: {}}), '');
+    assert.equal(element._computeLabelValue({labels: {}}, 'Verified'), '');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'), '✓');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {value: 1}}}, 'Verified'), '+1');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {value: -1}}}, 'Verified'), '-1');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {approved: true}}}, 'Verified'), '✓');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {rejected: true}}}, 'Verified'), '✕');
+  });
 
-    test('no hidden columns', () => {
-      element.visibleChangeTableColumns = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Repo',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
+  test('no hidden columns', () => {
+    element.visibleChangeTableColumns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
 
-      flushAsynchronousOperations();
+    flushAsynchronousOperations();
 
-      for (const column of element.columnNames) {
-        const elementClass = '.' + column.toLowerCase();
-        assert.isOk(element.shadowRoot
-            .querySelector(elementClass),
-        `Expect ${elementClass} element to be found`);
+    for (const column of element.columnNames) {
+      const elementClass = '.' + column.toLowerCase();
+      assert.isOk(element.shadowRoot
+          .querySelector(elementClass),
+      `Expect ${elementClass} element to be found`);
+      assert.isFalse(element.shadowRoot
+          .querySelector(elementClass).hidden);
+    }
+  });
+
+  test('repo column hidden', () => {
+    element.visibleChangeTableColumns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+
+    flushAsynchronousOperations();
+
+    for (const column of element.columnNames) {
+      const elementClass = '.' + column.toLowerCase();
+      if (column === 'Repo') {
+        assert.isTrue(element.shadowRoot
+            .querySelector(elementClass).hidden);
+      } else {
         assert.isFalse(element.shadowRoot
             .querySelector(elementClass).hidden);
       }
-    });
-
-    test('repo column hidden', () => {
-      element.visibleChangeTableColumns = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
-
-      flushAsynchronousOperations();
-
-      for (const column of element.columnNames) {
-        const elementClass = '.' + column.toLowerCase();
-        if (column === 'Repo') {
-          assert.isTrue(element.shadowRoot
-              .querySelector(elementClass).hidden);
-        } else {
-          assert.isFalse(element.shadowRoot
-              .querySelector(elementClass).hidden);
-        }
-      }
-    });
-
-    test('random column does not exist', () => {
-      element.visibleChangeTableColumns = [
-        'Bad',
-      ];
-
-      flushAsynchronousOperations();
-      const elementClass = '.bad';
-      assert.isNotOk(element.shadowRoot
-          .querySelector(elementClass));
-    });
-
-    test('assignee only displayed if there is one', () => {
-      element.change = {};
-      flushAsynchronousOperations();
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.assignee gr-account-link'));
-      assert.equal(element.shadowRoot
-          .querySelector('.assignee').textContent.trim(), '--');
-      element.change = {
-        assignee: {
-          name: 'test',
-          status: 'test',
-        },
-      };
-      flushAsynchronousOperations();
-      assert.isOk(element.shadowRoot
-          .querySelector('.assignee gr-account-link'));
-    });
-
-    test('TShirt sizing tooltip', () => {
-      assert.equal(element._computeSizeTooltip({
-        insertions: 'foo',
-        deletions: 'bar',
-      }), 'Size unknown');
-      assert.equal(element._computeSizeTooltip({
-        insertions: 0,
-        deletions: 0,
-      }), 'Size unknown');
-      assert.equal(element._computeSizeTooltip({
-        insertions: 1,
-        deletions: 2,
-      }), '+1, -2');
-    });
-
-    test('TShirt sizing', () => {
-      assert.equal(element._computeChangeSize({
-        insertions: 'foo',
-        deletions: 'bar',
-      }), null);
-      assert.equal(element._computeChangeSize({
-        insertions: 1,
-        deletions: 1,
-      }), 'XS');
-      assert.equal(element._computeChangeSize({
-        insertions: 9,
-        deletions: 1,
-      }), 'S');
-      assert.equal(element._computeChangeSize({
-        insertions: 10,
-        deletions: 200,
-      }), 'M');
-      assert.equal(element._computeChangeSize({
-        insertions: 99,
-        deletions: 900,
-      }), 'L');
-      assert.equal(element._computeChangeSize({
-        insertions: 99,
-        deletions: 999,
-      }), 'XL');
-    });
-
-    test('change params passed to gr-navigation', () => {
-      sandbox.stub(Gerrit.Nav);
-      const change = {
-        internalHost: 'test-host',
-        project: 'test-repo',
-        topic: 'test-topic',
-        branch: 'test-branch',
-      };
-      element.change = change;
-      flushAsynchronousOperations();
-
-      assert.deepEqual(Gerrit.Nav.getUrlForChange.lastCall.args, [change]);
-      assert.deepEqual(Gerrit.Nav.getUrlForProjectChanges.lastCall.args,
-          [change.project, true, change.internalHost]);
-      assert.deepEqual(Gerrit.Nav.getUrlForBranch.lastCall.args,
-          [change.branch, change.project, null, change.internalHost]);
-      assert.deepEqual(Gerrit.Nav.getUrlForTopic.lastCall.args,
-          [change.topic, change.internalHost]);
-    });
-
-    test('_computeRepoDisplay', () => {
-      const change = {
-        project: 'a/test/repo',
-        internalHost: 'host',
-      };
-      assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
-      assert.equal(element._computeRepoDisplay(change, true),
-          'host/…/test/repo');
-      delete change.internalHost;
-      assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
-      assert.equal(element._computeRepoDisplay(change, true),
-          '…/test/repo');
-    });
+    }
   });
+
+  test('random column does not exist', () => {
+    element.visibleChangeTableColumns = [
+      'Bad',
+    ];
+
+    flushAsynchronousOperations();
+    const elementClass = '.bad';
+    assert.isNotOk(element.shadowRoot
+        .querySelector(elementClass));
+  });
+
+  test('assignee only displayed if there is one', () => {
+    element.change = {};
+    flushAsynchronousOperations();
+    assert.isNotOk(element.shadowRoot
+        .querySelector('.assignee gr-account-link'));
+    assert.equal(element.shadowRoot
+        .querySelector('.assignee').textContent.trim(), '--');
+    element.change = {
+      assignee: {
+        name: 'test',
+        status: 'test',
+      },
+    };
+    flushAsynchronousOperations();
+    assert.isOk(element.shadowRoot
+        .querySelector('.assignee gr-account-link'));
+  });
+
+  test('TShirt sizing tooltip', () => {
+    assert.equal(element._computeSizeTooltip({
+      insertions: 'foo',
+      deletions: 'bar',
+    }), 'Size unknown');
+    assert.equal(element._computeSizeTooltip({
+      insertions: 0,
+      deletions: 0,
+    }), 'Size unknown');
+    assert.equal(element._computeSizeTooltip({
+      insertions: 1,
+      deletions: 2,
+    }), '+1, -2');
+  });
+
+  test('TShirt sizing', () => {
+    assert.equal(element._computeChangeSize({
+      insertions: 'foo',
+      deletions: 'bar',
+    }), null);
+    assert.equal(element._computeChangeSize({
+      insertions: 1,
+      deletions: 1,
+    }), 'XS');
+    assert.equal(element._computeChangeSize({
+      insertions: 9,
+      deletions: 1,
+    }), 'S');
+    assert.equal(element._computeChangeSize({
+      insertions: 10,
+      deletions: 200,
+    }), 'M');
+    assert.equal(element._computeChangeSize({
+      insertions: 99,
+      deletions: 900,
+    }), 'L');
+    assert.equal(element._computeChangeSize({
+      insertions: 99,
+      deletions: 999,
+    }), 'XL');
+  });
+
+  test('change params passed to gr-navigation', () => {
+    sandbox.stub(Gerrit.Nav);
+    const change = {
+      internalHost: 'test-host',
+      project: 'test-repo',
+      topic: 'test-topic',
+      branch: 'test-branch',
+    };
+    element.change = change;
+    flushAsynchronousOperations();
+
+    assert.deepEqual(Gerrit.Nav.getUrlForChange.lastCall.args, [change]);
+    assert.deepEqual(Gerrit.Nav.getUrlForProjectChanges.lastCall.args,
+        [change.project, true, change.internalHost]);
+    assert.deepEqual(Gerrit.Nav.getUrlForBranch.lastCall.args,
+        [change.branch, change.project, null, change.internalHost]);
+    assert.deepEqual(Gerrit.Nav.getUrlForTopic.lastCall.args,
+        [change.topic, change.internalHost]);
+  });
+
+  test('_computeRepoDisplay', () => {
+    const change = {
+      project: 'a/test/repo',
+      internalHost: 'host',
+    };
+    assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
+    assert.equal(element._computeRepoDisplay(change, true),
+        'host/…/test/repo');
+    delete change.internalHost;
+    assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
+    assert.equal(element._computeRepoDisplay(change, true),
+        '…/test/repo');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
index b82593d..383aafa 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,282 +14,298 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  const LookupQueryPatterns = {
-    CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
-    CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
-  };
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-change-list/gr-change-list.js';
+import '../gr-repo-header/gr-repo-header.js';
+import '../gr-user-header/gr-user-header.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-list-view_html.js';
 
-  const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
+const LookupQueryPatterns = {
+  CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
+  CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
+};
 
-  const REPO_QUERY_PATTERN =
-      /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
+const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
 
-  const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
+const REPO_QUERY_PATTERN =
+    /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
 
+const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
+
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrChangeListView extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.FireBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-change-list-view'; }
   /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
    */
-  class GrChangeListView extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.FireBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-change-list-view'; }
+
+  static get properties() {
+    return {
     /**
-     * Fired when the title of the page should change.
-     *
-     * @event title-change
+     * URL params passed from the router.
      */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
 
-    static get properties() {
-      return {
       /**
-       * URL params passed from the router.
+       * True when user is logged in.
        */
-        params: {
-          type: Object,
-          observer: '_paramsChanged',
-        },
+      _loggedIn: {
+        type: Boolean,
+        computed: '_computeLoggedIn(account)',
+      },
 
-        /**
-         * True when user is logged in.
-         */
-        _loggedIn: {
-          type: Boolean,
-          computed: '_computeLoggedIn(account)',
-        },
+      account: {
+        type: Object,
+        value: null,
+      },
 
-        account: {
-          type: Object,
-          value: null,
-        },
+      /**
+       * State persisted across restamps of the element.
+       *
+       * Need sub-property declaration since it is used in template before
+       * assignment.
+       *
+       * @type {{ selectedChangeIndex: (number|undefined) }}
+       *
+       */
+      viewState: {
+        type: Object,
+        notify: true,
+        value() { return {}; },
+      },
 
-        /**
-         * State persisted across restamps of the element.
-         *
-         * Need sub-property declaration since it is used in template before
-         * assignment.
-         *
-         * @type {{ selectedChangeIndex: (number|undefined) }}
-         *
-         */
-        viewState: {
-          type: Object,
-          notify: true,
-          value() { return {}; },
-        },
+      preferences: Object,
 
-        preferences: Object,
+      _changesPerPage: Number,
 
-        _changesPerPage: Number,
+      /**
+       * Currently active query.
+       */
+      _query: {
+        type: String,
+        value: '',
+      },
 
-        /**
-         * Currently active query.
-         */
-        _query: {
-          type: String,
-          value: '',
-        },
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: Number,
 
-        /**
-         * Offset of currently visible query results.
-         */
-        _offset: Number,
+      /**
+       * Change objects loaded from the server.
+       */
+      _changes: {
+        type: Array,
+        observer: '_changesChanged',
+      },
 
-        /**
-         * Change objects loaded from the server.
-         */
-        _changes: {
-          type: Array,
-          observer: '_changesChanged',
-        },
+      /**
+       * For showing a "loading..." string during ajax requests.
+       */
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
 
-        /**
-         * For showing a "loading..." string during ajax requests.
-         */
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
+      /** @type {?string} */
+      _userId: {
+        type: String,
+        value: null,
+      },
 
-        /** @type {?string} */
-        _userId: {
-          type: String,
-          value: null,
-        },
+      /** @type {?string} */
+      _repo: {
+        type: String,
+        value: null,
+      },
+    };
+  }
 
-        /** @type {?string} */
-        _repo: {
-          type: String,
-          value: null,
-        },
-      };
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('next-page',
+        () => this._handleNextPage());
+    this.addEventListener('previous-page',
+        () => this._handlePreviousPage());
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadPreferences();
+  }
+
+  _paramsChanged(value) {
+    if (value.view !== Gerrit.Nav.View.SEARCH) { return; }
+
+    this._loading = true;
+    this._query = value.query;
+    this._offset = value.offset || 0;
+    if (this.viewState.query != this._query ||
+        this.viewState.offset != this._offset) {
+      this.set('viewState.selectedChangeIndex', 0);
+      this.set('viewState.query', this._query);
+      this.set('viewState.offset', this._offset);
     }
 
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('next-page',
-          () => this._handleNextPage());
-      this.addEventListener('previous-page',
-          () => this._handlePreviousPage());
-    }
+    // NOTE: This method may be called before attachment. Fire title-change
+    // in an async so that attachment to the DOM can take place first.
+    this.async(() => this.fire('title-change', {title: this._query}));
 
-    /** @override */
-    attached() {
-      super.attached();
-      this._loadPreferences();
-    }
-
-    _paramsChanged(value) {
-      if (value.view !== Gerrit.Nav.View.SEARCH) { return; }
-
-      this._loading = true;
-      this._query = value.query;
-      this._offset = value.offset || 0;
-      if (this.viewState.query != this._query ||
-          this.viewState.offset != this._offset) {
-        this.set('viewState.selectedChangeIndex', 0);
-        this.set('viewState.query', this._query);
-        this.set('viewState.offset', this._offset);
-      }
-
-      // NOTE: This method may be called before attachment. Fire title-change
-      // in an async so that attachment to the DOM can take place first.
-      this.async(() => this.fire('title-change', {title: this._query}));
-
-      this._getPreferences()
-          .then(prefs => {
-            this._changesPerPage = prefs.changes_per_page;
-            return this._getChanges();
-          })
-          .then(changes => {
-            changes = changes || [];
-            if (this._query && changes.length === 1) {
-              for (const query in LookupQueryPatterns) {
-                if (LookupQueryPatterns.hasOwnProperty(query) &&
-                this._query.match(LookupQueryPatterns[query])) {
-                  Gerrit.Nav.navigateToChange(changes[0]);
-                  return;
-                }
+    this._getPreferences()
+        .then(prefs => {
+          this._changesPerPage = prefs.changes_per_page;
+          return this._getChanges();
+        })
+        .then(changes => {
+          changes = changes || [];
+          if (this._query && changes.length === 1) {
+            for (const query in LookupQueryPatterns) {
+              if (LookupQueryPatterns.hasOwnProperty(query) &&
+              this._query.match(LookupQueryPatterns[query])) {
+                Gerrit.Nav.navigateToChange(changes[0]);
+                return;
               }
             }
-            this._changes = changes;
-            this._loading = false;
-          });
-    }
+          }
+          this._changes = changes;
+          this._loading = false;
+        });
+  }
 
-    _loadPreferences() {
-      return this.$.restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          this._getPreferences().then(preferences => {
-            this.preferences = preferences;
-          });
-        } else {
-          this.preferences = {};
-        }
-      });
-    }
-
-    _getChanges() {
-      return this.$.restAPI.getChanges(this._changesPerPage, this._query,
-          this._offset);
-    }
-
-    _getPreferences() {
-      return this.$.restAPI.getPreferences();
-    }
-
-    _limitFor(query, defaultLimit) {
-      const match = query.match(LIMIT_OPERATOR_PATTERN);
-      if (!match) {
-        return defaultLimit;
+  _loadPreferences() {
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        this._getPreferences().then(preferences => {
+          this.preferences = preferences;
+        });
+      } else {
+        this.preferences = {};
       }
-      return parseInt(match[1], 10);
-    }
+    });
+  }
 
-    _computeNavLink(query, offset, direction, changesPerPage) {
-      // Offset could be a string when passed from the router.
-      offset = +(offset || 0);
-      const limit = this._limitFor(query, changesPerPage);
-      const newOffset = Math.max(0, offset + (limit * direction));
-      return Gerrit.Nav.getUrlForSearchQuery(query, newOffset);
-    }
+  _getChanges() {
+    return this.$.restAPI.getChanges(this._changesPerPage, this._query,
+        this._offset);
+  }
 
-    _computePrevArrowClass(offset) {
-      return offset === 0 ? 'hide' : '';
-    }
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
 
-    _computeNextArrowClass(changes) {
-      const more = changes.length && changes[changes.length - 1]._more_changes;
-      return more ? '' : 'hide';
+  _limitFor(query, defaultLimit) {
+    const match = query.match(LIMIT_OPERATOR_PATTERN);
+    if (!match) {
+      return defaultLimit;
     }
+    return parseInt(match[1], 10);
+  }
 
-    _computeNavClass(loading) {
-      return loading || !this._changes || !this._changes.length ? 'hide' : '';
+  _computeNavLink(query, offset, direction, changesPerPage) {
+    // Offset could be a string when passed from the router.
+    offset = +(offset || 0);
+    const limit = this._limitFor(query, changesPerPage);
+    const newOffset = Math.max(0, offset + (limit * direction));
+    return Gerrit.Nav.getUrlForSearchQuery(query, newOffset);
+  }
+
+  _computePrevArrowClass(offset) {
+    return offset === 0 ? 'hide' : '';
+  }
+
+  _computeNextArrowClass(changes) {
+    const more = changes.length && changes[changes.length - 1]._more_changes;
+    return more ? '' : 'hide';
+  }
+
+  _computeNavClass(loading) {
+    return loading || !this._changes || !this._changes.length ? 'hide' : '';
+  }
+
+  _handleNextPage() {
+    if (this.$.nextArrow.hidden) { return; }
+    page.show(this._computeNavLink(
+        this._query, this._offset, 1, this._changesPerPage));
+  }
+
+  _handlePreviousPage() {
+    if (this.$.prevArrow.hidden) { return; }
+    page.show(this._computeNavLink(
+        this._query, this._offset, -1, this._changesPerPage));
+  }
+
+  _changesChanged(changes) {
+    this._userId = null;
+    this._repo = null;
+    if (!changes || !changes.length) {
+      return;
     }
-
-    _handleNextPage() {
-      if (this.$.nextArrow.hidden) { return; }
-      page.show(this._computeNavLink(
-          this._query, this._offset, 1, this._changesPerPage));
-    }
-
-    _handlePreviousPage() {
-      if (this.$.prevArrow.hidden) { return; }
-      page.show(this._computeNavLink(
-          this._query, this._offset, -1, this._changesPerPage));
-    }
-
-    _changesChanged(changes) {
-      this._userId = null;
-      this._repo = null;
-      if (!changes || !changes.length) {
+    if (USER_QUERY_PATTERN.test(this._query)) {
+      const owner = changes[0].owner;
+      const userId = owner._account_id ? owner._account_id : owner.email;
+      if (userId) {
+        this._userId = userId;
         return;
       }
-      if (USER_QUERY_PATTERN.test(this._query)) {
-        const owner = changes[0].owner;
-        const userId = owner._account_id ? owner._account_id : owner.email;
-        if (userId) {
-          this._userId = userId;
-          return;
-        }
-      }
-      if (REPO_QUERY_PATTERN.test(this._query)) {
-        this._repo = changes[0].project;
-      }
     }
-
-    _computeHeaderClass(id) {
-      return id ? '' : 'hide';
-    }
-
-    _computePage(offset, changesPerPage) {
-      return offset / changesPerPage + 1;
-    }
-
-    _computeLoggedIn(account) {
-      return !!(account && Object.keys(account).length > 0);
-    }
-
-    _handleToggleStar(e) {
-      this.$.restAPI.saveChangeStarred(e.detail.change._number,
-          e.detail.starred);
-    }
-
-    _handleToggleReviewed(e) {
-      this.$.restAPI.saveChangeReviewed(e.detail.change._number,
-          e.detail.reviewed);
+    if (REPO_QUERY_PATTERN.test(this._query)) {
+      this._repo = changes[0].project;
     }
   }
 
-  customElements.define(GrChangeListView.is, GrChangeListView);
-})();
+  _computeHeaderClass(id) {
+    return id ? '' : 'hide';
+  }
+
+  _computePage(offset, changesPerPage) {
+    return offset / changesPerPage + 1;
+  }
+
+  _computeLoggedIn(account) {
+    return !!(account && Object.keys(account).length > 0);
+  }
+
+  _handleToggleStar(e) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number,
+        e.detail.starred);
+  }
+
+  _handleToggleReviewed(e) {
+    this.$.restAPI.saveChangeReviewed(e.detail.change._number,
+        e.detail.reviewed);
+  }
+}
+
+customElements.define(GrChangeListView.is, GrChangeListView);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
index 6c9d975..bed3985 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
@@ -1,34 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-change-list/gr-change-list.html">
-<link rel="import" href="../gr-repo-header/gr-repo-header.html">
-<link rel="import" href="../gr-user-header/gr-user-header.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-change-list-view">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -70,39 +58,20 @@
         }
       }
     </style>
-    <div class="loading" hidden$="[[!_loading]]" hidden>Loading...</div>
-    <div hidden$="[[_loading]]" hidden>
-      <gr-repo-header
-          repo="[[_repo]]"
-          class$="[[_computeHeaderClass(_repo)]]"></gr-repo-header>
-      <gr-user-header
-          user-id="[[_userId]]"
-          show-dashboard-link
-          logged-in="[[_loggedIn]]"
-          class$="[[_computeHeaderClass(_userId)]]"></gr-user-header>
-      <gr-change-list
-          account="[[account]]"
-          changes="{{_changes}}"
-          preferences="[[preferences]]"
-          selected-index="{{viewState.selectedChangeIndex}}"
-          show-star="[[_loggedIn]]"
-          on-toggle-star="_handleToggleStar"
-          on-toggle-reviewed="_handleToggleReviewed"></gr-change-list>
-      <nav class$="[[_computeNavClass(_loading)]]">
+    <div class="loading" hidden\$="[[!_loading]]" hidden="">Loading...</div>
+    <div hidden\$="[[_loading]]" hidden="">
+      <gr-repo-header repo="[[_repo]]" class\$="[[_computeHeaderClass(_repo)]]"></gr-repo-header>
+      <gr-user-header user-id="[[_userId]]" show-dashboard-link="" logged-in="[[_loggedIn]]" class\$="[[_computeHeaderClass(_userId)]]"></gr-user-header>
+      <gr-change-list account="[[account]]" changes="{{_changes}}" preferences="[[preferences]]" selected-index="{{viewState.selectedChangeIndex}}" show-star="[[_loggedIn]]" on-toggle-star="_handleToggleStar" on-toggle-reviewed="_handleToggleReviewed"></gr-change-list>
+      <nav class\$="[[_computeNavClass(_loading)]]">
           Page [[_computePage(_offset, _changesPerPage)]]
-          <a id="prevArrow"
-              href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
-              class$="[[_computePrevArrowClass(_offset)]]">
+          <a id="prevArrow" href\$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]" class\$="[[_computePrevArrowClass(_offset)]]">
             <iron-icon icon="gr-icons:chevron-left"></iron-icon>
           </a>
-          <a id="nextArrow"
-              href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
-              class$="[[_computeNextArrowClass(_changes)]]">
+          <a id="nextArrow" href\$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]" class\$="[[_computeNextArrowClass(_changes)]]">
             <iron-icon icon="gr-icons:chevron-right"></iron-icon>
           </a>
       </nav>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-change-list-view.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
index 1d81ab7..468cd41 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-list-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-change-list-view.html">
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-change-list-view.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-change-list-view.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,26 +41,170 @@
   </template>
 </test-fixture>
 
-<script>
-  const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
-  const COMMIT_HASH = '12345678';
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-change-list-view.js';
+const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
+const COMMIT_HASH = '12345678';
 
-  suite('gr-change-list-view tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+suite('gr-change-list-view tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getChanges(num, query) {
+        return Promise.resolve([]);
+      },
+      getAccountDetails() { return Promise.resolve({}); },
+      getAccountStatus() { return Promise.resolve({}); },
+    });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(done => {
+    flush(() => {
+      sandbox.restore();
+      done();
+    });
+  });
+
+  test('_computePage', () => {
+    assert.equal(element._computePage(0, 25), 1);
+    assert.equal(element._computePage(50, 25), 3);
+  });
+
+  test('_limitFor', () => {
+    const defaultLimit = 25;
+    const _limitFor = q => element._limitFor(q, defaultLimit);
+    assert.equal(_limitFor(''), defaultLimit);
+    assert.equal(_limitFor('limit:10'), 10);
+    assert.equal(_limitFor('xlimit:10'), defaultLimit);
+    assert.equal(_limitFor('x(limit:10'), 10);
+  });
+
+  test('_computeNavLink', () => {
+    const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForSearchQuery')
+        .returns('');
+    const query = 'status:open';
+    let offset = 0;
+    let direction = 1;
+    const changesPerPage = 5;
+
+    element._computeNavLink(query, offset, direction, changesPerPage);
+    assert.equal(getUrlStub.lastCall.args[1], 5);
+
+    direction = -1;
+    element._computeNavLink(query, offset, direction, changesPerPage);
+    assert.equal(getUrlStub.lastCall.args[1], 0);
+
+    offset = 5;
+    direction = 1;
+    element._computeNavLink(query, offset, direction, changesPerPage);
+    assert.equal(getUrlStub.lastCall.args[1], 10);
+  });
+
+  test('_computePrevArrowClass', () => {
+    let offset = 0;
+    assert.equal(element._computePrevArrowClass(offset), 'hide');
+    offset = 5;
+    assert.equal(element._computePrevArrowClass(offset), '');
+  });
+
+  test('_computeNextArrowClass', () => {
+    let changes = _.times(25, _.constant({_more_changes: true}));
+    assert.equal(element._computeNextArrowClass(changes), '');
+    changes = _.times(25, _.constant({}));
+    assert.equal(element._computeNextArrowClass(changes), 'hide');
+  });
+
+  test('_computeNavClass', () => {
+    let loading = true;
+    assert.equal(element._computeNavClass(loading), 'hide');
+    loading = false;
+    assert.equal(element._computeNavClass(loading), 'hide');
+    element._changes = [];
+    assert.equal(element._computeNavClass(loading), 'hide');
+    element._changes = _.times(5, _.constant({}));
+    assert.equal(element._computeNavClass(loading), '');
+  });
+
+  test('_handleNextPage', () => {
+    const showStub = sandbox.stub(page, 'show');
+    element.$.nextArrow.hidden = true;
+    element._handleNextPage();
+    assert.isFalse(showStub.called);
+    element.$.nextArrow.hidden = false;
+    element._handleNextPage();
+    assert.isTrue(showStub.called);
+  });
+
+  test('_handlePreviousPage', () => {
+    const showStub = sandbox.stub(page, 'show');
+    element.$.prevArrow.hidden = true;
+    element._handlePreviousPage();
+    assert.isFalse(showStub.called);
+    element.$.prevArrow.hidden = false;
+    element._handlePreviousPage();
+    assert.isTrue(showStub.called);
+  });
+
+  test('_userId query', done => {
+    assert.isNull(element._userId);
+    element._query = 'owner: foo@bar';
+    element._changes = [{owner: {email: 'foo@bar'}}];
+    flush(() => {
+      assert.equal(element._userId, 'foo@bar');
+
+      element._query = 'foo bar baz';
+      element._changes = [{owner: {email: 'foo@bar'}}];
+      assert.isNull(element._userId);
+
+      done();
+    });
+  });
+
+  test('_userId query without email', done => {
+    assert.isNull(element._userId);
+    element._query = 'owner: foo@bar';
+    element._changes = [{owner: {}}];
+    flush(() => {
+      assert.isNull(element._userId);
+      done();
+    });
+  });
+
+  test('_repo query', done => {
+    assert.isNull(element._repo);
+    element._query = 'project: test-repo';
+    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
+    flush(() => {
+      assert.equal(element._repo, 'test-repo');
+      element._query = 'foo bar baz';
+      element._changes = [{owner: {email: 'foo@bar'}}];
+      assert.isNull(element._repo);
+      done();
+    });
+  });
+
+  test('_repo query with open status', done => {
+    assert.isNull(element._repo);
+    element._query = 'project:test-repo status:open';
+    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
+    flush(() => {
+      assert.equal(element._repo, 'test-repo');
+      element._query = 'foo bar baz';
+      element._changes = [{owner: {email: 'foo@bar'}}];
+      assert.isNull(element._repo);
+      done();
+    });
+  });
+
+  suite('query based navigation', () => {
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        getChanges(num, query) {
-          return Promise.resolve([]);
-        },
-        getAccountDetails() { return Promise.resolve({}); },
-        getAccountStatus() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
     });
 
     teardown(done => {
@@ -65,205 +214,63 @@
       });
     });
 
-    test('_computePage', () => {
-      assert.equal(element._computePage(0, 25), 1);
-      assert.equal(element._computePage(50, 25), 3);
-    });
-
-    test('_limitFor', () => {
-      const defaultLimit = 25;
-      const _limitFor = q => element._limitFor(q, defaultLimit);
-      assert.equal(_limitFor(''), defaultLimit);
-      assert.equal(_limitFor('limit:10'), 10);
-      assert.equal(_limitFor('xlimit:10'), defaultLimit);
-      assert.equal(_limitFor('x(limit:10'), 10);
-    });
-
-    test('_computeNavLink', () => {
-      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForSearchQuery')
-          .returns('');
-      const query = 'status:open';
-      let offset = 0;
-      let direction = 1;
-      const changesPerPage = 5;
-
-      element._computeNavLink(query, offset, direction, changesPerPage);
-      assert.equal(getUrlStub.lastCall.args[1], 5);
-
-      direction = -1;
-      element._computeNavLink(query, offset, direction, changesPerPage);
-      assert.equal(getUrlStub.lastCall.args[1], 0);
-
-      offset = 5;
-      direction = 1;
-      element._computeNavLink(query, offset, direction, changesPerPage);
-      assert.equal(getUrlStub.lastCall.args[1], 10);
-    });
-
-    test('_computePrevArrowClass', () => {
-      let offset = 0;
-      assert.equal(element._computePrevArrowClass(offset), 'hide');
-      offset = 5;
-      assert.equal(element._computePrevArrowClass(offset), '');
-    });
-
-    test('_computeNextArrowClass', () => {
-      let changes = _.times(25, _.constant({_more_changes: true}));
-      assert.equal(element._computeNextArrowClass(changes), '');
-      changes = _.times(25, _.constant({}));
-      assert.equal(element._computeNextArrowClass(changes), 'hide');
-    });
-
-    test('_computeNavClass', () => {
-      let loading = true;
-      assert.equal(element._computeNavClass(loading), 'hide');
-      loading = false;
-      assert.equal(element._computeNavClass(loading), 'hide');
-      element._changes = [];
-      assert.equal(element._computeNavClass(loading), 'hide');
-      element._changes = _.times(5, _.constant({}));
-      assert.equal(element._computeNavClass(loading), '');
-    });
-
-    test('_handleNextPage', () => {
-      const showStub = sandbox.stub(page, 'show');
-      element.$.nextArrow.hidden = true;
-      element._handleNextPage();
-      assert.isFalse(showStub.called);
-      element.$.nextArrow.hidden = false;
-      element._handleNextPage();
-      assert.isTrue(showStub.called);
-    });
-
-    test('_handlePreviousPage', () => {
-      const showStub = sandbox.stub(page, 'show');
-      element.$.prevArrow.hidden = true;
-      element._handlePreviousPage();
-      assert.isFalse(showStub.called);
-      element.$.prevArrow.hidden = false;
-      element._handlePreviousPage();
-      assert.isTrue(showStub.called);
-    });
-
-    test('_userId query', done => {
-      assert.isNull(element._userId);
-      element._query = 'owner: foo@bar';
-      element._changes = [{owner: {email: 'foo@bar'}}];
-      flush(() => {
-        assert.equal(element._userId, 'foo@bar');
-
-        element._query = 'foo bar baz';
-        element._changes = [{owner: {email: 'foo@bar'}}];
-        assert.isNull(element._userId);
-
+    test('Searching for a change ID redirects to change', done => {
+      const change = {_number: 1};
+      sandbox.stub(element, '_getChanges')
+          .returns(Promise.resolve([change]));
+      sandbox.stub(Gerrit.Nav, 'navigateToChange', url => {
+        assert.equal(url, change);
         done();
       });
+
+      element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
     });
 
-    test('_userId query without email', done => {
-      assert.isNull(element._userId);
-      element._query = 'owner: foo@bar';
-      element._changes = [{owner: {}}];
-      flush(() => {
-        assert.isNull(element._userId);
+    test('Searching for a change num redirects to change', done => {
+      const change = {_number: 1};
+      sandbox.stub(element, '_getChanges')
+          .returns(Promise.resolve([change]));
+      sandbox.stub(Gerrit.Nav, 'navigateToChange', url => {
+        assert.equal(url, change);
         done();
       });
+
+      element.params = {view: Gerrit.Nav.View.SEARCH, query: '1'};
     });
 
-    test('_repo query', done => {
-      assert.isNull(element._repo);
-      element._query = 'project: test-repo';
-      element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-      flush(() => {
-        assert.equal(element._repo, 'test-repo');
-        element._query = 'foo bar baz';
-        element._changes = [{owner: {email: 'foo@bar'}}];
-        assert.isNull(element._repo);
+    test('Commit hash redirects to change', done => {
+      const change = {_number: 1};
+      sandbox.stub(element, '_getChanges')
+          .returns(Promise.resolve([change]));
+      sandbox.stub(Gerrit.Nav, 'navigateToChange', url => {
+        assert.equal(url, change);
         done();
       });
+
+      element.params = {view: Gerrit.Nav.View.SEARCH, query: COMMIT_HASH};
     });
 
-    test('_repo query with open status', done => {
-      assert.isNull(element._repo);
-      element._query = 'project:test-repo status:open';
-      element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-      flush(() => {
-        assert.equal(element._repo, 'test-repo');
-        element._query = 'foo bar baz';
-        element._changes = [{owner: {email: 'foo@bar'}}];
-        assert.isNull(element._repo);
-        done();
-      });
+    test('Searching for an invalid change ID searches', () => {
+      sandbox.stub(element, '_getChanges')
+          .returns(Promise.resolve([]));
+      const stub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+
+      element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
+      flushAsynchronousOperations();
+
+      assert.isFalse(stub.called);
     });
 
-    suite('query based navigation', () => {
-      setup(() => {
-      });
+    test('Change ID with multiple search results searches', () => {
+      sandbox.stub(element, '_getChanges')
+          .returns(Promise.resolve([{}, {}]));
+      const stub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
 
-      teardown(done => {
-        flush(() => {
-          sandbox.restore();
-          done();
-        });
-      });
+      element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
+      flushAsynchronousOperations();
 
-      test('Searching for a change ID redirects to change', done => {
-        const change = {_number: 1};
-        sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([change]));
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', url => {
-          assert.equal(url, change);
-          done();
-        });
-
-        element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
-      });
-
-      test('Searching for a change num redirects to change', done => {
-        const change = {_number: 1};
-        sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([change]));
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', url => {
-          assert.equal(url, change);
-          done();
-        });
-
-        element.params = {view: Gerrit.Nav.View.SEARCH, query: '1'};
-      });
-
-      test('Commit hash redirects to change', done => {
-        const change = {_number: 1};
-        sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([change]));
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', url => {
-          assert.equal(url, change);
-          done();
-        });
-
-        element.params = {view: Gerrit.Nav.View.SEARCH, query: COMMIT_HASH};
-      });
-
-      test('Searching for an invalid change ID searches', () => {
-        sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([]));
-        const stub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-
-        element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
-        flushAsynchronousOperations();
-
-        assert.isFalse(stub.called);
-      });
-
-      test('Change ID with multiple search results searches', () => {
-        sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([{}, {}]));
-        const stub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-
-        element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
-        flushAsynchronousOperations();
-
-        assert.isFalse(stub.called);
-      });
+      assert.isFalse(stub.called);
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index 30df8fd..33c2ea2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,403 +14,423 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  const NUMBER_FIXED_COLUMNS = 3;
-  const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
-  const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
-  const MAX_SHORTCUT_CHARS = 5;
+import '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/gr-change-list-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
+import '../gr-change-list-item/gr-change-list-item.js';
+import '../../../styles/shared-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-list_html.js';
+
+const NUMBER_FIXED_COLUMNS = 3;
+const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
+const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
+const MAX_SHORTCUT_CHARS = 5;
+
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.ChangeTableMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrChangeList extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.ChangeTableBehavior,
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+  Gerrit.RESTClientBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-change-list'; }
+  /**
+   * Fired when next page key shortcut was pressed.
+   *
+   * @event next-page
+   */
 
   /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.ChangeTableMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
+   * Fired when previous page key shortcut was pressed.
+   *
+   * @event previous-page
    */
-  class GrChangeList extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.ChangeTableBehavior,
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-    Gerrit.RESTClientBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-change-list'; }
-    /**
-     * Fired when next page key shortcut was pressed.
-     *
-     * @event next-page
-     */
 
+  static get properties() {
+    return {
     /**
-     * Fired when previous page key shortcut was pressed.
-     *
-     * @event previous-page
+     * The logged-in user's account, or an empty object if no user is logged
+     * in.
      */
-
-    static get properties() {
-      return {
+      account: {
+        type: Object,
+        value: null,
+      },
       /**
-       * The logged-in user's account, or an empty object if no user is logged
-       * in.
+       * An array of ChangeInfo objects to render.
+       * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
        */
-        account: {
-          type: Object,
-          value: null,
-        },
-        /**
-         * An array of ChangeInfo objects to render.
-         * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
-         */
-        changes: {
-          type: Array,
-          observer: '_changesChanged',
-        },
-        /**
-         * ChangeInfo objects grouped into arrays. The sections and changes
-         * properties should not be used together.
-         *
-         * @type {!Array<{
-         *   name: string,
-         *   query: string,
-         *   results: !Array<!Object>
-         * }>}
-         */
-        sections: {
-          type: Array,
-          value() { return []; },
-        },
-        labelNames: {
-          type: Array,
-          computed: '_computeLabelNames(sections)',
-        },
-        _dynamicHeaderEndpoints: {
-          type: Array,
-        },
-        selectedIndex: {
-          type: Number,
-          notify: true,
-        },
-        showNumber: Boolean, // No default value to prevent flickering.
-        showStar: {
-          type: Boolean,
-          value: false,
-        },
-        showReviewedState: {
-          type: Boolean,
-          value: false,
-        },
-        keyEventTarget: {
-          type: Object,
-          value() { return document.body; },
-        },
-        changeTableColumns: Array,
-        visibleChangeTableColumns: Array,
-        preferences: Object,
-      };
-    }
+      changes: {
+        type: Array,
+        observer: '_changesChanged',
+      },
+      /**
+       * ChangeInfo objects grouped into arrays. The sections and changes
+       * properties should not be used together.
+       *
+       * @type {!Array<{
+       *   name: string,
+       *   query: string,
+       *   results: !Array<!Object>
+       * }>}
+       */
+      sections: {
+        type: Array,
+        value() { return []; },
+      },
+      labelNames: {
+        type: Array,
+        computed: '_computeLabelNames(sections)',
+      },
+      _dynamicHeaderEndpoints: {
+        type: Array,
+      },
+      selectedIndex: {
+        type: Number,
+        notify: true,
+      },
+      showNumber: Boolean, // No default value to prevent flickering.
+      showStar: {
+        type: Boolean,
+        value: false,
+      },
+      showReviewedState: {
+        type: Boolean,
+        value: false,
+      },
+      keyEventTarget: {
+        type: Object,
+        value() { return document.body; },
+      },
+      changeTableColumns: Array,
+      visibleChangeTableColumns: Array,
+      preferences: Object,
+    };
+  }
 
-    static get observers() {
-      return [
-        '_sectionsChanged(sections.*)',
-        '_computePreferences(account, preferences)',
-      ];
-    }
+  static get observers() {
+    return [
+      '_sectionsChanged(sections.*)',
+      '_computePreferences(account, preferences)',
+    ];
+  }
 
-    keyboardShortcuts() {
-      return {
-        [this.Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
-        [this.Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
-        [this.Shortcut.NEXT_PAGE]: '_nextPage',
-        [this.Shortcut.PREV_PAGE]: '_prevPage',
-        [this.Shortcut.OPEN_CHANGE]: '_openChange',
-        [this.Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
-        [this.Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
-        [this.Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
-      };
-    }
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
+      [this.Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
+      [this.Shortcut.NEXT_PAGE]: '_nextPage',
+      [this.Shortcut.PREV_PAGE]: '_prevPage',
+      [this.Shortcut.OPEN_CHANGE]: '_openChange',
+      [this.Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
+      [this.Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
+      [this.Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
+    };
+  }
 
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('keydown',
-          e => this._scopedKeydownHandler(e));
-    }
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('keydown',
+        e => this._scopedKeydownHandler(e));
+  }
 
-    /** @override */
-    ready() {
-      super.ready();
-      this._ensureAttribute('tabindex', 0);
-    }
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('tabindex', 0);
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      Gerrit.awaitPluginsLoaded().then(() => {
-        this._dynamicHeaderEndpoints = Gerrit._endpoints.getDynamicEndpoints(
-            'change-list-header');
-      });
-    }
+  /** @override */
+  attached() {
+    super.attached();
+    Gerrit.awaitPluginsLoaded().then(() => {
+      this._dynamicHeaderEndpoints = Gerrit._endpoints.getDynamicEndpoints(
+          'change-list-header');
+    });
+  }
 
-    /**
-     * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
-     * events must be scoped to a component level (e.g. `enter`) in order to not
-     * override native browser functionality.
-     *
-     * Context: Issue 7294
-     */
-    _scopedKeydownHandler(e) {
-      if (e.keyCode === 13) {
-        // Enter.
-        this._openChange(e);
-      }
-    }
-
-    _lowerCase(column) {
-      return column.toLowerCase();
-    }
-
-    _computePreferences(account, preferences) {
-      // Polymer 2: check for undefined
-      if ([account, preferences].some(arg => arg === undefined)) {
-        return;
-      }
-
-      this.changeTableColumns = this.columnNames;
-
-      if (account) {
-        this.showNumber = !!(preferences &&
-            preferences.legacycid_in_change_table);
-        if (preferences.change_table &&
-            preferences.change_table.length > 0) {
-          this.visibleChangeTableColumns =
-            this.getVisibleColumns(preferences.change_table);
-        } else {
-          this.visibleChangeTableColumns = this.columnNames;
-        }
-      } else {
-        // Not logged in.
-        this.showNumber = false;
-        this.visibleChangeTableColumns = this.columnNames;
-      }
-    }
-
-    _computeColspan(changeTableColumns, labelNames) {
-      if (!changeTableColumns || !labelNames) return;
-      return changeTableColumns.length + labelNames.length +
-          NUMBER_FIXED_COLUMNS;
-    }
-
-    _computeLabelNames(sections) {
-      if (!sections) { return []; }
-      let labels = [];
-      const nonExistingLabel = function(item) {
-        return !labels.includes(item);
-      };
-      for (const section of sections) {
-        if (!section.results) { continue; }
-        for (const change of section.results) {
-          if (!change.labels) { continue; }
-          const currentLabels = Object.keys(change.labels);
-          labels = labels.concat(currentLabels.filter(nonExistingLabel));
-        }
-      }
-      return labels.sort();
-    }
-
-    _computeLabelShortcut(labelName) {
-      if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
-        labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
-      }
-      return labelName.split('-')
-          .reduce((a, i) => {
-            if (!i) { return a; }
-            return a + i[0].toUpperCase();
-          }, '')
-          .slice(0, MAX_SHORTCUT_CHARS);
-    }
-
-    _changesChanged(changes) {
-      this.sections = changes ? [{results: changes}] : [];
-    }
-
-    _processQuery(query) {
-      let tokens = query.split(' ');
-      const invalidTokens = ['limit:', 'age:', '-age:'];
-      tokens = tokens.filter(token => !invalidTokens
-          .some(invalidToken => token.startsWith(invalidToken)));
-      return tokens.join(' ');
-    }
-
-    _sectionHref(query) {
-      return Gerrit.Nav.getUrlForSearchQuery(this._processQuery(query));
-    }
-
-    /**
-     * Maps an index local to a particular section to the absolute index
-     * across all the changes on the page.
-     *
-     * @param {number} sectionIndex index of section
-     * @param {number} localIndex index of row within section
-     * @return {number} absolute index of row in the aggregate dashboard
-     */
-    _computeItemAbsoluteIndex(sectionIndex, localIndex) {
-      let idx = 0;
-      for (let i = 0; i < sectionIndex; i++) {
-        idx += this.sections[i].results.length;
-      }
-      return idx + localIndex;
-    }
-
-    _computeItemSelected(sectionIndex, index, selectedIndex) {
-      const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
-      return idx == selectedIndex;
-    }
-
-    _computeItemNeedsReview(account, change, showReviewedState) {
-      return showReviewedState && !change.reviewed &&
-          !change.work_in_progress &&
-          this.changeIsOpen(change) &&
-          (!account || account._account_id != change.owner._account_id);
-    }
-
-    _computeItemHighlight(account, change) {
-      // Do not show the assignee highlight if the change is not open.
-      if (!change ||!change.assignee ||
-          !account ||
-          CLOSED_STATUS.indexOf(change.status) !== -1) {
-        return false;
-      }
-      return account._account_id === change.assignee._account_id;
-    }
-
-    _nextChange(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.$.cursor.next();
-    }
-
-    _prevChange(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.$.cursor.previous();
-    }
-
-    _openChange(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      Gerrit.Nav.navigateToChange(this._changeForIndex(this.selectedIndex));
-    }
-
-    _nextPage(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
-        return;
-      }
-
-      e.preventDefault();
-      this.fire('next-page');
-    }
-
-    _prevPage(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
-        return;
-      }
-
-      e.preventDefault();
-      this.fire('previous-page');
-    }
-
-    _toggleChangeReviewed(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this._toggleReviewedForIndex(this.selectedIndex);
-    }
-
-    _toggleReviewedForIndex(index) {
-      const changeEls = this._getListItems();
-      if (index >= changeEls.length || !changeEls[index]) {
-        return;
-      }
-
-      const changeEl = changeEls[index];
-      changeEl.toggleReviewed();
-    }
-
-    _refreshChangeList(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      e.preventDefault();
-      this._reloadWindow();
-    }
-
-    _reloadWindow() {
-      window.location.reload();
-    }
-
-    _toggleChangeStar(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this._toggleStarForIndex(this.selectedIndex);
-    }
-
-    _toggleStarForIndex(index) {
-      const changeEls = this._getListItems();
-      if (index >= changeEls.length || !changeEls[index]) {
-        return;
-      }
-
-      const changeEl = changeEls[index];
-      changeEl.shadowRoot
-          .querySelector('gr-change-star').toggleStar();
-    }
-
-    _changeForIndex(index) {
-      const changeEls = this._getListItems();
-      if (index < changeEls.length && changeEls[index]) {
-        return changeEls[index].change;
-      }
-      return null;
-    }
-
-    _getListItems() {
-      return Array.from(
-          Polymer.dom(this.root).querySelectorAll('gr-change-list-item'));
-    }
-
-    _sectionsChanged() {
-      // Flush DOM operations so that the list item elements will be loaded.
-      Polymer.RenderStatus.afterNextRender(this, () => {
-        this.$.cursor.stops = this._getListItems();
-        this.$.cursor.moveToStart();
-      });
-    }
-
-    _isOutgoing(section) {
-      return !!section.isOutgoing;
-    }
-
-    _isEmpty(section) {
-      return !section.results.length;
+  /**
+   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+   * events must be scoped to a component level (e.g. `enter`) in order to not
+   * override native browser functionality.
+   *
+   * Context: Issue 7294
+   */
+  _scopedKeydownHandler(e) {
+    if (e.keyCode === 13) {
+      // Enter.
+      this._openChange(e);
     }
   }
 
-  customElements.define(GrChangeList.is, GrChangeList);
-})();
+  _lowerCase(column) {
+    return column.toLowerCase();
+  }
+
+  _computePreferences(account, preferences) {
+    // Polymer 2: check for undefined
+    if ([account, preferences].some(arg => arg === undefined)) {
+      return;
+    }
+
+    this.changeTableColumns = this.columnNames;
+
+    if (account) {
+      this.showNumber = !!(preferences &&
+          preferences.legacycid_in_change_table);
+      if (preferences.change_table &&
+          preferences.change_table.length > 0) {
+        this.visibleChangeTableColumns =
+          this.getVisibleColumns(preferences.change_table);
+      } else {
+        this.visibleChangeTableColumns = this.columnNames;
+      }
+    } else {
+      // Not logged in.
+      this.showNumber = false;
+      this.visibleChangeTableColumns = this.columnNames;
+    }
+  }
+
+  _computeColspan(changeTableColumns, labelNames) {
+    if (!changeTableColumns || !labelNames) return;
+    return changeTableColumns.length + labelNames.length +
+        NUMBER_FIXED_COLUMNS;
+  }
+
+  _computeLabelNames(sections) {
+    if (!sections) { return []; }
+    let labels = [];
+    const nonExistingLabel = function(item) {
+      return !labels.includes(item);
+    };
+    for (const section of sections) {
+      if (!section.results) { continue; }
+      for (const change of section.results) {
+        if (!change.labels) { continue; }
+        const currentLabels = Object.keys(change.labels);
+        labels = labels.concat(currentLabels.filter(nonExistingLabel));
+      }
+    }
+    return labels.sort();
+  }
+
+  _computeLabelShortcut(labelName) {
+    if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
+      labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
+    }
+    return labelName.split('-')
+        .reduce((a, i) => {
+          if (!i) { return a; }
+          return a + i[0].toUpperCase();
+        }, '')
+        .slice(0, MAX_SHORTCUT_CHARS);
+  }
+
+  _changesChanged(changes) {
+    this.sections = changes ? [{results: changes}] : [];
+  }
+
+  _processQuery(query) {
+    let tokens = query.split(' ');
+    const invalidTokens = ['limit:', 'age:', '-age:'];
+    tokens = tokens.filter(token => !invalidTokens
+        .some(invalidToken => token.startsWith(invalidToken)));
+    return tokens.join(' ');
+  }
+
+  _sectionHref(query) {
+    return Gerrit.Nav.getUrlForSearchQuery(this._processQuery(query));
+  }
+
+  /**
+   * Maps an index local to a particular section to the absolute index
+   * across all the changes on the page.
+   *
+   * @param {number} sectionIndex index of section
+   * @param {number} localIndex index of row within section
+   * @return {number} absolute index of row in the aggregate dashboard
+   */
+  _computeItemAbsoluteIndex(sectionIndex, localIndex) {
+    let idx = 0;
+    for (let i = 0; i < sectionIndex; i++) {
+      idx += this.sections[i].results.length;
+    }
+    return idx + localIndex;
+  }
+
+  _computeItemSelected(sectionIndex, index, selectedIndex) {
+    const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
+    return idx == selectedIndex;
+  }
+
+  _computeItemNeedsReview(account, change, showReviewedState) {
+    return showReviewedState && !change.reviewed &&
+        !change.work_in_progress &&
+        this.changeIsOpen(change) &&
+        (!account || account._account_id != change.owner._account_id);
+  }
+
+  _computeItemHighlight(account, change) {
+    // Do not show the assignee highlight if the change is not open.
+    if (!change ||!change.assignee ||
+        !account ||
+        CLOSED_STATUS.indexOf(change.status) !== -1) {
+      return false;
+    }
+    return account._account_id === change.assignee._account_id;
+  }
+
+  _nextChange(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this.$.cursor.next();
+  }
+
+  _prevChange(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this.$.cursor.previous();
+  }
+
+  _openChange(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    Gerrit.Nav.navigateToChange(this._changeForIndex(this.selectedIndex));
+  }
+
+  _nextPage(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
+      return;
+    }
+
+    e.preventDefault();
+    this.fire('next-page');
+  }
+
+  _prevPage(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
+      return;
+    }
+
+    e.preventDefault();
+    this.fire('previous-page');
+  }
+
+  _toggleChangeReviewed(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this._toggleReviewedForIndex(this.selectedIndex);
+  }
+
+  _toggleReviewedForIndex(index) {
+    const changeEls = this._getListItems();
+    if (index >= changeEls.length || !changeEls[index]) {
+      return;
+    }
+
+    const changeEl = changeEls[index];
+    changeEl.toggleReviewed();
+  }
+
+  _refreshChangeList(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    e.preventDefault();
+    this._reloadWindow();
+  }
+
+  _reloadWindow() {
+    window.location.reload();
+  }
+
+  _toggleChangeStar(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this._toggleStarForIndex(this.selectedIndex);
+  }
+
+  _toggleStarForIndex(index) {
+    const changeEls = this._getListItems();
+    if (index >= changeEls.length || !changeEls[index]) {
+      return;
+    }
+
+    const changeEl = changeEls[index];
+    changeEl.shadowRoot
+        .querySelector('gr-change-star').toggleStar();
+  }
+
+  _changeForIndex(index) {
+    const changeEls = this._getListItems();
+    if (index < changeEls.length && changeEls[index]) {
+      return changeEls[index].change;
+    }
+    return null;
+  }
+
+  _getListItems() {
+    return Array.from(
+        dom(this.root).querySelectorAll('gr-change-list-item'));
+  }
+
+  _sectionsChanged() {
+    // Flush DOM operations so that the list item elements will be loaded.
+    afterNextRender(this, () => {
+      this.$.cursor.stops = this._getListItems();
+      this.$.cursor.moveToStart();
+    });
+  }
+
+  _isOutgoing(section) {
+    return !!section.isOutgoing;
+  }
+
+  _isEmpty(section) {
+    return !section.results.length;
+  }
+}
+
+customElements.define(GrChangeList.is, GrChangeList);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
index 61b9960..ac37827 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
@@ -1,36 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/gr-change-list-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-<link rel="import" href="../gr-change-list-item/gr-change-list-item.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-
-<dom-module id="gr-change-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -57,16 +43,14 @@
       }
     </style>
     <table id="changeList">
-      <template is="dom-repeat" items="[[sections]]" as="changeSection"
-          index-as="sectionIndex">
+      <template is="dom-repeat" items="[[sections]]" as="changeSection" index-as="sectionIndex">
         <template is="dom-if" if="[[changeSection.name]]">
           <tbody>
             <tr class="groupHeader">
               <td class="leftPadding"></td>
-              <td class="star" hidden$="[[!showStar]]" hidden></td>
-              <td class="cell"
-                  colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
-                <a href$="[[_sectionHref(changeSection.query)]]" class="section-title">
+              <td class="star" hidden\$="[[!showStar]]" hidden=""></td>
+              <td class="cell" colspan\$="[[_computeColspan(changeTableColumns, labelNames)]]">
+                <a href\$="[[_sectionHref(changeSection.query)]]" class="section-title">
                   <span class="section-name">[[changeSection.name]]</span>
                   <span class="section-count-label">[[changeSection.countLabel]]</span>
                 </a>
@@ -78,9 +62,8 @@
           <template is="dom-if" if="[[_isEmpty(changeSection)]]">
             <tr class="noChanges">
               <td class="leftPadding"></td>
-              <td class="star" hidden$="[[!showStar]]" hidden></td>
-              <td class="cell"
-                  colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
+              <td class="star" hidden\$="[[!showStar]]" hidden=""></td>
+              <td class="cell" colspan\$="[[_computeColspan(changeTableColumns, labelNames)]]">
                 <template is="dom-if" if="[[_isOutgoing(changeSection)]]">
                   <slot name="empty-outgoing"></slot>
                 </template>
@@ -93,48 +76,31 @@
           <template is="dom-if" if="[[!_isEmpty(changeSection)]]">
             <tr class="groupTitle">
               <td class="leftPadding"></td>
-              <td class="star" hidden$="[[!showStar]]" hidden></td>
-              <td class="number" hidden$="[[!showNumber]]" hidden>#</td>
+              <td class="star" hidden\$="[[!showStar]]" hidden=""></td>
+              <td class="number" hidden\$="[[!showNumber]]" hidden="">#</td>
               <template is="dom-repeat" items="[[changeTableColumns]]" as="item">
-                <td class$="[[_lowerCase(item)]]"
-                    hidden$="[[isColumnHidden(item, visibleChangeTableColumns)]]">
+                <td class\$="[[_lowerCase(item)]]" hidden\$="[[isColumnHidden(item, visibleChangeTableColumns)]]">
                   [[item]]
                 </td>
               </template>
               <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-                <td class="label" title$="[[labelName]]">
+                <td class="label" title\$="[[labelName]]">
                   [[_computeLabelShortcut(labelName)]]
                 </td>
               </template>
-              <template is="dom-repeat" items="[[_dynamicHeaderEndpoints]]"
-                        as="pluginHeader">
+              <template is="dom-repeat" items="[[_dynamicHeaderEndpoints]]" as="pluginHeader">
                 <td class="endpoint">
-                  <gr-endpoint-decorator name$="[[pluginHeader]]">
+                  <gr-endpoint-decorator name\$="[[pluginHeader]]">
                   </gr-endpoint-decorator>
                 </td>
               </template>
             </tr>
           </template>
           <template is="dom-repeat" items="[[changeSection.results]]" as="change">
-            <gr-change-list-item
-                selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
-                highlight$="[[_computeItemHighlight(account, change)]]"
-                needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState)]]"
-                change="[[change]]"
-                visible-change-table-columns="[[visibleChangeTableColumns]]"
-                show-number="[[showNumber]]"
-                show-star="[[showStar]]"
-                tabindex="0"
-                label-names="[[labelNames]]"></gr-change-list-item>
+            <gr-change-list-item selected\$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]" highlight\$="[[_computeItemHighlight(account, change)]]" needs-review\$="[[_computeItemNeedsReview(account, change, showReviewedState)]]" change="[[change]]" visible-change-table-columns="[[visibleChangeTableColumns]]" show-number="[[showNumber]]" show-star="[[showStar]]" tabindex="0" label-names="[[labelNames]]"></gr-change-list-item>
           </template>
         </tbody>
       </template>
     </table>
-    <gr-cursor-manager
-        id="cursor"
-        index="{{selectedIndex}}"
-        scroll-behavior="keep-visible"
-        focus-on-move></gr-cursor-manager>
-  </template>
-  <script src="gr-change-list.js"></script>
-</dom-module>
+    <gr-cursor-manager id="cursor" index="{{selectedIndex}}" scroll-behavior="keep-visible" focus-on-move=""></gr-cursor-manager>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
index 23a5046..6329666 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -19,17 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
-<link rel="import" href="gr-change-list.html">
+<script type="module" src="./gr-change-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-change-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -43,20 +48,420 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-list basic tests', async () => {
-    await readyToTest();
-    // Define keybindings before attaching other fixtures.
-    const kb = window.Gerrit.KeyboardShortcutBinder;
-    kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_CHANGE, 'j');
-    kb.bindShortcut(kb.Shortcut.CURSOR_PREV_CHANGE, 'k');
-    kb.bindShortcut(kb.Shortcut.OPEN_CHANGE, 'o');
-    kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
-    kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'n');
-    kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'p');
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-change-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
+suite('gr-change-list basic tests', () => {
+  // Define keybindings before attaching other fixtures.
+  const kb = window.Gerrit.KeyboardShortcutBinder;
+  kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_CHANGE, 'j');
+  kb.bindShortcut(kb.Shortcut.CURSOR_PREV_CHANGE, 'k');
+  kb.bindShortcut(kb.Shortcut.OPEN_CHANGE, 'o');
+  kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
+  kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'n');
+  kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'p');
 
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  suite('test show change number not logged in', () => {
+    setup(() => {
+      element = fixture('basic');
+      element.account = null;
+      element.preferences = null;
+    });
+
+    test('show number disabled', () => {
+      assert.isFalse(element.showNumber);
+    });
+  });
+
+  suite('test show change number preference enabled', () => {
+    setup(() => {
+      element = fixture('basic');
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [],
+      };
+      element.account = {_account_id: 1001};
+      flushAsynchronousOperations();
+    });
+
+    test('show number enabled', () => {
+      assert.isTrue(element.showNumber);
+    });
+  });
+
+  suite('test show change number preference disabled', () => {
+    setup(() => {
+      element = fixture('basic');
+      // legacycid_in_change_table is not set when false.
+      element.preferences = {
+        time_format: 'HHMM_12',
+        change_table: [],
+      };
+      element.account = {_account_id: 1001};
+      flushAsynchronousOperations();
+    });
+
+    test('show number disabled', () => {
+      assert.isFalse(element.showNumber);
+    });
+  });
+
+  test('computed fields', () => {
+    assert.equal(element._computeLabelNames(
+        [{results: [{_number: 0, labels: {}}]}]).length, 0);
+    assert.equal(element._computeLabelNames([
+      {results: [
+        {_number: 0, labels: {Verified: {approved: {}}}},
+        {
+          _number: 1,
+          labels: {
+            'Verified': {approved: {}},
+            'Code-Review': {approved: {}},
+          },
+        },
+        {
+          _number: 2,
+          labels: {
+            'Verified': {approved: {}},
+            'Library-Compliance': {approved: {}},
+          },
+        },
+      ]},
+    ]).length, 3);
+
+    assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
+    assert.equal(element._computeLabelShortcut('Verified'), 'V');
+    assert.equal(element._computeLabelShortcut('Library-Compliance'), 'LC');
+    assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
+    assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
+    assert.equal(element._computeLabelShortcut(
+        'Invalid-Prolog-Rules-Label-Name--Verified'), 'V');
+    assert.equal(element._computeLabelShortcut(
+        'Some-Special-Label-7'), 'SSL7');
+    assert.equal(element._computeLabelShortcut('--Too----many----dashes---'),
+        'TMD');
+    assert.equal(element._computeLabelShortcut(
+        'Really-rather-entirely-too-long-of-a-label-name'), 'RRETL');
+  });
+
+  test('colspans', () => {
+    element.sections = [
+      {results: [{}]},
+    ];
+    flushAsynchronousOperations();
+    const tdItemCount = dom(element.root).querySelectorAll(
+        'td').length;
+
+    const changeTableColumns = [];
+    const labelNames = [];
+    assert.equal(tdItemCount, element._computeColspan(
+        changeTableColumns, labelNames));
+  });
+
+  test('keyboard shortcuts', done => {
+    sandbox.stub(element, '_computeLabelNames');
+    element.sections = [
+      {results: new Array(1)},
+      {results: new Array(2)},
+    ];
+    element.selectedIndex = 0;
+    element.changes = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    flushAsynchronousOperations();
+    afterNextRender(element, () => {
+      const elementItems = dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 3);
+
+      assert.isTrue(elementItems[0].hasAttribute('selected'));
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert.equal(element.selectedIndex, 1);
+      assert.isTrue(elementItems[1].hasAttribute('selected'));
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert.equal(element.selectedIndex, 2);
+      assert.isTrue(elementItems[2].hasAttribute('selected'));
+
+      const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      assert.equal(element.selectedIndex, 2);
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
+          'Should navigate to /c/2/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
+          'Should navigate to /c/1/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      assert.equal(element.selectedIndex, 0);
+
+      const reloadStub = sandbox.stub(element, '_reloadWindow');
+      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      assert.isTrue(reloadStub.called);
+
+      done();
+    });
+  });
+
+  test('changes needing review', () => {
+    element.changes = [
+      {
+        _number: 0,
+        status: 'NEW',
+        reviewed: true,
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 1,
+        status: 'NEW',
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 2,
+        status: 'MERGED',
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 3,
+        status: 'ABANDONED',
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 4,
+        status: 'NEW',
+        work_in_progress: true,
+        owner: {_account_id: 0},
+      },
+    ];
+    flushAsynchronousOperations();
+    let elementItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 5);
+    for (let i = 0; i < elementItems.length; i++) {
+      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
+    }
+
+    element.showReviewedState = true;
+    elementItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 5);
+    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
+    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+
+    element.account = {_account_id: 42};
+    elementItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 5);
+    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
+    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+  });
+
+  test('no changes', () => {
+    element.changes = [];
+    flushAsynchronousOperations();
+    const listItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(listItems.length, 0);
+    const noChangesMsg =
+        dom(element.root).querySelector('.noChanges');
+    assert.ok(noChangesMsg);
+  });
+
+  test('empty sections', () => {
+    element.sections = [{results: []}, {results: []}];
+    flushAsynchronousOperations();
+    const listItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(listItems.length, 0);
+    const noChangesMsg = dom(element.root).querySelectorAll(
+        '.noChanges');
+    assert.equal(noChangesMsg.length, 2);
+  });
+
+  suite('empty outgoing', () => {
+    test('not shown on empty non-outgoing sections', () => {
+      const section = {results: []};
+      assert.isTrue(element._isEmpty(section));
+      assert.isFalse(element._isOutgoing(section));
+    });
+
+    test('shown on empty outgoing sections', () => {
+      const section = {results: [], isOutgoing: true};
+      assert.isTrue(element._isEmpty(section));
+      assert.isTrue(element._isOutgoing(section));
+    });
+
+    test('not shown on non-empty outgoing sections', () => {
+      const section = {isOutgoing: true, results: [
+        {_number: 0, labels: {Verified: {approved: {}}}}]};
+      assert.isFalse(element._isEmpty(section));
+      assert.isTrue(element._isOutgoing(section));
+    });
+  });
+
+  test('_isOutgoing', () => {
+    assert.isTrue(element._isOutgoing({results: [], isOutgoing: true}));
+    assert.isFalse(element._isOutgoing({results: []}));
+  });
+
+  suite('empty column preference', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+      element.sections = [
+        {results: [{}]},
+      ];
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [],
+      };
+      flushAsynchronousOperations();
+    });
+
+    test('show number enabled', () => {
+      assert.isTrue(element.showNumber);
+    });
+
+    test('all columns visible', () => {
+      for (const column of element.columnNames) {
+        const elementClass = '.' + element._lowerCase(column);
+        assert.isFalse(element.shadowRoot
+            .querySelector(elementClass).hidden);
+      }
+    });
+  });
+
+  suite('full column preference', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+      element.sections = [
+        {results: [{}]},
+      ];
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [
+          'Subject',
+          'Status',
+          'Owner',
+          'Assignee',
+          'Repo',
+          'Branch',
+          'Updated',
+          'Size',
+        ],
+      };
+      flushAsynchronousOperations();
+    });
+
+    test('all columns visible', () => {
+      for (const column of element.changeTableColumns) {
+        const elementClass = '.' + element._lowerCase(column);
+        assert.isFalse(element.shadowRoot
+            .querySelector(elementClass).hidden);
+      }
+    });
+  });
+
+  suite('partial column preference', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+      element.sections = [
+        {results: [{}]},
+      ];
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [
+          'Subject',
+          'Status',
+          'Owner',
+          'Assignee',
+          'Branch',
+          'Updated',
+          'Size',
+        ],
+      };
+      flushAsynchronousOperations();
+    });
+
+    test('all columns except repo visible', () => {
+      for (const column of element.changeTableColumns) {
+        const elementClass = '.' + column.toLowerCase();
+        if (column === 'Repo') {
+          assert.isTrue(element.shadowRoot
+              .querySelector(elementClass).hidden);
+        } else {
+          assert.isFalse(element.shadowRoot
+              .querySelector(elementClass).hidden);
+        }
+      }
+    });
+  });
+
+  suite('random column does not exist', () => {
+    let element;
+
+    /* This would only exist if somebody manually updated the config
+    file. */
+    setup(() => {
+      element = fixture('basic');
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [
+          'Bad',
+        ],
+      };
+      flushAsynchronousOperations();
+    });
+
+    test('bad column does not exist', () => {
+      const elementClass = '.bad';
+      assert.isNotOk(element.shadowRoot
+          .querySelector(elementClass));
+    });
+  });
+
+  suite('dashboard queries', () => {
     let element;
     let sandbox;
 
@@ -67,576 +472,180 @@
 
     teardown(() => { sandbox.restore(); });
 
-    suite('test show change number not logged in', () => {
-      setup(() => {
-        element = fixture('basic');
-        element.account = null;
-        element.preferences = null;
-      });
-
-      test('show number disabled', () => {
-        assert.isFalse(element.showNumber);
-      });
+    test('query without age and limit unchanged', () => {
+      const query = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), query);
     });
 
-    suite('test show change number preference enabled', () => {
-      setup(() => {
-        element = fixture('basic');
-        element.preferences = {
-          legacycid_in_change_table: true,
-          time_format: 'HHMM_12',
-          change_table: [],
-        };
-        element.account = {_account_id: 1001};
-        flushAsynchronousOperations();
-      });
-
-      test('show number enabled', () => {
-        assert.isTrue(element.showNumber);
-      });
+    test('query with age and limit', () => {
+      const query = 'status:closed age:1week limit:10 owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
     });
 
-    suite('test show change number preference disabled', () => {
-      setup(() => {
-        element = fixture('basic');
-        // legacycid_in_change_table is not set when false.
-        element.preferences = {
-          time_format: 'HHMM_12',
-          change_table: [],
-        };
-        element.account = {_account_id: 1001};
-        flushAsynchronousOperations();
-      });
-
-      test('show number disabled', () => {
-        assert.isFalse(element.showNumber);
-      });
+    test('query with age', () => {
+      const query = 'status:closed age:1week owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
     });
 
-    test('computed fields', () => {
-      assert.equal(element._computeLabelNames(
-          [{results: [{_number: 0, labels: {}}]}]).length, 0);
-      assert.equal(element._computeLabelNames([
-        {results: [
-          {_number: 0, labels: {Verified: {approved: {}}}},
-          {
-            _number: 1,
-            labels: {
-              'Verified': {approved: {}},
-              'Code-Review': {approved: {}},
-            },
-          },
-          {
-            _number: 2,
-            labels: {
-              'Verified': {approved: {}},
-              'Library-Compliance': {approved: {}},
-            },
-          },
-        ]},
-      ]).length, 3);
-
-      assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
-      assert.equal(element._computeLabelShortcut('Verified'), 'V');
-      assert.equal(element._computeLabelShortcut('Library-Compliance'), 'LC');
-      assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
-      assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
-      assert.equal(element._computeLabelShortcut(
-          'Invalid-Prolog-Rules-Label-Name--Verified'), 'V');
-      assert.equal(element._computeLabelShortcut(
-          'Some-Special-Label-7'), 'SSL7');
-      assert.equal(element._computeLabelShortcut('--Too----many----dashes---'),
-          'TMD');
-      assert.equal(element._computeLabelShortcut(
-          'Really-rather-entirely-too-long-of-a-label-name'), 'RRETL');
+    test('query with limit', () => {
+      const query = 'status:closed limit:10 owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
     });
 
-    test('colspans', () => {
-      element.sections = [
-        {results: [{}]},
-      ];
-      flushAsynchronousOperations();
-      const tdItemCount = Polymer.dom(element.root).querySelectorAll(
-          'td').length;
-
-      const changeTableColumns = [];
-      const labelNames = [];
-      assert.equal(tdItemCount, element._computeColspan(
-          changeTableColumns, labelNames));
+    test('query with age as value and not key', () => {
+      const query = 'status:closed random:age';
+      const expectedQuery = 'status:closed random:age';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
     });
 
+    test('query with limit as value and not key', () => {
+      const query = 'status:closed random:limit';
+      const expectedQuery = 'status:closed random:limit';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with -age key', () => {
+      const query = 'status:closed -age:1week';
+      const expectedQuery = 'status:closed';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+  });
+
+  suite('gr-change-list sections', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
     test('keyboard shortcuts', done => {
-      sandbox.stub(element, '_computeLabelNames');
-      element.sections = [
-        {results: new Array(1)},
-        {results: new Array(2)},
-      ];
       element.selectedIndex = 0;
-      element.changes = [
-        {_number: 0},
-        {_number: 1},
-        {_number: 2},
+      element.sections = [
+        {
+          results: [
+            {_number: 0},
+            {_number: 1},
+            {_number: 2},
+          ],
+        },
+        {
+          results: [
+            {_number: 3},
+            {_number: 4},
+            {_number: 5},
+          ],
+        },
+        {
+          results: [
+            {_number: 6},
+            {_number: 7},
+            {_number: 8},
+          ],
+        },
       ];
       flushAsynchronousOperations();
-      Polymer.RenderStatus.afterNextRender(element, () => {
-        const elementItems = Polymer.dom(element.root).querySelectorAll(
+      afterNextRender(element, () => {
+        const elementItems = dom(element.root).querySelectorAll(
             'gr-change-list-item');
-        assert.equal(elementItems.length, 3);
+        assert.equal(elementItems.length, 9);
 
-        assert.isTrue(elementItems[0].hasAttribute('selected'));
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
         assert.equal(element.selectedIndex, 1);
-        assert.isTrue(elementItems[1].hasAttribute('selected'));
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-        assert.equal(element.selectedIndex, 2);
-        assert.isTrue(elementItems[2].hasAttribute('selected'));
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
 
         const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
         assert.equal(element.selectedIndex, 2);
-        MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
         assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
             'Should navigate to /c/2/');
 
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
         assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
         assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
             'Should navigate to /c/1/');
 
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.selectedIndex, 0);
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        assert.equal(element.selectedIndex, 4);
+        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+        assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
+            'Should navigate to /c/4/');
 
-        const reloadStub = sandbox.stub(element, '_reloadWindow');
-        MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-        assert.isTrue(reloadStub.called);
-
+        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+        const change = element._changeForIndex(element.selectedIndex);
+        assert.equal(change.reviewed, true,
+            'Should mark change as reviewed');
+        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+        assert.equal(change.reviewed, false,
+            'Should mark change as unreviewed');
         done();
       });
     });
 
-    test('changes needing review', () => {
+    test('highlight attribute is updated correctly', () => {
       element.changes = [
         {
           _number: 0,
           status: 'NEW',
-          reviewed: true,
           owner: {_account_id: 0},
         },
         {
           _number: 1,
-          status: 'NEW',
-          owner: {_account_id: 0},
-        },
-        {
-          _number: 2,
-          status: 'MERGED',
-          owner: {_account_id: 0},
-        },
-        {
-          _number: 3,
           status: 'ABANDONED',
           owner: {_account_id: 0},
         },
-        {
-          _number: 4,
-          status: 'NEW',
-          work_in_progress: true,
-          owner: {_account_id: 0},
-        },
       ];
-      flushAsynchronousOperations();
-      let elementItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 5);
-      for (let i = 0; i < elementItems.length; i++) {
-        assert.isFalse(elementItems[i].hasAttribute('needs-review'));
-      }
-
-      element.showReviewedState = true;
-      elementItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 5);
-      assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-      assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[4].hasAttribute('needs-review'));
-
       element.account = {_account_id: 42};
-      elementItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 5);
-      assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-      assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[4].hasAttribute('needs-review'));
-    });
-
-    test('no changes', () => {
-      element.changes = [];
       flushAsynchronousOperations();
-      const listItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(listItems.length, 0);
-      const noChangesMsg =
-          Polymer.dom(element.root).querySelector('.noChanges');
-      assert.ok(noChangesMsg);
-    });
+      let items = element._getListItems();
+      assert.equal(items.length, 2);
+      assert.isFalse(items[0].hasAttribute('highlight'));
+      assert.isFalse(items[1].hasAttribute('highlight'));
 
-    test('empty sections', () => {
-      element.sections = [{results: []}, {results: []}];
+      // Assign all issues to the user, but only the first one is highlighted
+      // because the second one is abandoned.
+      element.set(['changes', 0, 'assignee'], {_account_id: 12});
+      element.set(['changes', 1, 'assignee'], {_account_id: 12});
+      element.account = {_account_id: 12};
       flushAsynchronousOperations();
-      const listItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(listItems.length, 0);
-      const noChangesMsg = Polymer.dom(element.root).querySelectorAll(
-          '.noChanges');
-      assert.equal(noChangesMsg.length, 2);
+      items = element._getListItems();
+      assert.isTrue(items[0].hasAttribute('highlight'));
+      assert.isFalse(items[1].hasAttribute('highlight'));
     });
 
-    suite('empty outgoing', () => {
-      test('not shown on empty non-outgoing sections', () => {
-        const section = {results: []};
-        assert.isTrue(element._isEmpty(section));
-        assert.isFalse(element._isOutgoing(section));
-      });
-
-      test('shown on empty outgoing sections', () => {
-        const section = {results: [], isOutgoing: true};
-        assert.isTrue(element._isEmpty(section));
-        assert.isTrue(element._isOutgoing(section));
-      });
-
-      test('not shown on non-empty outgoing sections', () => {
-        const section = {isOutgoing: true, results: [
-          {_number: 0, labels: {Verified: {approved: {}}}}]};
-        assert.isFalse(element._isEmpty(section));
-        assert.isTrue(element._isOutgoing(section));
-      });
+    test('_computeItemHighlight gives false for null account', () => {
+      assert.isFalse(
+          element._computeItemHighlight(null, {assignee: {_account_id: 42}}));
     });
 
-    test('_isOutgoing', () => {
-      assert.isTrue(element._isOutgoing({results: [], isOutgoing: true}));
-      assert.isFalse(element._isOutgoing({results: []}));
-    });
+    test('_computeItemAbsoluteIndex', () => {
+      sandbox.stub(element, '_computeLabelNames');
+      element.sections = [
+        {results: new Array(1)},
+        {results: new Array(2)},
+        {results: new Array(3)},
+      ];
 
-    suite('empty column preference', () => {
-      let element;
+      assert.equal(element._computeItemAbsoluteIndex(0, 0), 0);
+      // Out of range but no matter.
+      assert.equal(element._computeItemAbsoluteIndex(0, 1), 1);
 
-      setup(() => {
-        element = fixture('basic');
-        element.sections = [
-          {results: [{}]},
-        ];
-        element.account = {_account_id: 1001};
-        element.preferences = {
-          legacycid_in_change_table: true,
-          time_format: 'HHMM_12',
-          change_table: [],
-        };
-        flushAsynchronousOperations();
-      });
-
-      test('show number enabled', () => {
-        assert.isTrue(element.showNumber);
-      });
-
-      test('all columns visible', () => {
-        for (const column of element.columnNames) {
-          const elementClass = '.' + element._lowerCase(column);
-          assert.isFalse(element.shadowRoot
-              .querySelector(elementClass).hidden);
-        }
-      });
-    });
-
-    suite('full column preference', () => {
-      let element;
-
-      setup(() => {
-        element = fixture('basic');
-        element.sections = [
-          {results: [{}]},
-        ];
-        element.account = {_account_id: 1001};
-        element.preferences = {
-          legacycid_in_change_table: true,
-          time_format: 'HHMM_12',
-          change_table: [
-            'Subject',
-            'Status',
-            'Owner',
-            'Assignee',
-            'Repo',
-            'Branch',
-            'Updated',
-            'Size',
-          ],
-        };
-        flushAsynchronousOperations();
-      });
-
-      test('all columns visible', () => {
-        for (const column of element.changeTableColumns) {
-          const elementClass = '.' + element._lowerCase(column);
-          assert.isFalse(element.shadowRoot
-              .querySelector(elementClass).hidden);
-        }
-      });
-    });
-
-    suite('partial column preference', () => {
-      let element;
-
-      setup(() => {
-        element = fixture('basic');
-        element.sections = [
-          {results: [{}]},
-        ];
-        element.account = {_account_id: 1001};
-        element.preferences = {
-          legacycid_in_change_table: true,
-          time_format: 'HHMM_12',
-          change_table: [
-            'Subject',
-            'Status',
-            'Owner',
-            'Assignee',
-            'Branch',
-            'Updated',
-            'Size',
-          ],
-        };
-        flushAsynchronousOperations();
-      });
-
-      test('all columns except repo visible', () => {
-        for (const column of element.changeTableColumns) {
-          const elementClass = '.' + column.toLowerCase();
-          if (column === 'Repo') {
-            assert.isTrue(element.shadowRoot
-                .querySelector(elementClass).hidden);
-          } else {
-            assert.isFalse(element.shadowRoot
-                .querySelector(elementClass).hidden);
-          }
-        }
-      });
-    });
-
-    suite('random column does not exist', () => {
-      let element;
-
-      /* This would only exist if somebody manually updated the config
-      file. */
-      setup(() => {
-        element = fixture('basic');
-        element.account = {_account_id: 1001};
-        element.preferences = {
-          legacycid_in_change_table: true,
-          time_format: 'HHMM_12',
-          change_table: [
-            'Bad',
-          ],
-        };
-        flushAsynchronousOperations();
-      });
-
-      test('bad column does not exist', () => {
-        const elementClass = '.bad';
-        assert.isNotOk(element.shadowRoot
-            .querySelector(elementClass));
-      });
-    });
-
-    suite('dashboard queries', () => {
-      let element;
-      let sandbox;
-
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-        element = fixture('basic');
-      });
-
-      teardown(() => { sandbox.restore(); });
-
-      test('query without age and limit unchanged', () => {
-        const query = 'status:closed owner:me';
-        assert.deepEqual(element._processQuery(query), query);
-      });
-
-      test('query with age and limit', () => {
-        const query = 'status:closed age:1week limit:10 owner:me';
-        const expectedQuery = 'status:closed owner:me';
-        assert.deepEqual(element._processQuery(query), expectedQuery);
-      });
-
-      test('query with age', () => {
-        const query = 'status:closed age:1week owner:me';
-        const expectedQuery = 'status:closed owner:me';
-        assert.deepEqual(element._processQuery(query), expectedQuery);
-      });
-
-      test('query with limit', () => {
-        const query = 'status:closed limit:10 owner:me';
-        const expectedQuery = 'status:closed owner:me';
-        assert.deepEqual(element._processQuery(query), expectedQuery);
-      });
-
-      test('query with age as value and not key', () => {
-        const query = 'status:closed random:age';
-        const expectedQuery = 'status:closed random:age';
-        assert.deepEqual(element._processQuery(query), expectedQuery);
-      });
-
-      test('query with limit as value and not key', () => {
-        const query = 'status:closed random:limit';
-        const expectedQuery = 'status:closed random:limit';
-        assert.deepEqual(element._processQuery(query), expectedQuery);
-      });
-
-      test('query with -age key', () => {
-        const query = 'status:closed -age:1week';
-        const expectedQuery = 'status:closed';
-        assert.deepEqual(element._processQuery(query), expectedQuery);
-      });
-    });
-
-    suite('gr-change-list sections', () => {
-      let element;
-      let sandbox;
-
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-        element = fixture('basic');
-      });
-
-      teardown(() => { sandbox.restore(); });
-
-      test('keyboard shortcuts', done => {
-        element.selectedIndex = 0;
-        element.sections = [
-          {
-            results: [
-              {_number: 0},
-              {_number: 1},
-              {_number: 2},
-            ],
-          },
-          {
-            results: [
-              {_number: 3},
-              {_number: 4},
-              {_number: 5},
-            ],
-          },
-          {
-            results: [
-              {_number: 6},
-              {_number: 7},
-              {_number: 8},
-            ],
-          },
-        ];
-        flushAsynchronousOperations();
-        Polymer.RenderStatus.afterNextRender(element, () => {
-          const elementItems = Polymer.dom(element.root).querySelectorAll(
-              'gr-change-list-item');
-          assert.equal(elementItems.length, 9);
-
-          MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-          assert.equal(element.selectedIndex, 1);
-          MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-
-          const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-          assert.equal(element.selectedIndex, 2);
-
-          MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-          assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-              'Should navigate to /c/2/');
-
-          MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
-          assert.equal(element.selectedIndex, 1);
-          MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-          assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-              'Should navigate to /c/1/');
-
-          MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-          MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-          MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-          assert.equal(element.selectedIndex, 4);
-          MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-          assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
-              'Should navigate to /c/4/');
-
-          MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
-          const change = element._changeForIndex(element.selectedIndex);
-          assert.equal(change.reviewed, true,
-              'Should mark change as reviewed');
-          MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
-          assert.equal(change.reviewed, false,
-              'Should mark change as unreviewed');
-          done();
-        });
-      });
-
-      test('highlight attribute is updated correctly', () => {
-        element.changes = [
-          {
-            _number: 0,
-            status: 'NEW',
-            owner: {_account_id: 0},
-          },
-          {
-            _number: 1,
-            status: 'ABANDONED',
-            owner: {_account_id: 0},
-          },
-        ];
-        element.account = {_account_id: 42};
-        flushAsynchronousOperations();
-        let items = element._getListItems();
-        assert.equal(items.length, 2);
-        assert.isFalse(items[0].hasAttribute('highlight'));
-        assert.isFalse(items[1].hasAttribute('highlight'));
-
-        // Assign all issues to the user, but only the first one is highlighted
-        // because the second one is abandoned.
-        element.set(['changes', 0, 'assignee'], {_account_id: 12});
-        element.set(['changes', 1, 'assignee'], {_account_id: 12});
-        element.account = {_account_id: 12};
-        flushAsynchronousOperations();
-        items = element._getListItems();
-        assert.isTrue(items[0].hasAttribute('highlight'));
-        assert.isFalse(items[1].hasAttribute('highlight'));
-      });
-
-      test('_computeItemHighlight gives false for null account', () => {
-        assert.isFalse(
-            element._computeItemHighlight(null, {assignee: {_account_id: 42}}));
-      });
-
-      test('_computeItemAbsoluteIndex', () => {
-        sandbox.stub(element, '_computeLabelNames');
-        element.sections = [
-          {results: new Array(1)},
-          {results: new Array(2)},
-          {results: new Array(3)},
-        ];
-
-        assert.equal(element._computeItemAbsoluteIndex(0, 0), 0);
-        // Out of range but no matter.
-        assert.equal(element._computeItemAbsoluteIndex(0, 1), 1);
-
-        assert.equal(element._computeItemAbsoluteIndex(1, 0), 1);
-        assert.equal(element._computeItemAbsoluteIndex(1, 1), 2);
-        assert.equal(element._computeItemAbsoluteIndex(1, 2), 3);
-        assert.equal(element._computeItemAbsoluteIndex(2, 0), 3);
-        assert.equal(element._computeItemAbsoluteIndex(3, 0), 6);
-      });
+      assert.equal(element._computeItemAbsoluteIndex(1, 0), 1);
+      assert.equal(element._computeItemAbsoluteIndex(1, 1), 2);
+      assert.equal(element._computeItemAbsoluteIndex(1, 2), 3);
+      assert.equal(element._computeItemAbsoluteIndex(2, 0), 3);
+      assert.equal(element._computeItemAbsoluteIndex(3, 0), 6);
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
index 64d2486..3758a78 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
@@ -14,27 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrCreateChangeHelp extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-create-change-help'; }
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-icons/gr-icons.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-change-help_html.js';
 
-    /**
-     * Fired when the "Create change" button is tapped.
-     *
-     * @event create-tap
-     */
+/** @extends Polymer.Element */
+class GrCreateChangeHelp extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _handleCreateTap(e) {
-      e.preventDefault();
-      this.dispatchEvent(
-          new CustomEvent('create-tap', {bubbles: true, composed: true}));
-    }
+  static get is() { return 'gr-create-change-help'; }
+
+  /**
+   * Fired when the "Create change" button is tapped.
+   *
+   * @event create-tap
+   */
+
+  _handleCreateTap(e) {
+    e.preventDefault();
+    this.dispatchEvent(
+        new CustomEvent('create-tap', {bubbles: true, composed: true}));
   }
+}
 
-  customElements.define(GrCreateChangeHelp.is, GrCreateChangeHelp);
-})();
+customElements.define(GrCreateChangeHelp.is, GrCreateChangeHelp);
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js
index 842c402..f40bda7 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-
-<dom-module id="gr-create-change-help">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -82,11 +76,9 @@
       <h1>Push your first change for code review</h1>
       <p>
         Pushing a change for review is easy, but a little different from
-        other git code review tools. Click on the `Create Change' button
+        other git code review tools. Click on the \`Create Change' button
         and follow the step by step instructions.
       </p>
       <gr-button on-click="_handleCreateTap">Create Change</gr-button>
     </div>
-  </template>
-  <script src="gr-create-change-help.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
index abc4a9b..3cbab30 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-change-help</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-create-change-help.html">
+<script type="module" src="./gr-create-change-help.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-create-change-help.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,19 +43,22 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-create-change-help tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-create-change-help.js';
+suite('gr-create-change-help tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('Create change tap', done => {
-      element.addEventListener('create-tap', () => done());
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('gr-button'));
-    });
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('Create change tap', done => {
+    element.addEventListener('create-tap', () => done());
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
index 7abd784..7e5e749 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
@@ -14,53 +14,61 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const Commands = {
-    CREATE: 'git commit',
-    AMEND: 'git commit --amend',
-    PUSH_PREFIX: 'git push origin HEAD:refs/for/',
-  };
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-shell-command/gr-shell-command.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-commands-dialog_html.js';
 
-  /** @extends Polymer.Element */
-  class GrCreateCommandsDialog extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-create-commands-dialog'; }
+const Commands = {
+  CREATE: 'git commit',
+  AMEND: 'git commit --amend',
+  PUSH_PREFIX: 'git push origin HEAD:refs/for/',
+};
 
-    static get properties() {
-      return {
-        branch: String,
-        _createNewCommitCommand: {
-          type: String,
-          readonly: true,
-          value: Commands.CREATE,
-        },
-        _amendExistingCommitCommand: {
-          type: String,
-          readonly: true,
-          value: Commands.AMEND,
-        },
-        _pushCommand: {
-          type: String,
-          computed: '_computePushCommand(branch)',
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrCreateCommandsDialog extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    open() {
-      this.$.commandsOverlay.open();
-    }
+  static get is() { return 'gr-create-commands-dialog'; }
 
-    _handleClose() {
-      this.$.commandsOverlay.close();
-    }
-
-    _computePushCommand(branch) {
-      return Commands.PUSH_PREFIX + branch;
-    }
+  static get properties() {
+    return {
+      branch: String,
+      _createNewCommitCommand: {
+        type: String,
+        readonly: true,
+        value: Commands.CREATE,
+      },
+      _amendExistingCommitCommand: {
+        type: String,
+        readonly: true,
+        value: Commands.AMEND,
+      },
+      _pushCommand: {
+        type: String,
+        computed: '_computePushCommand(branch)',
+      },
+    };
   }
 
-  customElements.define(GrCreateCommandsDialog.is, GrCreateCommandsDialog);
-})();
+  open() {
+    this.$.commandsOverlay.open();
+  }
+
+  _handleClose() {
+    this.$.commandsOverlay.close();
+  }
+
+  _computePushCommand(branch) {
+    return Commands.PUSH_PREFIX + branch;
+  }
+}
+
+customElements.define(GrCreateCommandsDialog.is, GrCreateCommandsDialog);
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.js
index 9e86058..aa13dca 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.js
@@ -1,27 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
-
-<dom-module id="gr-create-commands-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       ol {
         list-style: decimal;
@@ -34,13 +29,8 @@
         max-width: 40em;
       }
     </style>
-    <gr-overlay id="commandsOverlay" with-backdrop>
-      <gr-dialog
-          id="commandsDialog"
-          confirm-label="Done"
-          cancel-label=""
-          confirm-on-enter
-          on-confirm="_handleClose">
+    <gr-overlay id="commandsOverlay" with-backdrop="">
+      <gr-dialog id="commandsDialog" confirm-label="Done" cancel-label="" confirm-on-enter="" on-confirm="_handleClose">
         <div class="header" slot="header">
           Create change commands
         </div>
@@ -82,6 +72,4 @@
         </div>
       </gr-dialog>
     </gr-overlay>
-  </template>
-  <script src="gr-create-commands-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
index 41709a6..f992b0b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-commands-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-create-commands-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-create-commands-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-create-commands-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,23 +40,25 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-create-commands-dialog tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-create-commands-dialog.js';
+suite('gr-create-commands-dialog tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('_computePushCommand', () => {
-      element.branch = 'master';
-      assert.equal(element._pushCommand,
-          'git push origin HEAD:refs/for/master');
-
-      element.branch = 'stable-2.15';
-      assert.equal(element._pushCommand,
-          'git push origin HEAD:refs/for/stable-2.15');
-    });
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('_computePushCommand', () => {
+    element.branch = 'master';
+    assert.equal(element._pushCommand,
+        'git push origin HEAD:refs/for/master');
+
+    element.branch = 'stable-2.15';
+    assert.equal(element._pushCommand,
+        'git push origin HEAD:refs/for/stable-2.15');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
index 35f7450..f8757ba 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
@@ -14,58 +14,66 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * Fired when a destination has been picked. Event details contain the repo
-   * name and the branch name.
-   *
-   * @event confirm
-   * @extends Polymer.Element
-   */
-  class GrCreateDestinationDialog extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-create-destination-dialog'; }
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-destination-dialog_html.js';
 
-    static get properties() {
-      return {
-        _repo: String,
-        _branch: String,
-        _repoAndBranchSelected: {
-          type: Boolean,
-          value: false,
-          computed: '_computeRepoAndBranchSelected(_repo, _branch)',
-        },
-      };
-    }
+/**
+ * Fired when a destination has been picked. Event details contain the repo
+ * name and the branch name.
+ *
+ * @event confirm
+ * @extends Polymer.Element
+ */
+class GrCreateDestinationDialog extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    open() {
-      this._repo = '';
-      this._branch = '';
-      this.$.createOverlay.open();
-    }
+  static get is() { return 'gr-create-destination-dialog'; }
 
-    _handleClose() {
-      this.$.createOverlay.close();
-    }
-
-    _pickerConfirm(e) {
-      this.$.createOverlay.close();
-      const detail = {repo: this._repo, branch: this._branch};
-      // e is a 'confirm' event from gr-dialog. We want to fire a more detailed
-      // 'confirm' event here, so let's stop propagation of the bare event.
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
-    }
-
-    _computeRepoAndBranchSelected(repo, branch) {
-      return !!(repo && branch);
-    }
+  static get properties() {
+    return {
+      _repo: String,
+      _branch: String,
+      _repoAndBranchSelected: {
+        type: Boolean,
+        value: false,
+        computed: '_computeRepoAndBranchSelected(_repo, _branch)',
+      },
+    };
   }
 
-  customElements.define(GrCreateDestinationDialog.is,
-      GrCreateDestinationDialog);
-})();
+  open() {
+    this._repo = '';
+    this._branch = '';
+    this.$.createOverlay.open();
+  }
+
+  _handleClose() {
+    this.$.createOverlay.close();
+  }
+
+  _pickerConfirm(e) {
+    this.$.createOverlay.close();
+    const detail = {repo: this._repo, branch: this._branch};
+    // e is a 'confirm' event from gr-dialog. We want to fire a more detailed
+    // 'confirm' event here, so let's stop propagation of the bare event.
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
+  }
+
+  _computeRepoAndBranchSelected(repo, branch) {
+    return !!(repo && branch);
+  }
+}
+
+customElements.define(GrCreateDestinationDialog.is,
+    GrCreateDestinationDialog);
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.js b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.js
index def5228..73f6ec0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.js
@@ -1,48 +1,35 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-repo-branch-picker/gr-repo-branch-picker.html">
-
-<dom-module id="gr-create-destination-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
     </style>
-    <gr-overlay id="createOverlay" with-backdrop>
-      <gr-dialog
-          confirm-label="View commands"
-          on-confirm="_pickerConfirm"
-          on-cancel="_handleClose"
-          disabled="[[!_repoAndBranchSelected]]">
+    <gr-overlay id="createOverlay" with-backdrop="">
+      <gr-dialog confirm-label="View commands" on-confirm="_pickerConfirm" on-cancel="_handleClose" disabled="[[!_repoAndBranchSelected]]">
         <div class="header" slot="header">
           Create change
         </div>
         <div class="main" slot="main">
-          <gr-repo-branch-picker
-              repo="{{_repo}}"
-              branch="{{_branch}}"></gr-repo-branch-picker>
+          <gr-repo-branch-picker repo="{{_repo}}" branch="{{_branch}}"></gr-repo-branch-picker>
           <p>
             If you haven't done so, you will need to clone the repository.
           </p>
         </div>
       </gr-dialog>
     </gr-overlay>
-  </template>
-  <script src="gr-create-destination-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index d0e1db2..a4ed814 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,306 +14,325 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../gr-change-list/gr-change-list.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-create-commands-dialog/gr-create-commands-dialog.js';
+import '../gr-create-change-help/gr-create-change-help.js';
+import '../gr-create-destination-dialog/gr-create-destination-dialog.js';
+import '../gr-user-header/gr-user-header.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-dashboard-view_html.js';
 
+const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @extends Polymer.Element
+ */
+class GrDashboardView extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-dashboard-view'; }
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @extends Polymer.Element
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
    */
-  class GrDashboardView extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.RESTClientBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-dashboard-view'; }
-    /**
-     * Fired when the title of the page should change.
-     *
-     * @event title-change
-     */
 
-    static get properties() {
-      return {
-        account: {
-          type: Object,
-          value: null,
+  static get properties() {
+    return {
+      account: {
+        type: Object,
+        value: null,
+      },
+      preferences: Object,
+      /** @type {{ selectedChangeIndex: number }} */
+      viewState: Object,
+
+      /** @type {{ project: string, user: string }} */
+      params: {
+        type: Object,
+      },
+
+      createChangeTap: {
+        type: Function,
+        value() {
+          return this._createChangeTap.bind(this);
         },
-        preferences: Object,
-        /** @type {{ selectedChangeIndex: number }} */
-        viewState: Object,
+      },
 
-        /** @type {{ project: string, user: string }} */
-        params: {
-          type: Object,
-        },
+      _results: Array,
 
-        createChangeTap: {
-          type: Function,
-          value() {
-            return this._createChangeTap.bind(this);
-          },
-        },
+      /**
+       * For showing a "loading..." string during ajax requests.
+       */
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
 
-        _results: Array,
+      _showDraftsBanner: {
+        type: Boolean,
+        value: false,
+      },
 
-        /**
-         * For showing a "loading..." string during ajax requests.
-         */
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-
-        _showDraftsBanner: {
-          type: Boolean,
-          value: false,
-        },
-
-        _showNewUserHelp: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
-
-    static get observers() {
-      return [
-        '_paramsChanged(params.*)',
-      ];
-    }
-
-    get options() {
-      return this.listChangesOptionsToHex(
-          this.ListChangesOption.LABELS,
-          this.ListChangesOption.DETAILED_ACCOUNTS,
-          this.ListChangesOption.REVIEWED
-      );
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this._loadPreferences();
-    }
-
-    _loadPreferences() {
-      return this.$.restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          this.$.restAPI.getPreferences().then(preferences => {
-            this.preferences = preferences;
-          });
-        } else {
-          this.preferences = {};
-        }
-      });
-    }
-
-    _getProjectDashboard(project, dashboard) {
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
-      return this.$.restAPI.getDashboard(
-          project, dashboard, errFn).then(response => {
-        if (!response) {
-          return;
-        }
-        return {
-          title: response.title,
-          sections: response.sections.map(section => {
-            const suffix = response.foreach ? ' ' + response.foreach : '';
-            return {
-              name: section.name,
-              query: (section.query + suffix).replace(
-                  PROJECT_PLACEHOLDER_PATTERN, project),
-            };
-          }),
-        };
-      });
-    }
-
-    _computeTitle(user) {
-      if (!user || user === 'self') {
-        return 'My Reviews';
-      }
-      return 'Dashboard for ' + user;
-    }
-
-    _isViewActive(params) {
-      return params.view === Gerrit.Nav.View.DASHBOARD;
-    }
-
-    _paramsChanged(paramsChangeRecord) {
-      const params = paramsChangeRecord.base;
-
-      if (!this._isViewActive(params)) {
-        return Promise.resolve();
-      }
-
-      return this._reload();
-    }
-
-    /**
-     * Reloads the element.
-     *
-     * @return {Promise<!Object>}
-     */
-    _reload() {
-      this._loading = true;
-      const {project, dashboard, title, user, sections} = this.params;
-      const dashboardPromise = project ?
-        this._getProjectDashboard(project, dashboard) :
-        Promise.resolve(Gerrit.Nav.getUserDashboard(
-            user,
-            sections,
-            title || this._computeTitle(user)));
-
-      const checkForNewUser = !project && user === 'self';
-      return dashboardPromise
-          .then(res => {
-            if (res && res.title) {
-              this.fire('title-change', {title: res.title});
-            }
-            return this._fetchDashboardChanges(res, checkForNewUser);
-          })
-          .then(() => {
-            this._maybeShowDraftsBanner();
-            this.$.reporting.dashboardDisplayed();
-          })
-          .catch(err => {
-            this.fire('title-change', {
-              title: title || this._computeTitle(user),
-            });
-            console.warn(err);
-          })
-          .then(() => { this._loading = false; });
-    }
-
-    /**
-     * Fetches the changes for each dashboard section and sets this._results
-     * with the response.
-     *
-     * @param {!Object} res
-     * @param {boolean} checkForNewUser
-     * @return {Promise}
-     */
-    _fetchDashboardChanges(res, checkForNewUser) {
-      if (!res) { return Promise.resolve(); }
-
-      const queries = res.sections
-          .map(section => (section.suffixForDashboard ?
-            section.query + ' ' + section.suffixForDashboard :
-            section.query));
-
-      if (checkForNewUser) {
-        queries.push('owner:self limit:1');
-      }
-
-      return this.$.restAPI.getChanges(null, queries, null, this.options)
-          .then(changes => {
-            if (checkForNewUser) {
-              // Last set of results is not meant for dashboard display.
-              const lastResultSet = changes.pop();
-              this._showNewUserHelp = lastResultSet.length == 0;
-            }
-            this._results = changes.map((results, i) => {
-              return {
-                name: res.sections[i].name,
-                countLabel: this._computeSectionCountLabel(results),
-                query: res.sections[i].query,
-                results,
-                isOutgoing: res.sections[i].isOutgoing,
-              };
-            }).filter((section, i) => i < res.sections.length && (
-              !res.sections[i].hideIfEmpty ||
-                section.results.length));
-          });
-    }
-
-    _computeSectionCountLabel(changes) {
-      if (!changes || !changes.length || changes.length == 0) {
-        return '';
-      }
-      const more = changes[changes.length - 1]._more_changes;
-      const numChanges = changes.length;
-      const andMore = more ? ' and more' : '';
-      return `(${numChanges}${andMore})`;
-    }
-
-    _computeUserHeaderClass(params) {
-      if (!params || !!params.project || !params.user ||
-          params.user === 'self') {
-        return 'hide';
-      }
-      return '';
-    }
-
-    _handleToggleStar(e) {
-      this.$.restAPI.saveChangeStarred(e.detail.change._number,
-          e.detail.starred);
-    }
-
-    _handleToggleReviewed(e) {
-      this.$.restAPI.saveChangeReviewed(e.detail.change._number,
-          e.detail.reviewed);
-    }
-
-    /**
-     * Banner is shown if a user is on their own dashboard and they have draft
-     * comments on closed changes.
-     */
-    _maybeShowDraftsBanner() {
-      this._showDraftsBanner = false;
-      if (!(this.params.user === 'self')) { return; }
-
-      const draftSection = this._results
-          .find(section => section.query === 'has:draft');
-      if (!draftSection || !draftSection.results.length) { return; }
-
-      const closedChanges = draftSection.results
-          .filter(change => !this.changeIsOpen(change));
-      if (!closedChanges.length) { return; }
-
-      this._showDraftsBanner = true;
-    }
-
-    _computeBannerClass(show) {
-      return show ? '' : 'hide';
-    }
-
-    _handleOpenDeleteDialog() {
-      this.$.confirmDeleteOverlay.open();
-    }
-
-    _handleConfirmDelete() {
-      this.$.confirmDeleteDialog.disabled = true;
-      return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
-        this._closeConfirmDeleteOverlay();
-        this._reload();
-      });
-    }
-
-    _closeConfirmDeleteOverlay() {
-      this.$.confirmDeleteOverlay.close();
-    }
-
-    _computeDraftsLink() {
-      return Gerrit.Nav.getUrlForSearchQuery('has:draft -is:open');
-    }
-
-    _createChangeTap(e) {
-      this.$.destinationDialog.open();
-    }
-
-    _handleDestinationConfirm(e) {
-      this.$.commandsDialog.branch = e.detail.branch;
-      this.$.commandsDialog.open();
-    }
+      _showNewUserHelp: {
+        type: Boolean,
+        value: false,
+      },
+    };
   }
 
-  customElements.define(GrDashboardView.is, GrDashboardView);
-})();
+  static get observers() {
+    return [
+      '_paramsChanged(params.*)',
+    ];
+  }
+
+  get options() {
+    return this.listChangesOptionsToHex(
+        this.ListChangesOption.LABELS,
+        this.ListChangesOption.DETAILED_ACCOUNTS,
+        this.ListChangesOption.REVIEWED
+    );
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadPreferences();
+  }
+
+  _loadPreferences() {
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        this.$.restAPI.getPreferences().then(preferences => {
+          this.preferences = preferences;
+        });
+      } else {
+        this.preferences = {};
+      }
+    });
+  }
+
+  _getProjectDashboard(project, dashboard) {
+    const errFn = response => {
+      this.fire('page-error', {response});
+    };
+    return this.$.restAPI.getDashboard(
+        project, dashboard, errFn).then(response => {
+      if (!response) {
+        return;
+      }
+      return {
+        title: response.title,
+        sections: response.sections.map(section => {
+          const suffix = response.foreach ? ' ' + response.foreach : '';
+          return {
+            name: section.name,
+            query: (section.query + suffix).replace(
+                PROJECT_PLACEHOLDER_PATTERN, project),
+          };
+        }),
+      };
+    });
+  }
+
+  _computeTitle(user) {
+    if (!user || user === 'self') {
+      return 'My Reviews';
+    }
+    return 'Dashboard for ' + user;
+  }
+
+  _isViewActive(params) {
+    return params.view === Gerrit.Nav.View.DASHBOARD;
+  }
+
+  _paramsChanged(paramsChangeRecord) {
+    const params = paramsChangeRecord.base;
+
+    if (!this._isViewActive(params)) {
+      return Promise.resolve();
+    }
+
+    return this._reload();
+  }
+
+  /**
+   * Reloads the element.
+   *
+   * @return {Promise<!Object>}
+   */
+  _reload() {
+    this._loading = true;
+    const {project, dashboard, title, user, sections} = this.params;
+    const dashboardPromise = project ?
+      this._getProjectDashboard(project, dashboard) :
+      Promise.resolve(Gerrit.Nav.getUserDashboard(
+          user,
+          sections,
+          title || this._computeTitle(user)));
+
+    const checkForNewUser = !project && user === 'self';
+    return dashboardPromise
+        .then(res => {
+          if (res && res.title) {
+            this.fire('title-change', {title: res.title});
+          }
+          return this._fetchDashboardChanges(res, checkForNewUser);
+        })
+        .then(() => {
+          this._maybeShowDraftsBanner();
+          this.$.reporting.dashboardDisplayed();
+        })
+        .catch(err => {
+          this.fire('title-change', {
+            title: title || this._computeTitle(user),
+          });
+          console.warn(err);
+        })
+        .then(() => { this._loading = false; });
+  }
+
+  /**
+   * Fetches the changes for each dashboard section and sets this._results
+   * with the response.
+   *
+   * @param {!Object} res
+   * @param {boolean} checkForNewUser
+   * @return {Promise}
+   */
+  _fetchDashboardChanges(res, checkForNewUser) {
+    if (!res) { return Promise.resolve(); }
+
+    const queries = res.sections
+        .map(section => (section.suffixForDashboard ?
+          section.query + ' ' + section.suffixForDashboard :
+          section.query));
+
+    if (checkForNewUser) {
+      queries.push('owner:self limit:1');
+    }
+
+    return this.$.restAPI.getChanges(null, queries, null, this.options)
+        .then(changes => {
+          if (checkForNewUser) {
+            // Last set of results is not meant for dashboard display.
+            const lastResultSet = changes.pop();
+            this._showNewUserHelp = lastResultSet.length == 0;
+          }
+          this._results = changes.map((results, i) => {
+            return {
+              name: res.sections[i].name,
+              countLabel: this._computeSectionCountLabel(results),
+              query: res.sections[i].query,
+              results,
+              isOutgoing: res.sections[i].isOutgoing,
+            };
+          }).filter((section, i) => i < res.sections.length && (
+            !res.sections[i].hideIfEmpty ||
+              section.results.length));
+        });
+  }
+
+  _computeSectionCountLabel(changes) {
+    if (!changes || !changes.length || changes.length == 0) {
+      return '';
+    }
+    const more = changes[changes.length - 1]._more_changes;
+    const numChanges = changes.length;
+    const andMore = more ? ' and more' : '';
+    return `(${numChanges}${andMore})`;
+  }
+
+  _computeUserHeaderClass(params) {
+    if (!params || !!params.project || !params.user ||
+        params.user === 'self') {
+      return 'hide';
+    }
+    return '';
+  }
+
+  _handleToggleStar(e) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number,
+        e.detail.starred);
+  }
+
+  _handleToggleReviewed(e) {
+    this.$.restAPI.saveChangeReviewed(e.detail.change._number,
+        e.detail.reviewed);
+  }
+
+  /**
+   * Banner is shown if a user is on their own dashboard and they have draft
+   * comments on closed changes.
+   */
+  _maybeShowDraftsBanner() {
+    this._showDraftsBanner = false;
+    if (!(this.params.user === 'self')) { return; }
+
+    const draftSection = this._results
+        .find(section => section.query === 'has:draft');
+    if (!draftSection || !draftSection.results.length) { return; }
+
+    const closedChanges = draftSection.results
+        .filter(change => !this.changeIsOpen(change));
+    if (!closedChanges.length) { return; }
+
+    this._showDraftsBanner = true;
+  }
+
+  _computeBannerClass(show) {
+    return show ? '' : 'hide';
+  }
+
+  _handleOpenDeleteDialog() {
+    this.$.confirmDeleteOverlay.open();
+  }
+
+  _handleConfirmDelete() {
+    this.$.confirmDeleteDialog.disabled = true;
+    return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
+      this._closeConfirmDeleteOverlay();
+      this._reload();
+    });
+  }
+
+  _closeConfirmDeleteOverlay() {
+    this.$.confirmDeleteOverlay.close();
+  }
+
+  _computeDraftsLink() {
+    return Gerrit.Nav.getUrlForSearchQuery('has:draft -is:open');
+  }
+
+  _createChangeTap(e) {
+    this.$.destinationDialog.open();
+  }
+
+  _handleDestinationConfirm(e) {
+    this.$.commandsDialog.branch = e.detail.branch;
+    this.$.commandsDialog.open();
+  }
+}
+
+customElements.define(GrDashboardView.is, GrDashboardView);
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.js
index f119e98..07d638c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.js
@@ -1,37 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../change-list/gr-change-list/gr-change-list.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-create-commands-dialog/gr-create-commands-dialog.html">
-<link rel="import" href="../gr-create-change-help/gr-create-change-help.html">
-<link rel="import" href="../gr-create-destination-dialog/gr-create-destination-dialog.html">
-<link rel="import" href="../gr-user-header/gr-user-header.html">
-
-<dom-module id="gr-dashboard-view">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -71,32 +56,19 @@
         }
       }
     </style>
-    <div class$="banner [[_computeBannerClass(_showDraftsBanner)]]">
+    <div class\$="banner [[_computeBannerClass(_showDraftsBanner)]]">
       <div>
         You have draft comments on closed changes.
-        <a href$="[[_computeDraftsLink(_showDraftsBanner)]]" target="_blank">(view all)</a>
+        <a href\$="[[_computeDraftsLink(_showDraftsBanner)]]" target="_blank">(view all)</a>
       </div>
       <div>
-        <gr-button
-            class="delete"
-            link
-            on-click="_handleOpenDeleteDialog">Delete All</gr-button>
+        <gr-button class="delete" link="" on-click="_handleOpenDeleteDialog">Delete All</gr-button>
       </div>
     </div>
-    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-    <div hidden$="[[_loading]]" hidden>
-      <gr-user-header
-          user-id="[[params.user]]"
-          class$="[[_computeUserHeaderClass(params)]]"></gr-user-header>
-      <gr-change-list
-          show-star
-          show-reviewed-state
-          account="[[account]]"
-          preferences="[[preferences]]"
-          selected-index="{{viewState.selectedChangeIndex}}"
-          sections="[[_results]]"
-          on-toggle-star="_handleToggleStar"
-          on-toggle-reviewed="_handleToggleReviewed">
+    <div class="loading" hidden\$="[[!_loading]]">Loading...</div>
+    <div hidden\$="[[_loading]]" hidden="">
+      <gr-user-header user-id="[[params.user]]" class\$="[[_computeUserHeaderClass(params)]]"></gr-user-header>
+      <gr-change-list show-star="" show-reviewed-state="" account="[[account]]" preferences="[[preferences]]" selected-index="{{viewState.selectedChangeIndex}}" sections="[[_results]]" on-toggle-star="_handleToggleStar" on-toggle-reviewed="_handleToggleReviewed">
         <div id="emptyOutgoing" slot="empty-outgoing">
           <template is="dom-if" if="[[_showNewUserHelp]]">
             <gr-create-change-help on-create-tap="createChangeTap"></gr-create-change-help>
@@ -107,12 +79,8 @@
         </div>
       </gr-change-list>
     </div>
-    <gr-overlay id="confirmDeleteOverlay" with-backdrop>
-      <gr-dialog
-          id="confirmDeleteDialog"
-          confirm-label="Delete"
-          on-confirm="_handleConfirmDelete"
-          on-cancel="_closeConfirmDeleteOverlay">
+    <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
+      <gr-dialog id="confirmDeleteDialog" confirm-label="Delete" on-confirm="_handleConfirmDelete" on-cancel="_closeConfirmDeleteOverlay">
         <div class="header" slot="header">
           Delete comments
         </div>
@@ -122,12 +90,8 @@
         </div>
       </gr-dialog>
     </gr-overlay>
-    <gr-create-destination-dialog
-        id="destinationDialog"
-        on-confirm="_handleDestinationConfirm"></gr-create-destination-dialog>
+    <gr-create-destination-dialog id="destinationDialog" on-confirm="_handleDestinationConfirm"></gr-create-destination-dialog>
     <gr-create-commands-dialog id="commandsDialog"></gr-create-commands-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-dashboard-view.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
index 3d83e99..c879923 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dashboard-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-dashboard-view.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-dashboard-view.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-dashboard-view.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,351 +40,353 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-dashboard-view tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let paramsChangedPromise;
-    let getChangesStub;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-dashboard-view.js';
+suite('gr-dashboard-view tests', () => {
+  let element;
+  let sandbox;
+  let paramsChangedPromise;
+  let getChangesStub;
 
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        getAccountDetails() { return Promise.resolve({}); },
-        getAccountStatus() { return Promise.resolve(false); },
-      });
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      getChangesStub = sandbox.stub(element.$.restAPI, 'getChanges',
-          (_, qs) => Promise.resolve(qs.map(() => [])));
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getAccountDetails() { return Promise.resolve({}); },
+      getAccountStatus() { return Promise.resolve(false); },
+    });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    getChangesStub = sandbox.stub(element.$.restAPI, 'getChanges',
+        (_, qs) => Promise.resolve(qs.map(() => [])));
 
-      let resolver;
-      paramsChangedPromise = new Promise(resolve => {
-        resolver = resolve;
+    let resolver;
+    paramsChangedPromise = new Promise(resolve => {
+      resolver = resolve;
+    });
+    const paramsChanged = element._paramsChanged.bind(element);
+    sandbox.stub(element, '_paramsChanged', params => {
+      paramsChanged(params).then(() => resolver());
+    });
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('drafts banner functionality', () => {
+    suite('_maybeShowDraftsBanner', () => {
+      test('not dashboard/self', () => {
+        element.params = {user: 'notself'};
+        element._maybeShowDraftsBanner();
+        assert.isFalse(element._showDraftsBanner);
       });
-      const paramsChanged = element._paramsChanged.bind(element);
-      sandbox.stub(element, '_paramsChanged', params => {
-        paramsChanged(params).then(() => resolver());
+
+      test('no drafts at all', () => {
+        element.params = {user: 'self'};
+        element._results = [];
+        element._maybeShowDraftsBanner();
+        assert.isFalse(element._showDraftsBanner);
+      });
+
+      test('no drafts on open changes', () => {
+        element.params = {user: 'self'};
+        element._results = [{query: 'has:draft', results: [{status: '_'}]}];
+        sandbox.stub(element, 'changeIsOpen').returns(true);
+        element._maybeShowDraftsBanner();
+        assert.isFalse(element._showDraftsBanner);
+      });
+
+      test('no drafts on open changes', () => {
+        element.params = {user: 'self'};
+        element._results = [{query: 'has:draft', results: [{status: '_'}]}];
+        sandbox.stub(element, 'changeIsOpen').returns(false);
+        element._maybeShowDraftsBanner();
+        assert.isTrue(element._showDraftsBanner);
       });
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('_showDraftsBanner', () => {
+      element._showDraftsBanner = false;
+      flushAsynchronousOperations();
+      assert.isTrue(isHidden(element.shadowRoot
+          .querySelector('.banner')));
+
+      element._showDraftsBanner = true;
+      flushAsynchronousOperations();
+      assert.isFalse(isHidden(element.shadowRoot
+          .querySelector('.banner')));
     });
 
-    suite('drafts banner functionality', () => {
-      suite('_maybeShowDraftsBanner', () => {
-        test('not dashboard/self', () => {
-          element.params = {user: 'notself'};
-          element._maybeShowDraftsBanner();
-          assert.isFalse(element._showDraftsBanner);
-        });
+    test('delete tap opens dialog', () => {
+      sandbox.stub(element, '_handleOpenDeleteDialog');
+      element._showDraftsBanner = true;
+      flushAsynchronousOperations();
 
-        test('no drafts at all', () => {
-          element.params = {user: 'self'};
-          element._results = [];
-          element._maybeShowDraftsBanner();
-          assert.isFalse(element._showDraftsBanner);
-        });
-
-        test('no drafts on open changes', () => {
-          element.params = {user: 'self'};
-          element._results = [{query: 'has:draft', results: [{status: '_'}]}];
-          sandbox.stub(element, 'changeIsOpen').returns(true);
-          element._maybeShowDraftsBanner();
-          assert.isFalse(element._showDraftsBanner);
-        });
-
-        test('no drafts on open changes', () => {
-          element.params = {user: 'self'};
-          element._results = [{query: 'has:draft', results: [{status: '_'}]}];
-          sandbox.stub(element, 'changeIsOpen').returns(false);
-          element._maybeShowDraftsBanner();
-          assert.isTrue(element._showDraftsBanner);
-        });
-      });
-
-      test('_showDraftsBanner', () => {
-        element._showDraftsBanner = false;
-        flushAsynchronousOperations();
-        assert.isTrue(isHidden(element.shadowRoot
-            .querySelector('.banner')));
-
-        element._showDraftsBanner = true;
-        flushAsynchronousOperations();
-        assert.isFalse(isHidden(element.shadowRoot
-            .querySelector('.banner')));
-      });
-
-      test('delete tap opens dialog', () => {
-        sandbox.stub(element, '_handleOpenDeleteDialog');
-        element._showDraftsBanner = true;
-        flushAsynchronousOperations();
-
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.banner .delete'));
-        assert.isTrue(element._handleOpenDeleteDialog.called);
-      });
-
-      test('delete comments flow', async () => {
-        sandbox.spy(element, '_handleConfirmDelete');
-        sandbox.stub(element, '_reload');
-
-        // Set up control over timing of when RPC resolves.
-        let deleteDraftCommentsPromiseResolver;
-        const deleteDraftCommentsPromise = new Promise(resolve => {
-          deleteDraftCommentsPromiseResolver = resolve;
-        });
-        sandbox.stub(element.$.restAPI, 'deleteDraftComments')
-            .returns(deleteDraftCommentsPromise);
-
-        // Open confirmation dialog and tap confirm button.
-        await element.$.confirmDeleteOverlay.open();
-        MockInteractions.tap(element.$.confirmDeleteDialog.$.confirm);
-        flushAsynchronousOperations();
-        assert.isTrue(element.$.restAPI.deleteDraftComments
-            .calledWithExactly('-is:open'));
-        assert.isTrue(element.$.confirmDeleteDialog.disabled);
-        assert.equal(element._reload.callCount, 0);
-
-        // Verify state after RPC resolves.
-        deleteDraftCommentsPromiseResolver([]);
-        await deleteDraftCommentsPromise;
-        assert.equal(element._reload.callCount, 1);
-      });
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.banner .delete'));
+      assert.isTrue(element._handleOpenDeleteDialog.called);
     });
 
-    test('_computeTitle', () => {
-      assert.equal(element._computeTitle('self'), 'My Reviews');
-      assert.equal(element._computeTitle('not self'), 'Dashboard for not self');
+    test('delete comments flow', async () => {
+      sandbox.spy(element, '_handleConfirmDelete');
+      sandbox.stub(element, '_reload');
+
+      // Set up control over timing of when RPC resolves.
+      let deleteDraftCommentsPromiseResolver;
+      const deleteDraftCommentsPromise = new Promise(resolve => {
+        deleteDraftCommentsPromiseResolver = resolve;
+      });
+      sandbox.stub(element.$.restAPI, 'deleteDraftComments')
+          .returns(deleteDraftCommentsPromise);
+
+      // Open confirmation dialog and tap confirm button.
+      await element.$.confirmDeleteOverlay.open();
+      MockInteractions.tap(element.$.confirmDeleteDialog.$.confirm);
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.restAPI.deleteDraftComments
+          .calledWithExactly('-is:open'));
+      assert.isTrue(element.$.confirmDeleteDialog.disabled);
+      assert.equal(element._reload.callCount, 0);
+
+      // Verify state after RPC resolves.
+      deleteDraftCommentsPromiseResolver([]);
+      await deleteDraftCommentsPromise;
+      assert.equal(element._reload.callCount, 1);
+    });
+  });
+
+  test('_computeTitle', () => {
+    assert.equal(element._computeTitle('self'), 'My Reviews');
+    assert.equal(element._computeTitle('not self'), 'Dashboard for not self');
+  });
+
+  suite('_computeSectionCountLabel', () => {
+    test('empty changes dont count label', () => {
+      assert.equal('', element._computeSectionCountLabel([]));
     });
 
-    suite('_computeSectionCountLabel', () => {
-      test('empty changes dont count label', () => {
-        assert.equal('', element._computeSectionCountLabel([]));
-      });
-
-      test('1 change', () => {
-        assert.equal('(1)',
-            element._computeSectionCountLabel(['1']));
-      });
-
-      test('2 changes', () => {
-        assert.equal('(2)',
-            element._computeSectionCountLabel(['1', '2']));
-      });
-
-      test('1 change and more', () => {
-        assert.equal('(1 and more)',
-            element._computeSectionCountLabel([{_more_changes: true}]));
-      });
+    test('1 change', () => {
+      assert.equal('(1)',
+          element._computeSectionCountLabel(['1']));
     });
 
-    suite('_isViewActive', () => {
-      test('nothing happens when user param is falsy', () => {
-        element.params = {};
-        flushAsynchronousOperations();
-        assert.equal(getChangesStub.callCount, 0);
-
-        element.params = {user: ''};
-        flushAsynchronousOperations();
-        assert.equal(getChangesStub.callCount, 0);
-      });
-
-      test('content is refreshed when user param is updated', () => {
-        element.params = {
-          view: Gerrit.Nav.View.DASHBOARD,
-          user: 'self',
-        };
-        return paramsChangedPromise.then(() => {
-          assert.equal(getChangesStub.callCount, 1);
-        });
-      });
+    test('2 changes', () => {
+      assert.equal('(2)',
+          element._computeSectionCountLabel(['1', '2']));
     });
 
-    suite('selfOnly sections', () => {
-      test('viewing self dashboard includes selfOnly sections', () => {
-        element.params = {
-          view: Gerrit.Nav.View.DASHBOARD,
-          sections: [
-            {query: '1'},
-            {query: '2', selfOnly: true},
-          ],
-          user: 'self',
-        };
-        return paramsChangedPromise.then(() => {
-          assert.isTrue(
-              getChangesStub.calledWith(
-                  null, ['1', '2', 'owner:self limit:1'], null, element.options));
-        });
-      });
+    test('1 change and more', () => {
+      assert.equal('(1 and more)',
+          element._computeSectionCountLabel([{_more_changes: true}]));
+    });
+  });
 
-      test('viewing another user\'s dashboard omits selfOnly sections', () => {
-        element.params = {
-          view: Gerrit.Nav.View.DASHBOARD,
-          sections: [
-            {query: '1'},
-            {query: '2', selfOnly: true},
-          ],
-          user: 'user',
-        };
-        return paramsChangedPromise.then(() => {
-          assert.isTrue(
-              getChangesStub.calledWith(
-                  null, ['1'], null, element.options));
-        });
-      });
+  suite('_isViewActive', () => {
+    test('nothing happens when user param is falsy', () => {
+      element.params = {};
+      flushAsynchronousOperations();
+      assert.equal(getChangesStub.callCount, 0);
+
+      element.params = {user: ''};
+      flushAsynchronousOperations();
+      assert.equal(getChangesStub.callCount, 0);
     });
 
-    test('suffixForDashboard is included in getChanges query', () => {
+    test('content is refreshed when user param is updated', () => {
+      element.params = {
+        view: Gerrit.Nav.View.DASHBOARD,
+        user: 'self',
+      };
+      return paramsChangedPromise.then(() => {
+        assert.equal(getChangesStub.callCount, 1);
+      });
+    });
+  });
+
+  suite('selfOnly sections', () => {
+    test('viewing self dashboard includes selfOnly sections', () => {
       element.params = {
         view: Gerrit.Nav.View.DASHBOARD,
         sections: [
           {query: '1'},
-          {query: '2', suffixForDashboard: 'suffix'},
+          {query: '2', selfOnly: true},
         ],
+        user: 'self',
       };
       return paramsChangedPromise.then(() => {
-        assert.isTrue(getChangesStub.calledOnce);
-        assert.deepEqual(
-            getChangesStub.firstCall.args,
-            [null, ['1', '2 suffix'], null, element.options]);
+        assert.isTrue(
+            getChangesStub.calledWith(
+                null, ['1', '2', 'owner:self limit:1'], null, element.options));
       });
     });
 
-    suite('_getProjectDashboard', () => {
-      test('dashboard with foreach', () => {
-        sandbox.stub(element.$.restAPI, 'getDashboard', () => Promise.resolve({
-          title: 'title',
-          foreach: 'foreach for ${project}',
-          sections: [
-            {name: 'section 1', query: 'query 1'},
-            {name: 'section 2', query: '${project} query 2'},
-          ],
-        }));
-        return element._getProjectDashboard('project', '').then(dashboard => {
-          assert.deepEqual(
-              dashboard,
-              {
-                title: 'title',
-                sections: [
-                  {name: 'section 1', query: 'query 1 foreach for project'},
-                  {
-                    name: 'section 2',
-                    query: 'project query 2 foreach for project',
-                  },
-                ],
-              });
-        });
-      });
-
-      test('dashboard without foreach', () => {
-        sandbox.stub(element.$.restAPI, 'getDashboard', () => Promise.resolve({
-          title: 'title',
-          sections: [
-            {name: 'section 1', query: 'query 1'},
-            {name: 'section 2', query: '${project} query 2'},
-          ],
-        }));
-        return element._getProjectDashboard('project', '').then(dashboard => {
-          assert.deepEqual(
-              dashboard,
-              {
-                title: 'title',
-                sections: [
-                  {name: 'section 1', query: 'query 1'},
-                  {name: 'section 2', query: 'project query 2'},
-                ],
-              });
-        });
-      });
-    });
-
-    test('hideIfEmpty sections', () => {
-      const sections = [
-        {name: 'test1', query: 'test1', hideIfEmpty: true},
-        {name: 'test2', query: 'test2', hideIfEmpty: true},
-      ];
-      getChangesStub.restore();
-      sandbox.stub(element.$.restAPI, 'getChanges')
-          .returns(Promise.resolve([[], ['nonempty']]));
-
-      return element._fetchDashboardChanges({sections}, false).then(() => {
-        assert.equal(element._results.length, 1);
-        assert.equal(element._results[0].name, 'test2');
-      });
-    });
-
-    test('preserve isOutgoing sections', () => {
-      const sections = [
-        {name: 'test1', query: 'test1', isOutgoing: true},
-        {name: 'test2', query: 'test2'},
-      ];
-      getChangesStub.restore();
-      sandbox.stub(element.$.restAPI, 'getChanges')
-          .returns(Promise.resolve([[], []]));
-
-      return element._fetchDashboardChanges({sections}, false).then(() => {
-        assert.equal(element._results.length, 2);
-        assert.isTrue(element._results[0].isOutgoing);
-        assert.isNotOk(element._results[1].isOutgoing);
-      });
-    });
-
-    test('_showNewUserHelp', () => {
-      element._loading = false;
-      element._showNewUserHelp = false;
-      flushAsynchronousOperations();
-
-      assert.equal(element.$.emptyOutgoing.textContent.trim(), 'No changes');
-      assert.isNotOk(element.shadowRoot
-          .querySelector('gr-create-change-help'));
-      element._showNewUserHelp = true;
-      flushAsynchronousOperations();
-
-      assert.notEqual(element.$.emptyOutgoing.textContent.trim(), 'No changes');
-      assert.isOk(element.shadowRoot
-          .querySelector('gr-create-change-help'));
-    });
-
-    test('_computeUserHeaderClass', () => {
-      assert.equal(element._computeUserHeaderClass(undefined), 'hide');
-      assert.equal(element._computeUserHeaderClass({}), 'hide');
-      assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
-      assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
-      assert.equal(
-          element._computeUserHeaderClass({project: 'p', user: 'user'}),
-          'hide');
-    });
-
-    test('404 page', done => {
-      const response = {status: 404};
-      sandbox.stub(element.$.restAPI, 'getDashboard',
-          async (project, dashboard, errFn) => {
-            errFn(response);
-          });
-      element.addEventListener('page-error', e => {
-        assert.strictEqual(e.detail.response, response);
-        done();
-      });
+    test('viewing another user\'s dashboard omits selfOnly sections', () => {
       element.params = {
         view: Gerrit.Nav.View.DASHBOARD,
-        project: 'project',
-        dashboard: 'dashboard',
-      };
-    });
-
-    test('params change triggers dashboardDisplayed()', () => {
-      sandbox.stub(element.$.reporting, 'dashboardDisplayed');
-      element.params = {
-        view: Gerrit.Nav.View.DASHBOARD,
-        project: 'project',
-        dashboard: 'dashboard',
+        sections: [
+          {query: '1'},
+          {query: '2', selfOnly: true},
+        ],
+        user: 'user',
       };
       return paramsChangedPromise.then(() => {
-        assert.isTrue(element.$.reporting.dashboardDisplayed.calledOnce);
+        assert.isTrue(
+            getChangesStub.calledWith(
+                null, ['1'], null, element.options));
       });
     });
   });
+
+  test('suffixForDashboard is included in getChanges query', () => {
+    element.params = {
+      view: Gerrit.Nav.View.DASHBOARD,
+      sections: [
+        {query: '1'},
+        {query: '2', suffixForDashboard: 'suffix'},
+      ],
+    };
+    return paramsChangedPromise.then(() => {
+      assert.isTrue(getChangesStub.calledOnce);
+      assert.deepEqual(
+          getChangesStub.firstCall.args,
+          [null, ['1', '2 suffix'], null, element.options]);
+    });
+  });
+
+  suite('_getProjectDashboard', () => {
+    test('dashboard with foreach', () => {
+      sandbox.stub(element.$.restAPI, 'getDashboard', () => Promise.resolve({
+        title: 'title',
+        foreach: 'foreach for ${project}',
+        sections: [
+          {name: 'section 1', query: 'query 1'},
+          {name: 'section 2', query: '${project} query 2'},
+        ],
+      }));
+      return element._getProjectDashboard('project', '').then(dashboard => {
+        assert.deepEqual(
+            dashboard,
+            {
+              title: 'title',
+              sections: [
+                {name: 'section 1', query: 'query 1 foreach for project'},
+                {
+                  name: 'section 2',
+                  query: 'project query 2 foreach for project',
+                },
+              ],
+            });
+      });
+    });
+
+    test('dashboard without foreach', () => {
+      sandbox.stub(element.$.restAPI, 'getDashboard', () => Promise.resolve({
+        title: 'title',
+        sections: [
+          {name: 'section 1', query: 'query 1'},
+          {name: 'section 2', query: '${project} query 2'},
+        ],
+      }));
+      return element._getProjectDashboard('project', '').then(dashboard => {
+        assert.deepEqual(
+            dashboard,
+            {
+              title: 'title',
+              sections: [
+                {name: 'section 1', query: 'query 1'},
+                {name: 'section 2', query: 'project query 2'},
+              ],
+            });
+      });
+    });
+  });
+
+  test('hideIfEmpty sections', () => {
+    const sections = [
+      {name: 'test1', query: 'test1', hideIfEmpty: true},
+      {name: 'test2', query: 'test2', hideIfEmpty: true},
+    ];
+    getChangesStub.restore();
+    sandbox.stub(element.$.restAPI, 'getChanges')
+        .returns(Promise.resolve([[], ['nonempty']]));
+
+    return element._fetchDashboardChanges({sections}, false).then(() => {
+      assert.equal(element._results.length, 1);
+      assert.equal(element._results[0].name, 'test2');
+    });
+  });
+
+  test('preserve isOutgoing sections', () => {
+    const sections = [
+      {name: 'test1', query: 'test1', isOutgoing: true},
+      {name: 'test2', query: 'test2'},
+    ];
+    getChangesStub.restore();
+    sandbox.stub(element.$.restAPI, 'getChanges')
+        .returns(Promise.resolve([[], []]));
+
+    return element._fetchDashboardChanges({sections}, false).then(() => {
+      assert.equal(element._results.length, 2);
+      assert.isTrue(element._results[0].isOutgoing);
+      assert.isNotOk(element._results[1].isOutgoing);
+    });
+  });
+
+  test('_showNewUserHelp', () => {
+    element._loading = false;
+    element._showNewUserHelp = false;
+    flushAsynchronousOperations();
+
+    assert.equal(element.$.emptyOutgoing.textContent.trim(), 'No changes');
+    assert.isNotOk(element.shadowRoot
+        .querySelector('gr-create-change-help'));
+    element._showNewUserHelp = true;
+    flushAsynchronousOperations();
+
+    assert.notEqual(element.$.emptyOutgoing.textContent.trim(), 'No changes');
+    assert.isOk(element.shadowRoot
+        .querySelector('gr-create-change-help'));
+  });
+
+  test('_computeUserHeaderClass', () => {
+    assert.equal(element._computeUserHeaderClass(undefined), 'hide');
+    assert.equal(element._computeUserHeaderClass({}), 'hide');
+    assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
+    assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
+    assert.equal(
+        element._computeUserHeaderClass({project: 'p', user: 'user'}),
+        'hide');
+  });
+
+  test('404 page', done => {
+    const response = {status: 404};
+    sandbox.stub(element.$.restAPI, 'getDashboard',
+        async (project, dashboard, errFn) => {
+          errFn(response);
+        });
+    element.addEventListener('page-error', e => {
+      assert.strictEqual(e.detail.response, response);
+      done();
+    });
+    element.params = {
+      view: Gerrit.Nav.View.DASHBOARD,
+      project: 'project',
+      dashboard: 'dashboard',
+    };
+  });
+
+  test('params change triggers dashboardDisplayed()', () => {
+    sandbox.stub(element.$.reporting, 'dashboardDisplayed');
+    element.params = {
+      view: Gerrit.Nav.View.DASHBOARD,
+      project: 'project',
+      dashboard: 'dashboard',
+    };
+    return paramsChangedPromise.then(() => {
+      assert.isTrue(element.$.reporting.dashboardDisplayed.calledOnce);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
index de0a56e..2523700 100644
--- a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
+++ b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
@@ -14,24 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrEmbedDashboard extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-embed-dashboard'; }
+import '../gr-change-list/gr-change-list.js';
+import '../gr-create-change-help/gr-create-change-help.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-embed-dashboard_html.js';
 
-    static get properties() {
-      return {
-        account: Object,
-        sections: Array,
-        preferences: Object,
-        showNewUserHelp: Boolean,
-      };
-    }
+/** @extends Polymer.Element */
+class GrEmbedDashboard extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-embed-dashboard'; }
+
+  static get properties() {
+    return {
+      account: Object,
+      sections: Array,
+      preferences: Object,
+      showNewUserHelp: Boolean,
+    };
   }
+}
 
-  customElements.define(GrEmbedDashboard.is, GrEmbedDashboard);
-})();
+customElements.define(GrEmbedDashboard.is, GrEmbedDashboard);
diff --git a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard_html.js b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard_html.js
index d445185..2c122f3 100644
--- a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard_html.js
@@ -1,32 +1,23 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../change-list/gr-change-list/gr-change-list.html">
-<link rel="import" href="../gr-create-change-help/gr-create-change-help.html">
-
-<dom-module id="gr-embed-dashboard">
-  <template>
-    <gr-change-list
-        show-star
-        account="[[account]]"
-        preferences="[[preferences]]"
-        sections="[[sections]]">
+export const htmlTemplate = html`
+    <gr-change-list show-star="" account="[[account]]" preferences="[[preferences]]" sections="[[sections]]">
       <div id="emptyOutgoing" slot="empty-outgoing">
         <template is="dom-if" if="[[showNewUserHelp]]">
           <gr-create-change-help></gr-create-change-help>
@@ -36,6 +27,4 @@
         </template>
       </div>
     </gr-change-list>
-  </template>
-  <script src="gr-embed-dashboard.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
index c0e472a..e9a0387 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
@@ -14,35 +14,45 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrRepoHeader extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-repo-header'; }
+import '../../../styles/dashboard-header-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-header_html.js';
 
-    static get properties() {
-      return {
-      /** @type {?string} */
-        repo: {
-          type: String,
-          observer: '_repoChanged',
-        },
-        /** @type {string|null} */
-        _repoUrl: String,
-      };
-    }
+/** @extends Polymer.Element */
+class GrRepoHeader extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _repoChanged(repoName) {
-      if (!repoName) {
-        this._repoUrl = null;
-        return;
-      }
-      this._repoUrl = Gerrit.Nav.getUrlForRepo(repoName);
-    }
+  static get is() { return 'gr-repo-header'; }
+
+  static get properties() {
+    return {
+    /** @type {?string} */
+      repo: {
+        type: String,
+        observer: '_repoChanged',
+      },
+      /** @type {string|null} */
+      _repoUrl: String,
+    };
   }
 
-  customElements.define(GrRepoHeader.is, GrRepoHeader);
-})();
+  _repoChanged(repoName) {
+    if (!repoName) {
+      this._repoUrl = null;
+      return;
+    }
+    this._repoUrl = Gerrit.Nav.getUrlForRepo(repoName);
+  }
+}
+
+customElements.define(GrRepoHeader.is, GrRepoHeader);
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js
index 5d4b8a3..d1274ee 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/dashboard-header-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-repo-header">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -31,15 +24,13 @@
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
     <div class="info">
-      <h1 class$="name">
+      <h1 class\$="name">
         [[repo]]
-        <hr/>
+        <hr>
       </h1>
       <div>
-        <span>Detail:</span> <a href$="[[_repoUrl]]">Repo settings</a>
+        <span>Detail:</span> <a href\$="[[_repoUrl]]">Repo settings</a>
       </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-header.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
index d9cd75e..02fb715 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-header</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-header.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-repo-header.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-header.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,26 +40,28 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-header tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-header.js';
+suite('gr-repo-header tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('repoUrl reset once repo changed', () => {
-      sandbox.stub(Gerrit.Nav, 'getUrlForRepo',
-          repoName => `http://test.com/${repoName}`
-      );
-      assert.equal(element._repoUrl, undefined);
-      element.repo = 'test';
-      assert.equal(element._repoUrl, 'http://test.com/test');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('repoUrl reset once repo changed', () => {
+    sandbox.stub(Gerrit.Nav, 'getUrlForRepo',
+        repoName => `http://test.com/${repoName}`
+    );
+    assert.equal(element._repoUrl, undefined);
+    element.repo = 'test';
+    assert.equal(element._repoUrl, 'http://test.com/test');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
index 2fc8170..3fc4291 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
@@ -14,91 +14,104 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @extends Polymer.Element
-   */
-  class GrUserHeader extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-user-header'; }
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../shared/gr-avatar/gr-avatar.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/dashboard-header-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-user-header_html.js';
 
-    static get properties() {
-      return {
+/**
+ * @extends Polymer.Element
+ */
+class GrUserHeader extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-user-header'; }
+
+  static get properties() {
+    return {
+    /** @type {?string} */
+      userId: {
+        type: String,
+        observer: '_accountChanged',
+      },
+
+      showDashboardLink: {
+        type: Boolean,
+        value: false,
+      },
+
+      loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+
+      /**
+       * @type {?{name: ?, email: ?, registered_on: ?}}
+       */
+      _accountDetails: {
+        type: Object,
+        value: null,
+      },
+
       /** @type {?string} */
-        userId: {
-          type: String,
-          observer: '_accountChanged',
-        },
-
-        showDashboardLink: {
-          type: Boolean,
-          value: false,
-        },
-
-        loggedIn: {
-          type: Boolean,
-          value: false,
-        },
-
-        /**
-         * @type {?{name: ?, email: ?, registered_on: ?}}
-         */
-        _accountDetails: {
-          type: Object,
-          value: null,
-        },
-
-        /** @type {?string} */
-        _status: {
-          type: String,
-          value: null,
-        },
-      };
-    }
-
-    _accountChanged(userId) {
-      if (!userId) {
-        this._accountDetails = null;
-        this._status = null;
-        return;
-      }
-
-      this.$.restAPI.getAccountDetails(userId).then(details => {
-        this._accountDetails = details;
-      });
-      this.$.restAPI.getAccountStatus(userId).then(status => {
-        this._status = status;
-      });
-    }
-
-    _computeDisplayClass(status) {
-      return status ? ' ' : 'hide';
-    }
-
-    _computeDetail(accountDetails, name) {
-      return accountDetails ? accountDetails[name] : '';
-    }
-
-    _computeStatusClass(accountDetails) {
-      return this._computeDetail(accountDetails, 'status') ? '' : 'hide';
-    }
-
-    _computeDashboardUrl(accountDetails) {
-      if (!accountDetails) { return null; }
-      const id = accountDetails._account_id;
-      const email = accountDetails.email;
-      if (!id && !email ) { return null; }
-      return Gerrit.Nav.getUrlForUserDashboard(id ? id : email);
-    }
-
-    _computeDashboardLinkClass(showDashboardLink, loggedIn) {
-      return showDashboardLink && loggedIn ?
-        'dashboardLink' : 'dashboardLink hide';
-    }
+      _status: {
+        type: String,
+        value: null,
+      },
+    };
   }
 
-  customElements.define(GrUserHeader.is, GrUserHeader);
-})();
+  _accountChanged(userId) {
+    if (!userId) {
+      this._accountDetails = null;
+      this._status = null;
+      return;
+    }
+
+    this.$.restAPI.getAccountDetails(userId).then(details => {
+      this._accountDetails = details;
+    });
+    this.$.restAPI.getAccountStatus(userId).then(status => {
+      this._status = status;
+    });
+  }
+
+  _computeDisplayClass(status) {
+    return status ? ' ' : 'hide';
+  }
+
+  _computeDetail(accountDetails, name) {
+    return accountDetails ? accountDetails[name] : '';
+  }
+
+  _computeStatusClass(accountDetails) {
+    return this._computeDetail(accountDetails, 'status') ? '' : 'hide';
+  }
+
+  _computeDashboardUrl(accountDetails) {
+    if (!accountDetails) { return null; }
+    const id = accountDetails._account_id;
+    const email = accountDetails.email;
+    if (!id && !email ) { return null; }
+    return Gerrit.Nav.getUrlForUserDashboard(id ? id : email);
+  }
+
+  _computeDashboardLinkClass(showDashboardLink, loggedIn) {
+    return showDashboardLink && loggedIn ?
+      'dashboardLink' : 'dashboardLink hide';
+  }
+}
+
+customElements.define(GrUserHeader.is, GrUserHeader);
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js
index 8175849..3de4277 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js
@@ -1,32 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/dashboard-header-styles.html">
-
-<dom-module id="gr-user-header">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -43,16 +33,13 @@
         display: none;
       }
     </style>
-    <gr-avatar
-        account="[[_accountDetails]]"
-        image-size="100"
-        aria-label="Account avatar"></gr-avatar>
+    <gr-avatar account="[[_accountDetails]]" image-size="100" aria-label="Account avatar"></gr-avatar>
     <div class="info">
       <h1 class="name">
         [[_computeDetail(_accountDetails, 'name')]]
       </h1>
-      <hr/>
-      <div class$="status [[_computeStatusClass(_accountDetails)]]">
+      <hr>
+      <div class\$="status [[_computeStatusClass(_accountDetails)]]">
         <span>Status:</span> [[_status]]
       </div>
       <div>
@@ -62,8 +49,7 @@
       </div>
       <div>
         <span>Joined:</span>
-        <gr-date-formatter
-            date-str="[[_computeDetail(_accountDetails, 'registered_on')]]">
+        <gr-date-formatter date-str="[[_computeDetail(_accountDetails, 'registered_on')]]">
         </gr-date-formatter>
       </div>
       <gr-endpoint-decorator name="user-header">
@@ -74,11 +60,9 @@
       </gr-endpoint-decorator>
     </div>
     <div class="info">
-      <div class$="[[_computeDashboardLinkClass(showDashboardLink, loggedIn)]]">
-        <a href$="[[_computeDashboardUrl(_accountDetails)]]">View dashboard</a>
+      <div class\$="[[_computeDashboardLinkClass(showDashboardLink, loggedIn)]]">
+        <a href\$="[[_computeDashboardUrl(_accountDetails)]]">View dashboard</a>
       </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-user-header.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
index 26ebeb2..169c028 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-user-header</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-user-header.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-user-header.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-user-header.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,50 +40,52 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-user-header tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-user-header.js';
+suite('gr-user-header tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
 
-    teardown(() => { sandbox.restore(); });
+  teardown(() => { sandbox.restore(); });
 
-    test('loads and clears account info', done => {
-      sandbox.stub(element.$.restAPI, 'getAccountDetails')
-          .returns(Promise.resolve({
-            name: 'foo',
-            email: 'bar',
-            registered_on: '2015-03-12 18:32:08.000000000',
-          }));
-      sandbox.stub(element.$.restAPI, 'getAccountStatus')
-          .returns(Promise.resolve('baz'));
+  test('loads and clears account info', done => {
+    sandbox.stub(element.$.restAPI, 'getAccountDetails')
+        .returns(Promise.resolve({
+          name: 'foo',
+          email: 'bar',
+          registered_on: '2015-03-12 18:32:08.000000000',
+        }));
+    sandbox.stub(element.$.restAPI, 'getAccountStatus')
+        .returns(Promise.resolve('baz'));
 
-      element.userId = 'foo.bar@baz';
+    element.userId = 'foo.bar@baz';
+    flush(() => {
+      assert.isOk(element._accountDetails);
+      assert.isOk(element._status);
+
+      element.userId = null;
       flush(() => {
-        assert.isOk(element._accountDetails);
-        assert.isOk(element._status);
+        flushAsynchronousOperations();
+        assert.isNull(element._accountDetails);
+        assert.isNull(element._status);
 
-        element.userId = null;
-        flush(() => {
-          flushAsynchronousOperations();
-          assert.isNull(element._accountDetails);
-          assert.isNull(element._status);
-
-          done();
-        });
+        done();
       });
     });
-
-    test('_computeDashboardLinkClass', () => {
-      assert.include(element._computeDashboardLinkClass(false, false), 'hide');
-      assert.include(element._computeDashboardLinkClass(true, false), 'hide');
-      assert.include(element._computeDashboardLinkClass(false, true), 'hide');
-      assert.notInclude(element._computeDashboardLinkClass(true, true), 'hide');
-    });
   });
+
+  test('_computeDashboardLinkClass', () => {
+    assert.include(element._computeDashboardLinkClass(false, false), 'hide');
+    assert.include(element._computeDashboardLinkClass(true, false), 'hide');
+    assert.include(element._computeDashboardLinkClass(false, true), 'hide');
+    assert.notInclude(element._computeDashboardLinkClass(true, true), 'hide');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 12c6b58..51888d6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -14,1613 +14,1642 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
-  const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
-  const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import '../../admin/gr-create-change-dialog/gr-create-change-dialog.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-dropdown/gr-dropdown.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js';
+import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js';
+import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js';
+import '../gr-confirm-move-dialog/gr-confirm-move-dialog.js';
+import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js';
+import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog.js';
+import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js';
+import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-actions_html.js';
+
+const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
+const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
+const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
+/**
+ * @enum {string}
+ */
+const LabelStatus = {
   /**
-   * @enum {string}
+   * This label provides what is necessary for submission.
    */
-  const LabelStatus = {
-    /**
-     * This label provides what is necessary for submission.
-     */
-    OK: 'OK',
-    /**
-     * This label prevents the change from being submitted.
-     */
-    REJECT: 'REJECT',
-    /**
-     * The label may be set, but it's neither necessary for submission
-     * nor does it block submission if set.
-     */
-    MAY: 'MAY',
-    /**
-     * The label is required for submission, but has not been satisfied.
-     */
-    NEED: 'NEED',
-    /**
-     * The label is required for submission, but is impossible to complete.
-     * The likely cause is access has not been granted correctly by the
-     * project owner or site administrator.
-     */
-    IMPOSSIBLE: 'IMPOSSIBLE',
-    OPTIONAL: 'OPTIONAL',
-  };
+  OK: 'OK',
+  /**
+   * This label prevents the change from being submitted.
+   */
+  REJECT: 'REJECT',
+  /**
+   * The label may be set, but it's neither necessary for submission
+   * nor does it block submission if set.
+   */
+  MAY: 'MAY',
+  /**
+   * The label is required for submission, but has not been satisfied.
+   */
+  NEED: 'NEED',
+  /**
+   * The label is required for submission, but is impossible to complete.
+   * The likely cause is access has not been granted correctly by the
+   * project owner or site administrator.
+   */
+  IMPOSSIBLE: 'IMPOSSIBLE',
+  OPTIONAL: 'OPTIONAL',
+};
 
-  const ChangeActions = {
-    ABANDON: 'abandon',
-    DELETE: '/',
-    DELETE_EDIT: 'deleteEdit',
-    EDIT: 'edit',
-    FOLLOW_UP: 'followup',
-    IGNORE: 'ignore',
-    MOVE: 'move',
-    PRIVATE: 'private',
-    PRIVATE_DELETE: 'private.delete',
-    PUBLISH_EDIT: 'publishEdit',
-    REBASE_EDIT: 'rebaseEdit',
-    RESTORE: 'restore',
-    REVERT: 'revert',
-    REVERT_SUBMISSION: 'revert_submission',
-    REVIEWED: 'reviewed',
-    STOP_EDIT: 'stopEdit',
-    UNIGNORE: 'unignore',
-    UNREVIEWED: 'unreviewed',
-    WIP: 'wip',
-  };
+const ChangeActions = {
+  ABANDON: 'abandon',
+  DELETE: '/',
+  DELETE_EDIT: 'deleteEdit',
+  EDIT: 'edit',
+  FOLLOW_UP: 'followup',
+  IGNORE: 'ignore',
+  MOVE: 'move',
+  PRIVATE: 'private',
+  PRIVATE_DELETE: 'private.delete',
+  PUBLISH_EDIT: 'publishEdit',
+  REBASE_EDIT: 'rebaseEdit',
+  RESTORE: 'restore',
+  REVERT: 'revert',
+  REVERT_SUBMISSION: 'revert_submission',
+  REVIEWED: 'reviewed',
+  STOP_EDIT: 'stopEdit',
+  UNIGNORE: 'unignore',
+  UNREVIEWED: 'unreviewed',
+  WIP: 'wip',
+};
 
-  const RevisionActions = {
-    CHERRYPICK: 'cherrypick',
-    REBASE: 'rebase',
-    SUBMIT: 'submit',
-    DOWNLOAD: 'download',
-  };
+const RevisionActions = {
+  CHERRYPICK: 'cherrypick',
+  REBASE: 'rebase',
+  SUBMIT: 'submit',
+  DOWNLOAD: 'download',
+};
 
-  const ActionLoadingLabels = {
-    abandon: 'Abandoning...',
-    cherrypick: 'Cherry-picking...',
-    delete: 'Deleting...',
-    move: 'Moving..',
-    rebase: 'Rebasing...',
-    restore: 'Restoring...',
-    revert: 'Reverting...',
-    revert_submission: 'Reverting Submission...',
-    submit: 'Submitting...',
-  };
+const ActionLoadingLabels = {
+  abandon: 'Abandoning...',
+  cherrypick: 'Cherry-picking...',
+  delete: 'Deleting...',
+  move: 'Moving..',
+  rebase: 'Rebasing...',
+  restore: 'Restoring...',
+  revert: 'Reverting...',
+  revert_submission: 'Reverting Submission...',
+  submit: 'Submitting...',
+};
 
-  const ActionType = {
-    CHANGE: 'change',
-    REVISION: 'revision',
-  };
+const ActionType = {
+  CHANGE: 'change',
+  REVISION: 'revision',
+};
 
-  const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
+const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
 
-  const QUICK_APPROVE_ACTION = {
-    __key: 'review',
-    __type: 'change',
-    enabled: true,
-    key: 'review',
-    label: 'Quick approve',
-    method: 'POST',
-  };
+const QUICK_APPROVE_ACTION = {
+  __key: 'review',
+  __type: 'change',
+  enabled: true,
+  key: 'review',
+  label: 'Quick approve',
+  method: 'POST',
+};
 
-  const ActionPriority = {
-    CHANGE: 2,
-    DEFAULT: 0,
-    PRIMARY: 3,
-    REVIEW: -3,
-    REVISION: 1,
-  };
+const ActionPriority = {
+  CHANGE: 2,
+  DEFAULT: 0,
+  PRIMARY: 3,
+  REVIEW: -3,
+  REVISION: 1,
+};
 
-  const DOWNLOAD_ACTION = {
-    enabled: true,
-    label: 'Download patch',
-    title: 'Open download dialog',
-    __key: 'download',
-    __primary: false,
-    __type: 'revision',
-  };
+const DOWNLOAD_ACTION = {
+  enabled: true,
+  label: 'Download patch',
+  title: 'Open download dialog',
+  __key: 'download',
+  __primary: false,
+  __type: 'revision',
+};
 
-  const REBASE_EDIT = {
-    enabled: true,
-    label: 'Rebase edit',
-    title: 'Rebase change edit',
-    __key: 'rebaseEdit',
-    __primary: false,
-    __type: 'change',
-    method: 'POST',
-  };
+const REBASE_EDIT = {
+  enabled: true,
+  label: 'Rebase edit',
+  title: 'Rebase change edit',
+  __key: 'rebaseEdit',
+  __primary: false,
+  __type: 'change',
+  method: 'POST',
+};
 
-  const PUBLISH_EDIT = {
-    enabled: true,
-    label: 'Publish edit',
-    title: 'Publish change edit',
-    __key: 'publishEdit',
-    __primary: false,
-    __type: 'change',
-    method: 'POST',
-  };
+const PUBLISH_EDIT = {
+  enabled: true,
+  label: 'Publish edit',
+  title: 'Publish change edit',
+  __key: 'publishEdit',
+  __primary: false,
+  __type: 'change',
+  method: 'POST',
+};
 
-  const DELETE_EDIT = {
-    enabled: true,
-    label: 'Delete edit',
-    title: 'Delete change edit',
-    __key: 'deleteEdit',
-    __primary: false,
-    __type: 'change',
-    method: 'DELETE',
-  };
+const DELETE_EDIT = {
+  enabled: true,
+  label: 'Delete edit',
+  title: 'Delete change edit',
+  __key: 'deleteEdit',
+  __primary: false,
+  __type: 'change',
+  method: 'DELETE',
+};
 
-  const EDIT = {
-    enabled: true,
-    label: 'Edit',
-    title: 'Edit this change',
-    __key: 'edit',
-    __primary: false,
-    __type: 'change',
-  };
+const EDIT = {
+  enabled: true,
+  label: 'Edit',
+  title: 'Edit this change',
+  __key: 'edit',
+  __primary: false,
+  __type: 'change',
+};
 
-  const STOP_EDIT = {
-    enabled: true,
-    label: 'Stop editing',
-    title: 'Stop editing this change',
-    __key: 'stopEdit',
-    __primary: false,
-    __type: 'change',
-  };
+const STOP_EDIT = {
+  enabled: true,
+  label: 'Stop editing',
+  title: 'Stop editing this change',
+  __key: 'stopEdit',
+  __primary: false,
+  __type: 'change',
+};
 
-  // Set of keys that have icons. As more icons are added to gr-icons.html, this
-  // set should be expanded.
-  const ACTIONS_WITH_ICONS = new Set([
-    ChangeActions.ABANDON,
-    ChangeActions.DELETE_EDIT,
-    ChangeActions.EDIT,
-    ChangeActions.PUBLISH_EDIT,
-    ChangeActions.REBASE_EDIT,
-    ChangeActions.RESTORE,
-    ChangeActions.REVERT,
-    ChangeActions.REVERT_SUBMISSION,
-    ChangeActions.STOP_EDIT,
-    QUICK_APPROVE_ACTION.key,
-    RevisionActions.REBASE,
-    RevisionActions.SUBMIT,
-  ]);
+// Set of keys that have icons. As more icons are added to gr-icons.html, this
+// set should be expanded.
+const ACTIONS_WITH_ICONS = new Set([
+  ChangeActions.ABANDON,
+  ChangeActions.DELETE_EDIT,
+  ChangeActions.EDIT,
+  ChangeActions.PUBLISH_EDIT,
+  ChangeActions.REBASE_EDIT,
+  ChangeActions.RESTORE,
+  ChangeActions.REVERT,
+  ChangeActions.REVERT_SUBMISSION,
+  ChangeActions.STOP_EDIT,
+  QUICK_APPROVE_ACTION.key,
+  RevisionActions.REBASE,
+  RevisionActions.SUBMIT,
+]);
 
-  const AWAIT_CHANGE_ATTEMPTS = 5;
-  const AWAIT_CHANGE_TIMEOUT_MS = 1000;
+const AWAIT_CHANGE_ATTEMPTS = 5;
+const AWAIT_CHANGE_TIMEOUT_MS = 1000;
 
-  const REVERT_TYPES = {
-    REVERT_SINGLE_CHANGE: 1,
-    REVERT_SUBMISSION: 2,
-  };
+const REVERT_TYPES = {
+  REVERT_SINGLE_CHANGE: 1,
+  REVERT_SUBMISSION: 2,
+};
 
-  /* Revert submission is skipped as the normal revert dialog will now show
-  the user a choice between reverting single change or an entire submission.
-  Hence, a second button is not needed.
-  */
-  const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
+/* Revert submission is skipped as the normal revert dialog will now show
+the user a choice between reverting single change or an entire submission.
+Hence, a second button is not needed.
+*/
+const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @extends Polymer.Element
+ */
+class GrChangeActions extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.PatchSetBehavior,
+  Gerrit.RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-change-actions'; }
+  /**
+   * Fired when the change should be reloaded.
+   *
+   * @event reload-change
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @extends Polymer.Element
+   * Fired when an action is tapped.
+   *
+   * @event custom-tap - naming pattern: <action key>-tap
    */
-  class GrChangeActions extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.PatchSetBehavior,
-    Gerrit.RESTClientBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-change-actions'; }
+
+  /**
+   * Fires to show an alert when a send is attempted on the non-latest patch.
+   *
+   * @event show-alert
+   */
+
+  /**
+   * Fires when a change action fails.
+   *
+   * @event show-error
+   */
+
+  constructor() {
+    super();
+    this.ActionType = ActionType;
+    this.ChangeActions = ChangeActions;
+    this.RevisionActions = RevisionActions;
+  }
+
+  static get properties() {
+    return {
     /**
-     * Fired when the change should be reloaded.
-     *
-     * @event reload-change
+     * @type {{
+     *    _number: number,
+     *    branch: string,
+     *    id: string,
+     *    project: string,
+     *    subject: string,
+     *  }}
      */
+      change: Object,
+      actions: {
+        type: Object,
+        value() { return {}; },
+      },
+      primaryActionKeys: {
+        type: Array,
+        value() {
+          return [
+            RevisionActions.SUBMIT,
+          ];
+        },
+      },
+      disableEdit: {
+        type: Boolean,
+        value: false,
+      },
+      _hasKnownChainState: {
+        type: Boolean,
+        value: false,
+      },
+      _hideQuickApproveAction: {
+        type: Boolean,
+        value: false,
+      },
+      changeNum: String,
+      changeStatus: String,
+      commitNum: String,
+      hasParent: {
+        type: Boolean,
+        observer: '_computeChainState',
+      },
+      latestPatchNum: String,
+      commitMessage: {
+        type: String,
+        value: '',
+      },
+      /** @type {?} */
+      revisionActions: {
+        type: Object,
+        notify: true,
+        value() { return {}; },
+      },
+      // If property binds directly to [[revisionActions.submit]] it is not
+      // updated when revisionActions doesn't contain submit action.
+      /** @type {?} */
+      _revisionSubmitAction: {
+        type: Object,
+        computed: '_getSubmitAction(revisionActions)',
+      },
+      // If property binds directly to [[revisionActions.rebase]] it is not
+      // updated when revisionActions doesn't contain rebase action.
+      /** @type {?} */
+      _revisionRebaseAction: {
+        type: Object,
+        computed: '_getRebaseAction(revisionActions)',
+      },
+      privateByDefault: String,
 
-    /**
-     * Fired when an action is tapped.
-     *
-     * @event custom-tap - naming pattern: <action key>-tap
-     */
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _actionLoadingMessage: {
+        type: String,
+        value: '',
+      },
+      _allActionValues: {
+        type: Array,
+        computed: '_computeAllActions(actions.*, revisionActions.*,' +
+          'primaryActionKeys.*, _additionalActions.*, change, ' +
+          '_actionPriorityOverrides.*)',
+      },
+      _topLevelActions: {
+        type: Array,
+        computed: '_computeTopLevelActions(_allActionValues.*, ' +
+          '_hiddenActions.*, _overflowActions.*)',
+        observer: '_filterPrimaryActions',
+      },
+      _topLevelPrimaryActions: Array,
+      _topLevelSecondaryActions: Array,
+      _menuActions: {
+        type: Array,
+        computed: '_computeMenuActions(_allActionValues.*, ' +
+          '_hiddenActions.*, _overflowActions.*)',
+      },
+      _overflowActions: {
+        type: Array,
+        value() {
+          const value = [
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.WIP,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.DELETE,
+            },
+            {
+              type: ActionType.REVISION,
+              key: RevisionActions.CHERRYPICK,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.MOVE,
+            },
+            {
+              type: ActionType.REVISION,
+              key: RevisionActions.DOWNLOAD,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.IGNORE,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.UNIGNORE,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.REVIEWED,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.UNREVIEWED,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.PRIVATE,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.PRIVATE_DELETE,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.FOLLOW_UP,
+            },
+          ];
+          return value;
+        },
+      },
+      _actionPriorityOverrides: {
+        type: Array,
+        value() { return []; },
+      },
+      _additionalActions: {
+        type: Array,
+        value() { return []; },
+      },
+      _hiddenActions: {
+        type: Array,
+        value() { return []; },
+      },
+      _disabledMenuActions: {
+        type: Array,
+        value() { return []; },
+      },
+      // editPatchsetLoaded == "does the current selected patch range have
+      // 'edit' as one of either basePatchNum or patchNum".
+      editPatchsetLoaded: {
+        type: Boolean,
+        value: false,
+      },
+      // editMode == "is edit mode enabled in the file list".
+      editMode: {
+        type: Boolean,
+        value: false,
+      },
+      editBasedOnCurrentPatchSet: {
+        type: Boolean,
+        value: true,
+      },
+      _revertChanges: Array,
+    };
+  }
 
-    /**
-     * Fires to show an alert when a send is attempted on the non-latest patch.
-     *
-     * @event show-alert
-     */
+  static get observers() {
+    return [
+      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
+      '_changeChanged(change)',
+      '_editStatusChanged(editMode, editPatchsetLoaded, ' +
+        'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)',
+    ];
+  }
 
-    /**
-     * Fires when a change action fails.
-     *
-     * @event show-error
-     */
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('fullscreen-overlay-opened',
+        () => this._handleHideBackgroundContent());
+    this.addEventListener('fullscreen-overlay-closed',
+        () => this._handleShowBackgroundContent());
+  }
 
-    constructor() {
-      super();
-      this.ActionType = ActionType;
-      this.ChangeActions = ChangeActions;
-      this.RevisionActions = RevisionActions;
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
+    this._handleLoadingComplete();
+  }
+
+  _getSubmitAction(revisionActions) {
+    return this._getRevisionAction(revisionActions, 'submit', null);
+  }
+
+  _getRebaseAction(revisionActions) {
+    return this._getRevisionAction(revisionActions, 'rebase',
+        {rebaseOnCurrent: null}
+    );
+  }
+
+  _getRevisionAction(revisionActions, actionName, emptyActionValue) {
+    if (!revisionActions) {
+      return undefined;
+    }
+    if (revisionActions[actionName] === undefined) {
+      // Return null to fire an event when reveisionActions was loaded
+      // but doesn't contain actionName. undefined doesn't fire an event
+      return emptyActionValue;
+    }
+    return revisionActions[actionName];
+  }
+
+  reload() {
+    if (!this.changeNum || !this.latestPatchNum) {
+      return Promise.resolve();
     }
 
-    static get properties() {
-      return {
-      /**
-       * @type {{
-       *    _number: number,
-       *    branch: string,
-       *    id: string,
-       *    project: string,
-       *    subject: string,
-       *  }}
-       */
-        change: Object,
-        actions: {
-          type: Object,
-          value() { return {}; },
-        },
-        primaryActionKeys: {
-          type: Array,
-          value() {
-            return [
-              RevisionActions.SUBMIT,
-            ];
-          },
-        },
-        disableEdit: {
-          type: Boolean,
-          value: false,
-        },
-        _hasKnownChainState: {
-          type: Boolean,
-          value: false,
-        },
-        _hideQuickApproveAction: {
-          type: Boolean,
-          value: false,
-        },
-        changeNum: String,
-        changeStatus: String,
-        commitNum: String,
-        hasParent: {
-          type: Boolean,
-          observer: '_computeChainState',
-        },
-        latestPatchNum: String,
-        commitMessage: {
-          type: String,
-          value: '',
-        },
-        /** @type {?} */
-        revisionActions: {
-          type: Object,
-          notify: true,
-          value() { return {}; },
-        },
-        // If property binds directly to [[revisionActions.submit]] it is not
-        // updated when revisionActions doesn't contain submit action.
-        /** @type {?} */
-        _revisionSubmitAction: {
-          type: Object,
-          computed: '_getSubmitAction(revisionActions)',
-        },
-        // If property binds directly to [[revisionActions.rebase]] it is not
-        // updated when revisionActions doesn't contain rebase action.
-        /** @type {?} */
-        _revisionRebaseAction: {
-          type: Object,
-          computed: '_getRebaseAction(revisionActions)',
-        },
-        privateByDefault: String,
+    this._loading = true;
+    return this._getRevisionActions()
+        .then(revisionActions => {
+          if (!revisionActions) { return; }
 
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-        _actionLoadingMessage: {
-          type: String,
-          value: '',
-        },
-        _allActionValues: {
-          type: Array,
-          computed: '_computeAllActions(actions.*, revisionActions.*,' +
-            'primaryActionKeys.*, _additionalActions.*, change, ' +
-            '_actionPriorityOverrides.*)',
-        },
-        _topLevelActions: {
-          type: Array,
-          computed: '_computeTopLevelActions(_allActionValues.*, ' +
-            '_hiddenActions.*, _overflowActions.*)',
-          observer: '_filterPrimaryActions',
-        },
-        _topLevelPrimaryActions: Array,
-        _topLevelSecondaryActions: Array,
-        _menuActions: {
-          type: Array,
-          computed: '_computeMenuActions(_allActionValues.*, ' +
-            '_hiddenActions.*, _overflowActions.*)',
-        },
-        _overflowActions: {
-          type: Array,
-          value() {
-            const value = [
-              {
-                type: ActionType.CHANGE,
-                key: ChangeActions.WIP,
-              },
-              {
-                type: ActionType.CHANGE,
-                key: ChangeActions.DELETE,
-              },
-              {
-                type: ActionType.REVISION,
-                key: RevisionActions.CHERRYPICK,
-              },
-              {
-                type: ActionType.CHANGE,
-                key: ChangeActions.MOVE,
-              },
-              {
-                type: ActionType.REVISION,
-                key: RevisionActions.DOWNLOAD,
-              },
-              {
-                type: ActionType.CHANGE,
-                key: ChangeActions.IGNORE,
-              },
-              {
-                type: ActionType.CHANGE,
-                key: ChangeActions.UNIGNORE,
-              },
-              {
-                type: ActionType.CHANGE,
-                key: ChangeActions.REVIEWED,
-              },
-              {
-                type: ActionType.CHANGE,
-                key: ChangeActions.UNREVIEWED,
-              },
-              {
-                type: ActionType.CHANGE,
-                key: ChangeActions.PRIVATE,
-              },
-              {
-                type: ActionType.CHANGE,
-                key: ChangeActions.PRIVATE_DELETE,
-              },
-              {
-                type: ActionType.CHANGE,
-                key: ChangeActions.FOLLOW_UP,
-              },
-            ];
-            return value;
-          },
-        },
-        _actionPriorityOverrides: {
-          type: Array,
-          value() { return []; },
-        },
-        _additionalActions: {
-          type: Array,
-          value() { return []; },
-        },
-        _hiddenActions: {
-          type: Array,
-          value() { return []; },
-        },
-        _disabledMenuActions: {
-          type: Array,
-          value() { return []; },
-        },
-        // editPatchsetLoaded == "does the current selected patch range have
-        // 'edit' as one of either basePatchNum or patchNum".
-        editPatchsetLoaded: {
-          type: Boolean,
-          value: false,
-        },
-        // editMode == "is edit mode enabled in the file list".
-        editMode: {
-          type: Boolean,
-          value: false,
-        },
-        editBasedOnCurrentPatchSet: {
-          type: Boolean,
-          value: true,
-        },
-        _revertChanges: Array,
-      };
-    }
-
-    static get observers() {
-      return [
-        '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
-        '_changeChanged(change)',
-        '_editStatusChanged(editMode, editPatchsetLoaded, ' +
-          'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)',
-      ];
-    }
-
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('fullscreen-overlay-opened',
-          () => this._handleHideBackgroundContent());
-      this.addEventListener('fullscreen-overlay-closed',
-          () => this._handleShowBackgroundContent());
-    }
-
-    /** @override */
-    ready() {
-      super.ready();
-      this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
-      this._handleLoadingComplete();
-    }
-
-    _getSubmitAction(revisionActions) {
-      return this._getRevisionAction(revisionActions, 'submit', null);
-    }
-
-    _getRebaseAction(revisionActions) {
-      return this._getRevisionAction(revisionActions, 'rebase',
-          {rebaseOnCurrent: null}
-      );
-    }
-
-    _getRevisionAction(revisionActions, actionName, emptyActionValue) {
-      if (!revisionActions) {
-        return undefined;
-      }
-      if (revisionActions[actionName] === undefined) {
-        // Return null to fire an event when reveisionActions was loaded
-        // but doesn't contain actionName. undefined doesn't fire an event
-        return emptyActionValue;
-      }
-      return revisionActions[actionName];
-    }
-
-    reload() {
-      if (!this.changeNum || !this.latestPatchNum) {
-        return Promise.resolve();
-      }
-
-      this._loading = true;
-      return this._getRevisionActions()
-          .then(revisionActions => {
-            if (!revisionActions) { return; }
-
-            this.revisionActions = this._updateRebaseAction(revisionActions);
-            this._sendShowRevisionActions({
-              change: this.change,
-              revisionActions,
-            });
-            this._handleLoadingComplete();
-          })
-          .catch(err => {
-            this.fire('show-alert', {message: ERR_REVISION_ACTIONS});
-            this._loading = false;
-            throw err;
+          this.revisionActions = this._updateRebaseAction(revisionActions);
+          this._sendShowRevisionActions({
+            change: this.change,
+            revisionActions,
           });
-    }
+          this._handleLoadingComplete();
+        })
+        .catch(err => {
+          this.fire('show-alert', {message: ERR_REVISION_ACTIONS});
+          this._loading = false;
+          throw err;
+        });
+  }
 
-    _handleLoadingComplete() {
-      Gerrit.awaitPluginsLoaded().then(() => this._loading = false);
-    }
+  _handleLoadingComplete() {
+    Gerrit.awaitPluginsLoaded().then(() => this._loading = false);
+  }
 
-    _sendShowRevisionActions(detail) {
-      this.$.jsAPI.handleEvent(
-          this.$.jsAPI.EventType.SHOW_REVISION_ACTIONS,
-          detail
-      );
-    }
+  _sendShowRevisionActions(detail) {
+    this.$.jsAPI.handleEvent(
+        this.$.jsAPI.EventType.SHOW_REVISION_ACTIONS,
+        detail
+    );
+  }
 
-    _updateRebaseAction(revisionActions) {
-      if (revisionActions && revisionActions.rebase) {
-        revisionActions.rebase.rebaseOnCurrent =
-            !!revisionActions.rebase.enabled;
-        this._parentIsCurrent = !revisionActions.rebase.enabled;
-        revisionActions.rebase.enabled = true;
-      } else {
-        this._parentIsCurrent = true;
-      }
-      return revisionActions;
+  _updateRebaseAction(revisionActions) {
+    if (revisionActions && revisionActions.rebase) {
+      revisionActions.rebase.rebaseOnCurrent =
+          !!revisionActions.rebase.enabled;
+      this._parentIsCurrent = !revisionActions.rebase.enabled;
+      revisionActions.rebase.enabled = true;
+    } else {
+      this._parentIsCurrent = true;
     }
+    return revisionActions;
+  }
 
-    _changeChanged() {
-      this.reload();
-    }
+  _changeChanged() {
+    this.reload();
+  }
 
-    addActionButton(type, label) {
-      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-        throw Error(`Invalid action type: ${type}`);
-      }
-      const action = {
-        enabled: true,
-        label,
-        __type: type,
-        __key: ADDITIONAL_ACTION_KEY_PREFIX +
-            Math.random().toString(36)
-                .substr(2),
-      };
-      this.push('_additionalActions', action);
-      return action.__key;
+  addActionButton(type, label) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type: ${type}`);
     }
+    const action = {
+      enabled: true,
+      label,
+      __type: type,
+      __key: ADDITIONAL_ACTION_KEY_PREFIX +
+          Math.random().toString(36)
+              .substr(2),
+    };
+    this.push('_additionalActions', action);
+    return action.__key;
+  }
 
-    removeActionButton(key) {
-      const idx = this._indexOfActionButtonWithKey(key);
-      if (idx === -1) {
-        return;
-      }
-      this.splice('_additionalActions', idx, 1);
+  removeActionButton(key) {
+    const idx = this._indexOfActionButtonWithKey(key);
+    if (idx === -1) {
+      return;
     }
+    this.splice('_additionalActions', idx, 1);
+  }
 
-    setActionButtonProp(key, prop, value) {
-      this.set([
-        '_additionalActions',
-        this._indexOfActionButtonWithKey(key),
-        prop,
-      ], value);
-    }
+  setActionButtonProp(key, prop, value) {
+    this.set([
+      '_additionalActions',
+      this._indexOfActionButtonWithKey(key),
+      prop,
+    ], value);
+  }
 
-    setActionOverflow(type, key, overflow) {
-      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-        throw Error(`Invalid action type given: ${type}`);
-      }
-      const index = this._getActionOverflowIndex(type, key);
-      const action = {
-        type,
-        key,
-        overflow,
-      };
-      if (!overflow && index !== -1) {
-        this.splice('_overflowActions', index, 1);
-      } else if (overflow) {
-        this.push('_overflowActions', action);
-      }
+  setActionOverflow(type, key, overflow) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type given: ${type}`);
     }
-
-    setActionPriority(type, key, priority) {
-      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-        throw Error(`Invalid action type given: ${type}`);
-      }
-      const index = this._actionPriorityOverrides
-          .findIndex(action => action.type === type && action.key === key);
-      const action = {
-        type,
-        key,
-        priority,
-      };
-      if (index !== -1) {
-        this.set('_actionPriorityOverrides', index, action);
-      } else {
-        this.push('_actionPriorityOverrides', action);
-      }
-    }
-
-    setActionHidden(type, key, hidden) {
-      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-        throw Error(`Invalid action type given: ${type}`);
-      }
-
-      const idx = this._hiddenActions.indexOf(key);
-      if (hidden && idx === -1) {
-        this.push('_hiddenActions', key);
-      } else if (!hidden && idx !== -1) {
-        this.splice('_hiddenActions', idx, 1);
-      }
-    }
-
-    getActionDetails(action) {
-      if (this.revisionActions[action]) {
-        return this.revisionActions[action];
-      } else if (this.actions[action]) {
-        return this.actions[action];
-      }
-    }
-
-    _indexOfActionButtonWithKey(key) {
-      for (let i = 0; i < this._additionalActions.length; i++) {
-        if (this._additionalActions[i].__key === key) {
-          return i;
-        }
-      }
-      return -1;
-    }
-
-    _getRevisionActions() {
-      return this.$.restAPI.getChangeRevisionActions(this.changeNum,
-          this.latestPatchNum);
-    }
-
-    _shouldHideActions(actions, loading) {
-      return loading || !actions || !actions.base || !actions.base.length;
-    }
-
-    _keyCount(changeRecord) {
-      return Object.keys((changeRecord && changeRecord.base) || {}).length;
-    }
-
-    _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
-        additionalActionsChangeRecord) {
-      // Polymer 2: check for undefined
-      if ([
-        actionsChangeRecord,
-        revisionActionsChangeRecord,
-        additionalActionsChangeRecord,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      const additionalActions = (additionalActionsChangeRecord &&
-          additionalActionsChangeRecord.base) || [];
-      this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
-          this._keyCount(revisionActionsChangeRecord) === 0 &&
-              additionalActions.length === 0;
-      this._actionLoadingMessage = '';
-      this._disabledMenuActions = [];
-
-      const revisionActions = revisionActionsChangeRecord.base || {};
-      if (Object.keys(revisionActions).length !== 0) {
-        if (!revisionActions.download) {
-          this.set('revisionActions.download', DOWNLOAD_ACTION);
-        }
-      }
-    }
-
-    /**
-     * @param {string=} actionName
-     */
-    _deleteAndNotify(actionName) {
-      if (this.actions && this.actions[actionName]) {
-        delete this.actions[actionName];
-        // We assign a fake value of 'false' to support Polymer 2
-        // see https://github.com/Polymer/polymer/issues/2631
-        this.notifyPath('actions.' + actionName, false);
-      }
-    }
-
-    _editStatusChanged(editMode, editPatchsetLoaded,
-        editBasedOnCurrentPatchSet, disableEdit) {
-      // Polymer 2: check for undefined
-      if ([
-        editMode,
-        editBasedOnCurrentPatchSet,
-        disableEdit,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      if (disableEdit) {
-        this._deleteAndNotify('publishEdit');
-        this._deleteAndNotify('rebaseEdit');
-        this._deleteAndNotify('deleteEdit');
-        this._deleteAndNotify('stopEdit');
-        this._deleteAndNotify('edit');
-        return;
-      }
-      if (this.actions && editPatchsetLoaded) {
-        // Only show actions that mutate an edit if an actual edit patch set
-        // is loaded.
-        if (this.changeIsOpen(this.change)) {
-          if (editBasedOnCurrentPatchSet) {
-            if (!this.actions.publishEdit) {
-              this.set('actions.publishEdit', PUBLISH_EDIT);
-            }
-            this._deleteAndNotify('rebaseEdit');
-          } else {
-            if (!this.actions.rebaseEdit) {
-              this.set('actions.rebaseEdit', REBASE_EDIT);
-            }
-            this._deleteAndNotify('publishEdit');
-          }
-        }
-        if (!this.actions.deleteEdit) {
-          this.set('actions.deleteEdit', DELETE_EDIT);
-        }
-      } else {
-        this._deleteAndNotify('publishEdit');
-        this._deleteAndNotify('rebaseEdit');
-        this._deleteAndNotify('deleteEdit');
-      }
-
-      if (this.actions && this.changeIsOpen(this.change)) {
-        // Only show edit button if there is no edit patchset loaded and the
-        // file list is not in edit mode.
-        if (editPatchsetLoaded || editMode) {
-          this._deleteAndNotify('edit');
-        } else {
-          if (!this.actions.edit) { this.set('actions.edit', EDIT); }
-        }
-        // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
-        // is loaded.
-        if (editMode && !editPatchsetLoaded) {
-          if (!this.actions.stopEdit) {
-            this.set('actions.stopEdit', STOP_EDIT);
-          }
-        } else {
-          this._deleteAndNotify('stopEdit');
-        }
-      } else {
-        // Remove edit button.
-        this._deleteAndNotify('edit');
-      }
-    }
-
-    _getValuesFor(obj) {
-      return Object.keys(obj).map(key => obj[key]);
-    }
-
-    _getLabelStatus(label) {
-      if (label.approved) {
-        return LabelStatus.OK;
-      } else if (label.rejected) {
-        return LabelStatus.REJECT;
-      } else if (label.optional) {
-        return LabelStatus.OPTIONAL;
-      } else {
-        return LabelStatus.NEED;
-      }
-    }
-
-    /**
-     * Get highest score for last missing permitted label for current change.
-     * Returns null if no labels permitted or more than one label missing.
-     *
-     * @return {{label: string, score: string}|null}
-     */
-    _getTopMissingApproval() {
-      if (!this.change ||
-          !this.change.labels ||
-          !this.change.permitted_labels) {
-        return null;
-      }
-      let result;
-      for (const label in this.change.labels) {
-        if (!(label in this.change.permitted_labels)) {
-          continue;
-        }
-        if (this.change.permitted_labels[label].length === 0) {
-          continue;
-        }
-        const status = this._getLabelStatus(this.change.labels[label]);
-        if (status === LabelStatus.NEED) {
-          if (result) {
-            // More than one label is missing, so it's unclear which to quick
-            // approve, return null;
-            return null;
-          }
-          result = label;
-        } else if (status === LabelStatus.REJECT ||
-            status === LabelStatus.IMPOSSIBLE) {
-          return null;
-        }
-      }
-      if (result) {
-        const score = this.change.permitted_labels[result].slice(-1)[0];
-        const maxScore =
-            Object.keys(this.change.labels[result].values).slice(-1)[0];
-        if (score === maxScore) {
-          // Allow quick approve only for maximal score.
-          return {
-            label: result,
-            score,
-          };
-        }
-      }
-      return null;
-    }
-
-    hideQuickApproveAction() {
-      this._topLevelSecondaryActions =
-        this._topLevelSecondaryActions
-            .filter(sa => sa.key !== QUICK_APPROVE_ACTION.key);
-      this._hideQuickApproveAction = true;
-    }
-
-    _getQuickApproveAction() {
-      if (this._hideQuickApproveAction) {
-        return null;
-      }
-      const approval = this._getTopMissingApproval();
-      if (!approval) {
-        return null;
-      }
-      const action = Object.assign({}, QUICK_APPROVE_ACTION);
-      action.label = approval.label + approval.score;
-      const review = {
-        drafts: 'PUBLISH_ALL_REVISIONS',
-        labels: {},
-      };
-      review.labels[approval.label] = approval.score;
-      action.payload = review;
-      return action;
-    }
-
-    _getActionValues(actionsChangeRecord, primariesChangeRecord,
-        additionalActionsChangeRecord, type) {
-      if (!actionsChangeRecord || !primariesChangeRecord) { return []; }
-
-      const actions = actionsChangeRecord.base || {};
-      const primaryActionKeys = primariesChangeRecord.base || [];
-      const result = [];
-      const values = this._getValuesFor(
-          type === ActionType.CHANGE ? ChangeActions : RevisionActions);
-      const pluginActions = [];
-      Object.keys(actions).forEach(a => {
-        actions[a].__key = a;
-        actions[a].__type = type;
-        actions[a].__primary = primaryActionKeys.includes(a);
-        // Plugin actions always contain ~ in the key.
-        if (a.indexOf('~') !== -1) {
-          this._populateActionUrl(actions[a]);
-          pluginActions.push(actions[a]);
-          // Add server-side provided plugin actions to overflow menu.
-          this._overflowActions.push({
-            type,
-            key: a,
-          });
-          return;
-        } else if (!values.includes(a)) {
-          return;
-        }
-        actions[a].label = this._getActionLabel(actions[a]);
-
-        // Triggers a re-render by ensuring object inequality.
-        result.push(Object.assign({}, actions[a]));
-      });
-
-      let additionalActions = (additionalActionsChangeRecord &&
-      additionalActionsChangeRecord.base) || [];
-      additionalActions = additionalActions
-          .filter(a => a.__type === type)
-          .map(a => {
-            a.__primary = primaryActionKeys.includes(a.__key);
-            // Triggers a re-render by ensuring object inequality.
-            return Object.assign({}, a);
-          });
-      return result.concat(additionalActions).concat(pluginActions);
-    }
-
-    _populateActionUrl(action) {
-      const patchNum =
-            action.__type === ActionType.REVISION ? this.latestPatchNum : null;
-      this.$.restAPI.getChangeActionURL(
-          this.changeNum, patchNum, '/' + action.__key)
-          .then(url => action.__url = url);
-    }
-
-    /**
-     * Given a change action, return a display label that uses the appropriate
-     * casing or includes explanatory details.
-     */
-    _getActionLabel(action) {
-      if (action.label === 'Delete') {
-        // This label is common within change and revision actions. Make it more
-        // explicit to the user.
-        return 'Delete change';
-      } else if (action.label === 'WIP') {
-        return 'Mark as work in progress';
-      }
-      // Otherwise, just map the name to sentence case.
-      return this._toSentenceCase(action.label);
-    }
-
-    /**
-     * Capitalize the first letter and lowecase all others.
-     *
-     * @param {string} s
-     * @return {string}
-     */
-    _toSentenceCase(s) {
-      if (!s.length) { return ''; }
-      return s[0].toUpperCase() + s.slice(1).toLowerCase();
-    }
-
-    _computeLoadingLabel(action) {
-      return ActionLoadingLabels[action] || 'Working...';
-    }
-
-    _canSubmitChange() {
-      return this.$.jsAPI.canSubmitChange(this.change,
-          this._getRevision(this.change, this.latestPatchNum));
-    }
-
-    _getRevision(change, patchNum) {
-      for (const rev of Object.values(change.revisions)) {
-        if (this.patchNumEquals(rev._number, patchNum)) {
-          return rev;
-        }
-      }
-      return null;
-    }
-
-    showRevertDialog() {
-      const query = 'submissionid:' + this.change.submission_id;
-      /* A chromium plugin expects that the modifyRevertMsg hook will only
-      be called after the revert button is pressed, hence we populate the
-      revert dialog after revert button is pressed. */
-      this.$.restAPI.getChanges('', query)
-          .then(changes => {
-            this.$.confirmRevertDialog.populate(this.change,
-                this.commitMessage, changes);
-            this._showActionDialog(this.$.confirmRevertDialog);
-          });
-    }
-
-    showRevertSubmissionDialog() {
-      const query = 'submissionid:' + this.change.submission_id;
-      this.$.restAPI.getChanges('', query)
-          .then(changes => {
-            this.$.confirmRevertSubmissionDialog.
-                _populateRevertSubmissionMessage(
-                    this.commitMessage, this.change, changes);
-            this._showActionDialog(this.$.confirmRevertSubmissionDialog);
-          });
-    }
-
-    _handleActionTap(e) {
-      e.preventDefault();
-      let el = Polymer.dom(e).localTarget;
-      while (el.tagName.toLowerCase() !== 'gr-button') {
-        if (!el.parentElement) { return; }
-        el = el.parentElement;
-      }
-
-      const key = el.getAttribute('data-action-key');
-      if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
-          key.indexOf('~') !== -1) {
-        this.fire(`${key}-tap`, {node: el});
-        return;
-      }
-      const type = el.getAttribute('data-action-type');
-      this._handleAction(type, key);
-    }
-
-    _handleOveflowItemTap(e) {
-      e.preventDefault();
-      const el = Polymer.dom(e).localTarget;
-      const key = e.detail.action.__key;
-      if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
-          key.indexOf('~') !== -1) {
-        this.fire(`${key}-tap`, {node: el});
-        return;
-      }
-      this._handleAction(e.detail.action.__type, e.detail.action.__key);
-    }
-
-    _handleAction(type, key) {
-      this.$.reporting.reportInteraction(`${type}-${key}`);
-      switch (type) {
-        case ActionType.REVISION:
-          this._handleRevisionAction(key);
-          break;
-        case ActionType.CHANGE:
-          this._handleChangeAction(key);
-          break;
-        default:
-          this._fireAction(this._prependSlash(key), this.actions[key], false);
-      }
-    }
-
-    _handleChangeAction(key) {
-      let action;
-      switch (key) {
-        case ChangeActions.REVERT:
-          this.showRevertDialog();
-          break;
-        case ChangeActions.REVERT_SUBMISSION:
-          this.showRevertSubmissionDialog();
-          break;
-        case ChangeActions.ABANDON:
-          this._showActionDialog(this.$.confirmAbandonDialog);
-          break;
-        case QUICK_APPROVE_ACTION.key:
-          action = this._allActionValues.find(o => o.key === key);
-          this._fireAction(
-              this._prependSlash(key), action, true, action.payload);
-          break;
-        case ChangeActions.EDIT:
-          this._handleEditTap();
-          break;
-        case ChangeActions.STOP_EDIT:
-          this._handleStopEditTap();
-          break;
-        case ChangeActions.DELETE:
-          this._handleDeleteTap();
-          break;
-        case ChangeActions.DELETE_EDIT:
-          this._handleDeleteEditTap();
-          break;
-        case ChangeActions.FOLLOW_UP:
-          this._handleFollowUpTap();
-          break;
-        case ChangeActions.WIP:
-          this._handleWipTap();
-          break;
-        case ChangeActions.MOVE:
-          this._handleMoveTap();
-          break;
-        case ChangeActions.PUBLISH_EDIT:
-          this._handlePublishEditTap();
-          break;
-        case ChangeActions.REBASE_EDIT:
-          this._handleRebaseEditTap();
-          break;
-        default:
-          this._fireAction(this._prependSlash(key), this.actions[key], false);
-      }
-    }
-
-    _handleRevisionAction(key) {
-      switch (key) {
-        case RevisionActions.REBASE:
-          this._showActionDialog(this.$.confirmRebase);
-          this.$.confirmRebase.fetchRecentChanges();
-          break;
-        case RevisionActions.CHERRYPICK:
-          this._handleCherrypickTap();
-          break;
-        case RevisionActions.DOWNLOAD:
-          this._handleDownloadTap();
-          break;
-        case RevisionActions.SUBMIT:
-          if (!this._canSubmitChange()) { return; }
-          this._showActionDialog(this.$.confirmSubmitDialog);
-          break;
-        default:
-          this._fireAction(this._prependSlash(key),
-              this.revisionActions[key], true);
-      }
-    }
-
-    _prependSlash(key) {
-      return key === '/' ? key : `/${key}`;
-    }
-
-    /**
-     * _hasKnownChainState set to true true if hasParent is defined (can be
-     * either true or false). set to false otherwise.
-     */
-    _computeChainState(hasParent) {
-      this._hasKnownChainState = true;
-    }
-
-    _calculateDisabled(action, hasKnownChainState) {
-      if (action.__key === 'rebase' && hasKnownChainState === false) {
-        return true;
-      }
-      return !action.enabled;
-    }
-
-    _handleConfirmDialogCancel() {
-      this._hideAllDialogs();
-    }
-
-    _hideAllDialogs() {
-      const dialogEls =
-          Polymer.dom(this.root).querySelectorAll('.confirmDialog');
-      for (const dialogEl of dialogEls) { dialogEl.hidden = true; }
-      this.$.overlay.close();
-    }
-
-    _handleRebaseConfirm(e) {
-      const el = this.$.confirmRebase;
-      const payload = {base: e.detail.base};
-      this.$.overlay.close();
-      el.hidden = true;
-      this._fireAction('/rebase', this.revisionActions.rebase, true, payload);
-    }
-
-    _handleCherrypickConfirm() {
-      this._handleCherryPickRestApi(false);
-    }
-
-    _handleCherrypickConflictConfirm() {
-      this._handleCherryPickRestApi(true);
-    }
-
-    _handleCherryPickRestApi(conflicts) {
-      const el = this.$.confirmCherrypick;
-      if (!el.branch) {
-        this.fire('show-alert', {message: ERR_BRANCH_EMPTY});
-        return;
-      }
-      if (!el.message) {
-        this.fire('show-alert', {message: ERR_COMMIT_EMPTY});
-        return;
-      }
-      this.$.overlay.close();
-      el.hidden = true;
-      this._fireAction(
-          '/cherrypick',
-          this.revisionActions.cherrypick,
-          true,
-          {
-            destination: el.branch,
-            base: el.baseCommit ? el.baseCommit : null,
-            message: el.message,
-            allow_conflicts: conflicts,
-          }
-      );
-    }
-
-    _handleMoveConfirm() {
-      const el = this.$.confirmMove;
-      if (!el.branch) {
-        this.fire('show-alert', {message: ERR_BRANCH_EMPTY});
-        return;
-      }
-      this.$.overlay.close();
-      el.hidden = true;
-      this._fireAction(
-          '/move',
-          this.actions.move,
-          false,
-          {
-            destination_branch: el.branch,
-            message: el.message,
-          }
-      );
-    }
-
-    _handleRevertDialogConfirm(e) {
-      const revertType = e.detail.revertType;
-      const message = e.detail.message;
-      const el = this.$.confirmRevertDialog;
-      this.$.overlay.close();
-      el.hidden = true;
-      switch (revertType) {
-        case REVERT_TYPES.REVERT_SINGLE_CHANGE:
-          this._fireAction('/revert', this.actions.revert, false,
-              {message});
-          break;
-        case REVERT_TYPES.REVERT_SUBMISSION:
-          this._fireAction('/revert_submission', this.actions.revert_submission,
-              false, {message});
-          break;
-        default:
-          console.error('invalid revert type');
-      }
-    }
-
-    _handleRevertSubmissionDialogConfirm() {
-      const el = this.$.confirmRevertSubmissionDialog;
-      this.$.overlay.close();
-      el.hidden = true;
-      this._fireAction('/revert_submission', this.actions.revert_submission,
-          false, {message: el.message});
-    }
-
-    _handleAbandonDialogConfirm() {
-      const el = this.$.confirmAbandonDialog;
-      this.$.overlay.close();
-      el.hidden = true;
-      this._fireAction('/abandon', this.actions.abandon, false,
-          {message: el.message});
-    }
-
-    _handleCreateFollowUpChange() {
-      this.$.createFollowUpChange.handleCreateChange();
-      this._handleCloseCreateFollowUpChange();
-    }
-
-    _handleCloseCreateFollowUpChange() {
-      this.$.overlay.close();
-    }
-
-    _handleDeleteConfirm() {
-      this._fireAction('/', this.actions[ChangeActions.DELETE], false);
-    }
-
-    _handleDeleteEditConfirm() {
-      this._hideAllDialogs();
-
-      this._fireAction('/edit', this.actions.deleteEdit, false);
-    }
-
-    _handleSubmitConfirm() {
-      if (!this._canSubmitChange()) { return; }
-      this._hideAllDialogs();
-      this._fireAction('/submit', this.revisionActions.submit, true);
-    }
-
-    _getActionOverflowIndex(type, key) {
-      return this._overflowActions
-          .findIndex(action => action.type === type && action.key === key);
-    }
-
-    _setLoadingOnButtonWithKey(type, key) {
-      this._actionLoadingMessage = this._computeLoadingLabel(key);
-      let buttonKey = key;
-      // TODO(dhruvsri): clean this up later
-      // If key is revert-submission, then button key should be 'revert'
-      if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
-        // Revert submission button no longer exists
-        buttonKey = ChangeActions.REVERT;
-      }
-
-      // If the action appears in the overflow menu.
-      if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
-        this.push('_disabledMenuActions', buttonKey === '/' ? 'delete' :
-          buttonKey);
-        return function() {
-          this._actionLoadingMessage = '';
-          this._disabledMenuActions = [];
-        }.bind(this);
-      }
-
-      // Otherwise it's a top-level action.
-      const buttonEl = this.shadowRoot
-          .querySelector(`[data-action-key="${buttonKey}"]`);
-      buttonEl.setAttribute('loading', true);
-      buttonEl.disabled = true;
-      return function() {
-        this._actionLoadingMessage = '';
-        buttonEl.removeAttribute('loading');
-        buttonEl.disabled = false;
-      }.bind(this);
-    }
-
-    /**
-     * @param {string} endpoint
-     * @param {!Object|undefined} action
-     * @param {boolean} revAction
-     * @param {!Object|string=} opt_payload
-     */
-    _fireAction(endpoint, action, revAction, opt_payload) {
-      const cleanupFn =
-          this._setLoadingOnButtonWithKey(action.__type, action.__key);
-
-      this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
-          action).then(this._handleResponse.bind(this, action));
-    }
-
-    _showActionDialog(dialog) {
-      this._hideAllDialogs();
-
-      dialog.hidden = false;
-      this.$.overlay.open().then(() => {
-        if (dialog.resetFocus) {
-          dialog.resetFocus();
-        }
-      });
-    }
-
-    // TODO(rmistry): Redo this after
-    // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-    _setLabelValuesOnRevert(newChangeId) {
-      const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
-      if (!labels) { return Promise.resolve(); }
-      return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
-    }
-
-    _handleResponse(action, response) {
-      if (!response) { return; }
-      return this.$.restAPI.getResponseObject(response).then(obj => {
-        switch (action.__key) {
-          case ChangeActions.REVERT:
-            this._waitForChangeReachable(obj._number)
-                .then(() => this._setLabelValuesOnRevert(obj._number))
-                .then(() => {
-                  Gerrit.Nav.navigateToChange(obj);
-                });
-            break;
-          case RevisionActions.CHERRYPICK:
-            this._waitForChangeReachable(obj._number).then(() => {
-              Gerrit.Nav.navigateToChange(obj);
-            });
-            break;
-          case ChangeActions.DELETE:
-            if (action.__type === ActionType.CHANGE) {
-              Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getUrlForRoot());
-            }
-            break;
-          case ChangeActions.WIP:
-          case ChangeActions.DELETE_EDIT:
-          case ChangeActions.PUBLISH_EDIT:
-          case ChangeActions.REBASE_EDIT:
-            Gerrit.Nav.navigateToChange(this.change);
-            break;
-          case ChangeActions.REVERT_SUBMISSION:
-            if (!obj.revert_changes || !obj.revert_changes.length) return;
-            /* If there is only 1 change then gerrit will automatically
-               redirect to that change */
-            Gerrit.Nav.navigateToSearchQuery('topic: ' +
-                obj.revert_changes[0].topic);
-            break;
-          default:
-            this.dispatchEvent(new CustomEvent('reload-change',
-                {detail: {action: action.__key}, bubbles: false}));
-            break;
-        }
-      });
-    }
-
-    _handleShowRevertSubmissionChangesConfirm() {
-      this._hideAllDialogs();
-    }
-
-    _handleResponseError(action, response, body) {
-      if (action && action.__key === RevisionActions.CHERRYPICK) {
-        if (response && response.status === 409 &&
-            body && !body.allow_conflicts) {
-          return this._showActionDialog(
-              this.$.confirmCherrypickConflict);
-        }
-      }
-      return response.text().then(errText => {
-        this.fire('show-error',
-            {message: `Could not perform action: ${errText}`});
-        if (!errText.startsWith('Change is already up to date')) {
-          throw Error(errText);
-        }
-      });
-    }
-
-    /**
-     * @param {string} method
-     * @param {string|!Object|undefined} payload
-     * @param {string} actionEndpoint
-     * @param {boolean} revisionAction
-     * @param {?Function} cleanupFn
-     * @param {!Object|undefined} action
-     */
-    _send(method, payload, actionEndpoint, revisionAction, cleanupFn, action) {
-      const handleError = response => {
-        cleanupFn.call(this);
-        this._handleResponseError(action, response, payload);
-      };
-
-      return this.fetchChangeUpdates(this.change, this.$.restAPI)
-          .then(result => {
-            if (!result.isLatest) {
-              this.fire('show-alert', {
-                message: 'Cannot set label: a newer patch has been ' +
-                    'uploaded to this change.',
-                action: 'Reload',
-                callback: () => {
-                  // Load the current change without any patch range.
-                  Gerrit.Nav.navigateToChange(this.change);
-                },
-              });
-
-              // Because this is not a network error, call the cleanup function
-              // but not the error handler.
-              cleanupFn();
-
-              return Promise.resolve();
-            }
-            const patchNum = revisionAction ? this.latestPatchNum : null;
-            return this.$.restAPI.executeChangeAction(this.changeNum, method,
-                actionEndpoint, patchNum, payload, handleError)
-                .then(response => {
-                  cleanupFn.call(this);
-                  return response;
-                });
-          });
-    }
-
-    _handleAbandonTap() {
-      this._showActionDialog(this.$.confirmAbandonDialog);
-    }
-
-    _handleCherrypickTap() {
-      this.$.confirmCherrypick.branch = '';
-      this._showActionDialog(this.$.confirmCherrypick);
-    }
-
-    _handleMoveTap() {
-      this.$.confirmMove.branch = '';
-      this.$.confirmMove.message = '';
-      this._showActionDialog(this.$.confirmMove);
-    }
-
-    _handleDownloadTap() {
-      this.fire('download-tap', null, {bubbles: false});
-    }
-
-    _handleDeleteTap() {
-      this._showActionDialog(this.$.confirmDeleteDialog);
-    }
-
-    _handleDeleteEditTap() {
-      this._showActionDialog(this.$.confirmDeleteEditDialog);
-    }
-
-    _handleFollowUpTap() {
-      this._showActionDialog(this.$.createFollowUpDialog);
-    }
-
-    _handleWipTap() {
-      this._fireAction('/wip', this.actions.wip, false);
-    }
-
-    _handlePublishEditTap() {
-      this._fireAction('/edit:publish', this.actions.publishEdit, false);
-    }
-
-    _handleRebaseEditTap() {
-      this._fireAction('/edit:rebase', this.actions.rebaseEdit, false);
-    }
-
-    _handleHideBackgroundContent() {
-      this.$.mainContent.classList.add('overlayOpen');
-    }
-
-    _handleShowBackgroundContent() {
-      this.$.mainContent.classList.remove('overlayOpen');
-    }
-
-    /**
-     * Merge sources of change actions into a single ordered array of action
-     * values.
-     *
-     * @param {!Array} changeActionsRecord
-     * @param {!Array} revisionActionsRecord
-     * @param {!Array} primariesRecord
-     * @param {!Array} additionalActionsRecord
-     * @param {!Object} change The change object.
-     * @return {!Array}
-     */
-    _computeAllActions(changeActionsRecord, revisionActionsRecord,
-        primariesRecord, additionalActionsRecord, change) {
-      // Polymer 2: check for undefined
-      if ([
-        changeActionsRecord,
-        revisionActionsRecord,
-        primariesRecord,
-        additionalActionsRecord,
-        change,
-      ].some(arg => arg === undefined)) {
-        return [];
-      }
-
-      const revisionActionValues = this._getActionValues(revisionActionsRecord,
-          primariesRecord, additionalActionsRecord, ActionType.REVISION);
-      const changeActionValues = this._getActionValues(changeActionsRecord,
-          primariesRecord, additionalActionsRecord, ActionType.CHANGE);
-      const quickApprove = this._getQuickApproveAction();
-      if (quickApprove) {
-        changeActionValues.unshift(quickApprove);
-      }
-
-      return revisionActionValues
-          .concat(changeActionValues)
-          .sort(this._actionComparator.bind(this))
-          .map(action => {
-            if (ACTIONS_WITH_ICONS.has(action.__key)) {
-              action.icon = action.__key;
-            }
-            return action;
-          })
-          .filter(action => !this._shouldSkipAction(action));
-    }
-
-    _getActionPriority(action) {
-      if (action.__type && action.__key) {
-        const overrideAction = this._actionPriorityOverrides
-            .find(i => i.type === action.__type && i.key === action.__key);
-
-        if (overrideAction !== undefined) {
-          return overrideAction.priority;
-        }
-      }
-      if (action.__key === 'review') {
-        return ActionPriority.REVIEW;
-      } else if (action.__primary) {
-        return ActionPriority.PRIMARY;
-      } else if (action.__type === ActionType.CHANGE) {
-        return ActionPriority.CHANGE;
-      } else if (action.__type === ActionType.REVISION) {
-        return ActionPriority.REVISION;
-      }
-      return ActionPriority.DEFAULT;
-    }
-
-    /**
-     * Sort comparator to define the order of change actions.
-     */
-    _actionComparator(actionA, actionB) {
-      const priorityDelta = this._getActionPriority(actionA) -
-          this._getActionPriority(actionB);
-      // Sort by the button label if same priority.
-      if (priorityDelta === 0) {
-        return actionA.label > actionB.label ? 1 : -1;
-      } else {
-        return priorityDelta;
-      }
-    }
-
-    _shouldSkipAction(action) {
-      return SKIP_ACTION_KEYS.includes(action.__key);
-    }
-
-    _computeTopLevelActions(actionRecord, hiddenActionsRecord) {
-      const hiddenActions = hiddenActionsRecord.base || [];
-      return actionRecord.base.filter(a => {
-        const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
-        return !(overflow || hiddenActions.includes(a.__key));
-      });
-    }
-
-    _filterPrimaryActions(_topLevelActions) {
-      this._topLevelPrimaryActions = _topLevelActions.filter(action =>
-        action.__primary);
-      this._topLevelSecondaryActions = _topLevelActions.filter(action =>
-        !action.__primary);
-    }
-
-    _computeMenuActions(actionRecord, hiddenActionsRecord) {
-      const hiddenActions = hiddenActionsRecord.base || [];
-      return actionRecord.base.filter(a => {
-        const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
-        return overflow && !hiddenActions.includes(a.__key);
-      }).map(action => {
-        let key = action.__key;
-        if (key === '/') { key = 'delete'; }
-        return {
-          name: action.label,
-          id: `${key}-${action.__type}`,
-          action,
-          tooltip: action.title,
-        };
-      });
-    }
-
-    /**
-     * Occasionally, a change created by a change action is not yet knwon to the
-     * API for a brief time. Wait for the given change number to be recognized.
-     *
-     * Returns a promise that resolves with true if a request is recognized, or
-     * false if the change was never recognized after all attempts.
-     *
-     * @param  {number} changeNum
-     * @return {Promise<boolean>}
-     */
-    _waitForChangeReachable(changeNum) {
-      let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
-      return new Promise(resolve => {
-        const check = () => {
-          attempsRemaining--;
-          // Pass a no-op error handler to avoid the "not found" error toast.
-          this.$.restAPI.getChange(changeNum, () => {}).then(response => {
-            // If the response is 404, the response will be undefined.
-            if (response) {
-              resolve(true);
-              return;
-            }
-
-            if (attempsRemaining) {
-              this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
-            } else {
-              resolve(false);
-            }
-          });
-        };
-        check();
-      });
-    }
-
-    _handleEditTap() {
-      this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
-    }
-
-    _handleStopEditTap() {
-      this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
-    }
-
-    _computeHasTooltip(title) {
-      return !!title;
-    }
-
-    _computeHasIcon(action) {
-      return action.icon ? '' : 'hidden';
+    const index = this._getActionOverflowIndex(type, key);
+    const action = {
+      type,
+      key,
+      overflow,
+    };
+    if (!overflow && index !== -1) {
+      this.splice('_overflowActions', index, 1);
+    } else if (overflow) {
+      this.push('_overflowActions', action);
     }
   }
 
-  customElements.define(GrChangeActions.is, GrChangeActions);
-})();
+  setActionPriority(type, key, priority) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type given: ${type}`);
+    }
+    const index = this._actionPriorityOverrides
+        .findIndex(action => action.type === type && action.key === key);
+    const action = {
+      type,
+      key,
+      priority,
+    };
+    if (index !== -1) {
+      this.set('_actionPriorityOverrides', index, action);
+    } else {
+      this.push('_actionPriorityOverrides', action);
+    }
+  }
+
+  setActionHidden(type, key, hidden) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type given: ${type}`);
+    }
+
+    const idx = this._hiddenActions.indexOf(key);
+    if (hidden && idx === -1) {
+      this.push('_hiddenActions', key);
+    } else if (!hidden && idx !== -1) {
+      this.splice('_hiddenActions', idx, 1);
+    }
+  }
+
+  getActionDetails(action) {
+    if (this.revisionActions[action]) {
+      return this.revisionActions[action];
+    } else if (this.actions[action]) {
+      return this.actions[action];
+    }
+  }
+
+  _indexOfActionButtonWithKey(key) {
+    for (let i = 0; i < this._additionalActions.length; i++) {
+      if (this._additionalActions[i].__key === key) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  _getRevisionActions() {
+    return this.$.restAPI.getChangeRevisionActions(this.changeNum,
+        this.latestPatchNum);
+  }
+
+  _shouldHideActions(actions, loading) {
+    return loading || !actions || !actions.base || !actions.base.length;
+  }
+
+  _keyCount(changeRecord) {
+    return Object.keys((changeRecord && changeRecord.base) || {}).length;
+  }
+
+  _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
+      additionalActionsChangeRecord) {
+    // Polymer 2: check for undefined
+    if ([
+      actionsChangeRecord,
+      revisionActionsChangeRecord,
+      additionalActionsChangeRecord,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    const additionalActions = (additionalActionsChangeRecord &&
+        additionalActionsChangeRecord.base) || [];
+    this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
+        this._keyCount(revisionActionsChangeRecord) === 0 &&
+            additionalActions.length === 0;
+    this._actionLoadingMessage = '';
+    this._disabledMenuActions = [];
+
+    const revisionActions = revisionActionsChangeRecord.base || {};
+    if (Object.keys(revisionActions).length !== 0) {
+      if (!revisionActions.download) {
+        this.set('revisionActions.download', DOWNLOAD_ACTION);
+      }
+    }
+  }
+
+  /**
+   * @param {string=} actionName
+   */
+  _deleteAndNotify(actionName) {
+    if (this.actions && this.actions[actionName]) {
+      delete this.actions[actionName];
+      // We assign a fake value of 'false' to support Polymer 2
+      // see https://github.com/Polymer/polymer/issues/2631
+      this.notifyPath('actions.' + actionName, false);
+    }
+  }
+
+  _editStatusChanged(editMode, editPatchsetLoaded,
+      editBasedOnCurrentPatchSet, disableEdit) {
+    // Polymer 2: check for undefined
+    if ([
+      editMode,
+      editBasedOnCurrentPatchSet,
+      disableEdit,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    if (disableEdit) {
+      this._deleteAndNotify('publishEdit');
+      this._deleteAndNotify('rebaseEdit');
+      this._deleteAndNotify('deleteEdit');
+      this._deleteAndNotify('stopEdit');
+      this._deleteAndNotify('edit');
+      return;
+    }
+    if (this.actions && editPatchsetLoaded) {
+      // Only show actions that mutate an edit if an actual edit patch set
+      // is loaded.
+      if (this.changeIsOpen(this.change)) {
+        if (editBasedOnCurrentPatchSet) {
+          if (!this.actions.publishEdit) {
+            this.set('actions.publishEdit', PUBLISH_EDIT);
+          }
+          this._deleteAndNotify('rebaseEdit');
+        } else {
+          if (!this.actions.rebaseEdit) {
+            this.set('actions.rebaseEdit', REBASE_EDIT);
+          }
+          this._deleteAndNotify('publishEdit');
+        }
+      }
+      if (!this.actions.deleteEdit) {
+        this.set('actions.deleteEdit', DELETE_EDIT);
+      }
+    } else {
+      this._deleteAndNotify('publishEdit');
+      this._deleteAndNotify('rebaseEdit');
+      this._deleteAndNotify('deleteEdit');
+    }
+
+    if (this.actions && this.changeIsOpen(this.change)) {
+      // Only show edit button if there is no edit patchset loaded and the
+      // file list is not in edit mode.
+      if (editPatchsetLoaded || editMode) {
+        this._deleteAndNotify('edit');
+      } else {
+        if (!this.actions.edit) { this.set('actions.edit', EDIT); }
+      }
+      // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
+      // is loaded.
+      if (editMode && !editPatchsetLoaded) {
+        if (!this.actions.stopEdit) {
+          this.set('actions.stopEdit', STOP_EDIT);
+        }
+      } else {
+        this._deleteAndNotify('stopEdit');
+      }
+    } else {
+      // Remove edit button.
+      this._deleteAndNotify('edit');
+    }
+  }
+
+  _getValuesFor(obj) {
+    return Object.keys(obj).map(key => obj[key]);
+  }
+
+  _getLabelStatus(label) {
+    if (label.approved) {
+      return LabelStatus.OK;
+    } else if (label.rejected) {
+      return LabelStatus.REJECT;
+    } else if (label.optional) {
+      return LabelStatus.OPTIONAL;
+    } else {
+      return LabelStatus.NEED;
+    }
+  }
+
+  /**
+   * Get highest score for last missing permitted label for current change.
+   * Returns null if no labels permitted or more than one label missing.
+   *
+   * @return {{label: string, score: string}|null}
+   */
+  _getTopMissingApproval() {
+    if (!this.change ||
+        !this.change.labels ||
+        !this.change.permitted_labels) {
+      return null;
+    }
+    let result;
+    for (const label in this.change.labels) {
+      if (!(label in this.change.permitted_labels)) {
+        continue;
+      }
+      if (this.change.permitted_labels[label].length === 0) {
+        continue;
+      }
+      const status = this._getLabelStatus(this.change.labels[label]);
+      if (status === LabelStatus.NEED) {
+        if (result) {
+          // More than one label is missing, so it's unclear which to quick
+          // approve, return null;
+          return null;
+        }
+        result = label;
+      } else if (status === LabelStatus.REJECT ||
+          status === LabelStatus.IMPOSSIBLE) {
+        return null;
+      }
+    }
+    if (result) {
+      const score = this.change.permitted_labels[result].slice(-1)[0];
+      const maxScore =
+          Object.keys(this.change.labels[result].values).slice(-1)[0];
+      if (score === maxScore) {
+        // Allow quick approve only for maximal score.
+        return {
+          label: result,
+          score,
+        };
+      }
+    }
+    return null;
+  }
+
+  hideQuickApproveAction() {
+    this._topLevelSecondaryActions =
+      this._topLevelSecondaryActions
+          .filter(sa => sa.key !== QUICK_APPROVE_ACTION.key);
+    this._hideQuickApproveAction = true;
+  }
+
+  _getQuickApproveAction() {
+    if (this._hideQuickApproveAction) {
+      return null;
+    }
+    const approval = this._getTopMissingApproval();
+    if (!approval) {
+      return null;
+    }
+    const action = Object.assign({}, QUICK_APPROVE_ACTION);
+    action.label = approval.label + approval.score;
+    const review = {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {},
+    };
+    review.labels[approval.label] = approval.score;
+    action.payload = review;
+    return action;
+  }
+
+  _getActionValues(actionsChangeRecord, primariesChangeRecord,
+      additionalActionsChangeRecord, type) {
+    if (!actionsChangeRecord || !primariesChangeRecord) { return []; }
+
+    const actions = actionsChangeRecord.base || {};
+    const primaryActionKeys = primariesChangeRecord.base || [];
+    const result = [];
+    const values = this._getValuesFor(
+        type === ActionType.CHANGE ? ChangeActions : RevisionActions);
+    const pluginActions = [];
+    Object.keys(actions).forEach(a => {
+      actions[a].__key = a;
+      actions[a].__type = type;
+      actions[a].__primary = primaryActionKeys.includes(a);
+      // Plugin actions always contain ~ in the key.
+      if (a.indexOf('~') !== -1) {
+        this._populateActionUrl(actions[a]);
+        pluginActions.push(actions[a]);
+        // Add server-side provided plugin actions to overflow menu.
+        this._overflowActions.push({
+          type,
+          key: a,
+        });
+        return;
+      } else if (!values.includes(a)) {
+        return;
+      }
+      actions[a].label = this._getActionLabel(actions[a]);
+
+      // Triggers a re-render by ensuring object inequality.
+      result.push(Object.assign({}, actions[a]));
+    });
+
+    let additionalActions = (additionalActionsChangeRecord &&
+    additionalActionsChangeRecord.base) || [];
+    additionalActions = additionalActions
+        .filter(a => a.__type === type)
+        .map(a => {
+          a.__primary = primaryActionKeys.includes(a.__key);
+          // Triggers a re-render by ensuring object inequality.
+          return Object.assign({}, a);
+        });
+    return result.concat(additionalActions).concat(pluginActions);
+  }
+
+  _populateActionUrl(action) {
+    const patchNum =
+          action.__type === ActionType.REVISION ? this.latestPatchNum : null;
+    this.$.restAPI.getChangeActionURL(
+        this.changeNum, patchNum, '/' + action.__key)
+        .then(url => action.__url = url);
+  }
+
+  /**
+   * Given a change action, return a display label that uses the appropriate
+   * casing or includes explanatory details.
+   */
+  _getActionLabel(action) {
+    if (action.label === 'Delete') {
+      // This label is common within change and revision actions. Make it more
+      // explicit to the user.
+      return 'Delete change';
+    } else if (action.label === 'WIP') {
+      return 'Mark as work in progress';
+    }
+    // Otherwise, just map the name to sentence case.
+    return this._toSentenceCase(action.label);
+  }
+
+  /**
+   * Capitalize the first letter and lowecase all others.
+   *
+   * @param {string} s
+   * @return {string}
+   */
+  _toSentenceCase(s) {
+    if (!s.length) { return ''; }
+    return s[0].toUpperCase() + s.slice(1).toLowerCase();
+  }
+
+  _computeLoadingLabel(action) {
+    return ActionLoadingLabels[action] || 'Working...';
+  }
+
+  _canSubmitChange() {
+    return this.$.jsAPI.canSubmitChange(this.change,
+        this._getRevision(this.change, this.latestPatchNum));
+  }
+
+  _getRevision(change, patchNum) {
+    for (const rev of Object.values(change.revisions)) {
+      if (this.patchNumEquals(rev._number, patchNum)) {
+        return rev;
+      }
+    }
+    return null;
+  }
+
+  showRevertDialog() {
+    const query = 'submissionid:' + this.change.submission_id;
+    /* A chromium plugin expects that the modifyRevertMsg hook will only
+    be called after the revert button is pressed, hence we populate the
+    revert dialog after revert button is pressed. */
+    this.$.restAPI.getChanges('', query)
+        .then(changes => {
+          this.$.confirmRevertDialog.populate(this.change,
+              this.commitMessage, changes);
+          this._showActionDialog(this.$.confirmRevertDialog);
+        });
+  }
+
+  showRevertSubmissionDialog() {
+    const query = 'submissionid:' + this.change.submission_id;
+    this.$.restAPI.getChanges('', query)
+        .then(changes => {
+          this.$.confirmRevertSubmissionDialog.
+              _populateRevertSubmissionMessage(
+                  this.commitMessage, this.change, changes);
+          this._showActionDialog(this.$.confirmRevertSubmissionDialog);
+        });
+  }
+
+  _handleActionTap(e) {
+    e.preventDefault();
+    let el = dom(e).localTarget;
+    while (el.tagName.toLowerCase() !== 'gr-button') {
+      if (!el.parentElement) { return; }
+      el = el.parentElement;
+    }
+
+    const key = el.getAttribute('data-action-key');
+    if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+        key.indexOf('~') !== -1) {
+      this.fire(`${key}-tap`, {node: el});
+      return;
+    }
+    const type = el.getAttribute('data-action-type');
+    this._handleAction(type, key);
+  }
+
+  _handleOveflowItemTap(e) {
+    e.preventDefault();
+    const el = dom(e).localTarget;
+    const key = e.detail.action.__key;
+    if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+        key.indexOf('~') !== -1) {
+      this.fire(`${key}-tap`, {node: el});
+      return;
+    }
+    this._handleAction(e.detail.action.__type, e.detail.action.__key);
+  }
+
+  _handleAction(type, key) {
+    this.$.reporting.reportInteraction(`${type}-${key}`);
+    switch (type) {
+      case ActionType.REVISION:
+        this._handleRevisionAction(key);
+        break;
+      case ActionType.CHANGE:
+        this._handleChangeAction(key);
+        break;
+      default:
+        this._fireAction(this._prependSlash(key), this.actions[key], false);
+    }
+  }
+
+  _handleChangeAction(key) {
+    let action;
+    switch (key) {
+      case ChangeActions.REVERT:
+        this.showRevertDialog();
+        break;
+      case ChangeActions.REVERT_SUBMISSION:
+        this.showRevertSubmissionDialog();
+        break;
+      case ChangeActions.ABANDON:
+        this._showActionDialog(this.$.confirmAbandonDialog);
+        break;
+      case QUICK_APPROVE_ACTION.key:
+        action = this._allActionValues.find(o => o.key === key);
+        this._fireAction(
+            this._prependSlash(key), action, true, action.payload);
+        break;
+      case ChangeActions.EDIT:
+        this._handleEditTap();
+        break;
+      case ChangeActions.STOP_EDIT:
+        this._handleStopEditTap();
+        break;
+      case ChangeActions.DELETE:
+        this._handleDeleteTap();
+        break;
+      case ChangeActions.DELETE_EDIT:
+        this._handleDeleteEditTap();
+        break;
+      case ChangeActions.FOLLOW_UP:
+        this._handleFollowUpTap();
+        break;
+      case ChangeActions.WIP:
+        this._handleWipTap();
+        break;
+      case ChangeActions.MOVE:
+        this._handleMoveTap();
+        break;
+      case ChangeActions.PUBLISH_EDIT:
+        this._handlePublishEditTap();
+        break;
+      case ChangeActions.REBASE_EDIT:
+        this._handleRebaseEditTap();
+        break;
+      default:
+        this._fireAction(this._prependSlash(key), this.actions[key], false);
+    }
+  }
+
+  _handleRevisionAction(key) {
+    switch (key) {
+      case RevisionActions.REBASE:
+        this._showActionDialog(this.$.confirmRebase);
+        this.$.confirmRebase.fetchRecentChanges();
+        break;
+      case RevisionActions.CHERRYPICK:
+        this._handleCherrypickTap();
+        break;
+      case RevisionActions.DOWNLOAD:
+        this._handleDownloadTap();
+        break;
+      case RevisionActions.SUBMIT:
+        if (!this._canSubmitChange()) { return; }
+        this._showActionDialog(this.$.confirmSubmitDialog);
+        break;
+      default:
+        this._fireAction(this._prependSlash(key),
+            this.revisionActions[key], true);
+    }
+  }
+
+  _prependSlash(key) {
+    return key === '/' ? key : `/${key}`;
+  }
+
+  /**
+   * _hasKnownChainState set to true true if hasParent is defined (can be
+   * either true or false). set to false otherwise.
+   */
+  _computeChainState(hasParent) {
+    this._hasKnownChainState = true;
+  }
+
+  _calculateDisabled(action, hasKnownChainState) {
+    if (action.__key === 'rebase' && hasKnownChainState === false) {
+      return true;
+    }
+    return !action.enabled;
+  }
+
+  _handleConfirmDialogCancel() {
+    this._hideAllDialogs();
+  }
+
+  _hideAllDialogs() {
+    const dialogEls =
+        dom(this.root).querySelectorAll('.confirmDialog');
+    for (const dialogEl of dialogEls) { dialogEl.hidden = true; }
+    this.$.overlay.close();
+  }
+
+  _handleRebaseConfirm(e) {
+    const el = this.$.confirmRebase;
+    const payload = {base: e.detail.base};
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction('/rebase', this.revisionActions.rebase, true, payload);
+  }
+
+  _handleCherrypickConfirm() {
+    this._handleCherryPickRestApi(false);
+  }
+
+  _handleCherrypickConflictConfirm() {
+    this._handleCherryPickRestApi(true);
+  }
+
+  _handleCherryPickRestApi(conflicts) {
+    const el = this.$.confirmCherrypick;
+    if (!el.branch) {
+      this.fire('show-alert', {message: ERR_BRANCH_EMPTY});
+      return;
+    }
+    if (!el.message) {
+      this.fire('show-alert', {message: ERR_COMMIT_EMPTY});
+      return;
+    }
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+        '/cherrypick',
+        this.revisionActions.cherrypick,
+        true,
+        {
+          destination: el.branch,
+          base: el.baseCommit ? el.baseCommit : null,
+          message: el.message,
+          allow_conflicts: conflicts,
+        }
+    );
+  }
+
+  _handleMoveConfirm() {
+    const el = this.$.confirmMove;
+    if (!el.branch) {
+      this.fire('show-alert', {message: ERR_BRANCH_EMPTY});
+      return;
+    }
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+        '/move',
+        this.actions.move,
+        false,
+        {
+          destination_branch: el.branch,
+          message: el.message,
+        }
+    );
+  }
+
+  _handleRevertDialogConfirm(e) {
+    const revertType = e.detail.revertType;
+    const message = e.detail.message;
+    const el = this.$.confirmRevertDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    switch (revertType) {
+      case REVERT_TYPES.REVERT_SINGLE_CHANGE:
+        this._fireAction('/revert', this.actions.revert, false,
+            {message});
+        break;
+      case REVERT_TYPES.REVERT_SUBMISSION:
+        this._fireAction('/revert_submission', this.actions.revert_submission,
+            false, {message});
+        break;
+      default:
+        console.error('invalid revert type');
+    }
+  }
+
+  _handleRevertSubmissionDialogConfirm() {
+    const el = this.$.confirmRevertSubmissionDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction('/revert_submission', this.actions.revert_submission,
+        false, {message: el.message});
+  }
+
+  _handleAbandonDialogConfirm() {
+    const el = this.$.confirmAbandonDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction('/abandon', this.actions.abandon, false,
+        {message: el.message});
+  }
+
+  _handleCreateFollowUpChange() {
+    this.$.createFollowUpChange.handleCreateChange();
+    this._handleCloseCreateFollowUpChange();
+  }
+
+  _handleCloseCreateFollowUpChange() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteConfirm() {
+    this._fireAction('/', this.actions[ChangeActions.DELETE], false);
+  }
+
+  _handleDeleteEditConfirm() {
+    this._hideAllDialogs();
+
+    this._fireAction('/edit', this.actions.deleteEdit, false);
+  }
+
+  _handleSubmitConfirm() {
+    if (!this._canSubmitChange()) { return; }
+    this._hideAllDialogs();
+    this._fireAction('/submit', this.revisionActions.submit, true);
+  }
+
+  _getActionOverflowIndex(type, key) {
+    return this._overflowActions
+        .findIndex(action => action.type === type && action.key === key);
+  }
+
+  _setLoadingOnButtonWithKey(type, key) {
+    this._actionLoadingMessage = this._computeLoadingLabel(key);
+    let buttonKey = key;
+    // TODO(dhruvsri): clean this up later
+    // If key is revert-submission, then button key should be 'revert'
+    if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
+      // Revert submission button no longer exists
+      buttonKey = ChangeActions.REVERT;
+    }
+
+    // If the action appears in the overflow menu.
+    if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
+      this.push('_disabledMenuActions', buttonKey === '/' ? 'delete' :
+        buttonKey);
+      return function() {
+        this._actionLoadingMessage = '';
+        this._disabledMenuActions = [];
+      }.bind(this);
+    }
+
+    // Otherwise it's a top-level action.
+    const buttonEl = this.shadowRoot
+        .querySelector(`[data-action-key="${buttonKey}"]`);
+    buttonEl.setAttribute('loading', true);
+    buttonEl.disabled = true;
+    return function() {
+      this._actionLoadingMessage = '';
+      buttonEl.removeAttribute('loading');
+      buttonEl.disabled = false;
+    }.bind(this);
+  }
+
+  /**
+   * @param {string} endpoint
+   * @param {!Object|undefined} action
+   * @param {boolean} revAction
+   * @param {!Object|string=} opt_payload
+   */
+  _fireAction(endpoint, action, revAction, opt_payload) {
+    const cleanupFn =
+        this._setLoadingOnButtonWithKey(action.__type, action.__key);
+
+    this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
+        action).then(this._handleResponse.bind(this, action));
+  }
+
+  _showActionDialog(dialog) {
+    this._hideAllDialogs();
+
+    dialog.hidden = false;
+    this.$.overlay.open().then(() => {
+      if (dialog.resetFocus) {
+        dialog.resetFocus();
+      }
+    });
+  }
+
+  // TODO(rmistry): Redo this after
+  // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
+  _setLabelValuesOnRevert(newChangeId) {
+    const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
+    if (!labels) { return Promise.resolve(); }
+    return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
+  }
+
+  _handleResponse(action, response) {
+    if (!response) { return; }
+    return this.$.restAPI.getResponseObject(response).then(obj => {
+      switch (action.__key) {
+        case ChangeActions.REVERT:
+          this._waitForChangeReachable(obj._number)
+              .then(() => this._setLabelValuesOnRevert(obj._number))
+              .then(() => {
+                Gerrit.Nav.navigateToChange(obj);
+              });
+          break;
+        case RevisionActions.CHERRYPICK:
+          this._waitForChangeReachable(obj._number).then(() => {
+            Gerrit.Nav.navigateToChange(obj);
+          });
+          break;
+        case ChangeActions.DELETE:
+          if (action.__type === ActionType.CHANGE) {
+            Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getUrlForRoot());
+          }
+          break;
+        case ChangeActions.WIP:
+        case ChangeActions.DELETE_EDIT:
+        case ChangeActions.PUBLISH_EDIT:
+        case ChangeActions.REBASE_EDIT:
+          Gerrit.Nav.navigateToChange(this.change);
+          break;
+        case ChangeActions.REVERT_SUBMISSION:
+          if (!obj.revert_changes || !obj.revert_changes.length) return;
+          /* If there is only 1 change then gerrit will automatically
+             redirect to that change */
+          Gerrit.Nav.navigateToSearchQuery('topic: ' +
+              obj.revert_changes[0].topic);
+          break;
+        default:
+          this.dispatchEvent(new CustomEvent('reload-change',
+              {detail: {action: action.__key}, bubbles: false}));
+          break;
+      }
+    });
+  }
+
+  _handleShowRevertSubmissionChangesConfirm() {
+    this._hideAllDialogs();
+  }
+
+  _handleResponseError(action, response, body) {
+    if (action && action.__key === RevisionActions.CHERRYPICK) {
+      if (response && response.status === 409 &&
+          body && !body.allow_conflicts) {
+        return this._showActionDialog(
+            this.$.confirmCherrypickConflict);
+      }
+    }
+    return response.text().then(errText => {
+      this.fire('show-error',
+          {message: `Could not perform action: ${errText}`});
+      if (!errText.startsWith('Change is already up to date')) {
+        throw Error(errText);
+      }
+    });
+  }
+
+  /**
+   * @param {string} method
+   * @param {string|!Object|undefined} payload
+   * @param {string} actionEndpoint
+   * @param {boolean} revisionAction
+   * @param {?Function} cleanupFn
+   * @param {!Object|undefined} action
+   */
+  _send(method, payload, actionEndpoint, revisionAction, cleanupFn, action) {
+    const handleError = response => {
+      cleanupFn.call(this);
+      this._handleResponseError(action, response, payload);
+    };
+
+    return this.fetchChangeUpdates(this.change, this.$.restAPI)
+        .then(result => {
+          if (!result.isLatest) {
+            this.fire('show-alert', {
+              message: 'Cannot set label: a newer patch has been ' +
+                  'uploaded to this change.',
+              action: 'Reload',
+              callback: () => {
+                // Load the current change without any patch range.
+                Gerrit.Nav.navigateToChange(this.change);
+              },
+            });
+
+            // Because this is not a network error, call the cleanup function
+            // but not the error handler.
+            cleanupFn();
+
+            return Promise.resolve();
+          }
+          const patchNum = revisionAction ? this.latestPatchNum : null;
+          return this.$.restAPI.executeChangeAction(this.changeNum, method,
+              actionEndpoint, patchNum, payload, handleError)
+              .then(response => {
+                cleanupFn.call(this);
+                return response;
+              });
+        });
+  }
+
+  _handleAbandonTap() {
+    this._showActionDialog(this.$.confirmAbandonDialog);
+  }
+
+  _handleCherrypickTap() {
+    this.$.confirmCherrypick.branch = '';
+    this._showActionDialog(this.$.confirmCherrypick);
+  }
+
+  _handleMoveTap() {
+    this.$.confirmMove.branch = '';
+    this.$.confirmMove.message = '';
+    this._showActionDialog(this.$.confirmMove);
+  }
+
+  _handleDownloadTap() {
+    this.fire('download-tap', null, {bubbles: false});
+  }
+
+  _handleDeleteTap() {
+    this._showActionDialog(this.$.confirmDeleteDialog);
+  }
+
+  _handleDeleteEditTap() {
+    this._showActionDialog(this.$.confirmDeleteEditDialog);
+  }
+
+  _handleFollowUpTap() {
+    this._showActionDialog(this.$.createFollowUpDialog);
+  }
+
+  _handleWipTap() {
+    this._fireAction('/wip', this.actions.wip, false);
+  }
+
+  _handlePublishEditTap() {
+    this._fireAction('/edit:publish', this.actions.publishEdit, false);
+  }
+
+  _handleRebaseEditTap() {
+    this._fireAction('/edit:rebase', this.actions.rebaseEdit, false);
+  }
+
+  _handleHideBackgroundContent() {
+    this.$.mainContent.classList.add('overlayOpen');
+  }
+
+  _handleShowBackgroundContent() {
+    this.$.mainContent.classList.remove('overlayOpen');
+  }
+
+  /**
+   * Merge sources of change actions into a single ordered array of action
+   * values.
+   *
+   * @param {!Array} changeActionsRecord
+   * @param {!Array} revisionActionsRecord
+   * @param {!Array} primariesRecord
+   * @param {!Array} additionalActionsRecord
+   * @param {!Object} change The change object.
+   * @return {!Array}
+   */
+  _computeAllActions(changeActionsRecord, revisionActionsRecord,
+      primariesRecord, additionalActionsRecord, change) {
+    // Polymer 2: check for undefined
+    if ([
+      changeActionsRecord,
+      revisionActionsRecord,
+      primariesRecord,
+      additionalActionsRecord,
+      change,
+    ].some(arg => arg === undefined)) {
+      return [];
+    }
+
+    const revisionActionValues = this._getActionValues(revisionActionsRecord,
+        primariesRecord, additionalActionsRecord, ActionType.REVISION);
+    const changeActionValues = this._getActionValues(changeActionsRecord,
+        primariesRecord, additionalActionsRecord, ActionType.CHANGE);
+    const quickApprove = this._getQuickApproveAction();
+    if (quickApprove) {
+      changeActionValues.unshift(quickApprove);
+    }
+
+    return revisionActionValues
+        .concat(changeActionValues)
+        .sort(this._actionComparator.bind(this))
+        .map(action => {
+          if (ACTIONS_WITH_ICONS.has(action.__key)) {
+            action.icon = action.__key;
+          }
+          return action;
+        })
+        .filter(action => !this._shouldSkipAction(action));
+  }
+
+  _getActionPriority(action) {
+    if (action.__type && action.__key) {
+      const overrideAction = this._actionPriorityOverrides
+          .find(i => i.type === action.__type && i.key === action.__key);
+
+      if (overrideAction !== undefined) {
+        return overrideAction.priority;
+      }
+    }
+    if (action.__key === 'review') {
+      return ActionPriority.REVIEW;
+    } else if (action.__primary) {
+      return ActionPriority.PRIMARY;
+    } else if (action.__type === ActionType.CHANGE) {
+      return ActionPriority.CHANGE;
+    } else if (action.__type === ActionType.REVISION) {
+      return ActionPriority.REVISION;
+    }
+    return ActionPriority.DEFAULT;
+  }
+
+  /**
+   * Sort comparator to define the order of change actions.
+   */
+  _actionComparator(actionA, actionB) {
+    const priorityDelta = this._getActionPriority(actionA) -
+        this._getActionPriority(actionB);
+    // Sort by the button label if same priority.
+    if (priorityDelta === 0) {
+      return actionA.label > actionB.label ? 1 : -1;
+    } else {
+      return priorityDelta;
+    }
+  }
+
+  _shouldSkipAction(action) {
+    return SKIP_ACTION_KEYS.includes(action.__key);
+  }
+
+  _computeTopLevelActions(actionRecord, hiddenActionsRecord) {
+    const hiddenActions = hiddenActionsRecord.base || [];
+    return actionRecord.base.filter(a => {
+      const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+      return !(overflow || hiddenActions.includes(a.__key));
+    });
+  }
+
+  _filterPrimaryActions(_topLevelActions) {
+    this._topLevelPrimaryActions = _topLevelActions.filter(action =>
+      action.__primary);
+    this._topLevelSecondaryActions = _topLevelActions.filter(action =>
+      !action.__primary);
+  }
+
+  _computeMenuActions(actionRecord, hiddenActionsRecord) {
+    const hiddenActions = hiddenActionsRecord.base || [];
+    return actionRecord.base.filter(a => {
+      const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+      return overflow && !hiddenActions.includes(a.__key);
+    }).map(action => {
+      let key = action.__key;
+      if (key === '/') { key = 'delete'; }
+      return {
+        name: action.label,
+        id: `${key}-${action.__type}`,
+        action,
+        tooltip: action.title,
+      };
+    });
+  }
+
+  /**
+   * Occasionally, a change created by a change action is not yet knwon to the
+   * API for a brief time. Wait for the given change number to be recognized.
+   *
+   * Returns a promise that resolves with true if a request is recognized, or
+   * false if the change was never recognized after all attempts.
+   *
+   * @param  {number} changeNum
+   * @return {Promise<boolean>}
+   */
+  _waitForChangeReachable(changeNum) {
+    let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
+    return new Promise(resolve => {
+      const check = () => {
+        attempsRemaining--;
+        // Pass a no-op error handler to avoid the "not found" error toast.
+        this.$.restAPI.getChange(changeNum, () => {}).then(response => {
+          // If the response is 404, the response will be undefined.
+          if (response) {
+            resolve(true);
+            return;
+          }
+
+          if (attempsRemaining) {
+            this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
+          } else {
+            resolve(false);
+          }
+        });
+      };
+      check();
+    });
+  }
+
+  _handleEditTap() {
+    this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
+  }
+
+  _handleStopEditTap() {
+    this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
+  }
+
+  _computeHasTooltip(title) {
+    return !!title;
+  }
+
+  _computeHasIcon(action) {
+    return action.icon ? '' : 'hidden';
+  }
+}
+
+customElements.define(GrChangeActions.is, GrChangeActions);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
index 097b92c..7aa5ecd 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
@@ -1,47 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../admin/gr-create-change-dialog/gr-create-change-dialog.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html">
-<link rel="import" href="../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html">
-<link rel="import" href="../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html">
-<link rel="import" href="../gr-confirm-move-dialog/gr-confirm-move-dialog.html">
-<link rel="import" href="../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html">
-<link rel="import" href="../gr-confirm-revert-dialog/gr-confirm-revert-dialog.html">
-<link rel="import" href="../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.html">
-<link rel="import" href="../gr-confirm-submit-dialog/gr-confirm-submit-dialog.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-change-actions">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: flex;
@@ -101,144 +76,48 @@
       }
     </style>
     <div id="mainContent">
-      <span
-          id="actionLoadingMessage"
-          hidden$="[[!_actionLoadingMessage]]">
+      <span id="actionLoadingMessage" hidden\$="[[!_actionLoadingMessage]]">
         [[_actionLoadingMessage]]</span>
-        <section id="primaryActions"
-            hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]">
-          <template
-              is="dom-repeat"
-              items="[[_topLevelPrimaryActions]]"
-              as="action">
-            <gr-button
-                link
-                title$="[[action.title]]"
-                has-tooltip="[[_computeHasTooltip(action.title)]]"
-                position-below="true"
-                data-action-key$="[[action.__key]]"
-                data-action-type$="[[action.__type]]"
-                data-label$="[[action.label]]"
-                disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-                on-click="_handleActionTap">
-                <iron-icon class$="[[_computeHasIcon(action)]]" icon$="gr-icons:[[action.icon]]"></iron-icon>
+        <section id="primaryActions" hidden\$="[[_shouldHideActions(_topLevelActions.*, _loading)]]">
+          <template is="dom-repeat" items="[[_topLevelPrimaryActions]]" as="action">
+            <gr-button link="" title\$="[[action.title]]" has-tooltip="[[_computeHasTooltip(action.title)]]" position-below="true" data-action-key\$="[[action.__key]]" data-action-type\$="[[action.__type]]" data-label\$="[[action.label]]" disabled\$="[[_calculateDisabled(action, _hasKnownChainState)]]" on-click="_handleActionTap">
+                <iron-icon class\$="[[_computeHasIcon(action)]]" icon\$="gr-icons:[[action.icon]]"></iron-icon>
               [[action.label]]
             </gr-button>
           </template>
         </section>
-        <section id="secondaryActions"
-            hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]">
-          <template
-              is="dom-repeat"
-              items="[[_topLevelSecondaryActions]]"
-              as="action">
-            <gr-button
-                link
-                title$="[[action.title]]"
-                has-tooltip="[[_computeHasTooltip(action.title)]]"
-                position-below="true"
-                data-action-key$="[[action.__key]]"
-                data-action-type$="[[action.__type]]"
-                data-label$="[[action.label]]"
-                disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-                on-click="_handleActionTap">
-              <iron-icon class$="[[_computeHasIcon(action)]]" icon$="gr-icons:[[action.icon]]"></iron-icon>
+        <section id="secondaryActions" hidden\$="[[_shouldHideActions(_topLevelActions.*, _loading)]]">
+          <template is="dom-repeat" items="[[_topLevelSecondaryActions]]" as="action">
+            <gr-button link="" title\$="[[action.title]]" has-tooltip="[[_computeHasTooltip(action.title)]]" position-below="true" data-action-key\$="[[action.__key]]" data-action-type\$="[[action.__type]]" data-label\$="[[action.label]]" disabled\$="[[_calculateDisabled(action, _hasKnownChainState)]]" on-click="_handleActionTap">
+              <iron-icon class\$="[[_computeHasIcon(action)]]" icon\$="gr-icons:[[action.icon]]"></iron-icon>
               [[action.label]]
             </gr-button>
           </template>
         </section>
-      <gr-button hidden$="[[!_loading]]" disabled>Loading actions...</gr-button>
-      <gr-dropdown
-          id="moreActions"
-          link
-          tabindex="0"
-          vertical-offset="32"
-          horizontal-align="right"
-          on-tap-item="_handleOveflowItemTap"
-          hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
-          disabled-ids="[[_disabledMenuActions]]"
-          items="[[_menuActions]]">
+      <gr-button hidden\$="[[!_loading]]" disabled="">Loading actions...</gr-button>
+      <gr-dropdown id="moreActions" link="" tabindex="0" vertical-offset="32" horizontal-align="right" on-tap-item="_handleOveflowItemTap" hidden\$="[[_shouldHideActions(_menuActions.*, _loading)]]" disabled-ids="[[_disabledMenuActions]]" items="[[_menuActions]]">
           <iron-icon icon="gr-icons:more-vert"></iron-icon>
           <span id="moreMessage">More</span>
         </gr-dropdown>
     </div>
-    <gr-overlay id="overlay" with-backdrop>
-      <gr-confirm-rebase-dialog id="confirmRebase"
-          class="confirmDialog"
-          change-number="[[change._number]]"
-          on-confirm="_handleRebaseConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          branch="[[change.branch]]"
-          has-parent="[[hasParent]]"
-          rebase-on-current="[[_revisionRebaseAction.rebaseOnCurrent]]"
-          hidden></gr-confirm-rebase-dialog>
-      <gr-confirm-cherrypick-dialog id="confirmCherrypick"
-          class="confirmDialog"
-          change-status="[[changeStatus]]"
-          commit-message="[[commitMessage]]"
-          commit-num="[[commitNum]]"
-          on-confirm="_handleCherrypickConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          project="[[change.project]]"
-          hidden></gr-confirm-cherrypick-dialog>
-      <gr-confirm-cherrypick-conflict-dialog id="confirmCherrypickConflict"
-          class="confirmDialog"
-          on-confirm="_handleCherrypickConflictConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          hidden></gr-confirm-cherrypick-conflict-dialog>
-      <gr-confirm-move-dialog id="confirmMove"
-          class="confirmDialog"
-          on-confirm="_handleMoveConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          project="[[change.project]]"
-          hidden></gr-confirm-move-dialog>
-      <gr-confirm-revert-dialog id="confirmRevertDialog"
-          class="confirmDialog"
-          on-confirm="_handleRevertDialogConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          hidden></gr-confirm-revert-dialog>
-      <gr-confirm-revert-submission-dialog id="confirmRevertSubmissionDialog"
-          class="confirmDialog"
-          commit-message="[[commitMessage]]"
-          on-confirm="_handleRevertSubmissionDialogConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          hidden></gr-confirm-revert-submission-dialog>
-      <gr-confirm-abandon-dialog id="confirmAbandonDialog"
-          class="confirmDialog"
-          on-confirm="_handleAbandonDialogConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          hidden></gr-confirm-abandon-dialog>
-      <gr-confirm-submit-dialog
-          id="confirmSubmitDialog"
-          class="confirmDialog"
-          change="[[change]]"
-          action="[[_revisionSubmitAction]]"
-          on-cancel="_handleConfirmDialogCancel"
-          on-confirm="_handleSubmitConfirm" hidden></gr-confirm-submit-dialog>
-      <gr-dialog id="createFollowUpDialog"
-          class="confirmDialog"
-          confirm-label="Create"
-          on-confirm="_handleCreateFollowUpChange"
-          on-cancel="_handleCloseCreateFollowUpChange">
+    <gr-overlay id="overlay" with-backdrop="">
+      <gr-confirm-rebase-dialog id="confirmRebase" class="confirmDialog" change-number="[[change._number]]" on-confirm="_handleRebaseConfirm" on-cancel="_handleConfirmDialogCancel" branch="[[change.branch]]" has-parent="[[hasParent]]" rebase-on-current="[[_revisionRebaseAction.rebaseOnCurrent]]" hidden=""></gr-confirm-rebase-dialog>
+      <gr-confirm-cherrypick-dialog id="confirmCherrypick" class="confirmDialog" change-status="[[changeStatus]]" commit-message="[[commitMessage]]" commit-num="[[commitNum]]" on-confirm="_handleCherrypickConfirm" on-cancel="_handleConfirmDialogCancel" project="[[change.project]]" hidden=""></gr-confirm-cherrypick-dialog>
+      <gr-confirm-cherrypick-conflict-dialog id="confirmCherrypickConflict" class="confirmDialog" on-confirm="_handleCherrypickConflictConfirm" on-cancel="_handleConfirmDialogCancel" hidden=""></gr-confirm-cherrypick-conflict-dialog>
+      <gr-confirm-move-dialog id="confirmMove" class="confirmDialog" on-confirm="_handleMoveConfirm" on-cancel="_handleConfirmDialogCancel" project="[[change.project]]" hidden=""></gr-confirm-move-dialog>
+      <gr-confirm-revert-dialog id="confirmRevertDialog" class="confirmDialog" on-confirm="_handleRevertDialogConfirm" on-cancel="_handleConfirmDialogCancel" hidden=""></gr-confirm-revert-dialog>
+      <gr-confirm-revert-submission-dialog id="confirmRevertSubmissionDialog" class="confirmDialog" commit-message="[[commitMessage]]" on-confirm="_handleRevertSubmissionDialogConfirm" on-cancel="_handleConfirmDialogCancel" hidden=""></gr-confirm-revert-submission-dialog>
+      <gr-confirm-abandon-dialog id="confirmAbandonDialog" class="confirmDialog" on-confirm="_handleAbandonDialogConfirm" on-cancel="_handleConfirmDialogCancel" hidden=""></gr-confirm-abandon-dialog>
+      <gr-confirm-submit-dialog id="confirmSubmitDialog" class="confirmDialog" change="[[change]]" action="[[_revisionSubmitAction]]" on-cancel="_handleConfirmDialogCancel" on-confirm="_handleSubmitConfirm" hidden=""></gr-confirm-submit-dialog>
+      <gr-dialog id="createFollowUpDialog" class="confirmDialog" confirm-label="Create" on-confirm="_handleCreateFollowUpChange" on-cancel="_handleCloseCreateFollowUpChange">
         <div class="header" slot="header">
           Create Follow-Up Change
         </div>
         <div class="main" slot="main">
-          <gr-create-change-dialog
-              id="createFollowUpChange"
-              branch="[[change.branch]]"
-              base-change="[[change.id]]"
-              repo-name="[[change.project]]"
-              private-by-default="[[privateByDefault]]"></gr-create-change-dialog>
+          <gr-create-change-dialog id="createFollowUpChange" branch="[[change.branch]]" base-change="[[change.id]]" repo-name="[[change.project]]" private-by-default="[[privateByDefault]]"></gr-create-change-dialog>
         </div>
       </gr-dialog>
-      <gr-dialog
-          id="confirmDeleteDialog"
-          class="confirmDialog"
-          confirm-label="Delete"
-          confirm-on-enter
-          on-cancel="_handleConfirmDialogCancel"
-          on-confirm="_handleDeleteConfirm">
+      <gr-dialog id="confirmDeleteDialog" class="confirmDialog" confirm-label="Delete" confirm-on-enter="" on-cancel="_handleConfirmDialogCancel" on-confirm="_handleDeleteConfirm">
         <div class="header" slot="header">
           Delete Change
         </div>
@@ -246,13 +125,7 @@
           Do you really want to delete the change?
         </div>
       </gr-dialog>
-      <gr-dialog
-          id="confirmDeleteEditDialog"
-          class="confirmDialog"
-          confirm-label="Delete"
-          confirm-on-enter
-          on-cancel="_handleConfirmDialogCancel"
-          on-confirm="_handleDeleteEditConfirm">
+      <gr-dialog id="confirmDeleteEditDialog" class="confirmDialog" confirm-label="Delete" confirm-on-enter="" on-cancel="_handleConfirmDialogCancel" on-confirm="_handleDeleteEditConfirm">
         <div class="header" slot="header">
           Delete Change Edit
         </div>
@@ -264,6 +137,4 @@
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting" category="change-actions"></gr-reporting>
-  </template>
-  <script src="gr-change-actions.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index ee036cf..0d78fb4 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-actions</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-change-actions.html">
+<script type="module" src="./gr-change-actions.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-change-actions.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,885 +43,1704 @@
   </template>
 </test-fixture>
 
-<script>
-  // TODO(dhruvsri): remove use of _populateRevertMessage as it's private
-  suite('gr-change-actions tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-change-actions.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+// TODO(dhruvsri): remove use of _populateRevertMessage as it's private
+suite('gr-change-actions tests', () => {
+  let element;
+  let sandbox;
 
-    suite('basic tests', () => {
-      setup(() => {
-        stub('gr-rest-api-interface', {
-          getChangeRevisionActions() {
+  suite('basic tests', () => {
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getChangeRevisionActions() {
+          return Promise.resolve({
+            cherrypick: {
+              method: 'POST',
+              label: 'Cherry Pick',
+              title: 'Cherry pick change to a different branch',
+              enabled: true,
+            },
+            rebase: {
+              method: 'POST',
+              label: 'Rebase',
+              title: 'Rebase onto tip of branch or parent change',
+              enabled: true,
+            },
+            submit: {
+              method: 'POST',
+              label: 'Submit',
+              title: 'Submit patch set 2 into master',
+              enabled: true,
+            },
+            revert_submission: {
+              method: 'POST',
+              label: 'Revert submission',
+              title: 'Revert this submission',
+              enabled: true,
+            },
+          });
+        },
+        send(method, url, payload) {
+          if (method !== 'POST') {
+            return Promise.reject(new Error('bad method'));
+          }
+
+          if (url === '/changes/test~42/revisions/2/submit') {
             return Promise.resolve({
-              cherrypick: {
-                method: 'POST',
-                label: 'Cherry Pick',
-                title: 'Cherry pick change to a different branch',
-                enabled: true,
-              },
-              rebase: {
-                method: 'POST',
-                label: 'Rebase',
-                title: 'Rebase onto tip of branch or parent change',
-                enabled: true,
-              },
-              submit: {
-                method: 'POST',
-                label: 'Submit',
-                title: 'Submit patch set 2 into master',
-                enabled: true,
-              },
-              revert_submission: {
-                method: 'POST',
-                label: 'Revert submission',
-                title: 'Revert this submission',
-                enabled: true,
-              },
+              ok: true,
+              text() { return Promise.resolve(')]}\'\n{}'); },
             });
-          },
-          send(method, url, payload) {
-            if (method !== 'POST') {
-              return Promise.reject(new Error('bad method'));
-            }
+          } else if (url === '/changes/test~42/revisions/2/rebase') {
+            return Promise.resolve({
+              ok: true,
+              text() { return Promise.resolve(')]}\'\n{}'); },
+            });
+          }
 
-            if (url === '/changes/test~42/revisions/2/submit') {
-              return Promise.resolve({
-                ok: true,
-                text() { return Promise.resolve(')]}\'\n{}'); },
-              });
-            } else if (url === '/changes/test~42/revisions/2/rebase') {
-              return Promise.resolve({
-                ok: true,
-                text() { return Promise.resolve(')]}\'\n{}'); },
-              });
-            }
-
-            return Promise.reject(new Error('bad url'));
-          },
-          getProjectConfig() { return Promise.resolve({}); },
-        });
-
-        sandbox = sinon.sandbox.create();
-        sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
-
-        element = fixture('basic');
-        element.change = {};
-        element.changeNum = '42';
-        element.latestPatchNum = '2';
-        element.actions = {
-          '/': {
-            method: 'DELETE',
-            label: 'Delete Change',
-            title: 'Delete change X_X',
-            enabled: true,
-          },
-        };
-        sandbox.stub(element.$.confirmCherrypick.$.restAPI,
-            'getRepoBranches').returns(Promise.resolve([]));
-        sandbox.stub(element.$.confirmMove.$.restAPI,
-            'getRepoBranches').returns(Promise.resolve([]));
-
-        return element.reload();
+          return Promise.reject(new Error('bad url'));
+        },
+        getProjectConfig() { return Promise.resolve({}); },
       });
 
-      teardown(() => {
-        sandbox.restore();
-      });
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
 
-      test('show-revision-actions event should fire', done => {
-        const spy = sinon.spy(element, '_sendShowRevisionActions');
-        element.reload();
-        flush(() => {
-          assert.isTrue(spy.called);
-          done();
-        });
-      });
+      element = fixture('basic');
+      element.change = {};
+      element.changeNum = '42';
+      element.latestPatchNum = '2';
+      element.actions = {
+        '/': {
+          method: 'DELETE',
+          label: 'Delete Change',
+          title: 'Delete change X_X',
+          enabled: true,
+        },
+      };
+      sandbox.stub(element.$.confirmCherrypick.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      sandbox.stub(element.$.confirmMove.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
 
-      test('primary and secondary actions split properly', () => {
-        // Submit should be the only primary action.
-        assert.equal(element._topLevelPrimaryActions.length, 1);
-        assert.equal(element._topLevelPrimaryActions[0].label, 'Submit');
-        assert.equal(element._topLevelSecondaryActions.length,
-            element._topLevelActions.length - 1);
-      });
+      return element.reload();
+    });
 
-      test('revert submission action is skipped', () => {
-        assert.isFalse(element._allActionValues.includes(action =>
-          action.key === 'revert_submission'));
-      });
+    teardown(() => {
+      sandbox.restore();
+    });
 
-      test('_shouldHideActions', () => {
-        assert.isTrue(element._shouldHideActions(undefined, true));
-        assert.isTrue(element._shouldHideActions({base: {}}, false));
-        assert.isFalse(element._shouldHideActions({base: ['test']}, false));
+    test('show-revision-actions event should fire', done => {
+      const spy = sinon.spy(element, '_sendShowRevisionActions');
+      element.reload();
+      flush(() => {
+        assert.isTrue(spy.called);
+        done();
       });
+    });
 
-      test('plugin revision actions', done => {
-        sandbox.stub(element.$.restAPI, 'getChangeActionURL').returns(
-            Promise.resolve('the-url'));
-        element.revisionActions = {
-          'plugin~action': {},
-        };
-        assert.isOk(element.revisionActions['plugin~action']);
-        flush(() => {
-          assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
-              element.changeNum, element.latestPatchNum, '/plugin~action'));
-          assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
-          done();
-        });
+    test('primary and secondary actions split properly', () => {
+      // Submit should be the only primary action.
+      assert.equal(element._topLevelPrimaryActions.length, 1);
+      assert.equal(element._topLevelPrimaryActions[0].label, 'Submit');
+      assert.equal(element._topLevelSecondaryActions.length,
+          element._topLevelActions.length - 1);
+    });
+
+    test('revert submission action is skipped', () => {
+      assert.isFalse(element._allActionValues.includes(action =>
+        action.key === 'revert_submission'));
+    });
+
+    test('_shouldHideActions', () => {
+      assert.isTrue(element._shouldHideActions(undefined, true));
+      assert.isTrue(element._shouldHideActions({base: {}}, false));
+      assert.isFalse(element._shouldHideActions({base: ['test']}, false));
+    });
+
+    test('plugin revision actions', done => {
+      sandbox.stub(element.$.restAPI, 'getChangeActionURL').returns(
+          Promise.resolve('the-url'));
+      element.revisionActions = {
+        'plugin~action': {},
+      };
+      assert.isOk(element.revisionActions['plugin~action']);
+      flush(() => {
+        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+            element.changeNum, element.latestPatchNum, '/plugin~action'));
+        assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
+        done();
       });
+    });
 
-      test('plugin change actions', done => {
-        sandbox.stub(element.$.restAPI, 'getChangeActionURL').returns(
-            Promise.resolve('the-url'));
-        element.actions = {
-          'plugin~action': {},
-        };
-        assert.isOk(element.actions['plugin~action']);
-        flush(() => {
-          assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
-              element.changeNum, null, '/plugin~action'));
-          assert.equal(element.actions['plugin~action'].__url, 'the-url');
-          done();
-        });
+    test('plugin change actions', done => {
+      sandbox.stub(element.$.restAPI, 'getChangeActionURL').returns(
+          Promise.resolve('the-url'));
+      element.actions = {
+        'plugin~action': {},
+      };
+      assert.isOk(element.actions['plugin~action']);
+      flush(() => {
+        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+            element.changeNum, null, '/plugin~action'));
+        assert.equal(element.actions['plugin~action'].__url, 'the-url');
+        done();
       });
+    });
 
-      test('not supported actions are filtered out', () => {
-        element.revisionActions = {followup: {}};
-        assert.equal(element.querySelectorAll(
-            'section gr-button[data-action-type="revision"]').length, 0);
-      });
+    test('not supported actions are filtered out', () => {
+      element.revisionActions = {followup: {}};
+      assert.equal(element.querySelectorAll(
+          'section gr-button[data-action-type="revision"]').length, 0);
+    });
 
-      test('getActionDetails', () => {
-        element.revisionActions = Object.assign({
-          'plugin~action': {},
-        }, element.revisionActions);
-        assert.isUndefined(element.getActionDetails('rubbish'));
-        assert.strictEqual(element.revisionActions['plugin~action'],
-            element.getActionDetails('plugin~action'));
-        assert.strictEqual(element.revisionActions['rebase'],
-            element.getActionDetails('rebase'));
-      });
+    test('getActionDetails', () => {
+      element.revisionActions = Object.assign({
+        'plugin~action': {},
+      }, element.revisionActions);
+      assert.isUndefined(element.getActionDetails('rubbish'));
+      assert.strictEqual(element.revisionActions['plugin~action'],
+          element.getActionDetails('plugin~action'));
+      assert.strictEqual(element.revisionActions['rebase'],
+          element.getActionDetails('rebase'));
+    });
 
-      test('hide revision action', done => {
+    test('hide revision action', done => {
+      flush(() => {
+        const buttonEl = element.shadowRoot
+            .querySelector('[data-action-key="submit"]');
+        assert.isOk(buttonEl);
+        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
+        element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, true);
+        assert.lengthOf(element._hiddenActions, 1);
+        element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, true);
+        assert.lengthOf(element._hiddenActions, 1);
         flush(() => {
           const buttonEl = element.shadowRoot
               .querySelector('[data-action-key="submit"]');
-          assert.isOk(buttonEl);
-          assert.throws(element.setActionHidden.bind(element, 'invalid type'));
+          assert.isNotOk(buttonEl);
+
           element.setActionHidden(element.ActionType.REVISION,
-              element.RevisionActions.SUBMIT, true);
-          assert.lengthOf(element._hiddenActions, 1);
-          element.setActionHidden(element.ActionType.REVISION,
-              element.RevisionActions.SUBMIT, true);
-          assert.lengthOf(element._hiddenActions, 1);
+              element.RevisionActions.SUBMIT, false);
           flush(() => {
             const buttonEl = element.shadowRoot
                 .querySelector('[data-action-key="submit"]');
-            assert.isNotOk(buttonEl);
-
-            element.setActionHidden(element.ActionType.REVISION,
-                element.RevisionActions.SUBMIT, false);
-            flush(() => {
-              const buttonEl = element.shadowRoot
-                  .querySelector('[data-action-key="submit"]');
-              assert.isOk(buttonEl);
-              assert.isFalse(buttonEl.hasAttribute('hidden'));
-              done();
-            });
+            assert.isOk(buttonEl);
+            assert.isFalse(buttonEl.hasAttribute('hidden'));
+            done();
           });
         });
       });
+    });
 
-      test('buttons exist', done => {
-        element._loading = false;
-        flush(() => {
-          const buttonEls = Polymer.dom(element.root)
-              .querySelectorAll('gr-button');
-          const menuItems = element.$.moreActions.items;
+    test('buttons exist', done => {
+      element._loading = false;
+      flush(() => {
+        const buttonEls = dom(element.root)
+            .querySelectorAll('gr-button');
+        const menuItems = element.$.moreActions.items;
 
-          // Total button number is one greater than the number of total actions
-          // due to the existence of the overflow menu trigger.
-          assert.equal(buttonEls.length + menuItems.length,
-              element._allActionValues.length + 1);
-          assert.isFalse(element.hidden);
-          done();
-        });
+        // Total button number is one greater than the number of total actions
+        // due to the existence of the overflow menu trigger.
+        assert.equal(buttonEls.length + menuItems.length,
+            element._allActionValues.length + 1);
+        assert.isFalse(element.hidden);
+        done();
       });
+    });
 
-      test('delete buttons have explicit labels', done => {
-        flush(() => {
-          const deleteItems = element.$.moreActions.items
-              .filter(item => item.id.startsWith('delete'));
-          assert.equal(deleteItems.length, 1);
-          assert.notEqual(deleteItems[0].name);
-          assert.equal(deleteItems[0].name, 'Delete change');
-          done();
-        });
+    test('delete buttons have explicit labels', done => {
+      flush(() => {
+        const deleteItems = element.$.moreActions.items
+            .filter(item => item.id.startsWith('delete'));
+        assert.equal(deleteItems.length, 1);
+        assert.notEqual(deleteItems[0].name);
+        assert.equal(deleteItems[0].name, 'Delete change');
+        done();
       });
+    });
 
-      test('get revision object from change', () => {
-        const revObj = {_number: 2, foo: 'bar'};
-        const change = {
-          revisions: {
-            rev1: {_number: 1},
-            rev2: revObj,
-          },
-        };
-        assert.deepEqual(element._getRevision(change, '2'), revObj);
-      });
+    test('get revision object from change', () => {
+      const revObj = {_number: 2, foo: 'bar'};
+      const change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: revObj,
+        },
+      };
+      assert.deepEqual(element._getRevision(change, '2'), revObj);
+    });
 
-      test('_actionComparator sort order', () => {
-        const actions = [
-          {label: '123', __type: 'change', __key: 'review'},
-          {label: 'abc-ro', __type: 'revision'},
-          {label: 'abc', __type: 'change'},
-          {label: 'def', __type: 'change'},
-          {label: 'def-p', __type: 'change', __primary: true},
-        ];
+    test('_actionComparator sort order', () => {
+      const actions = [
+        {label: '123', __type: 'change', __key: 'review'},
+        {label: 'abc-ro', __type: 'revision'},
+        {label: 'abc', __type: 'change'},
+        {label: 'def', __type: 'change'},
+        {label: 'def-p', __type: 'change', __primary: true},
+      ];
 
-        const result = actions.slice();
-        result.reverse();
-        result.sort(element._actionComparator.bind(element));
-        assert.deepEqual(result, actions);
-      });
+      const result = actions.slice();
+      result.reverse();
+      result.sort(element._actionComparator.bind(element));
+      assert.deepEqual(result, actions);
+    });
 
-      test('submit change', () => {
-        const showSpy = sandbox.spy(element, '_showActionDialog');
-        sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
-            .returns(Promise.resolve('test'));
-        sandbox.stub(element, 'fetchChangeUpdates',
-            () => Promise.resolve({isLatest: true}));
-        sandbox.stub(element.$.overlay, 'open').returns(Promise.resolve());
-        element.change = {
-          revisions: {
-            rev1: {_number: 1},
-            rev2: {_number: 2},
-          },
-        };
-        element.latestPatchNum = '2';
+    test('submit change', () => {
+      const showSpy = sandbox.spy(element, '_showActionDialog');
+      sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
+          .returns(Promise.resolve('test'));
+      sandbox.stub(element, 'fetchChangeUpdates',
+          () => Promise.resolve({isLatest: true}));
+      sandbox.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      element.change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+        },
+      };
+      element.latestPatchNum = '2';
 
+      const submitButton = element.shadowRoot
+          .querySelector('gr-button[data-action-key="submit"]');
+      assert.ok(submitButton);
+      MockInteractions.tap(submitButton);
+
+      flushAsynchronousOperations();
+      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
+    });
+
+    test('submit change, tap on icon', done => {
+      sandbox.stub(element.$.confirmSubmitDialog, 'resetFocus', done);
+      sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
+          .returns(Promise.resolve('test'));
+      sandbox.stub(element, 'fetchChangeUpdates',
+          () => Promise.resolve({isLatest: true}));
+      sandbox.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      element.change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+        },
+      };
+      element.latestPatchNum = '2';
+
+      const submitIcon =
+          element.shadowRoot
+              .querySelector('gr-button[data-action-key="submit"] iron-icon');
+      assert.ok(submitIcon);
+      MockInteractions.tap(submitIcon);
+    });
+
+    test('_handleSubmitConfirm', () => {
+      const fireStub = sandbox.stub(element, '_fireAction');
+      sandbox.stub(element, '_canSubmitChange').returns(true);
+      element._handleSubmitConfirm();
+      assert.isTrue(fireStub.calledOnce);
+      assert.deepEqual(fireStub.lastCall.args,
+          ['/submit', element.revisionActions.submit, true]);
+    });
+
+    test('_handleSubmitConfirm when not able to submit', () => {
+      const fireStub = sandbox.stub(element, '_fireAction');
+      sandbox.stub(element, '_canSubmitChange').returns(false);
+      element._handleSubmitConfirm();
+      assert.isFalse(fireStub.called);
+    });
+
+    test('submit change with plugin hook', done => {
+      sandbox.stub(element, '_canSubmitChange',
+          () => false);
+      const fireActionStub = sandbox.stub(element, '_fireAction');
+      flush(() => {
         const submitButton = element.shadowRoot
             .querySelector('gr-button[data-action-key="submit"]');
         assert.ok(submitButton);
         MockInteractions.tap(submitButton);
+        assert.equal(fireActionStub.callCount, 0);
 
-        flushAsynchronousOperations();
-        assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
+        done();
       });
+    });
 
-      test('submit change, tap on icon', done => {
-        sandbox.stub(element.$.confirmSubmitDialog, 'resetFocus', done);
-        sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
-            .returns(Promise.resolve('test'));
-        sandbox.stub(element, 'fetchChangeUpdates',
-            () => Promise.resolve({isLatest: true}));
-        sandbox.stub(element.$.overlay, 'open').returns(Promise.resolve());
-        element.change = {
-          revisions: {
-            rev1: {_number: 1},
-            rev2: {_number: 2},
-          },
-        };
-        element.latestPatchNum = '2';
+    test('chain state', () => {
+      assert.equal(element._hasKnownChainState, false);
+      element.hasParent = true;
+      assert.equal(element._hasKnownChainState, true);
+      element.hasParent = false;
+    });
 
-        const submitIcon =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key="submit"] iron-icon');
-        assert.ok(submitIcon);
-        MockInteractions.tap(submitIcon);
-      });
+    test('_calculateDisabled', () => {
+      let hasKnownChainState = false;
+      const action = {__key: 'rebase', enabled: true};
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), true);
 
-      test('_handleSubmitConfirm', () => {
-        const fireStub = sandbox.stub(element, '_fireAction');
-        sandbox.stub(element, '_canSubmitChange').returns(true);
-        element._handleSubmitConfirm();
-        assert.isTrue(fireStub.calledOnce);
-        assert.deepEqual(fireStub.lastCall.args,
-            ['/submit', element.revisionActions.submit, true]);
-      });
+      action.__key = 'delete';
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), false);
 
-      test('_handleSubmitConfirm when not able to submit', () => {
-        const fireStub = sandbox.stub(element, '_fireAction');
-        sandbox.stub(element, '_canSubmitChange').returns(false);
-        element._handleSubmitConfirm();
-        assert.isFalse(fireStub.called);
-      });
+      action.__key = 'rebase';
+      hasKnownChainState = true;
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), false);
 
-      test('submit change with plugin hook', done => {
-        sandbox.stub(element, '_canSubmitChange',
-            () => false);
-        const fireActionStub = sandbox.stub(element, '_fireAction');
-        flush(() => {
-          const submitButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="submit"]');
-          assert.ok(submitButton);
-          MockInteractions.tap(submitButton);
-          assert.equal(fireActionStub.callCount, 0);
+      action.enabled = false;
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), true);
+    });
 
-          done();
-        });
-      });
-
-      test('chain state', () => {
-        assert.equal(element._hasKnownChainState, false);
-        element.hasParent = true;
-        assert.equal(element._hasKnownChainState, true);
-        element.hasParent = false;
-      });
-
-      test('_calculateDisabled', () => {
-        let hasKnownChainState = false;
-        const action = {__key: 'rebase', enabled: true};
-        assert.equal(
-            element._calculateDisabled(action, hasKnownChainState), true);
-
-        action.__key = 'delete';
-        assert.equal(
-            element._calculateDisabled(action, hasKnownChainState), false);
-
-        action.__key = 'rebase';
-        hasKnownChainState = true;
-        assert.equal(
-            element._calculateDisabled(action, hasKnownChainState), false);
-
-        action.enabled = false;
-        assert.equal(
-            element._calculateDisabled(action, hasKnownChainState), true);
-      });
-
-      test('rebase change', done => {
-        const fireActionStub = sandbox.stub(element, '_fireAction');
-        const fetchChangesStub = sandbox.stub(element.$.confirmRebase,
-            'fetchRecentChanges').returns(Promise.resolve([]));
-        element._hasKnownChainState = true;
-        flush(() => {
-          const rebaseButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="rebase"]');
-          MockInteractions.tap(rebaseButton);
-          const rebaseAction = {
-            __key: 'rebase',
-            __type: 'revision',
-            __primary: false,
-            enabled: true,
-            label: 'Rebase',
-            method: 'POST',
-            title: 'Rebase onto tip of branch or parent change',
-          };
-          assert.isTrue(fetchChangesStub.called);
-          element._handleRebaseConfirm({detail: {base: '1234'}});
-          rebaseAction.rebaseOnCurrent = true;
-          assert.deepEqual(fireActionStub.lastCall.args,
-              ['/rebase', rebaseAction, true, {base: '1234'}]);
-          done();
-        });
-      });
-
-      test(`rebase dialog gets recent changes each time it's opened`, done => {
-        const fetchChangesStub = sandbox.stub(element.$.confirmRebase,
-            'fetchRecentChanges').returns(Promise.resolve([]));
-        element._hasKnownChainState = true;
+    test('rebase change', done => {
+      const fireActionStub = sandbox.stub(element, '_fireAction');
+      const fetchChangesStub = sandbox.stub(element.$.confirmRebase,
+          'fetchRecentChanges').returns(Promise.resolve([]));
+      element._hasKnownChainState = true;
+      flush(() => {
         const rebaseButton = element.shadowRoot
             .querySelector('gr-button[data-action-key="rebase"]');
         MockInteractions.tap(rebaseButton);
-        assert.isTrue(fetchChangesStub.calledOnce);
+        const rebaseAction = {
+          __key: 'rebase',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Rebase',
+          method: 'POST',
+          title: 'Rebase onto tip of branch or parent change',
+        };
+        assert.isTrue(fetchChangesStub.called);
+        element._handleRebaseConfirm({detail: {base: '1234'}});
+        rebaseAction.rebaseOnCurrent = true;
+        assert.deepEqual(fireActionStub.lastCall.args,
+            ['/rebase', rebaseAction, true, {base: '1234'}]);
+        done();
+      });
+    });
 
+    test(`rebase dialog gets recent changes each time it's opened`, done => {
+      const fetchChangesStub = sandbox.stub(element.$.confirmRebase,
+          'fetchRecentChanges').returns(Promise.resolve([]));
+      element._hasKnownChainState = true;
+      const rebaseButton = element.shadowRoot
+          .querySelector('gr-button[data-action-key="rebase"]');
+      MockInteractions.tap(rebaseButton);
+      assert.isTrue(fetchChangesStub.calledOnce);
+
+      flush(() => {
+        element.$.confirmRebase.fire('cancel');
+        MockInteractions.tap(rebaseButton);
+        assert.isTrue(fetchChangesStub.calledTwice);
+        done();
+      });
+    });
+
+    test('two dialogs are not shown at the same time', done => {
+      element._hasKnownChainState = true;
+      flush(() => {
+        const rebaseButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebase"]');
+        assert.ok(rebaseButton);
+        MockInteractions.tap(rebaseButton);
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.confirmRebase.hidden);
+
+        element._handleCherrypickTap();
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.confirmRebase.hidden);
+        assert.isFalse(element.$.confirmCherrypick.hidden);
+        done();
+      });
+    });
+
+    test('fullscreen-overlay-opened hides content', () => {
+      sandbox.spy(element, '_handleHideBackgroundContent');
+      element.$.overlay.fire('fullscreen-overlay-opened');
+      assert.isTrue(element._handleHideBackgroundContent.called);
+      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('fullscreen-overlay-closed shows content', () => {
+      sandbox.spy(element, '_handleShowBackgroundContent');
+      element.$.overlay.fire('fullscreen-overlay-closed');
+      assert.isTrue(element._handleShowBackgroundContent.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('_setLabelValuesOnRevert', () => {
+      const labels = {'Foo': 1, 'Bar-Baz': -2};
+      const changeId = 1234;
+      sandbox.stub(element.$.jsAPI, 'getLabelValuesPostRevert').returns(labels);
+      const saveStub = sandbox.stub(element.$.restAPI, 'saveChangeReview')
+          .returns(Promise.resolve());
+      return element._setLabelValuesOnRevert(changeId).then(() => {
+        assert.isTrue(saveStub.calledOnce);
+        assert.equal(saveStub.lastCall.args[0], changeId);
+        assert.deepEqual(saveStub.lastCall.args[2], {labels});
+      });
+    });
+
+    suite('change edits', () => {
+      test('disableEdit', () => {
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {status: 'NEW'};
+        element.set('disableEdit', true);
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('shows confirm dialog for delete edit', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+
+        const fireActionStub = sandbox.stub(element, '_fireAction');
+        element._handleDeleteEditTap();
+        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
+        MockInteractions.tap(
+            element.shadowRoot
+                .querySelector('#confirmDeleteEditDialog')
+                .shadowRoot
+                .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.equal(fireActionStub.lastCall.args[0], '/edit');
+      });
+
+      test('hide publishEdit and rebaseEdit if change is not open', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {status: 'MERGED'};
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+      });
+
+      test('edit patchset is loaded, needs rebase', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {status: 'NEW'};
+        element.editBasedOnCurrentPatchSet = false;
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit patchset is loaded, does not need rebase', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {status: 'NEW'};
+        element.editBasedOnCurrentPatchSet = true;
+        flushAsynchronousOperations();
+
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit mode is loaded, no edit patchset', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', false);
+        element.change = {status: 'NEW'};
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('normal patch set', () => {
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {status: 'NEW'};
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit action', done => {
+        element.addEventListener('edit-tap', () => { done(); });
+        element.set('editMode', true);
+        element.change = {status: 'NEW'};
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+        element.change = {status: 'MERGED'};
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        element.change = {status: 'NEW'};
+        element.set('editMode', false);
+        flushAsynchronousOperations();
+
+        const editButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]');
+        assert.isOk(editButton);
+        MockInteractions.tap(editButton);
+      });
+    });
+
+    suite('cherry-pick', () => {
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        sandbox.stub(window, 'alert');
+      });
+
+      test('works', () => {
+        element._handleCherrypickTap();
+        const action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry pick',
+          method: 'POST',
+          title: 'Cherry pick change to a different branch',
+        };
+
+        element._handleCherrypickConfirm();
+        assert.equal(fireActionStub.callCount, 0);
+
+        element.$.confirmCherrypick.branch = 'master';
+        element._handleCherrypickConfirm();
+        assert.equal(fireActionStub.callCount, 0); // Still needs a message.
+
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = 'OPEN';
+        element.$.confirmCherrypick.commitNum = '123';
+
+        element._handleCherrypickConfirm();
+
+        assert.equal(element.$.confirmCherrypick.$.messageInput.value,
+            'foo message');
+
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/cherrypick', action, true, {
+            destination: 'master',
+            base: null,
+            message: 'foo message',
+            allow_conflicts: false,
+          },
+        ]);
+      });
+
+      test('cherry pick even with conflicts', () => {
+        element._handleCherrypickTap();
+        const action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry pick',
+          method: 'POST',
+          title: 'Cherry pick change to a different branch',
+        };
+
+        element.$.confirmCherrypick.branch = 'master';
+
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = 'OPEN';
+        element.$.confirmCherrypick.commitNum = '123';
+
+        element._handleCherrypickConflictConfirm();
+
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/cherrypick', action, true, {
+            destination: 'master',
+            base: null,
+            message: 'foo message',
+            allow_conflicts: true,
+          },
+        ]);
+      });
+
+      test('branch name cleared when re-open cherrypick', () => {
+        const emptyBranchName = '';
+        element.$.confirmCherrypick.branch = 'master';
+
+        element._handleCherrypickTap();
+        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
+      });
+    });
+
+    suite('move change', () => {
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        sandbox.stub(window, 'alert');
+      });
+
+      test('works', () => {
+        element._handleMoveTap();
+
+        element._handleMoveConfirm();
+        assert.equal(fireActionStub.callCount, 0);
+
+        element.$.confirmMove.branch = 'master';
+        element._handleMoveConfirm();
+        assert.equal(fireActionStub.callCount, 1);
+      });
+
+      test('branch name cleared when re-open move', () => {
+        const emptyBranchName = '';
+        element.$.confirmMove.branch = 'master';
+
+        element._handleMoveTap();
+        assert.equal(element.$.confirmMove.branch, emptyBranchName);
+      });
+    });
+
+    test('custom actions', done => {
+      // Add a button with the same key as a server-based one to ensure
+      // collisions are taken care of.
+      const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
+      element.addEventListener(key + '-tap', e => {
+        assert.equal(e.detail.node.getAttribute('data-action-key'), key);
+        element.removeActionButton(key);
         flush(() => {
-          element.$.confirmRebase.fire('cancel');
-          MockInteractions.tap(rebaseButton);
-          assert.isTrue(fetchChangesStub.calledTwice);
-          done();
-        });
-      });
-
-      test('two dialogs are not shown at the same time', done => {
-        element._hasKnownChainState = true;
-        flush(() => {
-          const rebaseButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="rebase"]');
-          assert.ok(rebaseButton);
-          MockInteractions.tap(rebaseButton);
-          flushAsynchronousOperations();
-          assert.isFalse(element.$.confirmRebase.hidden);
-
-          element._handleCherrypickTap();
-          flushAsynchronousOperations();
-          assert.isTrue(element.$.confirmRebase.hidden);
-          assert.isFalse(element.$.confirmCherrypick.hidden);
-          done();
-        });
-      });
-
-      test('fullscreen-overlay-opened hides content', () => {
-        sandbox.spy(element, '_handleHideBackgroundContent');
-        element.$.overlay.fire('fullscreen-overlay-opened');
-        assert.isTrue(element._handleHideBackgroundContent.called);
-        assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-      });
-
-      test('fullscreen-overlay-closed shows content', () => {
-        sandbox.spy(element, '_handleShowBackgroundContent');
-        element.$.overlay.fire('fullscreen-overlay-closed');
-        assert.isTrue(element._handleShowBackgroundContent.called);
-        assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
-      });
-
-      test('_setLabelValuesOnRevert', () => {
-        const labels = {'Foo': 1, 'Bar-Baz': -2};
-        const changeId = 1234;
-        sandbox.stub(element.$.jsAPI, 'getLabelValuesPostRevert').returns(labels);
-        const saveStub = sandbox.stub(element.$.restAPI, 'saveChangeReview')
-            .returns(Promise.resolve());
-        return element._setLabelValuesOnRevert(changeId).then(() => {
-          assert.isTrue(saveStub.calledOnce);
-          assert.equal(saveStub.lastCall.args[0], changeId);
-          assert.deepEqual(saveStub.lastCall.args[2], {labels});
-        });
-      });
-
-      suite('change edits', () => {
-        test('disableEdit', () => {
-          element.set('editMode', false);
-          element.set('editPatchsetLoaded', false);
-          element.change = {status: 'NEW'};
-          element.set('disableEdit', true);
-          flushAsynchronousOperations();
-
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="publishEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="deleteEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="edit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="stopEdit"]'));
-        });
-
-        test('shows confirm dialog for delete edit', () => {
-          element.set('editMode', true);
-          element.set('editPatchsetLoaded', true);
-
-          const fireActionStub = sandbox.stub(element, '_fireAction');
-          element._handleDeleteEditTap();
-          assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
-          MockInteractions.tap(
-              element.shadowRoot
-                  .querySelector('#confirmDeleteEditDialog')
-                  .shadowRoot
-                  .querySelector('gr-button[primary]'));
-          flushAsynchronousOperations();
-
-          assert.equal(fireActionStub.lastCall.args[0], '/edit');
-        });
-
-        test('hide publishEdit and rebaseEdit if change is not open', () => {
-          element.set('editMode', true);
-          element.set('editPatchsetLoaded', true);
-          element.change = {status: 'MERGED'};
-          flushAsynchronousOperations();
-
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="publishEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-          assert.isOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="deleteEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="edit"]'));
-        });
-
-        test('edit patchset is loaded, needs rebase', () => {
-          element.set('editMode', true);
-          element.set('editPatchsetLoaded', true);
-          element.change = {status: 'NEW'};
-          element.editBasedOnCurrentPatchSet = false;
-          flushAsynchronousOperations();
-
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="publishEdit"]'));
-          assert.isOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-          assert.isOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="deleteEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="edit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="stopEdit"]'));
-        });
-
-        test('edit patchset is loaded, does not need rebase', () => {
-          element.set('editMode', true);
-          element.set('editPatchsetLoaded', true);
-          element.change = {status: 'NEW'};
-          element.editBasedOnCurrentPatchSet = true;
-          flushAsynchronousOperations();
-
-          assert.isOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="publishEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-          assert.isOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="deleteEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="edit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="stopEdit"]'));
-        });
-
-        test('edit mode is loaded, no edit patchset', () => {
-          element.set('editMode', true);
-          element.set('editPatchsetLoaded', false);
-          element.change = {status: 'NEW'};
-          flushAsynchronousOperations();
-
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="publishEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="deleteEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="edit"]'));
-          assert.isOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="stopEdit"]'));
-        });
-
-        test('normal patch set', () => {
-          element.set('editMode', false);
-          element.set('editPatchsetLoaded', false);
-          element.change = {status: 'NEW'};
-          flushAsynchronousOperations();
-
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="publishEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="deleteEdit"]'));
-          assert.isOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="edit"]'));
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="stopEdit"]'));
-        });
-
-        test('edit action', done => {
-          element.addEventListener('edit-tap', () => { done(); });
-          element.set('editMode', true);
-          element.change = {status: 'NEW'};
-          flushAsynchronousOperations();
-
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="edit"]'));
-          assert.isOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="stopEdit"]'));
-          element.change = {status: 'MERGED'};
-          flushAsynchronousOperations();
-
-          assert.isNotOk(element.shadowRoot
-              .querySelector('gr-button[data-action-key="edit"]'));
-          element.change = {status: 'NEW'};
-          element.set('editMode', false);
-          flushAsynchronousOperations();
-
-          const editButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="edit"]');
-          assert.isOk(editButton);
-          MockInteractions.tap(editButton);
-        });
-      });
-
-      suite('cherry-pick', () => {
-        let fireActionStub;
-
-        setup(() => {
-          fireActionStub = sandbox.stub(element, '_fireAction');
-          sandbox.stub(window, 'alert');
-        });
-
-        test('works', () => {
-          element._handleCherrypickTap();
-          const action = {
-            __key: 'cherrypick',
-            __type: 'revision',
-            __primary: false,
-            enabled: true,
-            label: 'Cherry pick',
-            method: 'POST',
-            title: 'Cherry pick change to a different branch',
-          };
-
-          element._handleCherrypickConfirm();
-          assert.equal(fireActionStub.callCount, 0);
-
-          element.$.confirmCherrypick.branch = 'master';
-          element._handleCherrypickConfirm();
-          assert.equal(fireActionStub.callCount, 0); // Still needs a message.
-
-          // Add attributes that are used to determine the message.
-          element.$.confirmCherrypick.commitMessage = 'foo message';
-          element.$.confirmCherrypick.changeStatus = 'OPEN';
-          element.$.confirmCherrypick.commitNum = '123';
-
-          element._handleCherrypickConfirm();
-
-          assert.equal(element.$.confirmCherrypick.$.messageInput.value,
-              'foo message');
-
-          assert.deepEqual(fireActionStub.lastCall.args, [
-            '/cherrypick', action, true, {
-              destination: 'master',
-              base: null,
-              message: 'foo message',
-              allow_conflicts: false,
-            },
-          ]);
-        });
-
-        test('cherry pick even with conflicts', () => {
-          element._handleCherrypickTap();
-          const action = {
-            __key: 'cherrypick',
-            __type: 'revision',
-            __primary: false,
-            enabled: true,
-            label: 'Cherry pick',
-            method: 'POST',
-            title: 'Cherry pick change to a different branch',
-          };
-
-          element.$.confirmCherrypick.branch = 'master';
-
-          // Add attributes that are used to determine the message.
-          element.$.confirmCherrypick.commitMessage = 'foo message';
-          element.$.confirmCherrypick.changeStatus = 'OPEN';
-          element.$.confirmCherrypick.commitNum = '123';
-
-          element._handleCherrypickConflictConfirm();
-
-          assert.deepEqual(fireActionStub.lastCall.args, [
-            '/cherrypick', action, true, {
-              destination: 'master',
-              base: null,
-              message: 'foo message',
-              allow_conflicts: true,
-            },
-          ]);
-        });
-
-        test('branch name cleared when re-open cherrypick', () => {
-          const emptyBranchName = '';
-          element.$.confirmCherrypick.branch = 'master';
-
-          element._handleCherrypickTap();
-          assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
-        });
-      });
-
-      suite('move change', () => {
-        let fireActionStub;
-
-        setup(() => {
-          fireActionStub = sandbox.stub(element, '_fireAction');
-          sandbox.stub(window, 'alert');
-        });
-
-        test('works', () => {
-          element._handleMoveTap();
-
-          element._handleMoveConfirm();
-          assert.equal(fireActionStub.callCount, 0);
-
-          element.$.confirmMove.branch = 'master';
-          element._handleMoveConfirm();
-          assert.equal(fireActionStub.callCount, 1);
-        });
-
-        test('branch name cleared when re-open move', () => {
-          const emptyBranchName = '';
-          element.$.confirmMove.branch = 'master';
-
-          element._handleMoveTap();
-          assert.equal(element.$.confirmMove.branch, emptyBranchName);
-        });
-      });
-
-      test('custom actions', done => {
-        // Add a button with the same key as a server-based one to ensure
-        // collisions are taken care of.
-        const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
-        element.addEventListener(key + '-tap', e => {
-          assert.equal(e.detail.node.getAttribute('data-action-key'), key);
-          element.removeActionButton(key);
-          flush(() => {
-            assert.notOk(element.shadowRoot
-                .querySelector('[data-action-key="' + key + '"]'));
-            done();
-          });
-        });
-        flush(() => {
-          MockInteractions.tap(element.shadowRoot
+          assert.notOk(element.shadowRoot
               .querySelector('[data-action-key="' + key + '"]'));
+          done();
         });
       });
+      flush(() => {
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
+      });
+    });
 
-      test('_setLoadingOnButtonWithKey top-level', () => {
-        const key = 'rebase';
-        const type = 'revision';
-        const cleanup = element._setLoadingOnButtonWithKey(type, key);
-        assert.equal(element._actionLoadingMessage, 'Rebasing...');
+    test('_setLoadingOnButtonWithKey top-level', () => {
+      const key = 'rebase';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
+      assert.equal(element._actionLoadingMessage, 'Rebasing...');
 
-        const button = element.shadowRoot
-            .querySelector('[data-action-key="' + key + '"]');
-        assert.isTrue(button.hasAttribute('loading'));
-        assert.isTrue(button.disabled);
+      const button = element.shadowRoot
+          .querySelector('[data-action-key="' + key + '"]');
+      assert.isTrue(button.hasAttribute('loading'));
+      assert.isTrue(button.disabled);
 
-        assert.isOk(cleanup);
-        assert.isFunction(cleanup);
-        cleanup();
+      assert.isOk(cleanup);
+      assert.isFunction(cleanup);
+      cleanup();
 
-        assert.isFalse(button.hasAttribute('loading'));
-        assert.isFalse(button.disabled);
-        assert.isNotOk(element._actionLoadingMessage);
+      assert.isFalse(button.hasAttribute('loading'));
+      assert.isFalse(button.disabled);
+      assert.isNotOk(element._actionLoadingMessage);
+    });
+
+    test('_setLoadingOnButtonWithKey overflow menu', () => {
+      const key = 'cherrypick';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
+      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
+      assert.include(element._disabledMenuActions, 'cherrypick');
+      assert.isFunction(cleanup);
+
+      cleanup();
+
+      assert.notOk(element._actionLoadingMessage);
+      assert.notInclude(element._disabledMenuActions, 'cherrypick');
+    });
+
+    suite('abandon change', () => {
+      let alertStub;
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        alertStub = sandbox.stub(window, 'alert');
+        element.actions = {
+          abandon: {
+            method: 'POST',
+            label: 'Abandon',
+            title: 'Abandon the change',
+            enabled: true,
+          },
+        };
+        return element.reload();
       });
 
-      test('_setLoadingOnButtonWithKey overflow menu', () => {
-        const key = 'cherrypick';
-        const type = 'revision';
-        const cleanup = element._setLoadingOnButtonWithKey(type, key);
-        assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
-        assert.include(element._disabledMenuActions, 'cherrypick');
-        assert.isFunction(cleanup);
-
-        cleanup();
-
-        assert.notOk(element._actionLoadingMessage);
-        assert.notInclude(element._disabledMenuActions, 'cherrypick');
-      });
-
-      suite('abandon change', () => {
-        let alertStub;
-        let fireActionStub;
-
-        setup(() => {
-          fireActionStub = sandbox.stub(element, '_fireAction');
-          alertStub = sandbox.stub(window, 'alert');
-          element.actions = {
-            abandon: {
-              method: 'POST',
-              label: 'Abandon',
-              title: 'Abandon the change',
-              enabled: true,
-            },
-          };
-          return element.reload();
-        });
-
-        test('abandon change with message', done => {
-          const newAbandonMsg = 'Test Abandon Message';
-          element.$.confirmAbandonDialog.message = newAbandonMsg;
-          flush(() => {
-            const abandonButton =
-                element.shadowRoot
-                    .querySelector('gr-button[data-action-key="abandon"]');
-            MockInteractions.tap(abandonButton);
-
-            assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
-            done();
-          });
-        });
-
-        test('abandon change with no message', done => {
-          flush(() => {
-            const abandonButton =
-                element.shadowRoot
-                    .querySelector('gr-button[data-action-key="abandon"]');
-            MockInteractions.tap(abandonButton);
-
-            assert.isUndefined(element.$.confirmAbandonDialog.message);
-            done();
-          });
-        });
-
-        test('works', () => {
-          element.$.confirmAbandonDialog.message = 'original message';
-          const restoreButton =
+      test('abandon change with message', done => {
+        const newAbandonMsg = 'Test Abandon Message';
+        element.$.confirmAbandonDialog.message = newAbandonMsg;
+        flush(() => {
+          const abandonButton =
               element.shadowRoot
                   .querySelector('gr-button[data-action-key="abandon"]');
-          MockInteractions.tap(restoreButton);
+          MockInteractions.tap(abandonButton);
 
-          element.$.confirmAbandonDialog.message = 'foo message';
-          element._handleAbandonDialogConfirm();
-          assert.notOk(alertStub.called);
-
-          const action = {
-            __key: 'abandon',
-            __type: 'change',
-            __primary: false,
-            enabled: true,
-            label: 'Abandon',
-            method: 'POST',
-            title: 'Abandon the change',
-          };
-          assert.deepEqual(fireActionStub.lastCall.args, [
-            '/abandon', action, false, {
-              message: 'foo message',
-            }]);
+          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
+          done();
         });
       });
 
-      suite('revert change', () => {
-        let fireActionStub;
+      test('abandon change with no message', done => {
+        flush(() => {
+          const abandonButton =
+              element.shadowRoot
+                  .querySelector('gr-button[data-action-key="abandon"]');
+          MockInteractions.tap(abandonButton);
 
-        setup(() => {
-          fireActionStub = sandbox.stub(element, '_fireAction');
-          element.commitMessage = 'random commit message';
-          element.change.current_revision = 'abcdef';
-          element.actions = {
-            revert: {
-              method: 'POST',
-              label: 'Revert',
-              title: 'Revert the change',
-              enabled: true,
-            },
-          };
-          return element.reload();
+          assert.isUndefined(element.$.confirmAbandonDialog.message);
+          done();
         });
+      });
 
-        test('revert change with plugin hook', done => {
-          const newRevertMsg = 'Modified revert msg';
-          sandbox.stub(element.$.confirmRevertDialog, '_modifyRevertMsg',
-              () => newRevertMsg);
+      test('works', () => {
+        element.$.confirmAbandonDialog.message = 'original message';
+        const restoreButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key="abandon"]');
+        MockInteractions.tap(restoreButton);
+
+        element.$.confirmAbandonDialog.message = 'foo message';
+        element._handleAbandonDialogConfirm();
+        assert.notOk(alertStub.called);
+
+        const action = {
+          __key: 'abandon',
+          __type: 'change',
+          __primary: false,
+          enabled: true,
+          label: 'Abandon',
+          method: 'POST',
+          title: 'Abandon the change',
+        };
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/abandon', action, false, {
+            message: 'foo message',
+          }]);
+      });
+    });
+
+    suite('revert change', () => {
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        element.commitMessage = 'random commit message';
+        element.change.current_revision = 'abcdef';
+        element.actions = {
+          revert: {
+            method: 'POST',
+            label: 'Revert',
+            title: 'Revert the change',
+            enabled: true,
+          },
+        };
+        return element.reload();
+      });
+
+      test('revert change with plugin hook', done => {
+        const newRevertMsg = 'Modified revert msg';
+        sandbox.stub(element.$.confirmRevertDialog, '_modifyRevertMsg',
+            () => newRevertMsg);
+        element.change = {
+          current_revision: 'abc1234',
+        };
+        sandbox.stub(element.$.restAPI, 'getChanges')
+            .returns(Promise.resolve([
+              {change_id: '12345678901234', topic: 'T', subject: 'random'},
+              {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
+            ]));
+        sandbox.stub(element.$.confirmRevertDialog,
+            '_populateRevertSubmissionMessage', () => 'original msg');
+        flush(() => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
+            done();
+          });
+        });
+      });
+
+      suite('revert change submitted together', () => {
+        setup(() => {
           element.change = {
-            current_revision: 'abc1234',
+            submission_id: '199',
+            current_revision: '2000',
           };
           sandbox.stub(element.$.restAPI, 'getChanges')
               .returns(Promise.resolve([
                 {change_id: '12345678901234', topic: 'T', subject: 'random'},
                 {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
               ]));
-          sandbox.stub(element.$.confirmRevertDialog,
-              '_populateRevertSubmissionMessage', () => 'original msg');
+        });
+
+        test('confirm revert dialog shows both options', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
           flush(() => {
-            const revertButton = element.shadowRoot
-                .querySelector('gr-button[data-action-key="revert"]');
-            MockInteractions.tap(revertButton);
+            const confirmRevertDialog = element.$.confirmRevertDialog;
+            const revertSingleChangeLabel = confirmRevertDialog
+                .shadowRoot.querySelector('.revertSingleChange');
+            const revertSubmissionLabel = confirmRevertDialog.
+                shadowRoot.querySelector('.revertSubmission');
+            assert(revertSingleChangeLabel.innerText.trim() ===
+                'Revert single change');
+            assert(revertSubmissionLabel.innerText.trim() ===
+                'Revert entire submission (2 Changes)');
+            let expectedMsg = 'Revert submission 199' + '\n\n' +
+              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
+              'Reverted Changes:' + '\n' +
+              '1234567890:random' + '\n' +
+              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+              '\n';
+            assert.equal(confirmRevertDialog._message, expectedMsg);
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            MockInteractions.tap(radioInputs[0]);
             flush(() => {
-              assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
+              expectedMsg = 'Revert "random commit message"\n\nThis reverts '
+               + 'commit 2000.\n\nReason'
+               + ' for revert: <INSERT REASONING HERE>\n';
+              assert.equal(confirmRevertDialog._message, expectedMsg);
               done();
             });
           });
         });
 
-        suite('revert change submitted together', () => {
+        test('submit fails if message is not edited', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          MockInteractions.tap(revertButton);
+          const fireStub = sandbox.stub(confirmRevertDialog, 'fire');
+          flush(() => {
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.isTrue(confirmRevertDialog._showErrorMessage);
+              assert.isFalse(fireStub.called);
+              done();
+            });
+          });
+        });
+
+        test('message modification is retained on switching', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            const revertSubmissionMsg = 'Revert submission 199' + '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' + '\n' +
+            'Reverted Changes:' + '\n' +
+            '1234567890:random' + '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+            const singleChangeMsg =
+            'Revert "random commit message"\n\nThis reverts '
+              + 'commit 2000.\n\nReason'
+              + ' for revert: <INSERT REASONING HERE>\n';
+            assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+            const newRevertMsg = revertSubmissionMsg + 'random';
+            const newSingleChangeMsg = singleChangeMsg + 'random';
+            confirmRevertDialog._message = newRevertMsg;
+            MockInteractions.tap(radioInputs[0]);
+            flush(() => {
+              assert.equal(confirmRevertDialog._message, singleChangeMsg);
+              confirmRevertDialog._message = newSingleChangeMsg;
+              MockInteractions.tap(radioInputs[1]);
+              flush(() => {
+                assert.equal(confirmRevertDialog._message, newRevertMsg);
+                MockInteractions.tap(radioInputs[0]);
+                flush(() => {
+                  assert.equal(
+                      confirmRevertDialog._message,
+                      newSingleChangeMsg
+                  );
+                  done();
+                });
+              });
+            });
+          });
+        });
+      });
+
+      suite('revert single change', () => {
+        setup(() => {
+          element.change = {
+            submission_id: '199',
+            current_revision: '2000',
+          };
+          sandbox.stub(element.$.restAPI, 'getChanges')
+              .returns(Promise.resolve([
+                {change_id: '12345678901234', topic: 'T', subject: 'random'},
+              ]));
+        });
+
+        test('submit fails if message is not edited', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          MockInteractions.tap(revertButton);
+          const fireStub = sandbox.stub(confirmRevertDialog, 'fire');
+          flush(() => {
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.isTrue(confirmRevertDialog._showErrorMessage);
+              assert.isFalse(fireStub.called);
+              done();
+            });
+          });
+        });
+
+        test('confirm revert dialog shows no radio button', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            const confirmRevertDialog = element.$.confirmRevertDialog;
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            assert.equal(radioInputs.length, 0);
+            const msg = 'Revert "random commit message"\n\n'
+              + 'This reverts commit 2000.\n\nReason '
+              + 'for revert: <INSERT REASONING HERE>\n';
+            assert.equal(confirmRevertDialog._message, msg);
+            const editedMsg = msg + 'hello';
+            confirmRevertDialog._message += 'hello';
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.equal(fireActionStub.getCall(0).args[0], '/revert');
+              assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
+              assert.equal(fireActionStub.getCall(0).args[3].message,
+                  editedMsg);
+              done();
+            });
+          });
+        });
+      });
+    });
+
+    suite('mark change private', () => {
+      setup(() => {
+        const privateAction = {
+          __key: 'private',
+          __type: 'change',
+          __primary: false,
+          method: 'POST',
+          label: 'Mark private',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          private: privateAction,
+        };
+
+        element.change.is_private = false;
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        return element.reload();
+      });
+
+      test('make sure the mark private change button is not outside of the ' +
+           'overflow menu', done => {
+        flush(() => {
+          assert.isNotOk(element.shadowRoot
+              .querySelector('[data-action-key="private"]'));
+          done();
+        });
+      });
+
+      test('private change', done => {
+        flush(() => {
+          assert.isOk(
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private-change"]'));
+          element.setActionOverflow('change', 'private', false);
+          flushAsynchronousOperations();
+          assert.isOk(element.shadowRoot
+              .querySelector('[data-action-key="private"]'));
+          assert.isNotOk(
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private-change"]'));
+          done();
+        });
+      });
+    });
+
+    suite('unmark private change', () => {
+      setup(() => {
+        const unmarkPrivateAction = {
+          __key: 'private.delete',
+          __type: 'change',
+          __primary: false,
+          method: 'POST',
+          label: 'Unmark private',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          'private.delete': unmarkPrivateAction,
+        };
+
+        element.change.is_private = true;
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        return element.reload();
+      });
+
+      test('make sure the unmark private change button is not outside of the ' +
+           'overflow menu', done => {
+        flush(() => {
+          assert.isNotOk(element.shadowRoot
+              .querySelector('[data-action-key="private.delete"]'));
+          done();
+        });
+      });
+
+      test('unmark the private change', done => {
+        flush(() => {
+          assert.isOk(
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private.delete-change"]')
+          );
+          element.setActionOverflow('change', 'private.delete', false);
+          flushAsynchronousOperations();
+          assert.isOk(element.shadowRoot
+              .querySelector('[data-action-key="private.delete"]'));
+          assert.isNotOk(
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private.delete-change"]')
+          );
+          done();
+        });
+      });
+    });
+
+    suite('delete change', () => {
+      let fireActionStub;
+      let deleteAction;
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        element.change = {
+          current_revision: 'abc1234',
+        };
+        deleteAction = {
+          method: 'DELETE',
+          label: 'Delete Change',
+          title: 'Delete change X_X',
+          enabled: true,
+        };
+        element.actions = {
+          '/': deleteAction,
+        };
+      });
+
+      test('does not delete on action', () => {
+        element._handleDeleteTap();
+        assert.isFalse(fireActionStub.called);
+      });
+
+      test('shows confirm dialog', () => {
+        element._handleDeleteTap();
+        assert.isFalse(element.shadowRoot
+            .querySelector('#confirmDeleteDialog').hidden);
+        MockInteractions.tap(
+            element.shadowRoot
+                .querySelector('#confirmDeleteDialog')
+                .shadowRoot
+                .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+        assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
+      });
+
+      test('hides delete confirm on cancel', () => {
+        element._handleDeleteTap();
+        MockInteractions.tap(
+            element.shadowRoot
+                .querySelector('#confirmDeleteDialog')
+                .shadowRoot
+                .querySelector('gr-button:not([primary])'));
+        flushAsynchronousOperations();
+        assert.isTrue(element.shadowRoot
+            .querySelector('#confirmDeleteDialog').hidden);
+        assert.isFalse(fireActionStub.called);
+      });
+    });
+
+    suite('ignore change', () => {
+      setup(done => {
+        sandbox.stub(element, '_fireAction');
+
+        const IgnoreAction = {
+          __key: 'ignore',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Ignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          ignore: IgnoreAction,
+        };
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('make sure the ignore button is not outside of the overflow menu',
+          () => {
+            assert.isNotOk(element.shadowRoot
+                .querySelector('[data-action-key="ignore"]'));
+          });
+
+      test('ignoring change', () => {
+        assert.isOk(element.$.moreActions.shadowRoot
+            .querySelector('span[data-id="ignore-change"]'));
+        element.setActionOverflow('change', 'ignore', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="ignore"]'));
+        assert.isNotOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="ignore-change"]'));
+      });
+    });
+
+    suite('unignore change', () => {
+      setup(done => {
+        sandbox.stub(element, '_fireAction');
+
+        const UnignoreAction = {
+          __key: 'unignore',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Unignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unignore: UnignoreAction,
+        };
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('unignore button is not outside of the overflow menu', () => {
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="unignore"]'));
+      });
+
+      test('unignoring change', () => {
+        assert.isOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unignore-change"]'));
+        element.setActionOverflow('change', 'unignore', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="unignore"]'));
+        assert.isNotOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unignore-change"]'));
+      });
+    });
+
+    suite('reviewed change', () => {
+      setup(done => {
+        sandbox.stub(element, '_fireAction');
+
+        const ReviewedAction = {
+          __key: 'reviewed',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Mark reviewed',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          reviewed: ReviewedAction,
+        };
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('make sure the reviewed button is not outside of the overflow menu',
+          () => {
+            assert.isNotOk(element.shadowRoot
+                .querySelector('[data-action-key="reviewed"]'));
+          });
+
+      test('reviewing change', () => {
+        assert.isOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="reviewed-change"]'));
+        element.setActionOverflow('change', 'reviewed', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="reviewed"]'));
+        assert.isNotOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="reviewed-change"]'));
+      });
+    });
+
+    suite('unreviewed change', () => {
+      setup(done => {
+        sandbox.stub(element, '_fireAction');
+
+        const UnreviewedAction = {
+          __key: 'unreviewed',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Mark unreviewed',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unreviewed: UnreviewedAction,
+        };
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('unreviewed button not outside of the overflow menu', () => {
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="unreviewed"]'));
+      });
+
+      test('unreviewed change', () => {
+        assert.isOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unreviewed-change"]'));
+        element.setActionOverflow('change', 'unreviewed', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="unreviewed"]'));
+        assert.isNotOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unreviewed-change"]'));
+      });
+    });
+
+    suite('quick approve', () => {
+      setup(() => {
+        element.change = {
+          current_revision: 'abc1234',
+        };
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              values: {
+                '-1': '',
+                ' 0': '',
+                '+1': '',
+              },
+            },
+          },
+          permitted_labels: {
+            foo: ['-1', ' 0', '+1'],
+          },
+        };
+        flushAsynchronousOperations();
+      });
+
+      test('added when can approve', () => {
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNotNull(approveButton);
+      });
+
+      test('hide quick approve', () => {
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNotNull(approveButton);
+        assert.isFalse(element._hideQuickApproveAction);
+
+        // Assert approve button gets removed from list of buttons.
+        element.hideQuickApproveAction();
+        flushAsynchronousOperations();
+        const approveButtonUpdated =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButtonUpdated);
+        assert.isTrue(element._hideQuickApproveAction);
+      });
+
+      test('is first in list of secondary actions', () => {
+        const approveButton = element.$.secondaryActions
+            .querySelector('gr-button');
+        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('not added when already approved', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              approved: {},
+              values: {},
+            },
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+          },
+        };
+        flushAsynchronousOperations();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('not added when label not permitted', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {values: {}},
+          },
+          permitted_labels: {
+            bar: [],
+          },
+        };
+        flushAsynchronousOperations();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('approves when tapped', () => {
+        const fireActionStub = sandbox.stub(element, '_fireAction');
+        MockInteractions.tap(
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']'));
+        flushAsynchronousOperations();
+        assert.isTrue(fireActionStub.called);
+        assert.isTrue(fireActionStub.calledWith('/review'));
+        const payload = fireActionStub.lastCall.args[3];
+        assert.deepEqual(payload.labels, {foo: '+1'});
+      });
+
+      test('not added when multiple labels are required', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {values: {}},
+            bar: {values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flushAsynchronousOperations();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('button label for missing approval', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              values: {
+                ' 0': '',
+                '+1': '',
+              },
+            },
+            bar: {approved: {}, values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flushAsynchronousOperations();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('no quick approve if score is not maximal for a label', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1'],
+          },
+        };
+        flushAsynchronousOperations();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('approving label with a non-max score', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flushAsynchronousOperations();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
+      });
+    });
+
+    test('adds download revision action', () => {
+      const handler = sandbox.stub();
+      element.addEventListener('download-tap', handler);
+      assert.ok(element.revisionActions.download);
+      element._handleDownloadTap();
+      flushAsynchronousOperations();
+
+      assert.isTrue(handler.called);
+    });
+
+    test('changing changeNum or patchNum does not reload', () => {
+      const reloadStub = sandbox.stub(element, 'reload');
+      element.changeNum = 123;
+      assert.isFalse(reloadStub.called);
+      element.latestPatchNum = 456;
+      assert.isFalse(reloadStub.called);
+    });
+
+    test('_toSentenceCase', () => {
+      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
+      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
+      assert.equal(element._toSentenceCase('b'), 'B');
+      assert.equal(element._toSentenceCase(''), '');
+      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
+    });
+
+    suite('setActionOverflow', () => {
+      test('move action from overflow', () => {
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="cherrypick"]'));
+        assert.strictEqual(
+            element.$.moreActions.items[0].id, 'cherrypick-revision');
+        element.setActionOverflow('revision', 'cherrypick', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="cherrypick"]'));
+        assert.notEqual(
+            element.$.moreActions.items[0].id, 'cherrypick-revision');
+      });
+
+      test('move action to overflow', () => {
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="submit"]'));
+        element.setActionOverflow('revision', 'submit', true);
+        flushAsynchronousOperations();
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="submit"]'));
+        assert.strictEqual(
+            element.$.moreActions.items[3].id, 'submit-revision');
+      });
+
+      suite('_waitForChangeReachable', () => {
+        setup(() => {
+          sandbox.stub(element, 'async', fn => fn());
+        });
+
+        const makeGetChange = numTries => () => {
+          if (numTries === 1) {
+            return Promise.resolve({_number: 123});
+          } else {
+            numTries--;
+            return Promise.resolve(undefined);
+          }
+        };
+
+        test('succeed', () => {
+          sandbox.stub(element.$.restAPI, 'getChange', makeGetChange(5));
+          return element._waitForChangeReachable(123).then(success => {
+            assert.isTrue(success);
+          });
+        });
+
+        test('fail', () => {
+          sandbox.stub(element.$.restAPI, 'getChange', makeGetChange(6));
+          return element._waitForChangeReachable(123).then(success => {
+            assert.isFalse(success);
+          });
+        });
+      });
+    });
+
+    suite('_send', () => {
+      let cleanup;
+      let payload;
+      let onShowError;
+      let onShowAlert;
+      let getResponseObjectStub;
+
+      setup(() => {
+        cleanup = sinon.stub();
+        element.changeNum = 42;
+        element.latestPatchNum = 12;
+        payload = {foo: 'bar'};
+
+        onShowError = sinon.stub();
+        element.addEventListener('show-error', onShowError);
+        onShowAlert = sinon.stub();
+        element.addEventListener('show-alert', onShowAlert);
+      });
+
+      suite('happy path', () => {
+        let sendStub;
+        setup(() => {
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({isLatest: true}));
+          sendStub = sandbox.stub(element.$.restAPI, 'executeChangeAction')
+              .returns(Promise.resolve({}));
+          getResponseObjectStub = sandbox.stub(element.$.restAPI,
+              'getResponseObject');
+          sandbox.stub(Gerrit.Nav,
+              'navigateToChange').returns(Promise.resolve(true));
+        });
+
+        test('change action', done => {
+          element
+              ._send('DELETE', payload, '/endpoint', false, cleanup)
+              .then(() => {
+                assert.isFalse(onShowError.called);
+                assert.isTrue(cleanup.calledOnce);
+                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
+                    null, payload));
+                done();
+              });
+        });
+
+        suite('show revert submission dialog', () => {
           setup(() => {
-            element.change = {
-              submission_id: '199',
-              current_revision: '2000',
-            };
+            element.change.submission_id = '199';
+            element.change.current_revision = '2000';
             sandbox.stub(element.$.restAPI, 'getChanges')
                 .returns(Promise.resolve([
                   {change_id: '12345678901234', topic: 'T', subject: 'random'},
@@ -923,1047 +1748,232 @@
                 ]));
           });
 
-          test('confirm revert dialog shows both options', done => {
-            const revertButton = element.shadowRoot
-                .querySelector('gr-button[data-action-key="revert"]');
-            MockInteractions.tap(revertButton);
-            flush(() => {
-              const confirmRevertDialog = element.$.confirmRevertDialog;
-              const revertSingleChangeLabel = confirmRevertDialog
-                  .shadowRoot.querySelector('.revertSingleChange');
-              const revertSubmissionLabel = confirmRevertDialog.
-                  shadowRoot.querySelector('.revertSubmission');
-              assert(revertSingleChangeLabel.innerText.trim() ===
-                  'Revert single change');
-              assert(revertSubmissionLabel.innerText.trim() ===
-                  'Revert entire submission (2 Changes)');
-              let expectedMsg = 'Revert submission 199' + '\n\n' +
-                'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-                'Reverted Changes:' + '\n' +
-                '1234567890:random' + '\n' +
-                '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-                '\n';
-              assert.equal(confirmRevertDialog._message, expectedMsg);
-              const radioInputs = confirmRevertDialog.shadowRoot
-                  .querySelectorAll('input[name="revertOptions"]');
-              MockInteractions.tap(radioInputs[0]);
-              flush(() => {
-                expectedMsg = 'Revert "random commit message"\n\nThis reverts '
-                 + 'commit 2000.\n\nReason'
-                 + ' for revert: <INSERT REASONING HERE>\n';
-                assert.equal(confirmRevertDialog._message, expectedMsg);
-                done();
-              });
-            });
-          });
-
-          test('submit fails if message is not edited', done => {
-            const revertButton = element.shadowRoot
-                .querySelector('gr-button[data-action-key="revert"]');
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            MockInteractions.tap(revertButton);
-            const fireStub = sandbox.stub(confirmRevertDialog, 'fire');
-            flush(() => {
-              const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                  .querySelector('gr-dialog')
-                  .shadowRoot.querySelector('#confirm');
-              MockInteractions.tap(confirmButton);
-              flush(() => {
-                assert.isTrue(confirmRevertDialog._showErrorMessage);
-                assert.isFalse(fireStub.called);
-                done();
-              });
-            });
-          });
-
-          test('message modification is retained on switching', done => {
-            const revertButton = element.shadowRoot
-                .querySelector('gr-button[data-action-key="revert"]');
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            MockInteractions.tap(revertButton);
-            flush(() => {
-              const radioInputs = confirmRevertDialog.shadowRoot
-                  .querySelectorAll('input[name="revertOptions"]');
-              const revertSubmissionMsg = 'Revert submission 199' + '\n\n' +
+          test('revert submission shows submissionId', done => {
+            const expectedMsg = 'Revert submission 199' + '\n\n' +
               'Reason for revert: <INSERT REASONING HERE>' + '\n' +
               'Reverted Changes:' + '\n' +
-              '1234567890:random' + '\n' +
-              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+              '1234567890: random' + '\n' +
+              '23456: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
               '\n';
-              const singleChangeMsg =
-              'Revert "random commit message"\n\nThis reverts '
-                + 'commit 2000.\n\nReason'
-                + ' for revert: <INSERT REASONING HERE>\n';
-              assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
-              const newRevertMsg = revertSubmissionMsg + 'random';
-              const newSingleChangeMsg = singleChangeMsg + 'random';
-              confirmRevertDialog._message = newRevertMsg;
-              MockInteractions.tap(radioInputs[0]);
-              flush(() => {
-                assert.equal(confirmRevertDialog._message, singleChangeMsg);
-                confirmRevertDialog._message = newSingleChangeMsg;
-                MockInteractions.tap(radioInputs[1]);
-                flush(() => {
-                  assert.equal(confirmRevertDialog._message, newRevertMsg);
-                  MockInteractions.tap(radioInputs[0]);
-                  flush(() => {
-                    assert.equal(
-                        confirmRevertDialog._message,
-                        newSingleChangeMsg
-                    );
+            const modifiedMsg = expectedMsg + 'abcd';
+            sandbox.stub(element.$.confirmRevertSubmissionDialog,
+                '_modifyRevertSubmissionMsg').returns(modifiedMsg);
+            element.showRevertSubmissionDialog();
+            flush(() => {
+              const msg = element.$.confirmRevertSubmissionDialog.message;
+              assert.equal(msg, modifiedMsg);
+              done();
+            });
+          });
+        });
+
+        suite('single changes revert', () => {
+          let navigateToSearchQueryStub;
+          setup(() => {
+            getResponseObjectStub
+                .returns(Promise.resolve({revert_changes: [
+                  {change_id: 12345},
+                ]}));
+            navigateToSearchQueryStub = sandbox.stub(Gerrit.Nav,
+                'navigateToSearchQuery');
+          });
+
+          test('revert submission single change', done => {
+            element._send('POST', {message: 'Revert submission'},
+                '/revert_submission', false, cleanup).then(res => {
+              element._handleResponse({__key: 'revert_submission'}, {}).
+                  then(() => {
+                    assert.isTrue(navigateToSearchQueryStub.called);
                     done();
                   });
-                });
-              });
             });
           });
         });
 
-        suite('revert single change', () => {
+        suite('multiple changes revert', () => {
+          let showActionDialogStub;
+          let navigateToSearchQueryStub;
           setup(() => {
-            element.change = {
-              submission_id: '199',
-              current_revision: '2000',
-            };
-            sandbox.stub(element.$.restAPI, 'getChanges')
-                .returns(Promise.resolve([
-                  {change_id: '12345678901234', topic: 'T', subject: 'random'},
-                ]));
+            getResponseObjectStub
+                .returns(Promise.resolve({revert_changes: [
+                  {change_id: 12345, topic: 'T'},
+                  {change_id: 23456, topic: 'T'},
+                ]}));
+            showActionDialogStub = sandbox.stub(element, '_showActionDialog');
+            navigateToSearchQueryStub = sandbox.stub(Gerrit.Nav,
+                'navigateToSearchQuery');
           });
 
-          test('submit fails if message is not edited', done => {
-            const revertButton = element.shadowRoot
-                .querySelector('gr-button[data-action-key="revert"]');
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            MockInteractions.tap(revertButton);
-            const fireStub = sandbox.stub(confirmRevertDialog, 'fire');
-            flush(() => {
-              const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                  .querySelector('gr-dialog')
-                  .shadowRoot.querySelector('#confirm');
-              MockInteractions.tap(confirmButton);
-              flush(() => {
-                assert.isTrue(confirmRevertDialog._showErrorMessage);
-                assert.isFalse(fireStub.called);
+          test('revert submission multiple change', done => {
+            element._send('POST', {message: 'Revert submission'},
+                '/revert_submission', false, cleanup).then(res => {
+              element._handleResponse({__key: 'revert_submission'}, {}).then(
+                  () => {
+                    assert.isFalse(showActionDialogStub.called);
+                    assert.isTrue(navigateToSearchQueryStub.calledWith(
+                        'topic: T'));
+                    done();
+                  });
+            });
+          });
+        });
+
+        test('revision action', done => {
+          element
+              ._send('DELETE', payload, '/endpoint', true, cleanup)
+              .then(() => {
+                assert.isFalse(onShowError.called);
+                assert.isTrue(cleanup.calledOnce);
+                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
+                    12, payload));
                 done();
               });
-            });
-          });
+        });
+      });
 
-          test('confirm revert dialog shows no radio button', done => {
-            const revertButton = element.shadowRoot
-                .querySelector('gr-button[data-action-key="revert"]');
-            MockInteractions.tap(revertButton);
-            flush(() => {
-              const confirmRevertDialog = element.$.confirmRevertDialog;
-              const radioInputs = confirmRevertDialog.shadowRoot
-                  .querySelectorAll('input[name="revertOptions"]');
-              assert.equal(radioInputs.length, 0);
-              const msg = 'Revert "random commit message"\n\n'
-                + 'This reverts commit 2000.\n\nReason '
-                + 'for revert: <INSERT REASONING HERE>\n';
-              assert.equal(confirmRevertDialog._message, msg);
-              const editedMsg = msg + 'hello';
-              confirmRevertDialog._message += 'hello';
-              const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                  .querySelector('gr-dialog')
-                  .shadowRoot.querySelector('#confirm');
-              MockInteractions.tap(confirmButton);
-              flush(() => {
-                assert.equal(fireActionStub.getCall(0).args[0], '/revert');
-                assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
-                assert.equal(fireActionStub.getCall(0).args[3].message,
-                    editedMsg);
-                done();
+      suite('failure modes', () => {
+        test('non-latest', () => {
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({isLatest: false}));
+          const sendStub = sandbox.stub(element.$.restAPI,
+              'executeChangeAction');
+
+          return element._send('DELETE', payload, '/endpoint', true, cleanup)
+              .then(() => {
+                assert.isTrue(onShowAlert.calledOnce);
+                assert.isFalse(onShowError.called);
+                assert.isTrue(cleanup.calledOnce);
+                assert.isFalse(sendStub.called);
               });
-            });
-          });
-        });
-      });
-
-      suite('mark change private', () => {
-        setup(() => {
-          const privateAction = {
-            __key: 'private',
-            __type: 'change',
-            __primary: false,
-            method: 'POST',
-            label: 'Mark private',
-            title: 'Working...',
-            enabled: true,
-          };
-
-          element.actions = {
-            private: privateAction,
-          };
-
-          element.change.is_private = false;
-
-          element.changeNum = '2';
-          element.latestPatchNum = '2';
-
-          return element.reload();
         });
 
-        test('make sure the mark private change button is not outside of the ' +
-             'overflow menu', done => {
-          flush(() => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="private"]'));
-            done();
-          });
-        });
-
-        test('private change', done => {
-          flush(() => {
-            assert.isOk(
-                element.$.moreActions.shadowRoot
-                    .querySelector('span[data-id="private-change"]'));
-            element.setActionOverflow('change', 'private', false);
-            flushAsynchronousOperations();
-            assert.isOk(element.shadowRoot
-                .querySelector('[data-action-key="private"]'));
-            assert.isNotOk(
-                element.$.moreActions.shadowRoot
-                    .querySelector('span[data-id="private-change"]'));
-            done();
-          });
-        });
-      });
-
-      suite('unmark private change', () => {
-        setup(() => {
-          const unmarkPrivateAction = {
-            __key: 'private.delete',
-            __type: 'change',
-            __primary: false,
-            method: 'POST',
-            label: 'Unmark private',
-            title: 'Working...',
-            enabled: true,
-          };
-
-          element.actions = {
-            'private.delete': unmarkPrivateAction,
-          };
-
-          element.change.is_private = true;
-
-          element.changeNum = '2';
-          element.latestPatchNum = '2';
-
-          return element.reload();
-        });
-
-        test('make sure the unmark private change button is not outside of the ' +
-             'overflow menu', done => {
-          flush(() => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="private.delete"]'));
-            done();
-          });
-        });
-
-        test('unmark the private change', done => {
-          flush(() => {
-            assert.isOk(
-                element.$.moreActions.shadowRoot
-                    .querySelector('span[data-id="private.delete-change"]')
-            );
-            element.setActionOverflow('change', 'private.delete', false);
-            flushAsynchronousOperations();
-            assert.isOk(element.shadowRoot
-                .querySelector('[data-action-key="private.delete"]'));
-            assert.isNotOk(
-                element.$.moreActions.shadowRoot
-                    .querySelector('span[data-id="private.delete-change"]')
-            );
-            done();
-          });
-        });
-      });
-
-      suite('delete change', () => {
-        let fireActionStub;
-        let deleteAction;
-
-        setup(() => {
-          fireActionStub = sandbox.stub(element, '_fireAction');
-          element.change = {
-            current_revision: 'abc1234',
-          };
-          deleteAction = {
-            method: 'DELETE',
-            label: 'Delete Change',
-            title: 'Delete change X_X',
-            enabled: true,
-          };
-          element.actions = {
-            '/': deleteAction,
-          };
-        });
-
-        test('does not delete on action', () => {
-          element._handleDeleteTap();
-          assert.isFalse(fireActionStub.called);
-        });
-
-        test('shows confirm dialog', () => {
-          element._handleDeleteTap();
-          assert.isFalse(element.shadowRoot
-              .querySelector('#confirmDeleteDialog').hidden);
-          MockInteractions.tap(
-              element.shadowRoot
-                  .querySelector('#confirmDeleteDialog')
-                  .shadowRoot
-                  .querySelector('gr-button[primary]'));
-          flushAsynchronousOperations();
-          assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
-        });
-
-        test('hides delete confirm on cancel', () => {
-          element._handleDeleteTap();
-          MockInteractions.tap(
-              element.shadowRoot
-                  .querySelector('#confirmDeleteDialog')
-                  .shadowRoot
-                  .querySelector('gr-button:not([primary])'));
-          flushAsynchronousOperations();
-          assert.isTrue(element.shadowRoot
-              .querySelector('#confirmDeleteDialog').hidden);
-          assert.isFalse(fireActionStub.called);
-        });
-      });
-
-      suite('ignore change', () => {
-        setup(done => {
-          sandbox.stub(element, '_fireAction');
-
-          const IgnoreAction = {
-            __key: 'ignore',
-            __type: 'change',
-            __primary: false,
-            method: 'PUT',
-            label: 'Ignore',
-            title: 'Working...',
-            enabled: true,
-          };
-
-          element.actions = {
-            ignore: IgnoreAction,
-          };
-
-          element.changeNum = '2';
-          element.latestPatchNum = '2';
-
-          element.reload().then(() => { flush(done); });
-        });
-
-        test('make sure the ignore button is not outside of the overflow menu',
-            () => {
-              assert.isNotOk(element.shadowRoot
-                  .querySelector('[data-action-key="ignore"]'));
-            });
-
-        test('ignoring change', () => {
-          assert.isOk(element.$.moreActions.shadowRoot
-              .querySelector('span[data-id="ignore-change"]'));
-          element.setActionOverflow('change', 'ignore', false);
-          flushAsynchronousOperations();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="ignore"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="ignore-change"]'));
-        });
-      });
-
-      suite('unignore change', () => {
-        setup(done => {
-          sandbox.stub(element, '_fireAction');
-
-          const UnignoreAction = {
-            __key: 'unignore',
-            __type: 'change',
-            __primary: false,
-            method: 'PUT',
-            label: 'Unignore',
-            title: 'Working...',
-            enabled: true,
-          };
-
-          element.actions = {
-            unignore: UnignoreAction,
-          };
-
-          element.changeNum = '2';
-          element.latestPatchNum = '2';
-
-          element.reload().then(() => { flush(done); });
-        });
-
-        test('unignore button is not outside of the overflow menu', () => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="unignore"]'));
-        });
-
-        test('unignoring change', () => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="unignore-change"]'));
-          element.setActionOverflow('change', 'unignore', false);
-          flushAsynchronousOperations();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="unignore"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="unignore-change"]'));
-        });
-      });
-
-      suite('reviewed change', () => {
-        setup(done => {
-          sandbox.stub(element, '_fireAction');
-
-          const ReviewedAction = {
-            __key: 'reviewed',
-            __type: 'change',
-            __primary: false,
-            method: 'PUT',
-            label: 'Mark reviewed',
-            title: 'Working...',
-            enabled: true,
-          };
-
-          element.actions = {
-            reviewed: ReviewedAction,
-          };
-
-          element.changeNum = '2';
-          element.latestPatchNum = '2';
-
-          element.reload().then(() => { flush(done); });
-        });
-
-        test('make sure the reviewed button is not outside of the overflow menu',
-            () => {
-              assert.isNotOk(element.shadowRoot
-                  .querySelector('[data-action-key="reviewed"]'));
-            });
-
-        test('reviewing change', () => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="reviewed-change"]'));
-          element.setActionOverflow('change', 'reviewed', false);
-          flushAsynchronousOperations();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="reviewed"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="reviewed-change"]'));
-        });
-      });
-
-      suite('unreviewed change', () => {
-        setup(done => {
-          sandbox.stub(element, '_fireAction');
-
-          const UnreviewedAction = {
-            __key: 'unreviewed',
-            __type: 'change',
-            __primary: false,
-            method: 'PUT',
-            label: 'Mark unreviewed',
-            title: 'Working...',
-            enabled: true,
-          };
-
-          element.actions = {
-            unreviewed: UnreviewedAction,
-          };
-
-          element.changeNum = '2';
-          element.latestPatchNum = '2';
-
-          element.reload().then(() => { flush(done); });
-        });
-
-        test('unreviewed button not outside of the overflow menu', () => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="unreviewed"]'));
-        });
-
-        test('unreviewed change', () => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="unreviewed-change"]'));
-          element.setActionOverflow('change', 'unreviewed', false);
-          flushAsynchronousOperations();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="unreviewed"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="unreviewed-change"]'));
-        });
-      });
-
-      suite('quick approve', () => {
-        setup(() => {
-          element.change = {
-            current_revision: 'abc1234',
-          };
-          element.change = {
-            current_revision: 'abc1234',
-            labels: {
-              foo: {
-                values: {
-                  '-1': '',
-                  ' 0': '',
-                  '+1': '',
-                },
-              },
-            },
-            permitted_labels: {
-              foo: ['-1', ' 0', '+1'],
-            },
-          };
-          flushAsynchronousOperations();
-        });
-
-        test('added when can approve', () => {
-          const approveButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key=\'review\']');
-          assert.isNotNull(approveButton);
-        });
-
-        test('hide quick approve', () => {
-          const approveButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key=\'review\']');
-          assert.isNotNull(approveButton);
-          assert.isFalse(element._hideQuickApproveAction);
-
-          // Assert approve button gets removed from list of buttons.
-          element.hideQuickApproveAction();
-          flushAsynchronousOperations();
-          const approveButtonUpdated =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key=\'review\']');
-          assert.isNull(approveButtonUpdated);
-          assert.isTrue(element._hideQuickApproveAction);
-        });
-
-        test('is first in list of secondary actions', () => {
-          const approveButton = element.$.secondaryActions
-              .querySelector('gr-button');
-          assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
-        });
-
-        test('not added when already approved', () => {
-          element.change = {
-            current_revision: 'abc1234',
-            labels: {
-              foo: {
-                approved: {},
-                values: {},
-              },
-            },
-            permitted_labels: {
-              foo: [' 0', '+1'],
-            },
-          };
-          flushAsynchronousOperations();
-          const approveButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key=\'review\']');
-          assert.isNull(approveButton);
-        });
-
-        test('not added when label not permitted', () => {
-          element.change = {
-            current_revision: 'abc1234',
-            labels: {
-              foo: {values: {}},
-            },
-            permitted_labels: {
-              bar: [],
-            },
-          };
-          flushAsynchronousOperations();
-          const approveButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key=\'review\']');
-          assert.isNull(approveButton);
-        });
-
-        test('approves when tapped', () => {
-          const fireActionStub = sandbox.stub(element, '_fireAction');
-          MockInteractions.tap(
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key=\'review\']'));
-          flushAsynchronousOperations();
-          assert.isTrue(fireActionStub.called);
-          assert.isTrue(fireActionStub.calledWith('/review'));
-          const payload = fireActionStub.lastCall.args[3];
-          assert.deepEqual(payload.labels, {foo: '+1'});
-        });
-
-        test('not added when multiple labels are required', () => {
-          element.change = {
-            current_revision: 'abc1234',
-            labels: {
-              foo: {values: {}},
-              bar: {values: {}},
-            },
-            permitted_labels: {
-              foo: [' 0', '+1'],
-              bar: [' 0', '+1', '+2'],
-            },
-          };
-          flushAsynchronousOperations();
-          const approveButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key=\'review\']');
-          assert.isNull(approveButton);
-        });
-
-        test('button label for missing approval', () => {
-          element.change = {
-            current_revision: 'abc1234',
-            labels: {
-              foo: {
-                values: {
-                  ' 0': '',
-                  '+1': '',
-                },
-              },
-              bar: {approved: {}, values: {}},
-            },
-            permitted_labels: {
-              foo: [' 0', '+1'],
-              bar: [' 0', '+1', '+2'],
-            },
-          };
-          flushAsynchronousOperations();
-          const approveButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key=\'review\']');
-          assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
-        });
-
-        test('no quick approve if score is not maximal for a label', () => {
-          element.change = {
-            current_revision: 'abc1234',
-            labels: {
-              bar: {
-                value: 1,
-                values: {
-                  ' 0': '',
-                  '+1': '',
-                  '+2': '',
-                },
-              },
-            },
-            permitted_labels: {
-              bar: [' 0', '+1'],
-            },
-          };
-          flushAsynchronousOperations();
-          const approveButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key=\'review\']');
-          assert.isNull(approveButton);
-        });
-
-        test('approving label with a non-max score', () => {
-          element.change = {
-            current_revision: 'abc1234',
-            labels: {
-              bar: {
-                value: 1,
-                values: {
-                  ' 0': '',
-                  '+1': '',
-                  '+2': '',
-                },
-              },
-            },
-            permitted_labels: {
-              bar: [' 0', '+1', '+2'],
-            },
-          };
-          flushAsynchronousOperations();
-          const approveButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key=\'review\']');
-          assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
-        });
-      });
-
-      test('adds download revision action', () => {
-        const handler = sandbox.stub();
-        element.addEventListener('download-tap', handler);
-        assert.ok(element.revisionActions.download);
-        element._handleDownloadTap();
-        flushAsynchronousOperations();
-
-        assert.isTrue(handler.called);
-      });
-
-      test('changing changeNum or patchNum does not reload', () => {
-        const reloadStub = sandbox.stub(element, 'reload');
-        element.changeNum = 123;
-        assert.isFalse(reloadStub.called);
-        element.latestPatchNum = 456;
-        assert.isFalse(reloadStub.called);
-      });
-
-      test('_toSentenceCase', () => {
-        assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
-        assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
-        assert.equal(element._toSentenceCase('b'), 'B');
-        assert.equal(element._toSentenceCase(''), '');
-        assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
-      });
-
-      suite('setActionOverflow', () => {
-        test('move action from overflow', () => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="cherrypick"]'));
-          assert.strictEqual(
-              element.$.moreActions.items[0].id, 'cherrypick-revision');
-          element.setActionOverflow('revision', 'cherrypick', false);
-          flushAsynchronousOperations();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="cherrypick"]'));
-          assert.notEqual(
-              element.$.moreActions.items[0].id, 'cherrypick-revision');
-        });
-
-        test('move action to overflow', () => {
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="submit"]'));
-          element.setActionOverflow('revision', 'submit', true);
-          flushAsynchronousOperations();
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="submit"]'));
-          assert.strictEqual(
-              element.$.moreActions.items[3].id, 'submit-revision');
-        });
-
-        suite('_waitForChangeReachable', () => {
-          setup(() => {
-            sandbox.stub(element, 'async', fn => fn());
-          });
-
-          const makeGetChange = numTries => () => {
-            if (numTries === 1) {
-              return Promise.resolve({_number: 123});
-            } else {
-              numTries--;
-              return Promise.resolve(undefined);
-            }
-          };
-
-          test('succeed', () => {
-            sandbox.stub(element.$.restAPI, 'getChange', makeGetChange(5));
-            return element._waitForChangeReachable(123).then(success => {
-              assert.isTrue(success);
-            });
-          });
-
-          test('fail', () => {
-            sandbox.stub(element.$.restAPI, 'getChange', makeGetChange(6));
-            return element._waitForChangeReachable(123).then(success => {
-              assert.isFalse(success);
-            });
-          });
-        });
-      });
-
-      suite('_send', () => {
-        let cleanup;
-        let payload;
-        let onShowError;
-        let onShowAlert;
-        let getResponseObjectStub;
-
-        setup(() => {
-          cleanup = sinon.stub();
-          element.changeNum = 42;
-          element.latestPatchNum = 12;
-          payload = {foo: 'bar'};
-
-          onShowError = sinon.stub();
-          element.addEventListener('show-error', onShowError);
-          onShowAlert = sinon.stub();
-          element.addEventListener('show-alert', onShowAlert);
-        });
-
-        suite('happy path', () => {
-          let sendStub;
-          setup(() => {
-            sandbox.stub(element, 'fetchChangeUpdates')
-                .returns(Promise.resolve({isLatest: true}));
-            sendStub = sandbox.stub(element.$.restAPI, 'executeChangeAction')
-                .returns(Promise.resolve({}));
-            getResponseObjectStub = sandbox.stub(element.$.restAPI,
-                'getResponseObject');
-            sandbox.stub(Gerrit.Nav,
-                'navigateToChange').returns(Promise.resolve(true));
-          });
-
-          test('change action', done => {
-            element
-                ._send('DELETE', payload, '/endpoint', false, cleanup)
-                .then(() => {
-                  assert.isFalse(onShowError.called);
-                  assert.isTrue(cleanup.calledOnce);
-                  assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
-                      null, payload));
-                  done();
-                });
-          });
-
-          suite('show revert submission dialog', () => {
-            setup(() => {
-              element.change.submission_id = '199';
-              element.change.current_revision = '2000';
-              sandbox.stub(element.$.restAPI, 'getChanges')
-                  .returns(Promise.resolve([
-                    {change_id: '12345678901234', topic: 'T', subject: 'random'},
-                    {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-                  ]));
-            });
-
-            test('revert submission shows submissionId', done => {
-              const expectedMsg = 'Revert submission 199' + '\n\n' +
-                'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-                'Reverted Changes:' + '\n' +
-                '1234567890: random' + '\n' +
-                '23456: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-                '\n';
-              const modifiedMsg = expectedMsg + 'abcd';
-              sandbox.stub(element.$.confirmRevertSubmissionDialog,
-                  '_modifyRevertSubmissionMsg').returns(modifiedMsg);
-              element.showRevertSubmissionDialog();
-              flush(() => {
-                const msg = element.$.confirmRevertSubmissionDialog.message;
-                assert.equal(msg, modifiedMsg);
-                done();
+        test('send fails', () => {
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({isLatest: true}));
+          const sendStub = sandbox.stub(element.$.restAPI,
+              'executeChangeAction',
+              (num, method, patchNum, endpoint, payload, onErr) => {
+                onErr();
+                return Promise.resolve(null);
               });
-            });
-          });
+          const handleErrorStub = sandbox.stub(element, '_handleResponseError');
 
-          suite('single changes revert', () => {
-            let navigateToSearchQueryStub;
-            setup(() => {
-              getResponseObjectStub
-                  .returns(Promise.resolve({revert_changes: [
-                    {change_id: 12345},
-                  ]}));
-              navigateToSearchQueryStub = sandbox.stub(Gerrit.Nav,
-                  'navigateToSearchQuery');
-            });
-
-            test('revert submission single change', done => {
-              element._send('POST', {message: 'Revert submission'},
-                  '/revert_submission', false, cleanup).then(res => {
-                element._handleResponse({__key: 'revert_submission'}, {}).
-                    then(() => {
-                      assert.isTrue(navigateToSearchQueryStub.called);
-                      done();
-                    });
+          return element._send('DELETE', payload, '/endpoint', true, cleanup)
+              .then(() => {
+                assert.isFalse(onShowError.called);
+                assert.isTrue(cleanup.called);
+                assert.isTrue(sendStub.calledOnce);
+                assert.isTrue(handleErrorStub.called);
               });
-            });
-          });
-
-          suite('multiple changes revert', () => {
-            let showActionDialogStub;
-            let navigateToSearchQueryStub;
-            setup(() => {
-              getResponseObjectStub
-                  .returns(Promise.resolve({revert_changes: [
-                    {change_id: 12345, topic: 'T'},
-                    {change_id: 23456, topic: 'T'},
-                  ]}));
-              showActionDialogStub = sandbox.stub(element, '_showActionDialog');
-              navigateToSearchQueryStub = sandbox.stub(Gerrit.Nav,
-                  'navigateToSearchQuery');
-            });
-
-            test('revert submission multiple change', done => {
-              element._send('POST', {message: 'Revert submission'},
-                  '/revert_submission', false, cleanup).then(res => {
-                element._handleResponse({__key: 'revert_submission'}, {}).then(
-                    () => {
-                      assert.isFalse(showActionDialogStub.called);
-                      assert.isTrue(navigateToSearchQueryStub.calledWith(
-                          'topic: T'));
-                      done();
-                    });
-              });
-            });
-          });
-
-          test('revision action', done => {
-            element
-                ._send('DELETE', payload, '/endpoint', true, cleanup)
-                .then(() => {
-                  assert.isFalse(onShowError.called);
-                  assert.isTrue(cleanup.calledOnce);
-                  assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
-                      12, payload));
-                  done();
-                });
-          });
         });
-
-        suite('failure modes', () => {
-          test('non-latest', () => {
-            sandbox.stub(element, 'fetchChangeUpdates')
-                .returns(Promise.resolve({isLatest: false}));
-            const sendStub = sandbox.stub(element.$.restAPI,
-                'executeChangeAction');
-
-            return element._send('DELETE', payload, '/endpoint', true, cleanup)
-                .then(() => {
-                  assert.isTrue(onShowAlert.calledOnce);
-                  assert.isFalse(onShowError.called);
-                  assert.isTrue(cleanup.calledOnce);
-                  assert.isFalse(sendStub.called);
-                });
-          });
-
-          test('send fails', () => {
-            sandbox.stub(element, 'fetchChangeUpdates')
-                .returns(Promise.resolve({isLatest: true}));
-            const sendStub = sandbox.stub(element.$.restAPI,
-                'executeChangeAction',
-                (num, method, patchNum, endpoint, payload, onErr) => {
-                  onErr();
-                  return Promise.resolve(null);
-                });
-            const handleErrorStub = sandbox.stub(element, '_handleResponseError');
-
-            return element._send('DELETE', payload, '/endpoint', true, cleanup)
-                .then(() => {
-                  assert.isFalse(onShowError.called);
-                  assert.isTrue(cleanup.called);
-                  assert.isTrue(sendStub.calledOnce);
-                  assert.isTrue(handleErrorStub.called);
-                });
-          });
-        });
-      });
-
-      test('_handleAction reports', () => {
-        sandbox.stub(element, '_fireAction');
-        const reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
-        element._handleAction('type', 'key');
-        assert.isTrue(reportStub.called);
-        assert.equal(reportStub.lastCall.args[0], 'type-key');
       });
     });
 
-    suite('getChangeRevisionActions returns only some actions', () => {
-      let element;
-      let sandbox;
-      let changeRevisionActions;
-
-      setup(() => {
-        stub('gr-rest-api-interface', {
-          getChangeRevisionActions() {
-            return Promise.resolve(changeRevisionActions);
-          },
-          send(method, url, payload) {
-            return Promise.reject(new Error('error'));
-          },
-          getProjectConfig() { return Promise.resolve({}); },
-        });
-
-        sandbox = sinon.sandbox.create();
-        sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
-
-        element = fixture('basic');
-        // getChangeRevisionActions is not called without
-        // set the following properies
-        element.change = {};
-        element.changeNum = '42';
-        element.latestPatchNum = '2';
-
-        sandbox.stub(element.$.confirmCherrypick.$.restAPI,
-            'getRepoBranches').returns(Promise.resolve([]));
-        sandbox.stub(element.$.confirmMove.$.restAPI,
-            'getRepoBranches').returns(Promise.resolve([]));
-        return element.reload();
-      });
-
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      test('confirmSubmitDialog and confirmRebase properties are changed', () => {
-        changeRevisionActions = {};
-        element.reload();
-        assert.strictEqual(element.$.confirmSubmitDialog.action, null);
-        assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
-      });
-
-      test('_updateRebaseAction sets _parentIsCurrent on no rebase', () => {
-        const currentRevisionActions = {
-          cherrypick: {
-            enabled: true,
-            label: 'Cherry Pick',
-            method: 'POST',
-            title: 'cherrypick',
-          },
-        };
-        element._parentIsCurrent = undefined;
-        element._updateRebaseAction(currentRevisionActions);
-        assert.isTrue(element._parentIsCurrent);
-      });
-
-      test('_updateRebaseAction', () => {
-        const currentRevisionActions = {
-          cherrypick: {
-            enabled: true,
-            label: 'Cherry Pick',
-            method: 'POST',
-            title: 'cherrypick',
-          },
-          rebase: {
-            enabled: true,
-            label: 'Rebase',
-            method: 'POST',
-            title: 'Rebase onto tip of branch or parent change',
-          },
-        };
-        element._parentIsCurrent = undefined;
-
-        // Rebase enabled should always end up true.
-        // When rebase is enabled initially, rebaseOnCurrent should be set to
-        // true.
-        assert.equal(element._updateRebaseAction(currentRevisionActions),
-            currentRevisionActions);
-
-        assert.isTrue(currentRevisionActions.rebase.enabled);
-        assert.isTrue(currentRevisionActions.rebase.rebaseOnCurrent);
-        assert.isFalse(element._parentIsCurrent);
-
-        delete currentRevisionActions.rebase.enabled;
-
-        // When rebase is not enabled initially, rebaseOnCurrent should be set to
-        // false.
-        assert.equal(element._updateRebaseAction(currentRevisionActions),
-            currentRevisionActions);
-
-        assert.isTrue(currentRevisionActions.rebase.enabled);
-        assert.isFalse(currentRevisionActions.rebase.rebaseOnCurrent);
-        assert.isTrue(element._parentIsCurrent);
-      });
+    test('_handleAction reports', () => {
+      sandbox.stub(element, '_fireAction');
+      const reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
+      element._handleAction('type', 'key');
+      assert.isTrue(reportStub.called);
+      assert.equal(reportStub.lastCall.args[0], 'type-key');
     });
   });
+
+  suite('getChangeRevisionActions returns only some actions', () => {
+    let element;
+    let sandbox;
+    let changeRevisionActions;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getChangeRevisionActions() {
+          return Promise.resolve(changeRevisionActions);
+        },
+        send(method, url, payload) {
+          return Promise.reject(new Error('error'));
+        },
+        getProjectConfig() { return Promise.resolve({}); },
+      });
+
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
+
+      element = fixture('basic');
+      // getChangeRevisionActions is not called without
+      // set the following properies
+      element.change = {};
+      element.changeNum = '42';
+      element.latestPatchNum = '2';
+
+      sandbox.stub(element.$.confirmCherrypick.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      sandbox.stub(element.$.confirmMove.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      return element.reload();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('confirmSubmitDialog and confirmRebase properties are changed', () => {
+      changeRevisionActions = {};
+      element.reload();
+      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
+      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
+    });
+
+    test('_updateRebaseAction sets _parentIsCurrent on no rebase', () => {
+      const currentRevisionActions = {
+        cherrypick: {
+          enabled: true,
+          label: 'Cherry Pick',
+          method: 'POST',
+          title: 'cherrypick',
+        },
+      };
+      element._parentIsCurrent = undefined;
+      element._updateRebaseAction(currentRevisionActions);
+      assert.isTrue(element._parentIsCurrent);
+    });
+
+    test('_updateRebaseAction', () => {
+      const currentRevisionActions = {
+        cherrypick: {
+          enabled: true,
+          label: 'Cherry Pick',
+          method: 'POST',
+          title: 'cherrypick',
+        },
+        rebase: {
+          enabled: true,
+          label: 'Rebase',
+          method: 'POST',
+          title: 'Rebase onto tip of branch or parent change',
+        },
+      };
+      element._parentIsCurrent = undefined;
+
+      // Rebase enabled should always end up true.
+      // When rebase is enabled initially, rebaseOnCurrent should be set to
+      // true.
+      assert.equal(element._updateRebaseAction(currentRevisionActions),
+          currentRevisionActions);
+
+      assert.isTrue(currentRevisionActions.rebase.enabled);
+      assert.isTrue(currentRevisionActions.rebase.rebaseOnCurrent);
+      assert.isFalse(element._parentIsCurrent);
+
+      delete currentRevisionActions.rebase.enabled;
+
+      // When rebase is not enabled initially, rebaseOnCurrent should be set to
+      // false.
+      assert.equal(element._updateRebaseAction(currentRevisionActions),
+          currentRevisionActions);
+
+      assert.isTrue(currentRevisionActions.rebase.enabled);
+      assert.isFalse(currentRevisionActions.rebase.rebaseOnCurrent);
+      assert.isTrue(element._parentIsCurrent);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
index 3269dc0..3ee7219 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
@@ -19,16 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-metadata</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../plugins/gr-plugin-host/gr-plugin-host.html">
-<link rel="import" href="gr-change-metadata.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../plugins/gr-plugin-host/gr-plugin-host.js"></script>
+<script type="module" src="./gr-change-metadata.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../plugins/gr-plugin-host/gr-plugin-host.js';
+import './gr-change-metadata.js';
+void(0);
+</script>
 
 <test-fixture id="element">
   <template>
@@ -42,139 +48,143 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-metadata integration tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../plugins/gr-plugin-host/gr-plugin-host.js';
+import './gr-change-metadata.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-change-metadata integration tests', () => {
+  let sandbox;
+  let element;
 
-    const sectionSelectors = [
-      'section.assignee',
-      'section.strategy',
-      'section.topic',
-    ];
+  const sectionSelectors = [
+    'section.assignee',
+    'section.strategy',
+    'section.topic',
+  ];
 
-    const labels = {
-      CI: {
-        all: [
-          {value: 1, name: 'user 2', _account_id: 1},
-          {value: 2, name: 'user '},
-        ],
-        values: {
-          ' 0': 'Don\'t submit as-is',
-          '+1': 'No score',
-          '+2': 'Looks good to me',
-        },
+  const labels = {
+    CI: {
+      all: [
+        {value: 1, name: 'user 2', _account_id: 1},
+        {value: 2, name: 'user '},
+      ],
+      values: {
+        ' 0': 'Don\'t submit as-is',
+        '+1': 'No score',
+        '+2': 'Looks good to me',
       },
-    };
+    },
+  };
 
-    const getStyle = function(selector, name) {
-      return window.getComputedStyle(
-          Polymer.dom(element.root).querySelector(selector))[name];
-    };
+  const getStyle = function(selector, name) {
+    return window.getComputedStyle(
+        dom(element.root).querySelector(selector))[name];
+  };
 
-    function createElement() {
-      const element = fixture('element');
-      element.change = {labels, status: 'NEW'};
-      element.revision = {};
-      return element;
-    }
+  function createElement() {
+    const element = fixture('element');
+    element.change = {labels, status: 'NEW'};
+    element.revision = {};
+    return element;
+  }
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        deleteVote() { return Promise.resolve({ok: true}); },
-      });
-    });
-
-    teardown(() => {
-      sandbox.restore();
-      Gerrit._testOnly_resetPlugins();
-    });
-
-    suite('by default', () => {
-      setup(done => {
-        element = createElement();
-        flush(done);
-      });
-
-      for (const sectionSelector of sectionSelectors) {
-        test(sectionSelector + ' does not have display: none', () => {
-          assert.notEqual(getStyle(sectionSelector, 'display'), 'none');
-        });
-      }
-    });
-
-    suite('with plugin style', () => {
-      setup(done => {
-        Gerrit._testOnly_resetPlugins();
-        const pluginHost = fixture('plugin-host');
-        pluginHost.config = {
-          plugin: {
-            js_resource_paths: [],
-            html_resource_paths: [
-              new URL('test/plugin.html?' + Math.random(),
-                  window.location.href).toString(),
-            ],
-          },
-        };
-        element = createElement();
-        const importSpy = sandbox.spy(element.$.externalStyle, '_import');
-        Gerrit.awaitPluginsLoaded().then(() => {
-          Promise.all(importSpy.returnValues).then(() => {
-            flush(done);
-          });
-        });
-      });
-
-      for (const sectionSelector of sectionSelectors) {
-        test(sectionSelector + ' may have display: none', () => {
-          assert.equal(getStyle(sectionSelector, 'display'), 'none');
-        });
-      }
-    });
-
-    suite('label updates', () => {
-      let plugin;
-
-      setup(() => {
-        Gerrit.install(p => plugin = p, '0.1',
-            new URL('test/plugin.html?' + Math.random(),
-                window.location.href).toString());
-        sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
-        Gerrit._loadPlugins([]);
-        element = createElement();
-      });
-
-      test('labels changed callback', done => {
-        let callCount = 0;
-        const labelChangeSpy = sandbox.spy(arg => {
-          callCount++;
-          if (callCount === 1) {
-            assert.deepEqual(arg, labels);
-            assert.equal(arg.CI.all.length, 2);
-            element.set(['change', 'labels'], {
-              CI: {
-                all: [
-                  {value: 1, name: 'user 2', _account_id: 1},
-                ],
-                values: {
-                  ' 0': 'Don\'t submit as-is',
-                  '+1': 'No score',
-                  '+2': 'Looks good to me',
-                },
-              },
-            });
-          } else if (callCount === 2) {
-            assert.equal(arg.CI.all.length, 1);
-            done();
-          }
-        });
-
-        plugin.changeMetadata().onLabelsChanged(labelChangeSpy);
-      });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+      deleteVote() { return Promise.resolve({ok: true}); },
     });
   });
+
+  teardown(() => {
+    sandbox.restore();
+    Gerrit._testOnly_resetPlugins();
+  });
+
+  suite('by default', () => {
+    setup(done => {
+      element = createElement();
+      flush(done);
+    });
+
+    for (const sectionSelector of sectionSelectors) {
+      test(sectionSelector + ' does not have display: none', () => {
+        assert.notEqual(getStyle(sectionSelector, 'display'), 'none');
+      });
+    }
+  });
+
+  suite('with plugin style', () => {
+    setup(done => {
+      Gerrit._testOnly_resetPlugins();
+      const pluginHost = fixture('plugin-host');
+      pluginHost.config = {
+        plugin: {
+          js_resource_paths: [],
+          html_resource_paths: [
+            new URL('test/plugin.html?' + Math.random(),
+                window.location.href).toString(),
+          ],
+        },
+      };
+      element = createElement();
+      const importSpy = sandbox.spy(element.$.externalStyle, '_import');
+      Gerrit.awaitPluginsLoaded().then(() => {
+        Promise.all(importSpy.returnValues).then(() => {
+          flush(done);
+        });
+      });
+    });
+
+    for (const sectionSelector of sectionSelectors) {
+      test(sectionSelector + ' may have display: none', () => {
+        assert.equal(getStyle(sectionSelector, 'display'), 'none');
+      });
+    }
+  });
+
+  suite('label updates', () => {
+    let plugin;
+
+    setup(() => {
+      Gerrit.install(p => plugin = p, '0.1',
+          new URL('test/plugin.html?' + Math.random(),
+              window.location.href).toString());
+      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
+      Gerrit._loadPlugins([]);
+      element = createElement();
+    });
+
+    test('labels changed callback', done => {
+      let callCount = 0;
+      const labelChangeSpy = sandbox.spy(arg => {
+        callCount++;
+        if (callCount === 1) {
+          assert.deepEqual(arg, labels);
+          assert.equal(arg.CI.all.length, 2);
+          element.set(['change', 'labels'], {
+            CI: {
+              all: [
+                {value: 1, name: 'user 2', _account_id: 1},
+              ],
+              values: {
+                ' 0': 'Don\'t submit as-is',
+                '+1': 'No score',
+                '+2': 'Looks good to me',
+              },
+            },
+          });
+        } else if (callCount === 2) {
+          assert.equal(arg.CI.all.length, 1);
+          done();
+        }
+      });
+
+      plugin.changeMetadata().onLabelsChanged(labelChangeSpy);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index ea11514..2e9cdf9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -14,493 +14,524 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../../styles/gr-change-metadata-shared-styles.js';
+import '../../../styles/gr-change-view-integration-shared-styles.js';
+import '../../../styles/gr-voting-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../plugins/gr-external-style/gr-external-style.js';
+import '../../shared/gr-account-chip/gr-account-chip.js';
+import '../../shared/gr-account-link/gr-account-link.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-editable-label/gr-editable-label.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-limited-text/gr-limited-text.js';
+import '../../shared/gr-linked-chip/gr-linked-chip.js';
+import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-change-requirements/gr-change-requirements.js';
+import '../gr-commit-info/gr-commit-info.js';
+import '../gr-reviewer-list/gr-reviewer-list.js';
+import '../../shared/gr-account-list/gr-account-list.js';
+import '../../../scripts/gr-display-name-utils/gr-display-name-utils.js';
+import '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-metadata_html.js';
 
-  const SubmitTypeLabel = {
-    FAST_FORWARD_ONLY: 'Fast Forward Only',
-    MERGE_IF_NECESSARY: 'Merge if Necessary',
-    REBASE_IF_NECESSARY: 'Rebase if Necessary',
-    MERGE_ALWAYS: 'Always Merge',
-    REBASE_ALWAYS: 'Rebase Always',
-    CHERRY_PICK: 'Cherry Pick',
-  };
+const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
-  const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
+const SubmitTypeLabel = {
+  FAST_FORWARD_ONLY: 'Fast Forward Only',
+  MERGE_IF_NECESSARY: 'Merge if Necessary',
+  REBASE_IF_NECESSARY: 'Rebase if Necessary',
+  MERGE_ALWAYS: 'Always Merge',
+  REBASE_ALWAYS: 'Rebase Always',
+  CHERRY_PICK: 'Cherry Pick',
+};
 
+const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
+
+/**
+ * @enum {string}
+ */
+const CertificateStatus = {
   /**
-   * @enum {string}
+   * This certificate status is bad.
    */
-  const CertificateStatus = {
-    /**
-     * This certificate status is bad.
-     */
-    BAD: 'BAD',
-    /**
-     * This certificate status is OK.
-     */
-    OK: 'OK',
-    /**
-     * This certificate status is TRUSTED.
-     */
-    TRUSTED: 'TRUSTED',
-  };
-
+  BAD: 'BAD',
   /**
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @extends Polymer.Element
+   * This certificate status is OK.
    */
-  class GrChangeMetadata extends Polymer.mixinBehaviors( [
-    Gerrit.RESTClientBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-change-metadata'; }
-    /**
-     * Fired when the change topic is changed.
-     *
-     * @event topic-changed
-     */
+  OK: 'OK',
+  /**
+   * This certificate status is TRUSTED.
+   */
+  TRUSTED: 'TRUSTED',
+};
 
-    static get properties() {
-      return {
+/**
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @extends Polymer.Element
+ */
+class GrChangeMetadata extends mixinBehaviors( [
+  Gerrit.RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-change-metadata'; }
+  /**
+   * Fired when the change topic is changed.
+   *
+   * @event topic-changed
+   */
+
+  static get properties() {
+    return {
+    /** @type {?} */
+      change: Object,
+      labels: {
+        type: Object,
+        notify: true,
+      },
+      account: Object,
       /** @type {?} */
-        change: Object,
-        labels: {
-          type: Object,
-          notify: true,
+      revision: Object,
+      commitInfo: Object,
+      _mutable: {
+        type: Boolean,
+        computed: '_computeIsMutable(account)',
+      },
+      /** @type {?} */
+      serverConfig: Object,
+      parentIsCurrent: Boolean,
+      _notCurrentMessage: {
+        type: String,
+        value: NOT_CURRENT_MESSAGE,
+        readOnly: true,
+      },
+      _topicReadOnly: {
+        type: Boolean,
+        computed: '_computeTopicReadOnly(_mutable, change)',
+      },
+      _hashtagReadOnly: {
+        type: Boolean,
+        computed: '_computeHashtagReadOnly(_mutable, change)',
+      },
+      /**
+       * @type {Gerrit.PushCertificateValidation}
+       */
+      _pushCertificateValidation: {
+        type: Object,
+        computed: '_computePushCertificateValidation(serverConfig, change)',
+      },
+      _showRequirements: {
+        type: Boolean,
+        computed: '_computeShowRequirements(change)',
+      },
+
+      _assignee: Array,
+      _isWip: {
+        type: Boolean,
+        computed: '_computeIsWip(change)',
+      },
+      _newHashtag: String,
+
+      _settingTopic: {
+        type: Boolean,
+        value: false,
+      },
+
+      _currentParents: {
+        type: Array,
+        computed: '_computeParents(change)',
+      },
+
+      /** @type {?} */
+      _CHANGE_ROLE: {
+        type: Object,
+        readOnly: true,
+        value: {
+          OWNER: 'owner',
+          UPLOADER: 'uploader',
+          AUTHOR: 'author',
+          COMMITTER: 'committer',
         },
-        account: Object,
-        /** @type {?} */
-        revision: Object,
-        commitInfo: Object,
-        _mutable: {
-          type: Boolean,
-          computed: '_computeIsMutable(account)',
-        },
-        /** @type {?} */
-        serverConfig: Object,
-        parentIsCurrent: Boolean,
-        _notCurrentMessage: {
-          type: String,
-          value: NOT_CURRENT_MESSAGE,
-          readOnly: true,
-        },
-        _topicReadOnly: {
-          type: Boolean,
-          computed: '_computeTopicReadOnly(_mutable, change)',
-        },
-        _hashtagReadOnly: {
-          type: Boolean,
-          computed: '_computeHashtagReadOnly(_mutable, change)',
-        },
-        /**
-         * @type {Gerrit.PushCertificateValidation}
-         */
-        _pushCertificateValidation: {
-          type: Object,
-          computed: '_computePushCertificateValidation(serverConfig, change)',
-        },
-        _showRequirements: {
-          type: Boolean,
-          computed: '_computeShowRequirements(change)',
-        },
+      },
+    };
+  }
 
-        _assignee: Array,
-        _isWip: {
-          type: Boolean,
-          computed: '_computeIsWip(change)',
-        },
-        _newHashtag: String,
+  static get observers() {
+    return [
+      '_changeChanged(change)',
+      '_labelsChanged(change.labels)',
+      '_assigneeChanged(_assignee.*)',
+    ];
+  }
 
-        _settingTopic: {
-          type: Boolean,
-          value: false,
-        },
+  _labelsChanged(labels) {
+    this.labels = Object.assign({}, labels) || null;
+  }
 
-        _currentParents: {
-          type: Array,
-          computed: '_computeParents(change)',
-        },
+  _changeChanged(change) {
+    this._assignee = change.assignee ? [change.assignee] : [];
+  }
 
-        /** @type {?} */
-        _CHANGE_ROLE: {
-          type: Object,
-          readOnly: true,
-          value: {
-            OWNER: 'owner',
-            UPLOADER: 'uploader',
-            AUTHOR: 'author',
-            COMMITTER: 'committer',
-          },
-        },
-      };
-    }
-
-    static get observers() {
-      return [
-        '_changeChanged(change)',
-        '_labelsChanged(change.labels)',
-        '_assigneeChanged(_assignee.*)',
-      ];
-    }
-
-    _labelsChanged(labels) {
-      this.labels = Object.assign({}, labels) || null;
-    }
-
-    _changeChanged(change) {
-      this._assignee = change.assignee ? [change.assignee] : [];
-    }
-
-    _assigneeChanged(assigneeRecord) {
-      if (!this.change) { return; }
-      const assignee = assigneeRecord.base;
-      if (assignee.length) {
-        const acct = assignee[0];
-        if (this.change.assignee &&
-            acct._account_id === this.change.assignee._account_id) { return; }
-        this.set(['change', 'assignee'], acct);
-        this.$.restAPI.setAssignee(this.change._number, acct._account_id);
-      } else {
-        if (!this.change.assignee) { return; }
-        this.set(['change', 'assignee'], undefined);
-        this.$.restAPI.deleteAssignee(this.change._number);
-      }
-    }
-
-    _computeHideStrategy(change) {
-      return !this.changeIsOpen(change);
-    }
-
-    /**
-     * @param {Object} commitInfo
-     * @return {?Array} If array is empty, returns null instead so
-     * an existential check can be used to hide or show the webLinks
-     * section.
-     */
-    _computeWebLinks(commitInfo, serverConfig) {
-      if (!commitInfo) { return null; }
-      const weblinks = Gerrit.Nav.getChangeWeblinks(
-          this.change ? this.change.repo : '',
-          commitInfo.commit,
-          {
-            weblinks: commitInfo.web_links,
-            config: serverConfig,
-          });
-      return weblinks.length ? weblinks : null;
-    }
-
-    _computeStrategy(change) {
-      return SubmitTypeLabel[change.submit_type];
-    }
-
-    _computeLabelNames(labels) {
-      return Object.keys(labels).sort();
-    }
-
-    _handleTopicChanged(e, topic) {
-      const lastTopic = this.change.topic;
-      if (!topic.length) { topic = null; }
-      this._settingTopic = true;
-      this.$.restAPI.setChangeTopic(this.change._number, topic)
-          .then(newTopic => {
-            this._settingTopic = false;
-            this.set(['change', 'topic'], newTopic);
-            if (newTopic !== lastTopic) {
-              this.dispatchEvent(new CustomEvent(
-                  'topic-changed', {bubbles: true, composed: true}));
-            }
-          });
-    }
-
-    _showAddTopic(changeRecord, settingTopic) {
-      const hasTopic = !!changeRecord &&
-          !!changeRecord.base && !!changeRecord.base.topic;
-      return !hasTopic && !settingTopic;
-    }
-
-    _showTopicChip(changeRecord, settingTopic) {
-      const hasTopic = !!changeRecord &&
-          !!changeRecord.base && !!changeRecord.base.topic;
-      return hasTopic && !settingTopic;
-    }
-
-    _showCherryPickOf(changeRecord) {
-      const hasCherryPickOf = !!changeRecord &&
-          !!changeRecord.base && !!changeRecord.base.cherry_pick_of_change &&
-          !!changeRecord.base.cherry_pick_of_patch_set;
-      return hasCherryPickOf;
-    }
-
-    _handleHashtagChanged(e) {
-      const lastHashtag = this.change.hashtag;
-      if (!this._newHashtag.length) { return; }
-      const newHashtag = this._newHashtag;
-      this._newHashtag = '';
-      this.$.restAPI.setChangeHashtag(
-          this.change._number, {add: [newHashtag]}).then(newHashtag => {
-        this.set(['change', 'hashtags'], newHashtag);
-        if (newHashtag !== lastHashtag) {
-          this.dispatchEvent(
-              new CustomEvent('hashtag-changed', {
-                bubbles: true, composed: true}));
-        }
-      });
-    }
-
-    _computeTopicReadOnly(mutable, change) {
-      return !mutable ||
-          !change ||
-          !change.actions ||
-          !change.actions.topic ||
-          !change.actions.topic.enabled;
-    }
-
-    _computeHashtagReadOnly(mutable, change) {
-      return !mutable ||
-          !change ||
-          !change.actions ||
-          !change.actions.hashtags ||
-          !change.actions.hashtags.enabled;
-    }
-
-    _computeAssigneeReadOnly(mutable, change) {
-      return !mutable ||
-          !change ||
-          !change.actions ||
-          !change.actions.assignee ||
-          !change.actions.assignee.enabled;
-    }
-
-    _computeTopicPlaceholder(_topicReadOnly) {
-      // Action items in Material Design are uppercase -- placeholder label text
-      // is sentence case.
-      return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
-    }
-
-    _computeHashtagPlaceholder(_hashtagReadOnly) {
-      return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
-    }
-
-    _computeShowRequirements(change) {
-      if (change.status !== this.ChangeStatus.NEW) {
-        // TODO(maximeg) change this to display the stored
-        // requirements, once it is implemented server-side.
-        return false;
-      }
-      const hasRequirements = !!change.requirements &&
-          Object.keys(change.requirements).length > 0;
-      const hasLabels = !!change.labels &&
-          Object.keys(change.labels).length > 0;
-      return hasRequirements || hasLabels || !!change.work_in_progress;
-    }
-
-    /**
-     * @return {?Gerrit.PushCertificateValidation} object representing data for
-     *     the push validation.
-     */
-    _computePushCertificateValidation(serverConfig, change) {
-      if (!change || !serverConfig || !serverConfig.receive ||
-          !serverConfig.receive.enable_signed_push) {
-        return null;
-      }
-      const rev = change.revisions[change.current_revision];
-      if (!rev.push_certificate || !rev.push_certificate.key) {
-        return {
-          class: 'help',
-          icon: 'gr-icons:help',
-          message: 'This patch set was created without a push certificate',
-        };
-      }
-
-      const key = rev.push_certificate.key;
-      switch (key.status) {
-        case CertificateStatus.BAD:
-          return {
-            class: 'invalid',
-            icon: 'gr-icons:close',
-            message: this._problems('Push certificate is invalid', key),
-          };
-        case CertificateStatus.OK:
-          return {
-            class: 'notTrusted',
-            icon: 'gr-icons:info',
-            message: this._problems(
-                'Push certificate is valid, but key is not trusted', key),
-          };
-        case CertificateStatus.TRUSTED:
-          return {
-            class: 'trusted',
-            icon: 'gr-icons:check',
-            message: this._problems(
-                'Push certificate is valid and key is trusted', key),
-          };
-        default:
-          throw new Error(`unknown certificate status: ${key.status}`);
-      }
-    }
-
-    _problems(msg, key) {
-      if (!key || !key.problems || key.problems.length === 0) {
-        return msg;
-      }
-
-      return [msg + ':'].concat(key.problems).join('\n');
-    }
-
-    _computeShowRepoBranchTogether(repo, branch) {
-      return !!repo && !!branch && repo.length + branch.length < 40;
-    }
-
-    _computeProjectUrl(project) {
-      return Gerrit.Nav.getUrlForProjectChanges(project);
-    }
-
-    _computeBranchUrl(project, branch) {
-      if (!this.change || !this.change.status) return '';
-      return Gerrit.Nav.getUrlForBranch(branch, project,
-          this.change.status == this.ChangeStatus.NEW ? 'open' :
-            this.change.status.toLowerCase());
-    }
-
-    _computeCherryPickOfUrl(change, patchset, project) {
-      return Gerrit.Nav.getUrlForChangeById(change, project, patchset);
-    }
-
-    _computeTopicUrl(topic) {
-      return Gerrit.Nav.getUrlForTopic(topic);
-    }
-
-    _computeHashtagUrl(hashtag) {
-      return Gerrit.Nav.getUrlForHashtag(hashtag);
-    }
-
-    _handleTopicRemoved(e) {
-      const target = Polymer.dom(e).rootTarget;
-      target.disabled = true;
-      this.$.restAPI.setChangeTopic(this.change._number, null)
-          .then(() => {
-            target.disabled = false;
-            this.set(['change', 'topic'], '');
-            this.dispatchEvent(
-                new CustomEvent('topic-changed',
-                    {bubbles: true, composed: true}));
-          })
-          .catch(err => {
-            target.disabled = false;
-            return;
-          });
-    }
-
-    _handleHashtagRemoved(e) {
-      e.preventDefault();
-      const target = Polymer.dom(e).rootTarget;
-      target.disabled = true;
-      this.$.restAPI.setChangeHashtag(this.change._number,
-          {remove: [target.text]})
-          .then(newHashtag => {
-            target.disabled = false;
-            this.set(['change', 'hashtags'], newHashtag);
-          })
-          .catch(err => {
-            target.disabled = false;
-            return;
-          });
-    }
-
-    _computeIsWip(change) {
-      return !!change.work_in_progress;
-    }
-
-    _computeShowRoleClass(change, role) {
-      return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
-    }
-
-    /**
-     * Get the user with the specified role on the change. Returns null if the
-     * user with that role is the same as the owner.
-     *
-     * @param {!Object} change
-     * @param {string} role One of the values from _CHANGE_ROLE
-     * @return {Object|null} either an accound or null.
-     */
-    _getNonOwnerRole(change, role) {
-      if (!change || !change.current_revision ||
-          !change.revisions[change.current_revision]) {
-        return null;
-      }
-
-      const rev = change.revisions[change.current_revision];
-      if (!rev) { return null; }
-
-      if (role === this._CHANGE_ROLE.UPLOADER &&
-          rev.uploader &&
-          change.owner._account_id !== rev.uploader._account_id) {
-        return rev.uploader;
-      }
-
-      if (role === this._CHANGE_ROLE.AUTHOR &&
-          rev.commit && rev.commit.author &&
-          change.owner.email !== rev.commit.author.email) {
-        return rev.commit.author;
-      }
-
-      if (role === this._CHANGE_ROLE.COMMITTER &&
-          rev.commit && rev.commit.committer &&
-          change.owner.email !== rev.commit.committer.email) {
-        return rev.commit.committer;
-      }
-
-      return null;
-    }
-
-    _computeParents(change) {
-      if (!change || !change.current_revision ||
-          !change.revisions[change.current_revision] ||
-          !change.revisions[change.current_revision].commit) {
-        return undefined;
-      }
-      return change.revisions[change.current_revision].commit.parents;
-    }
-
-    _computeParentsLabel(parents) {
-      return parents && parents.length > 1 ? 'Parents' : 'Parent';
-    }
-
-    _computeParentListClass(parents, parentIsCurrent) {
-      // Undefined check for polymer 2
-      if (parents === undefined || parentIsCurrent === undefined) {
-        return '';
-      }
-
-      return [
-        'parentList',
-        parents && parents.length > 1 ? 'merge' : 'nonMerge',
-        parentIsCurrent ? 'current' : 'notCurrent',
-      ].join(' ');
-    }
-
-    _computeIsMutable(account) {
-      return !!Object.keys(account).length;
-    }
-
-    editTopic() {
-      if (this._topicReadOnly || this.change.topic) { return; }
-      // Cannot use `this.$.ID` syntax because the element exists inside of a
-      // dom-if.
-      this.shadowRoot.querySelector('.topicEditableLabel').open();
-    }
-
-    _getReviewerSuggestionsProvider(change) {
-      const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
-          change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
-      provider.init();
-      return provider;
+  _assigneeChanged(assigneeRecord) {
+    if (!this.change) { return; }
+    const assignee = assigneeRecord.base;
+    if (assignee.length) {
+      const acct = assignee[0];
+      if (this.change.assignee &&
+          acct._account_id === this.change.assignee._account_id) { return; }
+      this.set(['change', 'assignee'], acct);
+      this.$.restAPI.setAssignee(this.change._number, acct._account_id);
+    } else {
+      if (!this.change.assignee) { return; }
+      this.set(['change', 'assignee'], undefined);
+      this.$.restAPI.deleteAssignee(this.change._number);
     }
   }
 
-  customElements.define(GrChangeMetadata.is, GrChangeMetadata);
-})();
+  _computeHideStrategy(change) {
+    return !this.changeIsOpen(change);
+  }
+
+  /**
+   * @param {Object} commitInfo
+   * @return {?Array} If array is empty, returns null instead so
+   * an existential check can be used to hide or show the webLinks
+   * section.
+   */
+  _computeWebLinks(commitInfo, serverConfig) {
+    if (!commitInfo) { return null; }
+    const weblinks = Gerrit.Nav.getChangeWeblinks(
+        this.change ? this.change.repo : '',
+        commitInfo.commit,
+        {
+          weblinks: commitInfo.web_links,
+          config: serverConfig,
+        });
+    return weblinks.length ? weblinks : null;
+  }
+
+  _computeStrategy(change) {
+    return SubmitTypeLabel[change.submit_type];
+  }
+
+  _computeLabelNames(labels) {
+    return Object.keys(labels).sort();
+  }
+
+  _handleTopicChanged(e, topic) {
+    const lastTopic = this.change.topic;
+    if (!topic.length) { topic = null; }
+    this._settingTopic = true;
+    this.$.restAPI.setChangeTopic(this.change._number, topic)
+        .then(newTopic => {
+          this._settingTopic = false;
+          this.set(['change', 'topic'], newTopic);
+          if (newTopic !== lastTopic) {
+            this.dispatchEvent(new CustomEvent(
+                'topic-changed', {bubbles: true, composed: true}));
+          }
+        });
+  }
+
+  _showAddTopic(changeRecord, settingTopic) {
+    const hasTopic = !!changeRecord &&
+        !!changeRecord.base && !!changeRecord.base.topic;
+    return !hasTopic && !settingTopic;
+  }
+
+  _showTopicChip(changeRecord, settingTopic) {
+    const hasTopic = !!changeRecord &&
+        !!changeRecord.base && !!changeRecord.base.topic;
+    return hasTopic && !settingTopic;
+  }
+
+  _showCherryPickOf(changeRecord) {
+    const hasCherryPickOf = !!changeRecord &&
+        !!changeRecord.base && !!changeRecord.base.cherry_pick_of_change &&
+        !!changeRecord.base.cherry_pick_of_patch_set;
+    return hasCherryPickOf;
+  }
+
+  _handleHashtagChanged(e) {
+    const lastHashtag = this.change.hashtag;
+    if (!this._newHashtag.length) { return; }
+    const newHashtag = this._newHashtag;
+    this._newHashtag = '';
+    this.$.restAPI.setChangeHashtag(
+        this.change._number, {add: [newHashtag]}).then(newHashtag => {
+      this.set(['change', 'hashtags'], newHashtag);
+      if (newHashtag !== lastHashtag) {
+        this.dispatchEvent(
+            new CustomEvent('hashtag-changed', {
+              bubbles: true, composed: true}));
+      }
+    });
+  }
+
+  _computeTopicReadOnly(mutable, change) {
+    return !mutable ||
+        !change ||
+        !change.actions ||
+        !change.actions.topic ||
+        !change.actions.topic.enabled;
+  }
+
+  _computeHashtagReadOnly(mutable, change) {
+    return !mutable ||
+        !change ||
+        !change.actions ||
+        !change.actions.hashtags ||
+        !change.actions.hashtags.enabled;
+  }
+
+  _computeAssigneeReadOnly(mutable, change) {
+    return !mutable ||
+        !change ||
+        !change.actions ||
+        !change.actions.assignee ||
+        !change.actions.assignee.enabled;
+  }
+
+  _computeTopicPlaceholder(_topicReadOnly) {
+    // Action items in Material Design are uppercase -- placeholder label text
+    // is sentence case.
+    return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
+  }
+
+  _computeHashtagPlaceholder(_hashtagReadOnly) {
+    return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
+  }
+
+  _computeShowRequirements(change) {
+    if (change.status !== this.ChangeStatus.NEW) {
+      // TODO(maximeg) change this to display the stored
+      // requirements, once it is implemented server-side.
+      return false;
+    }
+    const hasRequirements = !!change.requirements &&
+        Object.keys(change.requirements).length > 0;
+    const hasLabels = !!change.labels &&
+        Object.keys(change.labels).length > 0;
+    return hasRequirements || hasLabels || !!change.work_in_progress;
+  }
+
+  /**
+   * @return {?Gerrit.PushCertificateValidation} object representing data for
+   *     the push validation.
+   */
+  _computePushCertificateValidation(serverConfig, change) {
+    if (!change || !serverConfig || !serverConfig.receive ||
+        !serverConfig.receive.enable_signed_push) {
+      return null;
+    }
+    const rev = change.revisions[change.current_revision];
+    if (!rev.push_certificate || !rev.push_certificate.key) {
+      return {
+        class: 'help',
+        icon: 'gr-icons:help',
+        message: 'This patch set was created without a push certificate',
+      };
+    }
+
+    const key = rev.push_certificate.key;
+    switch (key.status) {
+      case CertificateStatus.BAD:
+        return {
+          class: 'invalid',
+          icon: 'gr-icons:close',
+          message: this._problems('Push certificate is invalid', key),
+        };
+      case CertificateStatus.OK:
+        return {
+          class: 'notTrusted',
+          icon: 'gr-icons:info',
+          message: this._problems(
+              'Push certificate is valid, but key is not trusted', key),
+        };
+      case CertificateStatus.TRUSTED:
+        return {
+          class: 'trusted',
+          icon: 'gr-icons:check',
+          message: this._problems(
+              'Push certificate is valid and key is trusted', key),
+        };
+      default:
+        throw new Error(`unknown certificate status: ${key.status}`);
+    }
+  }
+
+  _problems(msg, key) {
+    if (!key || !key.problems || key.problems.length === 0) {
+      return msg;
+    }
+
+    return [msg + ':'].concat(key.problems).join('\n');
+  }
+
+  _computeShowRepoBranchTogether(repo, branch) {
+    return !!repo && !!branch && repo.length + branch.length < 40;
+  }
+
+  _computeProjectUrl(project) {
+    return Gerrit.Nav.getUrlForProjectChanges(project);
+  }
+
+  _computeBranchUrl(project, branch) {
+    if (!this.change || !this.change.status) return '';
+    return Gerrit.Nav.getUrlForBranch(branch, project,
+        this.change.status == this.ChangeStatus.NEW ? 'open' :
+          this.change.status.toLowerCase());
+  }
+
+  _computeCherryPickOfUrl(change, patchset, project) {
+    return Gerrit.Nav.getUrlForChangeById(change, project, patchset);
+  }
+
+  _computeTopicUrl(topic) {
+    return Gerrit.Nav.getUrlForTopic(topic);
+  }
+
+  _computeHashtagUrl(hashtag) {
+    return Gerrit.Nav.getUrlForHashtag(hashtag);
+  }
+
+  _handleTopicRemoved(e) {
+    const target = dom(e).rootTarget;
+    target.disabled = true;
+    this.$.restAPI.setChangeTopic(this.change._number, null)
+        .then(() => {
+          target.disabled = false;
+          this.set(['change', 'topic'], '');
+          this.dispatchEvent(
+              new CustomEvent('topic-changed',
+                  {bubbles: true, composed: true}));
+        })
+        .catch(err => {
+          target.disabled = false;
+          return;
+        });
+  }
+
+  _handleHashtagRemoved(e) {
+    e.preventDefault();
+    const target = dom(e).rootTarget;
+    target.disabled = true;
+    this.$.restAPI.setChangeHashtag(this.change._number,
+        {remove: [target.text]})
+        .then(newHashtag => {
+          target.disabled = false;
+          this.set(['change', 'hashtags'], newHashtag);
+        })
+        .catch(err => {
+          target.disabled = false;
+          return;
+        });
+  }
+
+  _computeIsWip(change) {
+    return !!change.work_in_progress;
+  }
+
+  _computeShowRoleClass(change, role) {
+    return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
+  }
+
+  /**
+   * Get the user with the specified role on the change. Returns null if the
+   * user with that role is the same as the owner.
+   *
+   * @param {!Object} change
+   * @param {string} role One of the values from _CHANGE_ROLE
+   * @return {Object|null} either an accound or null.
+   */
+  _getNonOwnerRole(change, role) {
+    if (!change || !change.current_revision ||
+        !change.revisions[change.current_revision]) {
+      return null;
+    }
+
+    const rev = change.revisions[change.current_revision];
+    if (!rev) { return null; }
+
+    if (role === this._CHANGE_ROLE.UPLOADER &&
+        rev.uploader &&
+        change.owner._account_id !== rev.uploader._account_id) {
+      return rev.uploader;
+    }
+
+    if (role === this._CHANGE_ROLE.AUTHOR &&
+        rev.commit && rev.commit.author &&
+        change.owner.email !== rev.commit.author.email) {
+      return rev.commit.author;
+    }
+
+    if (role === this._CHANGE_ROLE.COMMITTER &&
+        rev.commit && rev.commit.committer &&
+        change.owner.email !== rev.commit.committer.email) {
+      return rev.commit.committer;
+    }
+
+    return null;
+  }
+
+  _computeParents(change) {
+    if (!change || !change.current_revision ||
+        !change.revisions[change.current_revision] ||
+        !change.revisions[change.current_revision].commit) {
+      return undefined;
+    }
+    return change.revisions[change.current_revision].commit.parents;
+  }
+
+  _computeParentsLabel(parents) {
+    return parents && parents.length > 1 ? 'Parents' : 'Parent';
+  }
+
+  _computeParentListClass(parents, parentIsCurrent) {
+    // Undefined check for polymer 2
+    if (parents === undefined || parentIsCurrent === undefined) {
+      return '';
+    }
+
+    return [
+      'parentList',
+      parents && parents.length > 1 ? 'merge' : 'nonMerge',
+      parentIsCurrent ? 'current' : 'notCurrent',
+    ].join(' ');
+  }
+
+  _computeIsMutable(account) {
+    return !!Object.keys(account).length;
+  }
+
+  editTopic() {
+    if (this._topicReadOnly || this.change.topic) { return; }
+    // Cannot use `this.$.ID` syntax because the element exists inside of a
+    // dom-if.
+    this.shadowRoot.querySelector('.topicEditableLabel').open();
+  }
+
+  _getReviewerSuggestionsProvider(change) {
+    const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
+        change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
+    provider.init();
+    return provider;
+  }
+}
+
+customElements.define(GrChangeMetadata.is, GrChangeMetadata);
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
index ad3b621..786a118 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
@@ -1,48 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../styles/gr-change-metadata-shared-styles.html">
-<link rel="import" href="../../../styles/gr-change-view-integration-shared-styles.html">
-<link rel="import" href="../../../styles/gr-voting-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../plugins/gr-external-style/gr-external-style.html">
-<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
-<link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-change-requirements/gr-change-requirements.html">
-<link rel="import" href="../gr-commit-info/gr-commit-info.html">
-<link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
-<link rel="import" href="../../shared/gr-account-list/gr-account-list.html">
-<script src="../../../scripts/gr-display-name-utils/gr-display-name-utils.js"></script>
-<script src="../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js"></script>
-
-<dom-module id="gr-change-metadata">
-  <template>
+export const htmlTemplate = html`
     <style include="gr-change-metadata-shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -128,9 +102,7 @@
       <section>
         <span class="title">Updated</span>
         <span class="value">
-          <gr-date-formatter
-              has-tooltip
-              date-str="[[change.updated]]"></gr-date-formatter>
+          <gr-date-formatter has-tooltip="" date-str="[[change.updated]]"></gr-date-formatter>
         </span>
       </section>
       <section>
@@ -138,92 +110,65 @@
         <span class="value">
           <gr-account-link account="[[change.owner]]"></gr-account-link>
           <template is="dom-if" if="[[_pushCertificateValidation]]">
-            <gr-tooltip-content
-                has-tooltip
-                title$="[[_pushCertificateValidation.message]]">
-              <iron-icon
-                  class$="icon [[_pushCertificateValidation.class]]"
-                  icon="[[_pushCertificateValidation.icon]]">
+            <gr-tooltip-content has-tooltip="" title\$="[[_pushCertificateValidation.message]]">
+              <iron-icon class\$="icon [[_pushCertificateValidation.class]]" icon="[[_pushCertificateValidation.icon]]">
               </iron-icon>
             </gr-tooltip-content>
           </template>
         </span>
       </section>
-      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
+      <section class\$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
         <span class="title">Uploader</span>
         <span class="value">
-          <gr-account-link
-              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
-              ></gr-account-link>
+          <gr-account-link account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"></gr-account-link>
         </span>
       </section>
-      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
+      <section class\$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
         <span class="title">Author</span>
         <span class="value">
-          <gr-account-link
-              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
-              ></gr-account-link>
+          <gr-account-link account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"></gr-account-link>
         </span>
       </section>
-      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
+      <section class\$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
         <span class="title">Committer</span>
         <span class="value">
-          <gr-account-link
-              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
-              ></gr-account-link>
+          <gr-account-link account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"></gr-account-link>
         </span>
       </section>
       <section class="assignee">
         <span class="title">Assignee</span>
         <span class="value">
-          <gr-account-list
-              id="assigneeValue"
-              placeholder="Set assignee..."
-              max-count="1"
-              skip-suggest-on-empty
-              accounts="{{_assignee}}"
-              readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
-              suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]">
+          <gr-account-list id="assigneeValue" placeholder="Set assignee..." max-count="1" skip-suggest-on-empty="" accounts="{{_assignee}}" readonly="[[_computeAssigneeReadOnly(_mutable, change)]]" suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]">
           </gr-account-list>
         </span>
       </section>
       <section>
         <span class="title">Reviewers</span>
         <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[_mutable]]"
-              reviewers-only
-              max-reviewers-displayed="3"></gr-reviewer-list>
+          <gr-reviewer-list change="{{change}}" mutable="[[_mutable]]" reviewers-only="" max-reviewers-displayed="3"></gr-reviewer-list>
         </span>
       </section>
       <section>
         <span class="title">CC</span>
         <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[_mutable]]"
-              ccs-only
-              max-reviewers-displayed="3"></gr-reviewer-list>
+          <gr-reviewer-list change="{{change}}" mutable="[[_mutable]]" ccs-only="" max-reviewers-displayed="3"></gr-reviewer-list>
         </span>
       </section>
-      <template is="dom-if"
-                if="[[_computeShowRepoBranchTogether(change.project, change.branch)]]">
+      <template is="dom-if" if="[[_computeShowRepoBranchTogether(change.project, change.branch)]]">
         <section>
           <span class="title">Repo / Branch</span>
           <span class="value">
-            <a href$="[[_computeProjectUrl(change.project)]]">[[change.project]]</a>
+            <a href\$="[[_computeProjectUrl(change.project)]]">[[change.project]]</a>
             /
-            <a href$="[[_computeBranchUrl(change.project, change.branch)]]">[[change.branch]]</a>
+            <a href\$="[[_computeBranchUrl(change.project, change.branch)]]">[[change.branch]]</a>
           </span>
         </section>
       </template>
-      <template is="dom-if"
-                if="[[!_computeShowRepoBranchTogether(change.project, change.branch)]]">
+      <template is="dom-if" if="[[!_computeShowRepoBranchTogether(change.project, change.branch)]]">
         <section>
           <span class="title">Repo</span>
           <span class="value">
-            <a href$="[[_computeProjectUrl(change.project)]]">
+            <a href\$="[[_computeProjectUrl(change.project)]]">
               <gr-limited-text limit="40" text="[[change.project]]"></gr-limited-text>
             </a>
           </span>
@@ -231,7 +176,7 @@
         <section>
           <span class="title">Branch</span>
           <span class="value">
-            <a href$="[[_computeBranchUrl(change.project, change.branch)]]">
+            <a href\$="[[_computeBranchUrl(change.project, change.branch)]]">
               <gr-limited-text limit="40" text="[[change.branch]]"></gr-limited-text>
             </a>
           </span>
@@ -240,18 +185,11 @@
       <section>
         <span class="title">[[_computeParentsLabel(_currentParents)]]</span>
         <span class="value">
-          <ol class$="[[_computeParentListClass(_currentParents, parentIsCurrent)]]">
+          <ol class\$="[[_computeParentListClass(_currentParents, parentIsCurrent)]]">
             <template is="dom-repeat" items="[[_currentParents]]" as="parent">
               <li>
-                <gr-commit-info
-                    change="[[change]]"
-                    commit-info="[[parent]]"
-                    server-config="[[serverConfig]]"></gr-commit-info>
-                <gr-tooltip-content
-                    id="parentNotCurrentMessage"
-                    has-tooltip
-                    show-icon
-                    title$="[[_notCurrentMessage]]"></gr-tooltip-content>
+                <gr-commit-info change="[[change]]" commit-info="[[parent]]" server-config="[[serverConfig]]"></gr-commit-info>
+                <gr-tooltip-content id="parentNotCurrentMessage" has-tooltip="" show-icon="" title\$="[[_notCurrentMessage]]"></gr-tooltip-content>
               </li>
             </template>
           </ol>
@@ -260,27 +198,11 @@
       <section class="topic">
         <span class="title">Topic</span>
         <span class="value">
-          <template
-              is="dom-if"
-              if="[[_showTopicChip(change.*, _settingTopic)]]">
-            <gr-linked-chip
-                text="[[change.topic]]"
-                limit="40"
-                href="[[_computeTopicUrl(change.topic)]]"
-                removable="[[!_topicReadOnly]]"
-                on-remove="_handleTopicRemoved"></gr-linked-chip>
+          <template is="dom-if" if="[[_showTopicChip(change.*, _settingTopic)]]">
+            <gr-linked-chip text="[[change.topic]]" limit="40" href="[[_computeTopicUrl(change.topic)]]" removable="[[!_topicReadOnly]]" on-remove="_handleTopicRemoved"></gr-linked-chip>
           </template>
-          <template
-              is="dom-if"
-              if="[[_showAddTopic(change.*, _settingTopic)]]">
-            <gr-editable-label
-                class="topicEditableLabel"
-                label-text="Add a topic"
-                value="[[change.topic]]"
-                max-length="1024"
-                placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
-                read-only="[[_topicReadOnly]]"
-                on-changed="_handleTopicChanged"></gr-editable-label>
+          <template is="dom-if" if="[[_showAddTopic(change.*, _settingTopic)]]">
+            <gr-editable-label class="topicEditableLabel" label-text="Add a topic" value="[[change.topic]]" max-length="1024" placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]" read-only="[[_topicReadOnly]]" on-changed="_handleTopicChanged"></gr-editable-label>
           </template>
         </span>
       </section>
@@ -288,16 +210,14 @@
         <section>
           <span class="title">Cherry pick of</span>
           <span class="value">
-            <a href$="[[_computeCherryPickOfUrl(change.cherry_pick_of_change, change.cherry_pick_of_patch_set, change.project)]]">
-              <gr-limited-text
-                  text="[[change.cherry_pick_of_change]],[[change.cherry_pick_of_patch_set]]"
-                  limit="40">
+            <a href\$="[[_computeCherryPickOfUrl(change.cherry_pick_of_change, change.cherry_pick_of_patch_set, change.project)]]">
+              <gr-limited-text text="[[change.cherry_pick_of_change]],[[change.cherry_pick_of_patch_set]]" limit="40">
               </gr-limited-text>
             </a>
           </span>
         </section>
       </template>
-      <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
+      <section class="strategy" hidden\$="[[_computeHideStrategy(change)]]" hidden="">
         <span class="title">Strategy</span>
         <span class="value">[[_computeStrategy(change)]]</span>
       </section>
@@ -305,36 +225,21 @@
         <span class="title">Hashtags</span>
         <span class="value">
           <template is="dom-repeat" items="[[change.hashtags]]">
-            <gr-linked-chip
-                class="hashtagChip"
-                text="[[item]]"
-                href="[[_computeHashtagUrl(item)]]"
-                removable="[[!_hashtagReadOnly]]"
-                on-remove="_handleHashtagRemoved">
+            <gr-linked-chip class="hashtagChip" text="[[item]]" href="[[_computeHashtagUrl(item)]]" removable="[[!_hashtagReadOnly]]" on-remove="_handleHashtagRemoved">
             </gr-linked-chip>
           </template>
           <template is="dom-if" if="[[!_hashtagReadOnly]]">
-            <gr-editable-label
-                uppercase
-                label-text="Add a hashtag"
-                value="{{_newHashtag}}"
-                placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
-                read-only="[[_hashtagReadOnly]]"
-                on-changed="_handleHashtagChanged"></gr-editable-label>
+            <gr-editable-label uppercase="" label-text="Add a hashtag" value="{{_newHashtag}}" placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]" read-only="[[_hashtagReadOnly]]" on-changed="_handleHashtagChanged"></gr-editable-label>
           </template>
         </span>
       </section>
       <div class="separatedSection">
-        <gr-change-requirements
-            change="{{change}}"
-            account="[[account]]"
-            mutable="[[_mutable]]"></gr-change-requirements>
+        <gr-change-requirements change="{{change}}" account="[[account]]" mutable="[[_mutable]]"></gr-change-requirements>
       </div>
-      <section id="webLinks" hidden$="[[!_computeWebLinks(commitInfo, serverConfig)]]">
+      <section id="webLinks" hidden\$="[[!_computeWebLinks(commitInfo, serverConfig)]]">
         <span class="title">Links</span>
         <span class="value">
-          <template is="dom-repeat"
-              items="[[_computeWebLinks(commitInfo, serverConfig)]]" as="link">
+          <template is="dom-repeat" items="[[_computeWebLinks(commitInfo, serverConfig)]]" as="link">
             <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
               [[link.name]]
             </a>
@@ -348,6 +253,4 @@
       </gr-endpoint-decorator>
     </gr-external-style>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-change-metadata.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 055f3f0..5fe53f8d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -19,16 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-metadata</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../core/gr-router/gr-router.html">
-<link rel="import" href="gr-change-metadata.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../core/gr-router/gr-router.js"></script>
+<script type="module" src="./gr-change-metadata.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../core/gr-router/gr-router.js';
+import './gr-change-metadata.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,733 +42,736 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-metadata tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../core/gr-router/gr-router.js';
+import './gr-change-metadata.js';
+suite('gr-change-metadata tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-endpoint-decorator', {
+      _import: sandbox.stub().returns(Promise.resolve()),
+    });
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('computed fields', () => {
+    assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
+    assert.isTrue(element._computeHideStrategy({status: 'MERGED'}));
+    assert.isTrue(element._computeHideStrategy({status: 'ABANDONED'}));
+    assert.equal(element._computeStrategy({submit_type: 'CHERRY_PICK'}),
+        'Cherry Pick');
+    assert.equal(element._computeStrategy({submit_type: 'REBASE_ALWAYS'}),
+        'Rebase Always');
+  });
+
+  test('computed fields requirements', () => {
+    assert.isFalse(element._computeShowRequirements({status: 'MERGED'}));
+    assert.isFalse(element._computeShowRequirements({status: 'ABANDONED'}));
+
+    // No labels and no requirements: submit status is useless
+    assert.isFalse(element._computeShowRequirements({
+      status: 'NEW',
+      labels: {},
+    }));
+
+    // Work in Progress: submit status should be present
+    assert.isTrue(element._computeShowRequirements({
+      status: 'NEW',
+      labels: {},
+      work_in_progress: true,
+    }));
+
+    // We have at least one reason to display Submit Status
+    assert.isTrue(element._computeShowRequirements({
+      status: 'NEW',
+      labels: {
+        Verified: {
+          approved: false,
+        },
+      },
+      requirements: [],
+    }));
+    assert.isTrue(element._computeShowRequirements({
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'OK',
+      }],
+    }));
+  });
+
+  test('show strategy for open change', () => {
+    element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
+    flushAsynchronousOperations();
+    const strategy = element.shadowRoot
+        .querySelector('.strategy');
+    assert.ok(strategy);
+    assert.isFalse(strategy.hasAttribute('hidden'));
+    assert.equal(strategy.children[1].innerHTML, 'Cherry Pick');
+  });
+
+  test('hide strategy for closed change', () => {
+    element.change = {status: 'MERGED', labels: {}};
+    flushAsynchronousOperations();
+    assert.isTrue(element.shadowRoot
+        .querySelector('.strategy').hasAttribute('hidden'));
+  });
+
+  test('weblinks use Gerrit.Nav interface', () => {
+    const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
+        .returns([{name: 'stubb', url: '#s'}]);
+    element.commitInfo = {};
+    element.serverConfig = {};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(weblinksStub.called);
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
+  });
+
+  test('weblinks hidden when no weblinks', () => {
+    element.commitInfo = {};
+    element.serverConfig = {};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+  });
+
+  test('weblinks hidden when only gitiles weblink', () => {
+    element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
+    element.serverConfig = {};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo), null);
+  });
+
+  test('weblinks hidden when sole weblink is set as primary', () => {
+    const browser = 'browser';
+    element.commitInfo = {web_links: [{name: browser, url: '#'}]};
+    element.serverConfig = {
+      gerrit: {
+        primary_weblink_name: browser,
+      },
+    };
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+  });
+
+  test('weblinks are visible when other weblinks', () => {
+    const router = document.createElement('gr-router');
+    sandbox.stub(Gerrit.Nav, '_generateWeblinks',
+        router._generateWeblinks.bind(router));
+
+    element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
+    // With two non-gitiles weblinks, there are two returned.
+    element.commitInfo = {
+      web_links: [{name: 'test', url: '#'}, {name: 'test2', url: '#'}]};
+    assert.equal(element._computeWebLinks(element.commitInfo).length, 2);
+  });
+
+  test('weblinks are visible when gitiles and other weblinks', () => {
+    const router = document.createElement('gr-router');
+    sandbox.stub(Gerrit.Nav, '_generateWeblinks',
+        router._generateWeblinks.bind(router));
+
+    element.commitInfo = {
+      web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    // Only the non-gitiles weblink is returned.
+    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
+  });
+
+  suite('_getNonOwnerRole', () => {
+    let change;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-endpoint-decorator', {
-        _import: sandbox.stub().returns(Promise.resolve()),
-      });
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('computed fields', () => {
-      assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
-      assert.isTrue(element._computeHideStrategy({status: 'MERGED'}));
-      assert.isTrue(element._computeHideStrategy({status: 'ABANDONED'}));
-      assert.equal(element._computeStrategy({submit_type: 'CHERRY_PICK'}),
-          'Cherry Pick');
-      assert.equal(element._computeStrategy({submit_type: 'REBASE_ALWAYS'}),
-          'Rebase Always');
-    });
-
-    test('computed fields requirements', () => {
-      assert.isFalse(element._computeShowRequirements({status: 'MERGED'}));
-      assert.isFalse(element._computeShowRequirements({status: 'ABANDONED'}));
-
-      // No labels and no requirements: submit status is useless
-      assert.isFalse(element._computeShowRequirements({
-        status: 'NEW',
-        labels: {},
-      }));
-
-      // Work in Progress: submit status should be present
-      assert.isTrue(element._computeShowRequirements({
-        status: 'NEW',
-        labels: {},
-        work_in_progress: true,
-      }));
-
-      // We have at least one reason to display Submit Status
-      assert.isTrue(element._computeShowRequirements({
-        status: 'NEW',
-        labels: {
-          Verified: {
-            approved: false,
-          },
-        },
-        requirements: [],
-      }));
-      assert.isTrue(element._computeShowRequirements({
-        status: 'NEW',
-        labels: {},
-        requirements: [{
-          fallback_text: 'Resolve all comments',
-          status: 'OK',
-        }],
-      }));
-    });
-
-    test('show strategy for open change', () => {
-      element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
-      flushAsynchronousOperations();
-      const strategy = element.shadowRoot
-          .querySelector('.strategy');
-      assert.ok(strategy);
-      assert.isFalse(strategy.hasAttribute('hidden'));
-      assert.equal(strategy.children[1].innerHTML, 'Cherry Pick');
-    });
-
-    test('hide strategy for closed change', () => {
-      element.change = {status: 'MERGED', labels: {}};
-      flushAsynchronousOperations();
-      assert.isTrue(element.shadowRoot
-          .querySelector('.strategy').hasAttribute('hidden'));
-    });
-
-    test('weblinks use Gerrit.Nav interface', () => {
-      const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
-          .returns([{name: 'stubb', url: '#s'}]);
-      element.commitInfo = {};
-      element.serverConfig = {};
-      flushAsynchronousOperations();
-      const webLinks = element.$.webLinks;
-      assert.isTrue(weblinksStub.called);
-      assert.isFalse(webLinks.hasAttribute('hidden'));
-      assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-    });
-
-    test('weblinks hidden when no weblinks', () => {
-      element.commitInfo = {};
-      element.serverConfig = {};
-      flushAsynchronousOperations();
-      const webLinks = element.$.webLinks;
-      assert.isTrue(webLinks.hasAttribute('hidden'));
-    });
-
-    test('weblinks hidden when only gitiles weblink', () => {
-      element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
-      element.serverConfig = {};
-      flushAsynchronousOperations();
-      const webLinks = element.$.webLinks;
-      assert.isTrue(webLinks.hasAttribute('hidden'));
-      assert.equal(element._computeWebLinks(element.commitInfo), null);
-    });
-
-    test('weblinks hidden when sole weblink is set as primary', () => {
-      const browser = 'browser';
-      element.commitInfo = {web_links: [{name: browser, url: '#'}]};
-      element.serverConfig = {
-        gerrit: {
-          primary_weblink_name: browser,
-        },
-      };
-      flushAsynchronousOperations();
-      const webLinks = element.$.webLinks;
-      assert.isTrue(webLinks.hasAttribute('hidden'));
-    });
-
-    test('weblinks are visible when other weblinks', () => {
-      const router = document.createElement('gr-router');
-      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
-          router._generateWeblinks.bind(router));
-
-      element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
-      flushAsynchronousOperations();
-      const webLinks = element.$.webLinks;
-      assert.isFalse(webLinks.hasAttribute('hidden'));
-      assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-      // With two non-gitiles weblinks, there are two returned.
-      element.commitInfo = {
-        web_links: [{name: 'test', url: '#'}, {name: 'test2', url: '#'}]};
-      assert.equal(element._computeWebLinks(element.commitInfo).length, 2);
-    });
-
-    test('weblinks are visible when gitiles and other weblinks', () => {
-      const router = document.createElement('gr-router');
-      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
-          router._generateWeblinks.bind(router));
-
-      element.commitInfo = {
-        web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
-      flushAsynchronousOperations();
-      const webLinks = element.$.webLinks;
-      assert.isFalse(webLinks.hasAttribute('hidden'));
-      // Only the non-gitiles weblink is returned.
-      assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-    });
-
-    suite('_getNonOwnerRole', () => {
-      let change;
-
-      setup(() => {
-        change = {
-          owner: {
-            email: 'abc@def',
-            _account_id: 1019328,
-          },
-          revisions: {
-            rev1: {
-              _number: 1,
-              uploader: {
-                email: 'ghi@def',
-                _account_id: 1011123,
-              },
-              commit: {
-                author: {email: 'jkl@def'},
-                committer: {email: 'ghi@def'},
-              },
-            },
-          },
-          current_revision: 'rev1',
-        };
-      });
-
-      suite('role=uploader', () => {
-        test('_getNonOwnerRole for uploader', () => {
-          assert.deepEqual(
-              element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
-              {email: 'ghi@def', _account_id: 1011123});
-        });
-
-        test('_getNonOwnerRole that it does not return uploader', () => {
-          // Set the uploader email to be the same as the owner.
-          change.revisions.rev1.uploader._account_id = 1019328;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.UPLOADER));
-        });
-
-        test('_getNonOwnerRole null for uploader with no current rev', () => {
-          delete change.current_revision;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.UPLOADER));
-        });
-
-        test('_computeShowRoleClass show uploader', () => {
-          assert.equal(element._computeShowRoleClass(
-              change, element._CHANGE_ROLE.UPLOADER), '');
-        });
-
-        test('_computeShowRoleClass hide uploader', () => {
-          // Set the uploader email to be the same as the owner.
-          change.revisions.rev1.uploader._account_id = 1019328;
-          assert.equal(element._computeShowRoleClass(change,
-              element._CHANGE_ROLE.UPLOADER), 'hideDisplay');
-        });
-      });
-
-      suite('role=committer', () => {
-        test('_getNonOwnerRole for committer', () => {
-          assert.deepEqual(
-              element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
-              {email: 'ghi@def'});
-        });
-
-        test('_getNonOwnerRole that it does not return committer', () => {
-          // Set the committer email to be the same as the owner.
-          change.revisions.rev1.commit.committer.email = 'abc@def';
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.COMMITTER));
-        });
-
-        test('_getNonOwnerRole null for committer with no current rev', () => {
-          delete change.current_revision;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.COMMITTER));
-        });
-
-        test('_getNonOwnerRole null for committer with no commit', () => {
-          delete change.revisions.rev1.commit;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.COMMITTER));
-        });
-
-        test('_getNonOwnerRole null for committer with no committer', () => {
-          delete change.revisions.rev1.commit.committer;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.COMMITTER));
-        });
-      });
-
-      suite('role=author', () => {
-        test('_getNonOwnerRole for author', () => {
-          assert.deepEqual(
-              element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
-              {email: 'jkl@def'});
-        });
-
-        test('_getNonOwnerRole that it does not return author', () => {
-          // Set the author email to be the same as the owner.
-          change.revisions.rev1.commit.author.email = 'abc@def';
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.AUTHOR));
-        });
-
-        test('_getNonOwnerRole null for author with no current rev', () => {
-          delete change.current_revision;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.AUTHOR));
-        });
-
-        test('_getNonOwnerRole null for author with no commit', () => {
-          delete change.revisions.rev1.commit;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.AUTHOR));
-        });
-
-        test('_getNonOwnerRole null for author with no author', () => {
-          delete change.revisions.rev1.commit.author;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.AUTHOR));
-        });
-      });
-    });
-
-    test('Push Certificate Validation test BAD', () => {
-      const serverConfig = {
-        receive: {
-          enable_signed_push: true,
-        },
-      };
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      change = {
         owner: {
+          email: 'abc@def',
           _account_id: 1019328,
         },
         revisions: {
           rev1: {
             _number: 1,
-            push_certificate: {
-              key: {
-                status: 'BAD',
-                problems: [
-                  'No public keys found for key ID E5E20E52',
-                ],
-              },
+            uploader: {
+              email: 'ghi@def',
+              _account_id: 1011123,
+            },
+            commit: {
+              author: {email: 'jkl@def'},
+              committer: {email: 'ghi@def'},
             },
           },
         },
         current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
       };
-      const result =
-          element._computePushCertificateValidation(serverConfig, change);
-      assert.equal(result.message,
-          'Push certificate is invalid:\n' +
-          'No public keys found for key ID E5E20E52');
-      assert.equal(result.icon, 'gr-icons:close');
-      assert.equal(result.class, 'invalid');
     });
 
-    test('Push Certificate Validation test TRUSTED', () => {
-      const serverConfig = {
-        receive: {
-          enable_signed_push: true,
-        },
-      };
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1019328,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-            push_certificate: {
-              key: {
-                status: 'TRUSTED',
-              },
-            },
-          },
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      const result =
-          element._computePushCertificateValidation(serverConfig, change);
-      assert.equal(result.message,
-          'Push certificate is valid and key is trusted');
-      assert.equal(result.icon, 'gr-icons:check');
-      assert.equal(result.class, 'trusted');
-    });
-
-    test('Push Certificate Validation is missing test', () => {
-      const serverConfig = {
-        receive: {
-          enable_signed_push: true,
-        },
-      };
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1019328,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-          },
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      const result =
-          element._computePushCertificateValidation(serverConfig, change);
-      assert.equal(result.message,
-          'This patch set was created without a push certificate');
-      assert.equal(result.icon, 'gr-icons:help');
-      assert.equal(result.class, 'help');
-    });
-
-    test('_computeParents', () => {
-      const parents = [{commit: '123', subject: 'abc'}];
-      assert.isUndefined(element._computeParents(
-          {revisions: {456: {commit: {parents}}}}));
-      assert.isUndefined(element._computeParents(
-          {current_revision: '789', revisions: {456: {commit: {parents}}}}));
-      assert.equal(element._computeParents(
-          {current_revision: '456', revisions: {456: {commit: {parents}}}}),
-      parents);
-    });
-
-    test('_computeParentsLabel', () => {
-      const parent = {commit: 'abc123', subject: 'My parent commit'};
-      assert.equal(element._computeParentsLabel([parent]), 'Parent');
-      assert.equal(element._computeParentsLabel([parent, parent]),
-          'Parents');
-    });
-
-    test('_computeParentListClass', () => {
-      const parent = {commit: 'abc123', subject: 'My parent commit'};
-      assert.equal(element._computeParentListClass([parent], true),
-          'parentList nonMerge current');
-      assert.equal(element._computeParentListClass([parent], false),
-          'parentList nonMerge notCurrent');
-      assert.equal(element._computeParentListClass([parent, parent], false),
-          'parentList merge notCurrent');
-      assert.equal(element._computeParentListClass([parent, parent], true),
-          'parentList merge current');
-    });
-
-    test('_showAddTopic', () => {
-      assert.isTrue(element._showAddTopic(null, false));
-      assert.isTrue(element._showAddTopic({base: {topic: null}}, false));
-      assert.isFalse(element._showAddTopic({base: {topic: null}}, true));
-      assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, true));
-      assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, false));
-    });
-
-    test('_showTopicChip', () => {
-      assert.isFalse(element._showTopicChip(null, false));
-      assert.isFalse(element._showTopicChip({base: {topic: null}}, false));
-      assert.isFalse(element._showTopicChip({base: {topic: null}}, true));
-      assert.isFalse(element._showTopicChip({base: {topic: 'foo'}}, true));
-      assert.isTrue(element._showTopicChip({base: {topic: 'foo'}}, false));
-    });
-
-    test('_showCherryPickOf', () => {
-      assert.isFalse(element._showCherryPickOf(null));
-      assert.isFalse(element._showCherryPickOf({
-        base: {
-          cherry_pick_of_change: null,
-          cherry_pick_of_patch_set: null,
-        },
-      }));
-      assert.isTrue(element._showCherryPickOf({
-        base: {
-          cherry_pick_of_change: 123,
-          cherry_pick_of_patch_set: 1,
-        },
-      }));
-    });
-
-    suite('Topic removal', () => {
-      let change;
-      setup(() => {
-        change = {
-          _number: 'the number',
-          actions: {
-            topic: {enabled: false},
-          },
-          change_id: 'the id',
-          topic: 'the topic',
-          status: 'NEW',
-          submit_type: 'CHERRY_PICK',
-          labels: {
-            test: {
-              all: [{_account_id: 1, name: 'bojack', value: 1}],
-              default_value: 0,
-              values: [],
-            },
-          },
-          removable_reviewers: [],
-        };
+    suite('role=uploader', () => {
+      test('_getNonOwnerRole for uploader', () => {
+        assert.deepEqual(
+            element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
+            {email: 'ghi@def', _account_id: 1011123});
       });
 
-      test('_computeTopicReadOnly', () => {
-        let mutable = false;
-        assert.isTrue(element._computeTopicReadOnly(mutable, change));
-        mutable = true;
-        assert.isTrue(element._computeTopicReadOnly(mutable, change));
-        change.actions.topic.enabled = true;
-        assert.isFalse(element._computeTopicReadOnly(mutable, change));
-        mutable = false;
-        assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      test('_getNonOwnerRole that it does not return uploader', () => {
+        // Set the uploader email to be the same as the owner.
+        change.revisions.rev1.uploader._account_id = 1019328;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.UPLOADER));
       });
 
-      test('topic read only hides delete button', () => {
-        element.account = {};
-        element.change = change;
-        flushAsynchronousOperations();
-        const button = element.shadowRoot
-            .querySelector('gr-linked-chip').shadowRoot
-            .querySelector('gr-button');
-        assert.isTrue(button.hasAttribute('hidden'));
+      test('_getNonOwnerRole null for uploader with no current rev', () => {
+        delete change.current_revision;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.UPLOADER));
       });
 
-      test('topic not read only does not hide delete button', () => {
-        element.account = {test: true};
-        change.actions.topic.enabled = true;
-        element.change = change;
-        flushAsynchronousOperations();
-        const button = element.shadowRoot
-            .querySelector('gr-linked-chip').shadowRoot
-            .querySelector('gr-button');
-        assert.isFalse(button.hasAttribute('hidden'));
+      test('_computeShowRoleClass show uploader', () => {
+        assert.equal(element._computeShowRoleClass(
+            change, element._CHANGE_ROLE.UPLOADER), '');
+      });
+
+      test('_computeShowRoleClass hide uploader', () => {
+        // Set the uploader email to be the same as the owner.
+        change.revisions.rev1.uploader._account_id = 1019328;
+        assert.equal(element._computeShowRoleClass(change,
+            element._CHANGE_ROLE.UPLOADER), 'hideDisplay');
       });
     });
 
-    suite('Hashtag removal', () => {
-      let change;
-      setup(() => {
-        change = {
-          _number: 'the number',
-          actions: {
-            hashtags: {enabled: false},
-          },
-          change_id: 'the id',
-          hashtags: ['test-hashtag'],
-          status: 'NEW',
-          submit_type: 'CHERRY_PICK',
-          labels: {
-            test: {
-              all: [{_account_id: 1, name: 'bojack', value: 1}],
-              default_value: 0,
-              values: [],
-            },
-          },
-          removable_reviewers: [],
-        };
+    suite('role=committer', () => {
+      test('_getNonOwnerRole for committer', () => {
+        assert.deepEqual(
+            element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
+            {email: 'ghi@def'});
       });
 
-      test('_computeHashtagReadOnly', () => {
-        flushAsynchronousOperations();
-        let mutable = false;
-        assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-        mutable = true;
-        assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-        change.actions.hashtags.enabled = true;
-        assert.isFalse(element._computeHashtagReadOnly(mutable, change));
-        mutable = false;
-        assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      test('_getNonOwnerRole that it does not return committer', () => {
+        // Set the committer email to be the same as the owner.
+        change.revisions.rev1.commit.committer.email = 'abc@def';
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
       });
 
-      test('hashtag read only hides delete button', () => {
-        flushAsynchronousOperations();
-        element.account = {};
-        element.change = change;
-        flushAsynchronousOperations();
-        const button = element.shadowRoot
-            .querySelector('gr-linked-chip').shadowRoot
-            .querySelector('gr-button');
-        assert.isTrue(button.hasAttribute('hidden'));
+      test('_getNonOwnerRole null for committer with no current rev', () => {
+        delete change.current_revision;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
       });
 
-      test('hashtag not read only does not hide delete button', () => {
-        flushAsynchronousOperations();
-        element.account = {test: true};
-        change.actions.hashtags.enabled = true;
-        element.change = change;
-        flushAsynchronousOperations();
-        const button = element.shadowRoot
-            .querySelector('gr-linked-chip').shadowRoot
-            .querySelector('gr-button');
-        assert.isFalse(button.hasAttribute('hidden'));
+      test('_getNonOwnerRole null for committer with no commit', () => {
+        delete change.revisions.rev1.commit;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
+      });
+
+      test('_getNonOwnerRole null for committer with no committer', () => {
+        delete change.revisions.rev1.commit.committer;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
       });
     });
 
-    suite('remove reviewer votes', () => {
-      setup(() => {
-        sandbox.stub(element, '_computeTopicReadOnly').returns(true);
-        element.change = {
-          _number: 42,
-          change_id: 'the id',
-          actions: [],
-          topic: 'the topic',
-          status: 'NEW',
-          submit_type: 'CHERRY_PICK',
-          labels: {
-            test: {
-              all: [{_account_id: 1, name: 'bojack', value: 1}],
-              default_value: 0,
-              values: [],
-            },
-          },
-          removable_reviewers: [],
-        };
-        flushAsynchronousOperations();
+    suite('role=author', () => {
+      test('_getNonOwnerRole for author', () => {
+        assert.deepEqual(
+            element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
+            {email: 'jkl@def'});
       });
 
-      suite('assignee field', () => {
-        const dummyAccount = {
-          _account_id: 1,
-          name: 'bojack',
-        };
-        const change = {
-          actions: {
-            assignee: {enabled: false},
-          },
-          assignee: dummyAccount,
-        };
-        let deleteStub;
-        let setStub;
-
-        setup(() => {
-          deleteStub = sandbox.stub(element.$.restAPI, 'deleteAssignee');
-          setStub = sandbox.stub(element.$.restAPI, 'setAssignee');
-        });
-
-        test('changing change recomputes _assignee', () => {
-          assert.isFalse(!!element._assignee.length);
-          const change = element.change;
-          change.assignee = dummyAccount;
-          element._changeChanged(change);
-          assert.deepEqual(element._assignee[0], dummyAccount);
-        });
-
-        test('modifying _assignee calls API', () => {
-          assert.isFalse(!!element._assignee.length);
-          element.set('_assignee', [dummyAccount]);
-          assert.isTrue(setStub.calledOnce);
-          assert.deepEqual(element.change.assignee, dummyAccount);
-          element.set('_assignee', [dummyAccount]);
-          assert.isTrue(setStub.calledOnce);
-          element.set('_assignee', []);
-          assert.isTrue(deleteStub.calledOnce);
-          assert.equal(element.change.assignee, undefined);
-          element.set('_assignee', []);
-          assert.isTrue(deleteStub.calledOnce);
-        });
-
-        test('_computeAssigneeReadOnly', () => {
-          let mutable = false;
-          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-          mutable = true;
-          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-          change.actions.assignee.enabled = true;
-          assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
-          mutable = false;
-          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-        });
+      test('_getNonOwnerRole that it does not return author', () => {
+        // Set the author email to be the same as the owner.
+        change.revisions.rev1.commit.author.email = 'abc@def';
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
       });
 
-      test('changing topic', () => {
-        const newTopic = 'the new topic';
-        sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
-            Promise.resolve(newTopic));
-        element._handleTopicChanged({}, newTopic);
-        const topicChangedSpy = sandbox.spy();
-        element.addEventListener('topic-changed', topicChangedSpy);
-        assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-            42, newTopic));
-        return element.$.restAPI.setChangeTopic.lastCall.returnValue
-            .then(() => {
-              assert.equal(element.change.topic, newTopic);
-              assert.isTrue(topicChangedSpy.called);
-            });
+      test('_getNonOwnerRole null for author with no current rev', () => {
+        delete change.current_revision;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
       });
 
-      test('topic removal', () => {
-        sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
-            Promise.resolve());
-        const chip = element.shadowRoot
-            .querySelector('gr-linked-chip');
-        const remove = chip.$.remove;
-        const topicChangedSpy = sandbox.spy();
-        element.addEventListener('topic-changed', topicChangedSpy);
-        MockInteractions.tap(remove);
-        assert.isTrue(chip.disabled);
-        assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-            42, null));
-        return element.$.restAPI.setChangeTopic.lastCall.returnValue
-            .then(() => {
-              assert.isFalse(chip.disabled);
-              assert.equal(element.change.topic, '');
-              assert.isTrue(topicChangedSpy.called);
-            });
+      test('_getNonOwnerRole null for author with no commit', () => {
+        delete change.revisions.rev1.commit;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
       });
 
-      test('changing hashtag', () => {
-        flushAsynchronousOperations();
-        element._newHashtag = 'new hashtag';
-        const newHashtag = ['new hashtag'];
-        sandbox.stub(element.$.restAPI, 'setChangeHashtag').returns(
-            Promise.resolve(newHashtag));
-        element._handleHashtagChanged({}, 'new hashtag');
-        assert.isTrue(element.$.restAPI.setChangeHashtag.calledWith(
-            42, {add: ['new hashtag']}));
-        return element.$.restAPI.setChangeHashtag.lastCall.returnValue
-            .then(() => {
-              assert.equal(element.change.hashtags, newHashtag);
-            });
-      });
-    });
-
-    test('editTopic', () => {
-      element.account = {test: true};
-      element.change = {actions: {topic: {enabled: true}}};
-      flushAsynchronousOperations();
-
-      const label = element.shadowRoot
-          .querySelector('.topicEditableLabel');
-      assert.ok(label);
-      sandbox.stub(label, 'open');
-      element.editTopic();
-      flushAsynchronousOperations();
-
-      assert.isTrue(label.open.called);
-    });
-
-    suite('plugin endpoints', () => {
-      test('endpoint params', done => {
-        element.change = {labels: {}};
-        element.revision = {};
-        let hookEl;
-        let plugin;
-        Gerrit.install(
-            p => {
-              plugin = p;
-              plugin.hook('change-metadata-item').getLastAttached()
-                  .then(el => hookEl = el);
-            },
-            '0.1',
-            'http://some/plugins/url.html');
-        Gerrit._loadPlugins([]);
-        flush(() => {
-          assert.strictEqual(hookEl.plugin, plugin);
-          assert.strictEqual(hookEl.change, element.change);
-          assert.strictEqual(hookEl.revision, element.revision);
-          done();
-        });
+      test('_getNonOwnerRole null for author with no author', () => {
+        delete change.revisions.rev1.commit.author;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
       });
     });
   });
+
+  test('Push Certificate Validation test BAD', () => {
+    const serverConfig = {
+      receive: {
+        enable_signed_push: true,
+      },
+    };
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {
+        _account_id: 1019328,
+      },
+      revisions: {
+        rev1: {
+          _number: 1,
+          push_certificate: {
+            key: {
+              status: 'BAD',
+              problems: [
+                'No public keys found for key ID E5E20E52',
+              ],
+            },
+          },
+        },
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: true,
+    };
+    const result =
+        element._computePushCertificateValidation(serverConfig, change);
+    assert.equal(result.message,
+        'Push certificate is invalid:\n' +
+        'No public keys found for key ID E5E20E52');
+    assert.equal(result.icon, 'gr-icons:close');
+    assert.equal(result.class, 'invalid');
+  });
+
+  test('Push Certificate Validation test TRUSTED', () => {
+    const serverConfig = {
+      receive: {
+        enable_signed_push: true,
+      },
+    };
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {
+        _account_id: 1019328,
+      },
+      revisions: {
+        rev1: {
+          _number: 1,
+          push_certificate: {
+            key: {
+              status: 'TRUSTED',
+            },
+          },
+        },
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: true,
+    };
+    const result =
+        element._computePushCertificateValidation(serverConfig, change);
+    assert.equal(result.message,
+        'Push certificate is valid and key is trusted');
+    assert.equal(result.icon, 'gr-icons:check');
+    assert.equal(result.class, 'trusted');
+  });
+
+  test('Push Certificate Validation is missing test', () => {
+    const serverConfig = {
+      receive: {
+        enable_signed_push: true,
+      },
+    };
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {
+        _account_id: 1019328,
+      },
+      revisions: {
+        rev1: {
+          _number: 1,
+        },
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: true,
+    };
+    const result =
+        element._computePushCertificateValidation(serverConfig, change);
+    assert.equal(result.message,
+        'This patch set was created without a push certificate');
+    assert.equal(result.icon, 'gr-icons:help');
+    assert.equal(result.class, 'help');
+  });
+
+  test('_computeParents', () => {
+    const parents = [{commit: '123', subject: 'abc'}];
+    assert.isUndefined(element._computeParents(
+        {revisions: {456: {commit: {parents}}}}));
+    assert.isUndefined(element._computeParents(
+        {current_revision: '789', revisions: {456: {commit: {parents}}}}));
+    assert.equal(element._computeParents(
+        {current_revision: '456', revisions: {456: {commit: {parents}}}}),
+    parents);
+  });
+
+  test('_computeParentsLabel', () => {
+    const parent = {commit: 'abc123', subject: 'My parent commit'};
+    assert.equal(element._computeParentsLabel([parent]), 'Parent');
+    assert.equal(element._computeParentsLabel([parent, parent]),
+        'Parents');
+  });
+
+  test('_computeParentListClass', () => {
+    const parent = {commit: 'abc123', subject: 'My parent commit'};
+    assert.equal(element._computeParentListClass([parent], true),
+        'parentList nonMerge current');
+    assert.equal(element._computeParentListClass([parent], false),
+        'parentList nonMerge notCurrent');
+    assert.equal(element._computeParentListClass([parent, parent], false),
+        'parentList merge notCurrent');
+    assert.equal(element._computeParentListClass([parent, parent], true),
+        'parentList merge current');
+  });
+
+  test('_showAddTopic', () => {
+    assert.isTrue(element._showAddTopic(null, false));
+    assert.isTrue(element._showAddTopic({base: {topic: null}}, false));
+    assert.isFalse(element._showAddTopic({base: {topic: null}}, true));
+    assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, true));
+    assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, false));
+  });
+
+  test('_showTopicChip', () => {
+    assert.isFalse(element._showTopicChip(null, false));
+    assert.isFalse(element._showTopicChip({base: {topic: null}}, false));
+    assert.isFalse(element._showTopicChip({base: {topic: null}}, true));
+    assert.isFalse(element._showTopicChip({base: {topic: 'foo'}}, true));
+    assert.isTrue(element._showTopicChip({base: {topic: 'foo'}}, false));
+  });
+
+  test('_showCherryPickOf', () => {
+    assert.isFalse(element._showCherryPickOf(null));
+    assert.isFalse(element._showCherryPickOf({
+      base: {
+        cherry_pick_of_change: null,
+        cherry_pick_of_patch_set: null,
+      },
+    }));
+    assert.isTrue(element._showCherryPickOf({
+      base: {
+        cherry_pick_of_change: 123,
+        cherry_pick_of_patch_set: 1,
+      },
+    }));
+  });
+
+  suite('Topic removal', () => {
+    let change;
+    setup(() => {
+      change = {
+        _number: 'the number',
+        actions: {
+          topic: {enabled: false},
+        },
+        change_id: 'the id',
+        topic: 'the topic',
+        status: 'NEW',
+        submit_type: 'CHERRY_PICK',
+        labels: {
+          test: {
+            all: [{_account_id: 1, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: [],
+          },
+        },
+        removable_reviewers: [],
+      };
+    });
+
+    test('_computeTopicReadOnly', () => {
+      let mutable = false;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      mutable = true;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      change.actions.topic.enabled = true;
+      assert.isFalse(element._computeTopicReadOnly(mutable, change));
+      mutable = false;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+    });
+
+    test('topic read only hides delete button', () => {
+      element.account = {};
+      element.change = change;
+      flushAsynchronousOperations();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isTrue(button.hasAttribute('hidden'));
+    });
+
+    test('topic not read only does not hide delete button', () => {
+      element.account = {test: true};
+      change.actions.topic.enabled = true;
+      element.change = change;
+      flushAsynchronousOperations();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isFalse(button.hasAttribute('hidden'));
+    });
+  });
+
+  suite('Hashtag removal', () => {
+    let change;
+    setup(() => {
+      change = {
+        _number: 'the number',
+        actions: {
+          hashtags: {enabled: false},
+        },
+        change_id: 'the id',
+        hashtags: ['test-hashtag'],
+        status: 'NEW',
+        submit_type: 'CHERRY_PICK',
+        labels: {
+          test: {
+            all: [{_account_id: 1, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: [],
+          },
+        },
+        removable_reviewers: [],
+      };
+    });
+
+    test('_computeHashtagReadOnly', () => {
+      flushAsynchronousOperations();
+      let mutable = false;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      mutable = true;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      change.actions.hashtags.enabled = true;
+      assert.isFalse(element._computeHashtagReadOnly(mutable, change));
+      mutable = false;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+    });
+
+    test('hashtag read only hides delete button', () => {
+      flushAsynchronousOperations();
+      element.account = {};
+      element.change = change;
+      flushAsynchronousOperations();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isTrue(button.hasAttribute('hidden'));
+    });
+
+    test('hashtag not read only does not hide delete button', () => {
+      flushAsynchronousOperations();
+      element.account = {test: true};
+      change.actions.hashtags.enabled = true;
+      element.change = change;
+      flushAsynchronousOperations();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isFalse(button.hasAttribute('hidden'));
+    });
+  });
+
+  suite('remove reviewer votes', () => {
+    setup(() => {
+      sandbox.stub(element, '_computeTopicReadOnly').returns(true);
+      element.change = {
+        _number: 42,
+        change_id: 'the id',
+        actions: [],
+        topic: 'the topic',
+        status: 'NEW',
+        submit_type: 'CHERRY_PICK',
+        labels: {
+          test: {
+            all: [{_account_id: 1, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: [],
+          },
+        },
+        removable_reviewers: [],
+      };
+      flushAsynchronousOperations();
+    });
+
+    suite('assignee field', () => {
+      const dummyAccount = {
+        _account_id: 1,
+        name: 'bojack',
+      };
+      const change = {
+        actions: {
+          assignee: {enabled: false},
+        },
+        assignee: dummyAccount,
+      };
+      let deleteStub;
+      let setStub;
+
+      setup(() => {
+        deleteStub = sandbox.stub(element.$.restAPI, 'deleteAssignee');
+        setStub = sandbox.stub(element.$.restAPI, 'setAssignee');
+      });
+
+      test('changing change recomputes _assignee', () => {
+        assert.isFalse(!!element._assignee.length);
+        const change = element.change;
+        change.assignee = dummyAccount;
+        element._changeChanged(change);
+        assert.deepEqual(element._assignee[0], dummyAccount);
+      });
+
+      test('modifying _assignee calls API', () => {
+        assert.isFalse(!!element._assignee.length);
+        element.set('_assignee', [dummyAccount]);
+        assert.isTrue(setStub.calledOnce);
+        assert.deepEqual(element.change.assignee, dummyAccount);
+        element.set('_assignee', [dummyAccount]);
+        assert.isTrue(setStub.calledOnce);
+        element.set('_assignee', []);
+        assert.isTrue(deleteStub.calledOnce);
+        assert.equal(element.change.assignee, undefined);
+        element.set('_assignee', []);
+        assert.isTrue(deleteStub.calledOnce);
+      });
+
+      test('_computeAssigneeReadOnly', () => {
+        let mutable = false;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        mutable = true;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        change.actions.assignee.enabled = true;
+        assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
+        mutable = false;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+      });
+    });
+
+    test('changing topic', () => {
+      const newTopic = 'the new topic';
+      sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
+          Promise.resolve(newTopic));
+      element._handleTopicChanged({}, newTopic);
+      const topicChangedSpy = sandbox.spy();
+      element.addEventListener('topic-changed', topicChangedSpy);
+      assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
+          42, newTopic));
+      return element.$.restAPI.setChangeTopic.lastCall.returnValue
+          .then(() => {
+            assert.equal(element.change.topic, newTopic);
+            assert.isTrue(topicChangedSpy.called);
+          });
+    });
+
+    test('topic removal', () => {
+      sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
+          Promise.resolve());
+      const chip = element.shadowRoot
+          .querySelector('gr-linked-chip');
+      const remove = chip.$.remove;
+      const topicChangedSpy = sandbox.spy();
+      element.addEventListener('topic-changed', topicChangedSpy);
+      MockInteractions.tap(remove);
+      assert.isTrue(chip.disabled);
+      assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
+          42, null));
+      return element.$.restAPI.setChangeTopic.lastCall.returnValue
+          .then(() => {
+            assert.isFalse(chip.disabled);
+            assert.equal(element.change.topic, '');
+            assert.isTrue(topicChangedSpy.called);
+          });
+    });
+
+    test('changing hashtag', () => {
+      flushAsynchronousOperations();
+      element._newHashtag = 'new hashtag';
+      const newHashtag = ['new hashtag'];
+      sandbox.stub(element.$.restAPI, 'setChangeHashtag').returns(
+          Promise.resolve(newHashtag));
+      element._handleHashtagChanged({}, 'new hashtag');
+      assert.isTrue(element.$.restAPI.setChangeHashtag.calledWith(
+          42, {add: ['new hashtag']}));
+      return element.$.restAPI.setChangeHashtag.lastCall.returnValue
+          .then(() => {
+            assert.equal(element.change.hashtags, newHashtag);
+          });
+    });
+  });
+
+  test('editTopic', () => {
+    element.account = {test: true};
+    element.change = {actions: {topic: {enabled: true}}};
+    flushAsynchronousOperations();
+
+    const label = element.shadowRoot
+        .querySelector('.topicEditableLabel');
+    assert.ok(label);
+    sandbox.stub(label, 'open');
+    element.editTopic();
+    flushAsynchronousOperations();
+
+    assert.isTrue(label.open.called);
+  });
+
+  suite('plugin endpoints', () => {
+    test('endpoint params', done => {
+      element.change = {labels: {}};
+      element.revision = {};
+      let hookEl;
+      let plugin;
+      Gerrit.install(
+          p => {
+            plugin = p;
+            plugin.hook('change-metadata-item').getLastAttached()
+                .then(el => hookEl = el);
+          },
+          '0.1',
+          'http://some/plugins/url.html');
+      Gerrit._loadPlugins([]);
+      flush(() => {
+        assert.strictEqual(hookEl.plugin, plugin);
+        assert.strictEqual(hookEl.change, element.change);
+        assert.strictEqual(hookEl.revision, element.revision);
+        done();
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
index a413c6f..9dd5acf 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
@@ -14,147 +14,160 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @extends Polymer.Element
-   */
-  class GrChangeRequirements extends Polymer.mixinBehaviors( [
-    Gerrit.RESTClientBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-change-requirements'; }
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-label/gr-label.js';
+import '../../shared/gr-label-info/gr-label-info.js';
+import '../../shared/gr-limited-text/gr-limited-text.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-requirements_html.js';
 
-    static get properties() {
-      return {
-      /** @type {?} */
-        change: Object,
-        account: Object,
-        mutable: Boolean,
-        _requirements: {
-          type: Array,
-          computed: '_computeRequirements(change)',
-        },
-        _requiredLabels: {
-          type: Array,
-          value: () => [],
-        },
-        _optionalLabels: {
-          type: Array,
-          value: () => [],
-        },
-        _showWip: {
-          type: Boolean,
-          computed: '_computeShowWip(change)',
-        },
-        _showOptionalLabels: {
-          type: Boolean,
-          value: true,
-        },
-      };
-    }
+/**
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @extends Polymer.Element
+ */
+class GrChangeRequirements extends mixinBehaviors( [
+  Gerrit.RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    static get observers() {
-      return [
-        '_computeLabels(change.labels.*)',
-      ];
-    }
+  static get is() { return 'gr-change-requirements'; }
 
-    _computeShowWip(change) {
-      return change.work_in_progress;
-    }
+  static get properties() {
+    return {
+    /** @type {?} */
+      change: Object,
+      account: Object,
+      mutable: Boolean,
+      _requirements: {
+        type: Array,
+        computed: '_computeRequirements(change)',
+      },
+      _requiredLabels: {
+        type: Array,
+        value: () => [],
+      },
+      _optionalLabels: {
+        type: Array,
+        value: () => [],
+      },
+      _showWip: {
+        type: Boolean,
+        computed: '_computeShowWip(change)',
+      },
+      _showOptionalLabels: {
+        type: Boolean,
+        value: true,
+      },
+    };
+  }
 
-    _computeRequirements(change) {
-      const _requirements = [];
+  static get observers() {
+    return [
+      '_computeLabels(change.labels.*)',
+    ];
+  }
 
-      if (change.requirements) {
-        for (const requirement of change.requirements) {
-          requirement.satisfied = requirement.status === 'OK';
-          requirement.style =
-              this._computeRequirementClass(requirement.satisfied);
-          _requirements.push(requirement);
-        }
-      }
-      if (change.work_in_progress) {
-        _requirements.push({
-          fallback_text: 'Work-in-progress',
-          tooltip: 'Change must not be in \'Work in Progress\' state.',
-        });
-      }
+  _computeShowWip(change) {
+    return change.work_in_progress;
+  }
 
-      return _requirements;
-    }
+  _computeRequirements(change) {
+    const _requirements = [];
 
-    _computeRequirementClass(requirementStatus) {
-      return requirementStatus ? 'approved' : '';
-    }
-
-    _computeRequirementIcon(requirementStatus) {
-      return requirementStatus ? 'gr-icons:check' : 'gr-icons:hourglass';
-    }
-
-    _computeLabels(labelsRecord) {
-      const labels = labelsRecord.base;
-      this._optionalLabels = [];
-      this._requiredLabels = [];
-
-      for (const label in labels) {
-        if (!labels.hasOwnProperty(label)) { continue; }
-
-        const labelInfo = labels[label];
-        const icon = this._computeLabelIcon(labelInfo);
-        const style = this._computeLabelClass(labelInfo);
-        const path = labelInfo.optional ? '_optionalLabels' : '_requiredLabels';
-
-        this.push(path, {label, icon, style, labelInfo});
+    if (change.requirements) {
+      for (const requirement of change.requirements) {
+        requirement.satisfied = requirement.status === 'OK';
+        requirement.style =
+            this._computeRequirementClass(requirement.satisfied);
+        _requirements.push(requirement);
       }
     }
-
-    /**
-     * @param {Object} labelInfo
-     * @return {string} The icon name, or undefined if no icon should
-     *     be used.
-     */
-    _computeLabelIcon(labelInfo) {
-      if (labelInfo.approved) { return 'gr-icons:check'; }
-      if (labelInfo.rejected) { return 'gr-icons:close'; }
-      return 'gr-icons:hourglass';
+    if (change.work_in_progress) {
+      _requirements.push({
+        fallback_text: 'Work-in-progress',
+        tooltip: 'Change must not be in \'Work in Progress\' state.',
+      });
     }
 
-    /**
-     * @param {Object} labelInfo
-     */
-    _computeLabelClass(labelInfo) {
-      if (labelInfo.approved) { return 'approved'; }
-      if (labelInfo.rejected) { return 'rejected'; }
-      return '';
-    }
+    return _requirements;
+  }
 
-    _computeShowOptional(optionalFieldsRecord) {
-      return optionalFieldsRecord.base.length ? '' : 'hidden';
-    }
+  _computeRequirementClass(requirementStatus) {
+    return requirementStatus ? 'approved' : '';
+  }
 
-    _computeLabelValue(value) {
-      return (value > 0 ? '+' : '') + value;
-    }
+  _computeRequirementIcon(requirementStatus) {
+    return requirementStatus ? 'gr-icons:check' : 'gr-icons:hourglass';
+  }
 
-    _computeShowHideIcon(showOptionalLabels) {
-      return showOptionalLabels ?
-        'gr-icons:expand-less' :
-        'gr-icons:expand-more';
-    }
+  _computeLabels(labelsRecord) {
+    const labels = labelsRecord.base;
+    this._optionalLabels = [];
+    this._requiredLabels = [];
 
-    _computeSectionClass(show) {
-      return show ? '' : 'hidden';
-    }
+    for (const label in labels) {
+      if (!labels.hasOwnProperty(label)) { continue; }
 
-    _handleShowHide(e) {
-      this._showOptionalLabels = !this._showOptionalLabels;
+      const labelInfo = labels[label];
+      const icon = this._computeLabelIcon(labelInfo);
+      const style = this._computeLabelClass(labelInfo);
+      const path = labelInfo.optional ? '_optionalLabels' : '_requiredLabels';
+
+      this.push(path, {label, icon, style, labelInfo});
     }
   }
 
-  customElements.define(GrChangeRequirements.is, GrChangeRequirements);
-})();
+  /**
+   * @param {Object} labelInfo
+   * @return {string} The icon name, or undefined if no icon should
+   *     be used.
+   */
+  _computeLabelIcon(labelInfo) {
+    if (labelInfo.approved) { return 'gr-icons:check'; }
+    if (labelInfo.rejected) { return 'gr-icons:close'; }
+    return 'gr-icons:hourglass';
+  }
+
+  /**
+   * @param {Object} labelInfo
+   */
+  _computeLabelClass(labelInfo) {
+    if (labelInfo.approved) { return 'approved'; }
+    if (labelInfo.rejected) { return 'rejected'; }
+    return '';
+  }
+
+  _computeShowOptional(optionalFieldsRecord) {
+    return optionalFieldsRecord.base.length ? '' : 'hidden';
+  }
+
+  _computeLabelValue(value) {
+    return (value > 0 ? '+' : '') + value;
+  }
+
+  _computeShowHideIcon(showOptionalLabels) {
+    return showOptionalLabels ?
+      'gr-icons:expand-less' :
+      'gr-icons:expand-more';
+  }
+
+  _computeSectionClass(show) {
+    return show ? '' : 'hidden';
+  }
+
+  _handleShowHide(e) {
+    this._showOptionalLabels = !this._showOptionalLabels;
+  }
+}
+
+customElements.define(GrChangeRequirements.is, GrChangeRequirements);
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js
index 372ae50..311cfe4 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js
@@ -1,31 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-label/gr-label.html">
-<link rel="import" href="../../shared/gr-label-info/gr-label-info.html">
-<link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
-
-<dom-module id="gr-change-requirements">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: table;
@@ -91,59 +82,43 @@
         height: var(--spacing-m);
       }
     </style>
-    <template
-        is="dom-repeat"
-        items="[[_requirements]]">
+    <template is="dom-repeat" items="[[_requirements]]">
       <section>
         <div class="title requirement">
-          <span class$="status [[item.style]]">
+          <span class\$="status [[item.style]]">
             <iron-icon class="icon" icon="[[_computeRequirementIcon(item.satisfied)]]"></iron-icon>
           </span>
           <gr-limited-text class="name" limit="40" text="[[item.fallback_text]]"></gr-limited-text>
         </div>
       </section>
     </template>
-    <template
-        is="dom-repeat"
-        items="[[_requiredLabels]]">
+    <template is="dom-repeat" items="[[_requiredLabels]]">
       <section>
         <div class="title">
-          <span class$="status [[item.style]]">
+          <span class\$="status [[item.style]]">
             <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
           </span>
           <gr-limited-text class="name" limit="40" text="[[item.label]]"></gr-limited-text>
         </div>
         <div class="value">
-          <gr-label-info
-              change="{{change}}"
-              account="[[account]]"
-              mutable="[[mutable]]"
-              label="[[item.label]]"
-              label-info="[[item.labelInfo]]"></gr-label-info>
+          <gr-label-info change="{{change}}" account="[[account]]" mutable="[[mutable]]" label="[[item.label]]" label-info="[[item.labelInfo]]"></gr-label-info>
         </div>
       </section>
     </template>
     <section class="spacer"></section>
-    <section class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"></section>
-    <section
-        show-bottom-border$="[[_showOptionalLabels]]"
-        on-click="_handleShowHide"
-        class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
+    <section class\$="spacer [[_computeShowOptional(_optionalLabels.*)]]"></section>
+    <section show-bottom-border\$="[[_showOptionalLabels]]" on-click="_handleShowHide" class\$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
       <div class="title">Other labels</div>
       <div class="value">
-        <iron-icon
-            id="showHide"
-            icon="[[_computeShowHideIcon(_showOptionalLabels)]]">
+        <iron-icon id="showHide" icon="[[_computeShowHideIcon(_showOptionalLabels)]]">
         </iron-icon>
-      </label>
+      
       </div>
     </section>
-    <template
-        is="dom-repeat"
-        items="[[_optionalLabels]]">
-      <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
+    <template is="dom-repeat" items="[[_optionalLabels]]">
+      <section class\$="optional [[_computeSectionClass(_showOptionalLabels)]]">
         <div class="title">
-          <span class$="status [[item.style]]">
+          <span class\$="status [[item.style]]">
             <template is="dom-if" if="[[item.icon]]">
               <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
             </template>
@@ -154,16 +129,9 @@
           <gr-limited-text class="name" limit="40" text="[[item.label]]"></gr-limited-text>
         </div>
         <div class="value">
-          <gr-label-info
-              change="{{change}}"
-              account="[[account]]"
-              mutable="[[mutable]]"
-              label="[[item.label]]"
-              label-info="[[item.labelInfo]]"></gr-label-info>
+          <gr-label-info change="{{change}}" account="[[account]]" mutable="[[mutable]]" label="[[item.label]]" label-info="[[item.labelInfo]]"></gr-label-info>
         </div>
       </section>
     </template>
-    <section class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"></section>
-  </template>
-  <script src="gr-change-requirements.js"></script>
-</dom-module>
+    <section class\$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"></section>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
index 10466db..2883f20 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-requirements</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-change-requirements.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-change-requirements.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-change-requirements.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,204 +40,206 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-metadata tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-change-requirements.js';
+suite('gr-change-metadata tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('requirements computed fields', () => {
-      assert.isTrue(element._computeShowWip({work_in_progress: true}));
-      assert.isFalse(element._computeShowWip({work_in_progress: false}));
-
-      assert.equal(element._computeRequirementClass(true), 'approved');
-      assert.equal(element._computeRequirementClass(false), '');
-
-      assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
-      assert.equal(element._computeRequirementIcon(false),
-          'gr-icons:hourglass');
-    });
-
-    test('label computed fields', () => {
-      assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
-      assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
-      assert.equal(element._computeLabelIcon({}), 'gr-icons:hourglass');
-
-      assert.equal(element._computeLabelClass({approved: []}), 'approved');
-      assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
-      assert.equal(element._computeLabelClass({}), '');
-      assert.equal(element._computeLabelClass({value: 0}), '');
-
-      assert.equal(element._computeLabelValue(1), '+1');
-      assert.equal(element._computeLabelValue(-1), '-1');
-      assert.equal(element._computeLabelValue(0), '0');
-    });
-
-    test('_computeLabels', () => {
-      assert.equal(element._optionalLabels.length, 0);
-      assert.equal(element._requiredLabels.length, 0);
-      element._computeLabels({base: {
-        test: {
-          all: [{_account_id: 1, name: 'bojack', value: 1}],
-          default_value: 0,
-          values: [],
-          value: 1,
-        },
-        opt_test: {
-          all: [{_account_id: 1, name: 'bojack', value: 1}],
-          default_value: 0,
-          values: [],
-          optional: true,
-        },
-      }});
-      assert.equal(element._optionalLabels.length, 1);
-      assert.equal(element._requiredLabels.length, 1);
-
-      assert.equal(element._optionalLabels[0].label, 'opt_test');
-      assert.equal(element._optionalLabels[0].icon, 'gr-icons:hourglass');
-      assert.equal(element._optionalLabels[0].style, '');
-      assert.ok(element._optionalLabels[0].labelInfo);
-    });
-
-    test('optional show/hide', () => {
-      element._optionalLabels = [{label: 'test'}];
-      flushAsynchronousOperations();
-
-      assert.ok(element.shadowRoot
-          .querySelector('section.optional'));
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.showHide'));
-      flushAsynchronousOperations();
-
-      assert.isFalse(element._showOptionalLabels);
-      assert.isTrue(isHidden(element.shadowRoot
-          .querySelector('section.optional')));
-    });
-
-    test('properly converts satisfied labels', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {
-          Verified: {
-            approved: [],
-          },
-        },
-        requirements: [],
-      };
-      flushAsynchronousOperations();
-
-      assert.ok(element.shadowRoot
-          .querySelector('.approved'));
-      assert.ok(element.shadowRoot
-          .querySelector('.name'));
-      assert.equal(element.shadowRoot
-          .querySelector('.name').text, 'Verified');
-    });
-
-    test('properly converts unsatisfied labels', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {
-          Verified: {
-            approved: false,
-          },
-        },
-      };
-      flushAsynchronousOperations();
-
-      const name = element.shadowRoot
-          .querySelector('.name');
-      assert.ok(name);
-      assert.isFalse(name.hasAttribute('hidden'));
-      assert.equal(name.text, 'Verified');
-    });
-
-    test('properly displays Work In Progress', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {},
-        requirements: [],
-        work_in_progress: true,
-      };
-      flushAsynchronousOperations();
-
-      const changeIsWip = element.shadowRoot
-          .querySelector('.title');
-      assert.ok(changeIsWip);
-    });
-
-    test('properly displays a satisfied requirement', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {},
-        requirements: [{
-          fallback_text: 'Resolve all comments',
-          status: 'OK',
-        }],
-      };
-      flushAsynchronousOperations();
-
-      const requirement = element.shadowRoot
-          .querySelector('.requirement');
-      assert.ok(requirement);
-      assert.isFalse(requirement.hasAttribute('hidden'));
-      assert.ok(requirement.querySelector('.approved'));
-      assert.equal(requirement.querySelector('.name').text,
-          'Resolve all comments');
-    });
-
-    test('satisfied class is applied with OK', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {},
-        requirements: [{
-          fallback_text: 'Resolve all comments',
-          status: 'OK',
-        }],
-      };
-      flushAsynchronousOperations();
-
-      const requirement = element.shadowRoot
-          .querySelector('.requirement');
-      assert.ok(requirement);
-      assert.ok(requirement.querySelector('.approved'));
-    });
-
-    test('satisfied class is not applied with NOT_READY', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {},
-        requirements: [{
-          fallback_text: 'Resolve all comments',
-          status: 'NOT_READY',
-        }],
-      };
-      flushAsynchronousOperations();
-
-      const requirement = element.shadowRoot
-          .querySelector('.requirement');
-      assert.ok(requirement);
-      assert.strictEqual(requirement.querySelector('.approved'), null);
-    });
-
-    test('satisfied class is not applied with RULE_ERROR', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {},
-        requirements: [{
-          fallback_text: 'Resolve all comments',
-          status: 'RULE_ERROR',
-        }],
-      };
-      flushAsynchronousOperations();
-
-      const requirement = element.shadowRoot
-          .querySelector('.requirement');
-      assert.ok(requirement);
-      assert.strictEqual(requirement.querySelector('.approved'), null);
-    });
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('requirements computed fields', () => {
+    assert.isTrue(element._computeShowWip({work_in_progress: true}));
+    assert.isFalse(element._computeShowWip({work_in_progress: false}));
+
+    assert.equal(element._computeRequirementClass(true), 'approved');
+    assert.equal(element._computeRequirementClass(false), '');
+
+    assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
+    assert.equal(element._computeRequirementIcon(false),
+        'gr-icons:hourglass');
+  });
+
+  test('label computed fields', () => {
+    assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
+    assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
+    assert.equal(element._computeLabelIcon({}), 'gr-icons:hourglass');
+
+    assert.equal(element._computeLabelClass({approved: []}), 'approved');
+    assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
+    assert.equal(element._computeLabelClass({}), '');
+    assert.equal(element._computeLabelClass({value: 0}), '');
+
+    assert.equal(element._computeLabelValue(1), '+1');
+    assert.equal(element._computeLabelValue(-1), '-1');
+    assert.equal(element._computeLabelValue(0), '0');
+  });
+
+  test('_computeLabels', () => {
+    assert.equal(element._optionalLabels.length, 0);
+    assert.equal(element._requiredLabels.length, 0);
+    element._computeLabels({base: {
+      test: {
+        all: [{_account_id: 1, name: 'bojack', value: 1}],
+        default_value: 0,
+        values: [],
+        value: 1,
+      },
+      opt_test: {
+        all: [{_account_id: 1, name: 'bojack', value: 1}],
+        default_value: 0,
+        values: [],
+        optional: true,
+      },
+    }});
+    assert.equal(element._optionalLabels.length, 1);
+    assert.equal(element._requiredLabels.length, 1);
+
+    assert.equal(element._optionalLabels[0].label, 'opt_test');
+    assert.equal(element._optionalLabels[0].icon, 'gr-icons:hourglass');
+    assert.equal(element._optionalLabels[0].style, '');
+    assert.ok(element._optionalLabels[0].labelInfo);
+  });
+
+  test('optional show/hide', () => {
+    element._optionalLabels = [{label: 'test'}];
+    flushAsynchronousOperations();
+
+    assert.ok(element.shadowRoot
+        .querySelector('section.optional'));
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.showHide'));
+    flushAsynchronousOperations();
+
+    assert.isFalse(element._showOptionalLabels);
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('section.optional')));
+  });
+
+  test('properly converts satisfied labels', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {
+        Verified: {
+          approved: [],
+        },
+      },
+      requirements: [],
+    };
+    flushAsynchronousOperations();
+
+    assert.ok(element.shadowRoot
+        .querySelector('.approved'));
+    assert.ok(element.shadowRoot
+        .querySelector('.name'));
+    assert.equal(element.shadowRoot
+        .querySelector('.name').text, 'Verified');
+  });
+
+  test('properly converts unsatisfied labels', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {
+        Verified: {
+          approved: false,
+        },
+      },
+    };
+    flushAsynchronousOperations();
+
+    const name = element.shadowRoot
+        .querySelector('.name');
+    assert.ok(name);
+    assert.isFalse(name.hasAttribute('hidden'));
+    assert.equal(name.text, 'Verified');
+  });
+
+  test('properly displays Work In Progress', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [],
+      work_in_progress: true,
+    };
+    flushAsynchronousOperations();
+
+    const changeIsWip = element.shadowRoot
+        .querySelector('.title');
+    assert.ok(changeIsWip);
+  });
+
+  test('properly displays a satisfied requirement', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'OK',
+      }],
+    };
+    flushAsynchronousOperations();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.isFalse(requirement.hasAttribute('hidden'));
+    assert.ok(requirement.querySelector('.approved'));
+    assert.equal(requirement.querySelector('.name').text,
+        'Resolve all comments');
+  });
+
+  test('satisfied class is applied with OK', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'OK',
+      }],
+    };
+    flushAsynchronousOperations();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.ok(requirement.querySelector('.approved'));
+  });
+
+  test('satisfied class is not applied with NOT_READY', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'NOT_READY',
+      }],
+    };
+    flushAsynchronousOperations();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.strictEqual(requirement.querySelector('.approved'), null);
+  });
+
+  test('satisfied class is not applied with RULE_ERROR', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'RULE_ERROR',
+      }],
+    };
+    flushAsynchronousOperations();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.strictEqual(requirement.querySelector('.approved'), null);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 58505ff..7a80d34 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,2064 +14,2111 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const CHANGE_ID_ERROR = {
-    MISMATCH: 'mismatch',
-    MISSING: 'missing',
-  };
-  const CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import '@polymer/paper-tabs/paper-tabs.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import '../../edit/gr-edit-constants.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../shared/gr-account-link/gr-account-link.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-change-star/gr-change-star.js';
+import '../../shared/gr-change-status/gr-change-status.js';
+import '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-editable-content/gr-editable-content.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import '../../shared/gr-linked-text/gr-linked-text.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
+import '../../shared/revision-info/revision-info.js';
+import '../gr-change-actions/gr-change-actions.js';
+import '../gr-change-metadata/gr-change-metadata.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../gr-commit-info/gr-commit-info.js';
+import '../gr-download-dialog/gr-download-dialog.js';
+import '../gr-file-list-header/gr-file-list-header.js';
+import '../gr-file-list/gr-file-list.js';
+import '../gr-included-in-dialog/gr-included-in-dialog.js';
+import '../gr-messages-list/gr-messages-list.js';
+import '../gr-related-changes-list/gr-related-changes-list.js';
+import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js';
+import '../gr-reply-dialog/gr-reply-dialog.js';
+import '../gr-thread-list/gr-thread-list.js';
+import '../gr-upload-help-dialog/gr-upload-help-dialog.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {beforeNextRender} from '@polymer/polymer/lib/utils/render-status.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-view_html.js';
 
-  const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
-  const DEFAULT_NUM_FILES_SHOWN = 200;
+const CHANGE_ID_ERROR = {
+  MISMATCH: 'mismatch',
+  MISSING: 'missing',
+};
+const CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
 
-  const REVIEWERS_REGEX = /^(R|CC)=/gm;
-  const MIN_CHECK_INTERVAL_SECS = 0;
+const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+const DEFAULT_NUM_FILES_SHOWN = 200;
 
-  // These are the same as the breakpoint set in CSS. Make sure both are changed
-  // together.
-  const BREAKPOINT_RELATED_SMALL = '50em';
-  const BREAKPOINT_RELATED_MED = '75em';
+const REVIEWERS_REGEX = /^(R|CC)=/gm;
+const MIN_CHECK_INTERVAL_SECS = 0;
 
-  // In the event that the related changes medium width calculation is too close
-  // to zero, provide some height.
-  const MINIMUM_RELATED_MAX_HEIGHT = 100;
+// These are the same as the breakpoint set in CSS. Make sure both are changed
+// together.
+const BREAKPOINT_RELATED_SMALL = '50em';
+const BREAKPOINT_RELATED_MED = '75em';
 
-  const SMALL_RELATED_HEIGHT = 400;
+// In the event that the related changes medium width calculation is too close
+// to zero, provide some height.
+const MINIMUM_RELATED_MAX_HEIGHT = 100;
 
-  const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
+const SMALL_RELATED_HEIGHT = 400;
 
-  const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
+const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
 
-  const MSG_PREFIX = '#message-';
+const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
 
-  const ReloadToastMessage = {
-    NEWER_REVISION: 'A newer patch set has been uploaded',
-    RESTORED: 'This change has been restored',
-    ABANDONED: 'This change has been abandoned',
-    MERGED: 'This change has been merged',
-    NEW_MESSAGE: 'There are new messages on this change',
-  };
+const MSG_PREFIX = '#message-';
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+const ReloadToastMessage = {
+  NEWER_REVISION: 'A newer patch set has been uploaded',
+  RESTORED: 'This change has been restored',
+  ABANDONED: 'This change has been abandoned',
+  MERGED: 'This change has been merged',
+  NEW_MESSAGE: 'There are new messages on this change',
+};
 
-  const CommentTabs = {
-    CHANGE_LOG: 0,
-    COMMENT_THREADS: 1,
-    ROBOT_COMMENTS: 2,
-  };
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
 
-  const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
-  const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
-  const SEND_REPLY_TIMING_LABEL = 'SendReply';
-  // Making the tab names more unique in case a plugin adds one with same name
-  const FILES_TAB_NAME = '__gerrit_internal_files';
-  const FINDINGS_TAB_NAME = '__gerrit_internal_findings';
-  const ROBOT_COMMENTS_LIMIT = 10;
+const CommentTabs = {
+  CHANGE_LOG: 0,
+  COMMENT_THREADS: 1,
+  ROBOT_COMMENTS: 2,
+};
+
+const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
+const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
+const SEND_REPLY_TIMING_LABEL = 'SendReply';
+// Making the tab names more unique in case a plugin adds one with same name
+const FILES_TAB_NAME = '__gerrit_internal_files';
+const FINDINGS_TAB_NAME = '__gerrit_internal_findings';
+const ROBOT_COMMENTS_LIMIT = 10;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @extends Polymer.Element
+ */
+class GrChangeView extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+  Gerrit.PatchSetBehavior,
+  Gerrit.RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-change-view'; }
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @extends Polymer.Element
+   * Fired if an error occurs when fetching the change data.
+   *
+   * @event page-error
    */
-  class GrChangeView extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-    Gerrit.PatchSetBehavior,
-    Gerrit.RESTClientBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-change-view'; }
+
+  /**
+   * Fired if being logged in is required.
+   *
+   * @event show-auth-required
+   */
+
+  static get properties() {
+    return {
     /**
-     * Fired when the title of the page should change.
-     *
-     * @event title-change
+     * URL params passed from the router.
      */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+      /** @type {?} */
+      viewState: {
+        type: Object,
+        notify: true,
+        value() { return {}; },
+        observer: '_viewStateChanged',
+      },
+      backPage: String,
+      hasParent: Boolean,
+      keyEventTarget: {
+        type: Object,
+        value() { return document.body; },
+      },
+      disableEdit: {
+        type: Boolean,
+        value: false,
+      },
+      disableDiffPrefs: {
+        type: Boolean,
+        value: false,
+      },
+      _diffPrefsDisabled: {
+        type: Boolean,
+        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+      },
+      _commentThreads: Array,
+      // TODO(taoalpha): Consider replacing diffDrafts
+      // with _draftCommentThreads everywhere, currently only
+      // replaced in reply-dialoig
+      _draftCommentThreads: {
+        type: Array,
+      },
+      _robotCommentThreads: {
+        type: Array,
+        computed: '_computeRobotCommentThreads(_commentThreads,'
+          + ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
+      },
+      /** @type {?} */
+      _serverConfig: {
+        type: Object,
+        observer: '_startUpdateCheckTimer',
+      },
+      _diffPrefs: Object,
+      _numFilesShown: {
+        type: Number,
+        value: DEFAULT_NUM_FILES_SHOWN,
+        observer: '_numFilesShownChanged',
+      },
+      _account: {
+        type: Object,
+        value: {},
+      },
+      _prefs: Object,
+      /** @type {?} */
+      _changeComments: Object,
+      _canStartReview: {
+        type: Boolean,
+        computed: '_computeCanStartReview(_change)',
+      },
+      _comments: Object,
+      /** @type {?} */
+      _change: {
+        type: Object,
+        observer: '_changeChanged',
+      },
+      _revisionInfo: {
+        type: Object,
+        computed: '_getRevisionInfo(_change)',
+      },
+      /** @type {?} */
+      _commitInfo: Object,
+      _currentRevision: {
+        type: Object,
+        computed: '_computeCurrentRevision(_change.current_revision, ' +
+          '_change.revisions)',
+        observer: '_handleCurrentRevisionUpdate',
+      },
+      _files: Object,
+      _changeNum: String,
+      _diffDrafts: {
+        type: Object,
+        value() { return {}; },
+      },
+      _editingCommitMessage: {
+        type: Boolean,
+        value: false,
+      },
+      _hideEditCommitMessage: {
+        type: Boolean,
+        computed: '_computeHideEditCommitMessage(_loggedIn, ' +
+            '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
+            '_commitCollapsible)',
+      },
+      _diffAgainst: String,
+      /** @type {?string} */
+      _latestCommitMessage: {
+        type: String,
+        value: '',
+      },
+      _commentTabs: {
+        type: Object,
+        value: CommentTabs,
+      },
+      _lineHeight: Number,
+      _changeIdCommitMessageError: {
+        type: String,
+        computed:
+        '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
+      },
+      /** @type {?} */
+      _patchRange: {
+        type: Object,
+      },
+      _filesExpanded: String,
+      _basePatchNum: String,
+      _selectedRevision: Object,
+      _currentRevisionActions: Object,
+      _allPatchSets: {
+        type: Array,
+        computed: 'computeAllPatchSets(_change, _change.revisions.*)',
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _loading: Boolean,
+      /** @type {?} */
+      _projectConfig: Object,
+      _rebaseOnCurrent: Boolean,
+      _replyButtonLabel: {
+        type: String,
+        value: 'Reply',
+        computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
+      },
+      _selectedPatchSet: String,
+      _shownFileCount: Number,
+      _initialLoadComplete: {
+        type: Boolean,
+        value: false,
+      },
+      _replyDisabled: {
+        type: Boolean,
+        value: true,
+        computed: '_computeReplyDisabled(_serverConfig)',
+      },
+      _changeStatus: {
+        type: String,
+        computed: 'changeStatusString(_change)',
+      },
+      _changeStatuses: {
+        type: String,
+        computed:
+        '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
+      },
+      /** If false, then the "Show more" button was used to expand. */
+      _commitCollapsed: {
+        type: Boolean,
+        value: true,
+      },
+      /** Is the "Show more/less" button visible? */
+      _commitCollapsible: {
+        type: Boolean,
+        computed: '_computeCommitCollapsible(_latestCommitMessage)',
+      },
+      _relatedChangesCollapsed: {
+        type: Boolean,
+        value: true,
+      },
+      /** @type {?number} */
+      _updateCheckTimerHandle: Number,
+      _editMode: {
+        type: Boolean,
+        computed: '_computeEditMode(_patchRange.*, params.*)',
+      },
+      _showRelatedToggle: {
+        type: Boolean,
+        value: false,
+        observer: '_updateToggleContainerClass',
+      },
+      _parentIsCurrent: Boolean,
+      _submitEnabled: {
+        type: Boolean,
+        computed: '_isSubmitEnabled(_currentRevisionActions)',
+      },
 
-    /**
-     * Fired if an error occurs when fetching the change data.
-     *
-     * @event page-error
-     */
+      /** @type {?} */
+      _mergeable: {
+        type: Boolean,
+        value: undefined,
+      },
+      _currentView: {
+        type: Number,
+        value: CommentTabs.CHANGE_LOG,
+      },
+      _showFileTabContent: {
+        type: Boolean,
+        value: true,
+      },
+      /** @type {Array<string>} */
+      _dynamicTabHeaderEndpoints: {
+        type: Array,
+      },
+      /** @type {Array<string>} */
+      _dynamicTabContentEndpoints: {
+        type: Array,
+      },
+      // The dynamic content of the plugin added tab
+      _selectedTabPluginEndpoint: {
+        type: String,
+      },
+      // The dynamic heading of the plugin added tab
+      _selectedTabPluginHeader: {
+        type: String,
+      },
+      _robotCommentsPatchSetDropdownItems: {
+        type: Array,
+        value() { return []; },
+        computed: '_computeRobotCommentsPatchSetDropdownItems(_change, ' +
+          '_commentThreads)',
+      },
+      _currentRobotCommentsPatchSet: {
+        type: Number,
+      },
+      _files_tab_name: {
+        type: String,
+        value: FILES_TAB_NAME,
+      },
+      _findings_tab_name: {
+        type: String,
+        value: FINDINGS_TAB_NAME,
+      },
+      _currentTabName: {
+        type: String,
+        value: FILES_TAB_NAME,
+      },
+      _showAllRobotComments: {
+        type: Boolean,
+        value: false,
+      },
+      _showRobotCommentsButton: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-    /**
-     * Fired if being logged in is required.
-     *
-     * @event show-auth-required
-     */
+  static get observers() {
+    return [
+      '_labelsChanged(_change.labels.*)',
+      '_paramsAndChangeChanged(params, _change)',
+      '_patchNumChanged(_patchRange.patchNum)',
+    ];
+  }
 
-    static get properties() {
-      return {
-      /**
-       * URL params passed from the router.
-       */
-        params: {
-          type: Object,
-          observer: '_paramsChanged',
-        },
-        /** @type {?} */
-        viewState: {
-          type: Object,
-          notify: true,
-          value() { return {}; },
-          observer: '_viewStateChanged',
-        },
-        backPage: String,
-        hasParent: Boolean,
-        keyEventTarget: {
-          type: Object,
-          value() { return document.body; },
-        },
-        disableEdit: {
-          type: Boolean,
-          value: false,
-        },
-        disableDiffPrefs: {
-          type: Boolean,
-          value: false,
-        },
-        _diffPrefsDisabled: {
-          type: Boolean,
-          computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
-        },
-        _commentThreads: Array,
-        // TODO(taoalpha): Consider replacing diffDrafts
-        // with _draftCommentThreads everywhere, currently only
-        // replaced in reply-dialoig
-        _draftCommentThreads: {
-          type: Array,
-        },
-        _robotCommentThreads: {
-          type: Array,
-          computed: '_computeRobotCommentThreads(_commentThreads,'
-            + ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
-        },
-        /** @type {?} */
-        _serverConfig: {
-          type: Object,
-          observer: '_startUpdateCheckTimer',
-        },
-        _diffPrefs: Object,
-        _numFilesShown: {
-          type: Number,
-          value: DEFAULT_NUM_FILES_SHOWN,
-          observer: '_numFilesShownChanged',
-        },
-        _account: {
-          type: Object,
-          value: {},
-        },
-        _prefs: Object,
-        /** @type {?} */
-        _changeComments: Object,
-        _canStartReview: {
-          type: Boolean,
-          computed: '_computeCanStartReview(_change)',
-        },
-        _comments: Object,
-        /** @type {?} */
-        _change: {
-          type: Object,
-          observer: '_changeChanged',
-        },
-        _revisionInfo: {
-          type: Object,
-          computed: '_getRevisionInfo(_change)',
-        },
-        /** @type {?} */
-        _commitInfo: Object,
-        _currentRevision: {
-          type: Object,
-          computed: '_computeCurrentRevision(_change.current_revision, ' +
-            '_change.revisions)',
-          observer: '_handleCurrentRevisionUpdate',
-        },
-        _files: Object,
-        _changeNum: String,
-        _diffDrafts: {
-          type: Object,
-          value() { return {}; },
-        },
-        _editingCommitMessage: {
-          type: Boolean,
-          value: false,
-        },
-        _hideEditCommitMessage: {
-          type: Boolean,
-          computed: '_computeHideEditCommitMessage(_loggedIn, ' +
-              '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
-              '_commitCollapsible)',
-        },
-        _diffAgainst: String,
-        /** @type {?string} */
-        _latestCommitMessage: {
-          type: String,
-          value: '',
-        },
-        _commentTabs: {
-          type: Object,
-          value: CommentTabs,
-        },
-        _lineHeight: Number,
-        _changeIdCommitMessageError: {
-          type: String,
-          computed:
-          '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
-        },
-        /** @type {?} */
-        _patchRange: {
-          type: Object,
-        },
-        _filesExpanded: String,
-        _basePatchNum: String,
-        _selectedRevision: Object,
-        _currentRevisionActions: Object,
-        _allPatchSets: {
-          type: Array,
-          computed: 'computeAllPatchSets(_change, _change.revisions.*)',
-        },
-        _loggedIn: {
-          type: Boolean,
-          value: false,
-        },
-        _loading: Boolean,
-        /** @type {?} */
-        _projectConfig: Object,
-        _rebaseOnCurrent: Boolean,
-        _replyButtonLabel: {
-          type: String,
-          value: 'Reply',
-          computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
-        },
-        _selectedPatchSet: String,
-        _shownFileCount: Number,
-        _initialLoadComplete: {
-          type: Boolean,
-          value: false,
-        },
-        _replyDisabled: {
-          type: Boolean,
-          value: true,
-          computed: '_computeReplyDisabled(_serverConfig)',
-        },
-        _changeStatus: {
-          type: String,
-          computed: 'changeStatusString(_change)',
-        },
-        _changeStatuses: {
-          type: String,
-          computed:
-          '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
-        },
-        /** If false, then the "Show more" button was used to expand. */
-        _commitCollapsed: {
-          type: Boolean,
-          value: true,
-        },
-        /** Is the "Show more/less" button visible? */
-        _commitCollapsible: {
-          type: Boolean,
-          computed: '_computeCommitCollapsible(_latestCommitMessage)',
-        },
-        _relatedChangesCollapsed: {
-          type: Boolean,
-          value: true,
-        },
-        /** @type {?number} */
-        _updateCheckTimerHandle: Number,
-        _editMode: {
-          type: Boolean,
-          computed: '_computeEditMode(_patchRange.*, params.*)',
-        },
-        _showRelatedToggle: {
-          type: Boolean,
-          value: false,
-          observer: '_updateToggleContainerClass',
-        },
-        _parentIsCurrent: Boolean,
-        _submitEnabled: {
-          type: Boolean,
-          computed: '_isSubmitEnabled(_currentRevisionActions)',
-        },
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
+      [this.Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
+      [this.Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
+      [this.Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+      [this.Shortcut.OPEN_DOWNLOAD_DIALOG]:
+          '_handleOpenDownloadDialogShortcut',
+      [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+      [this.Shortcut.TOGGLE_CHANGE_STAR]: '_handleToggleChangeStar',
+      [this.Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
+      [this.Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
+      [this.Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
+      [this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
+      [this.Shortcut.EDIT_TOPIC]: '_handleEditTopic',
+    };
+  }
 
-        /** @type {?} */
-        _mergeable: {
-          type: Boolean,
-          value: undefined,
-        },
-        _currentView: {
-          type: Number,
-          value: CommentTabs.CHANGE_LOG,
-        },
-        _showFileTabContent: {
-          type: Boolean,
-          value: true,
-        },
-        /** @type {Array<string>} */
-        _dynamicTabHeaderEndpoints: {
-          type: Array,
-        },
-        /** @type {Array<string>} */
-        _dynamicTabContentEndpoints: {
-          type: Array,
-        },
-        // The dynamic content of the plugin added tab
-        _selectedTabPluginEndpoint: {
-          type: String,
-        },
-        // The dynamic heading of the plugin added tab
-        _selectedTabPluginHeader: {
-          type: String,
-        },
-        _robotCommentsPatchSetDropdownItems: {
-          type: Array,
-          value() { return []; },
-          computed: '_computeRobotCommentsPatchSetDropdownItems(_change, ' +
-            '_commentThreads)',
-        },
-        _currentRobotCommentsPatchSet: {
-          type: Number,
-        },
-        _files_tab_name: {
-          type: String,
-          value: FILES_TAB_NAME,
-        },
-        _findings_tab_name: {
-          type: String,
-          value: FINDINGS_TAB_NAME,
-        },
-        _currentTabName: {
-          type: String,
-          value: FILES_TAB_NAME,
-        },
-        _showAllRobotComments: {
-          type: Boolean,
-          value: false,
-        },
-        _showRobotCommentsButton: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
+  /** @override */
+  created() {
+    super.created();
 
-    static get observers() {
-      return [
-        '_labelsChanged(_change.labels.*)',
-        '_paramsAndChangeChanged(params, _change)',
-        '_patchNumChanged(_patchRange.patchNum)',
-      ];
-    }
+    this.addEventListener('topic-changed',
+        () => this._handleTopicChanged());
 
-    keyboardShortcuts() {
-      return {
-        [this.Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
-        [this.Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
-        [this.Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
-        [this.Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
-        [this.Shortcut.OPEN_DOWNLOAD_DIALOG]:
-            '_handleOpenDownloadDialogShortcut',
-        [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-        [this.Shortcut.TOGGLE_CHANGE_STAR]: '_handleToggleChangeStar',
-        [this.Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
-        [this.Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
-        [this.Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
-        [this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
-        [this.Shortcut.EDIT_TOPIC]: '_handleEditTopic',
-      };
-    }
+    this.addEventListener(
+        // When an overlay is opened in a mobile viewport, the overlay has a full
+        // screen view. When it has a full screen view, we do not want the
+        // background to be scrollable. This will eliminate background scroll by
+        // hiding most of the contents on the screen upon opening, and showing
+        // again upon closing.
+        'fullscreen-overlay-opened',
+        () => this._handleHideBackgroundContent());
 
-    /** @override */
-    created() {
-      super.created();
-
-      this.addEventListener('topic-changed',
-          () => this._handleTopicChanged());
-
-      this.addEventListener(
-          // When an overlay is opened in a mobile viewport, the overlay has a full
-          // screen view. When it has a full screen view, we do not want the
-          // background to be scrollable. This will eliminate background scroll by
-          // hiding most of the contents on the screen upon opening, and showing
-          // again upon closing.
-          'fullscreen-overlay-opened',
-          () => this._handleHideBackgroundContent());
-
-      this.addEventListener('fullscreen-overlay-closed',
-          () => this._handleShowBackgroundContent());
-
-      this.addEventListener('diff-comments-modified',
-          () => this._handleReloadCommentThreads());
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this._getServerConfig().then(config => {
-        this._serverConfig = config;
-      });
-
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-        if (loggedIn) {
-          this.$.restAPI.getAccount().then(acct => {
-            this._account = acct;
-          });
-        }
-        this._setDiffViewMode();
-      });
-
-      Gerrit.awaitPluginsLoaded()
-          .then(() => {
-            this._dynamicTabHeaderEndpoints =
-            Gerrit._endpoints.getDynamicEndpoints('change-view-tab-header');
-            this._dynamicTabContentEndpoints =
-            Gerrit._endpoints.getDynamicEndpoints('change-view-tab-content');
-            if (this._dynamicTabContentEndpoints.length !==
-            this._dynamicTabHeaderEndpoints.length) {
-              console.warn('Different number of tab headers and tab content.');
-            }
-          })
-          .then(() => this._setPrimaryTab());
-
-      this.addEventListener('comment-save', this._handleCommentSave.bind(this));
-      this.addEventListener('comment-refresh', this._reloadDrafts.bind(this));
-      this.addEventListener('comment-discard',
-          this._handleCommentDiscard.bind(this));
-      this.addEventListener('change-message-deleted',
-          () => this._reload());
-      this.addEventListener('editable-content-save',
-          this._handleCommitMessageSave.bind(this));
-      this.addEventListener('editable-content-cancel',
-          this._handleCommitMessageCancel.bind(this));
-      this.addEventListener('open-fix-preview',
-          this._onOpenFixPreview.bind(this));
-      this.addEventListener('close-fix-preview',
-          this._onCloseFixPreview.bind(this));
-      this.listen(window, 'scroll', '_handleScroll');
-      this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-    }
-
-    /** @override */
-    detached() {
-      super.detached();
-      this.unlisten(window, 'scroll', '_handleScroll');
-      this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
-
-      if (this._updateCheckTimerHandle) {
-        this._cancelUpdateCheckTimer();
-      }
-    }
-
-    get messagesList() {
-      return this.shadowRoot.querySelector('gr-messages-list');
-    }
-
-    get threadList() {
-      return this.shadowRoot.querySelector('gr-thread-list');
-    }
-
-    /**
-     * @param {boolean=} opt_reset
-     */
-    _setDiffViewMode(opt_reset) {
-      if (!opt_reset && this.viewState.diffViewMode) { return; }
-
-      return this._getPreferences()
-          .then( prefs => {
-            if (!this.viewState.diffMode) {
-              this.set('viewState.diffMode', prefs.default_diff_view);
-            }
-          })
-          .then(() => {
-            if (!this.viewState.diffMode) {
-              this.set('viewState.diffMode', 'SIDE_BY_SIDE');
-            }
-          });
-    }
-
-    _onOpenFixPreview(e) {
-      this.$.applyFixDialog.open(e);
-    }
-
-    _onCloseFixPreview(e) {
-      this._reload();
-    }
-
-    _handleToggleDiffMode(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
-        this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
-      } else {
-        this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
-      }
-    }
-
-    _handleCommentTabChange() {
-      this._currentView = this.$.commentTabs.selected;
-      const type = Object.keys(CommentTabs).find(key => CommentTabs[key] ===
-          this._currentView);
-      this.$.reporting.reportInteraction('comment-tab-changed', {tabName:
-          type});
-    }
-
-    _isSelectedView(currentView, view) {
-      return currentView === view;
-    }
-
-    _findIfTabMatches(currentTab, tab) {
-      return currentTab === tab;
-    }
-
-    _handleFileTabChange(e) {
-      const selectedIndex = e.target.selected;
-      const tabs = e.target.querySelectorAll('paper-tab');
-      this._currentTabName = tabs[selectedIndex] &&
-        tabs[selectedIndex].dataset.name;
-      const source = e && e.type ? e.type : '';
-      const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
-          this._currentTabName);
-      if (pluginIndex !== -1) {
-        this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
-            pluginIndex];
-        this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
-            pluginIndex];
-      } else {
-        this._selectedTabPluginEndpoint = '';
-        this._selectedTabPluginHeader = '';
-      }
-      this.$.reporting.reportInteraction('tab-changed',
-          {tabName: this._currentTabName, source});
-    }
-
-    _handleShowTab(e) {
-      const primaryTabs = this.shadowRoot.querySelector('#primaryTabs');
-      const tabs = primaryTabs.querySelectorAll('paper-tab');
-      let idx = -1;
-      tabs.forEach((tab, index) => {
-        if (tab.dataset.name === e.detail.tab) idx = index;
-      });
-      if (idx === -1) {
-        console.error(e.detail.tab + ' tab not found');
-        return;
-      }
-      primaryTabs.selected = idx;
-      primaryTabs.scrollIntoView();
-      this.$.reporting.reportInteraction('show-tab', {tabName: e.detail.tab});
-    }
-
-    _handleEditCommitMessage(e) {
-      this._editingCommitMessage = true;
-      this.$.commitMessageEditor.focusTextarea();
-    }
-
-    _handleCommitMessageSave(e) {
-      // Trim trailing whitespace from each line.
-      const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
-
-      this.$.jsAPI.handleCommitMessage(this._change, message);
-
-      this.$.commitMessageEditor.disabled = true;
-      this.$.restAPI.putChangeCommitMessage(
-          this._changeNum, message).then(resp => {
-        this.$.commitMessageEditor.disabled = false;
-        if (!resp.ok) { return; }
-
-        this._latestCommitMessage = this._prepareCommitMsgForLinkify(
-            message);
-        this._editingCommitMessage = false;
-        this._reloadWindow();
-      })
-          .catch(err => {
-            this.$.commitMessageEditor.disabled = false;
-          });
-    }
-
-    _reloadWindow() {
-      window.location.reload();
-    }
-
-    _handleCommitMessageCancel(e) {
-      this._editingCommitMessage = false;
-    }
-
-    _computeChangeStatusChips(change, mergeable, submitEnabled) {
-      // Polymer 2: check for undefined
-      if ([
-        change,
-        mergeable,
-      ].some(arg => arg === undefined)) {
-        // To keep consistent with Polymer 1, we are returning undefined
-        // if not all dependencies are defined
-        return undefined;
-      }
-
-      // Show no chips until mergeability is loaded.
-      if (mergeable === null) {
-        return [];
-      }
-
-      const options = {
-        includeDerived: true,
-        mergeable: !!mergeable,
-        submitEnabled: !!submitEnabled,
-      };
-      return this.changeStatuses(change, options);
-    }
-
-    _computeHideEditCommitMessage(
-        loggedIn, editing, change, editMode, collapsed, collapsible) {
-      if (!loggedIn || editing ||
-          (change && change.status === this.ChangeStatus.MERGED) ||
-          editMode ||
-          (collapsed && collapsible)) {
-        return true;
-      }
-
-      return false;
-    }
-
-    _robotCommentCountPerPatchSet(threads) {
-      return threads.reduce((robotCommentCountMap, thread) => {
-        const comments = thread.comments;
-        const robotCommentsCount = comments.reduce((acc, comment) =>
-          (comment.robot_id ? acc + 1 : acc), 0);
-        robotCommentCountMap[comments[0].patch_set] =
-            (robotCommentCountMap[comments[0].patch_set] || 0) +
-          robotCommentsCount;
-        return robotCommentCountMap;
-      }, {});
-    }
-
-    _computeText(patch, commentThreads) {
-      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})`;
-    }
-
-    _computeRobotCommentsPatchSetDropdownItems(change, commentThreads) {
-      if (!change || !commentThreads || !change.revisions) return [];
-
-      return Object.values(change.revisions)
-          .filter(patch => patch._number !== 'edit')
-          .map(patch => {
-            return {
-              text: this._computeText(patch, commentThreads),
-              value: patch._number,
-            };
-          })
-          .sort((a, b) => b.value - a.value);
-    }
-
-    _handleCurrentRevisionUpdate(currentRevision) {
-      this._currentRobotCommentsPatchSet = currentRevision._number;
-    }
-
-    _handleRobotCommentPatchSetChanged(e) {
-      const patchSet = parseInt(e.detail.value);
-      if (patchSet === this._currentRobotCommentsPatchSet) return;
-      this._currentRobotCommentsPatchSet = patchSet;
-    }
-
-    _computeShowText(showAllRobotComments) {
-      return showAllRobotComments ? 'Show Less' : 'Show more';
-    }
-
-    _toggleShowRobotComments() {
-      this._showAllRobotComments = !this._showAllRobotComments;
-    }
-
-    _computeRobotCommentThreads(commentThreads, currentRobotCommentsPatchSet,
-        showAllRobotComments) {
-      if (!commentThreads || !currentRobotCommentsPatchSet) return [];
-      const threads = commentThreads.filter(thread => {
-        const comments = thread.comments || [];
-        return comments.length && comments[0].robot_id && (comments[0].patch_set
-          === currentRobotCommentsPatchSet);
-      });
-      this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
-      return threads.slice(0, showAllRobotComments ? undefined :
-        ROBOT_COMMENTS_LIMIT);
-    }
-
-    _handleReloadCommentThreads() {
-      // Get any new drafts that have been saved in the diff view and show
-      // in the comment thread view.
-      this._reloadDrafts().then(() => {
-        this._commentThreads = this._changeComments.getAllThreadsForChange()
-            .map(c => Object.assign({}, c));
-        Polymer.dom.flush();
-      });
-    }
-
-    _handleReloadDiffComments(e) {
-      // Keeps the file list counts updated.
-      this._reloadDrafts().then(() => {
-        // Get any new drafts that have been saved in the thread view and show
-        // in the diff view.
-        this.$.fileList.reloadCommentsForThreadWithRootId(e.detail.rootId,
-            e.detail.path);
-        Polymer.dom.flush();
-      });
-    }
-
-    _computeTotalCommentCounts(unresolvedCount, changeComments) {
-      if (!changeComments) return undefined;
-      const draftCount = changeComments.computeDraftCount();
-      const unresolvedString = GrCountStringFormatter.computeString(
-          unresolvedCount, 'unresolved');
-      const draftString = GrCountStringFormatter.computePluralString(
-          draftCount, 'draft');
-
-      return unresolvedString +
-          // Add a comma and space if both unresolved and draft comments exist.
-          (unresolvedString && draftString ? ', ' : '') +
-          draftString;
-    }
-
-    _handleCommentSave(e) {
-      const draft = e.detail.comment;
-      if (!draft.__draft) { return; }
-
-      draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-      // The use of path-based notification helpers (set, push) can’t be used
-      // because the paths could contain dots in them. A new object must be
-      // created to satisfy Polymer’s dirty checking.
-      // https://github.com/Polymer/polymer/issues/3127
-      const diffDrafts = Object.assign({}, this._diffDrafts);
-      if (!diffDrafts[draft.path]) {
-        diffDrafts[draft.path] = [draft];
-        this._diffDrafts = diffDrafts;
-        return;
-      }
-      for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
-        if (this._diffDrafts[draft.path][i].id === draft.id) {
-          diffDrafts[draft.path][i] = draft;
-          this._diffDrafts = diffDrafts;
-          return;
-        }
-      }
-      diffDrafts[draft.path].push(draft);
-      diffDrafts[draft.path].sort((c1, c2) =>
-        // No line number means that it’s a file comment. Sort it above the
-        // others.
-        (c1.line || -1) - (c2.line || -1)
-      );
-      this._diffDrafts = diffDrafts;
-    }
-
-    _handleCommentDiscard(e) {
-      const draft = e.detail.comment;
-      if (!draft.__draft) { return; }
-
-      if (!this._diffDrafts[draft.path]) {
-        return;
-      }
-      let index = -1;
-      for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
-        if (this._diffDrafts[draft.path][i].id === draft.id) {
-          index = i;
-          break;
-        }
-      }
-      if (index === -1) {
-        // It may be a draft that hasn’t been added to _diffDrafts since it was
-        // never saved.
-        return;
-      }
-
-      draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-      // The use of path-based notification helpers (set, push) can’t be used
-      // because the paths could contain dots in them. A new object must be
-      // created to satisfy Polymer’s dirty checking.
-      // https://github.com/Polymer/polymer/issues/3127
-      const diffDrafts = Object.assign({}, this._diffDrafts);
-      diffDrafts[draft.path].splice(index, 1);
-      if (diffDrafts[draft.path].length === 0) {
-        delete diffDrafts[draft.path];
-      }
-      this._diffDrafts = diffDrafts;
-    }
-
-    _handleReplyTap(e) {
-      e.preventDefault();
-      this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-    }
-
-    _handleOpenDiffPrefs() {
-      this.$.fileList.openDiffPrefs();
-    }
-
-    _handleOpenIncludedInDialog() {
-      this.$.includedInDialog.loadData().then(() => {
-        Polymer.dom.flush();
-        this.$.includedInOverlay.refit();
-      });
-      this.$.includedInOverlay.open();
-    }
-
-    _handleIncludedInDialogClose(e) {
-      this.$.includedInOverlay.close();
-    }
-
-    _handleOpenDownloadDialog() {
-      this.$.downloadOverlay.open().then(() => {
-        this.$.downloadOverlay
-            .setFocusStops(this.$.downloadDialog.getFocusStops());
-        this.$.downloadDialog.focus();
-      });
-    }
-
-    _handleDownloadDialogClose(e) {
-      this.$.downloadOverlay.close();
-    }
-
-    _handleOpenUploadHelpDialog(e) {
-      this.$.uploadHelpOverlay.open();
-    }
-
-    _handleCloseUploadHelpDialog(e) {
-      this.$.uploadHelpOverlay.close();
-    }
-
-    _handleMessageReply(e) {
-      const msg = e.detail.message.message;
-      const quoteStr = msg.split('\n').map(
-          line => '> ' + line)
-          .join('\n') + '\n\n';
-      this.$.replyDialog.quote = quoteStr;
-      this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
-    }
-
-    _handleHideBackgroundContent() {
-      this.$.mainContent.classList.add('overlayOpen');
-    }
-
-    _handleShowBackgroundContent() {
-      this.$.mainContent.classList.remove('overlayOpen');
-    }
-
-    _handleReplySent(e) {
-      this.addEventListener('change-details-loaded',
-          () => {
-            this.$.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
-          }, {once: true});
-      this.$.replyOverlay.close();
-      this._reload();
-    }
-
-    _handleReplyCancel(e) {
-      this.$.replyOverlay.close();
-    }
-
-    _handleReplyAutogrow(e) {
-      // If the textarea resizes, we need to re-fit the overlay.
-      this.debounce('reply-overlay-refit', () => {
-        this.$.replyOverlay.refit();
-      }, REPLY_REFIT_DEBOUNCE_INTERVAL_MS);
-    }
-
-    _handleShowReplyDialog(e) {
-      let target = this.$.replyDialog.FocusTarget.REVIEWERS;
-      if (e.detail.value && e.detail.value.ccsOnly) {
-        target = this.$.replyDialog.FocusTarget.CCS;
-      }
-      this._openReplyDialog(target);
-    }
-
-    _handleScroll() {
-      this.debounce('scroll', () => {
-        this.viewState.scrollTop = document.body.scrollTop;
-      }, 150);
-    }
-
-    _setShownFiles(e) {
-      this._shownFileCount = e.detail.length;
-    }
-
-    _expandAllDiffs() {
-      this.$.fileList.expandAllDiffs();
-    }
-
-    _collapseAllDiffs() {
-      this.$.fileList.collapseAllDiffs();
-    }
-
-    _paramsChanged(value) {
-      this._currentView = CommentTabs.CHANGE_LOG;
-      this._setPrimaryTab();
-      if (value.view !== Gerrit.Nav.View.CHANGE) {
-        this._initialLoadComplete = false;
-        return;
-      }
-
-      if (value.changeNum && value.project) {
-        this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
-      }
-
-      const patchChanged = this._patchRange &&
-          (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
-          (this._patchRange.patchNum !== value.patchNum ||
-          this._patchRange.basePatchNum !== value.basePatchNum);
-
-      if (this._changeNum !== value.changeNum) {
-        this._initialLoadComplete = false;
-      }
+    this.addEventListener('fullscreen-overlay-closed',
+        () => this._handleShowBackgroundContent());
 
-      const patchRange = {
-        patchNum: value.patchNum,
-        basePatchNum: value.basePatchNum || 'PARENT',
-      };
+    this.addEventListener('diff-comments-modified',
+        () => this._handleReloadCommentThreads());
+  }
 
-      this.$.fileList.collapseAllDiffs();
-      this._patchRange = patchRange;
+  /** @override */
+  attached() {
+    super.attached();
+    this._getServerConfig().then(config => {
+      this._serverConfig = config;
+    });
 
-      // If the change has already been loaded and the parameter change is only
-      // in the patch range, then don't do a full reload.
-      if (this._initialLoadComplete && patchChanged) {
-        if (patchRange.patchNum == null) {
-          patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets);
-        }
-        this._reloadPatchNumDependentResources().then(() => {
-          this._sendShowChangeEvent();
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this.$.restAPI.getAccount().then(acct => {
+          this._account = acct;
         });
-        return;
       }
+      this._setDiffViewMode();
+    });
 
-      this._changeNum = value.changeNum;
-      this.$.relatedChanges.clear();
-
-      this._reload(true).then(() => {
-        this._performPostLoadTasks();
-      });
-    }
-
-    _sendShowChangeEvent() {
-      this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
-        change: this._change,
-        patchNum: this._patchRange.patchNum,
-        info: {mergeable: this._mergeable},
-      });
-    }
-
-    _setPrimaryTab() {
-      // Selected has to be set after the paper-tabs are visible, because
-      // the selected underline depends on calculations made by the browser.
-      // paper-tabs depends on iron-resizable-behavior, which only fires on
-      // attached() without using RenderStatus.beforeNextRender. Not changing
-      // this when migrating from Polymer 1 to 2 was probably an oversight by
-      // the paper component maintainers.
-      // https://polymer-library.polymer-project.org/2.0/docs/upgrade#attach-time-attached-connectedcallback
-      // By calling _onTabSizingChanged() we are reaching into the private API
-      // of paper-tabs, but we believe this workaround is acceptable for the
-      // time being.
-      Polymer.RenderStatus.beforeNextRender(this, () => {
-        this.$.commentTabs.selected = 0;
-        this.$.commentTabs._onTabSizingChanged();
-        const primaryTabs = this.shadowRoot.querySelector('#primaryTabs');
-        if (primaryTabs) {
-          primaryTabs.selected = 0;
-          primaryTabs._onTabSizingChanged();
-        }
-      });
-    }
-
-    _performPostLoadTasks() {
-      this._maybeShowReplyDialog();
-      this._maybeShowRevertDialog();
-
-      this._sendShowChangeEvent();
-
-      this.async(() => {
-        if (this.viewState.scrollTop) {
-          document.documentElement.scrollTop =
-              document.body.scrollTop = this.viewState.scrollTop;
-        } else {
-          this._maybeScrollToMessage(window.location.hash);
-        }
-        this._initialLoadComplete = true;
-      });
-    }
-
-    _paramsAndChangeChanged(value, change) {
-      // Polymer 2: check for undefined
-      if ([value, change].some(arg => arg === undefined)) {
-        return;
-      }
-
-      // If the change number or patch range is different, then reset the
-      // selected file index.
-      const patchRangeState = this.viewState.patchRange;
-      if (this.viewState.changeNum !== this._changeNum ||
-          patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
-          patchRangeState.patchNum !== this._patchRange.patchNum) {
-        this._resetFileListViewState();
-      }
-    }
-
-    _viewStateChanged(viewState) {
-      this._numFilesShown = viewState.numFilesShown ?
-        viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
-    }
-
-    _numFilesShownChanged(numFilesShown) {
-      this.viewState.numFilesShown = numFilesShown;
-    }
-
-    _handleMessageAnchorTap(e) {
-      const hash = MSG_PREFIX + e.detail.id;
-      const url = Gerrit.Nav.getUrlForChange(this._change,
-          this._patchRange.patchNum, this._patchRange.basePatchNum,
-          this._editMode, hash);
-      history.replaceState(null, '', url);
-    }
-
-    _maybeScrollToMessage(hash) {
-      if (hash.startsWith(MSG_PREFIX)) {
-        this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
-      }
-    }
-
-    _getLocationSearch() {
-      // Not inlining to make it easier to test.
-      return window.location.search;
-    }
-
-    _getUrlParameter(param) {
-      const pageURL = this._getLocationSearch().substring(1);
-      const vars = pageURL.split('&');
-      for (let i = 0; i < vars.length; i++) {
-        const name = vars[i].split('=');
-        if (name[0] == param) {
-          return name[0];
-        }
-      }
-      return null;
-    }
-
-    _maybeShowRevertDialog() {
-      Gerrit.awaitPluginsLoaded()
-          .then(this._getLoggedIn.bind(this))
-          .then(loggedIn => {
-            if (!loggedIn || !this._change ||
-                this._change.status !== this.ChangeStatus.MERGED) {
-            // Do not display dialog if not logged-in or the change is not
-            // merged.
-              return;
-            }
-            if (this._getUrlParameter('revert')) {
-              this.$.actions.showRevertDialog();
-            }
-          });
-    }
-
-    _maybeShowReplyDialog() {
-      this._getLoggedIn().then(loggedIn => {
-        if (!loggedIn) { return; }
-
-        if (this.viewState.showReplyDialog) {
-          this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-          // TODO(kaspern@): Find a better signal for when to call center.
-          this.async(() => { this.$.replyOverlay.center(); }, 100);
-          this.async(() => { this.$.replyOverlay.center(); }, 1000);
-          this.set('viewState.showReplyDialog', false);
-        }
-      });
-    }
-
-    _resetFileListViewState() {
-      this.set('viewState.selectedFileIndex', 0);
-      this.set('viewState.scrollTop', 0);
-      if (!!this.viewState.changeNum &&
-          this.viewState.changeNum !== this._changeNum) {
-        // Reset the diff mode to null when navigating from one change to
-        // another, so that the user's preference is restored.
-        this._setDiffViewMode(true);
-        this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
-      }
-      this.set('viewState.changeNum', this._changeNum);
-      this.set('viewState.patchRange', this._patchRange);
-    }
-
-    _changeChanged(change) {
-      if (!change || !this._patchRange || !this._allPatchSets) { return; }
-
-      // We get the parent first so we keep the original value for basePatchNum
-      // and not the updated value.
-      const parent = this._getBasePatchNum(change, this._patchRange);
-
-      this.set('_patchRange.patchNum', this._patchRange.patchNum ||
-              this.computeLatestPatchNum(this._allPatchSets));
-
-      this.set('_patchRange.basePatchNum', parent);
-
-      const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
-      this.fire('title-change', {title});
-    }
-
-    /**
-     * Gets base patch number, if it is a parent try and decide from
-     * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
-     *
-     * @param {Object} change
-     * @param {Object} patchRange
-     * @return {number|string}
-     */
-    _getBasePatchNum(change, patchRange) {
-      if (patchRange.basePatchNum &&
-          patchRange.basePatchNum !== 'PARENT') {
-        return patchRange.basePatchNum;
-      }
-
-      const revisionInfo = this._getRevisionInfo(change);
-      if (!revisionInfo) return 'PARENT';
-
-      const parentCounts = revisionInfo.getParentCountMap();
-      // check that there is at least 2 parents otherwise fall back to 1,
-      // which means there is only one parent.
-      const parentCount = parentCounts.hasOwnProperty(1) ?
-        parentCounts[1] : 1;
-
-      const preferFirst = this._prefs &&
-          this._prefs.default_base_for_merges === 'FIRST_PARENT';
-
-      if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
-        return -1;
-      }
-
-      return 'PARENT';
-    }
-
-    _computeChangeUrl(change) {
-      return Gerrit.Nav.getUrlForChange(change);
-    }
-
-    _computeShowCommitInfo(changeStatus, current_revision) {
-      return changeStatus === 'Merged' && current_revision;
-    }
-
-    _computeMergedCommitInfo(current_revision, revisions) {
-      const rev = revisions[current_revision];
-      if (!rev || !rev.commit) { return {}; }
-      // CommitInfo.commit is optional. Set commit in all cases to avoid error
-      // in <gr-commit-info>. @see Issue 5337
-      if (!rev.commit.commit) { rev.commit.commit = current_revision; }
-      return rev.commit;
-    }
-
-    _computeChangeIdClass(displayChangeId) {
-      return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
-    }
-
-    _computeTitleAttributeWarning(displayChangeId) {
-      if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
-        return 'Change-Id mismatch';
-      } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
-        return 'No Change-Id in commit message';
-      }
-    }
-
-    _computeChangeIdCommitMessageError(commitMessage, change) {
-      // Polymer 2: check for undefined
-      if ([commitMessage, change].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
-
-      // Find the last match in the commit message:
-      let changeId;
-      let changeIdArr;
-
-      while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
-        changeId = changeIdArr[1];
-      }
-
-      if (changeId) {
-        // A change-id is detected in the commit message.
-
-        if (changeId === change.change_id) {
-          // The change-id found matches the real change-id.
-          return null;
-        }
-        // The change-id found does not match the change-id.
-        return CHANGE_ID_ERROR.MISMATCH;
-      }
-      // There is no change-id in the commit message.
-      return CHANGE_ID_ERROR.MISSING;
-    }
-
-    _computeLabelNames(labels) {
-      return Object.keys(labels).sort();
-    }
-
-    _computeLabelValues(labelName, labels) {
-      const result = [];
-      const t = labels[labelName];
-      if (!t) { return result; }
-      const approvals = t.all || [];
-      for (const label of approvals) {
-        if (label.value && label.value != labels[labelName].default_value) {
-          let labelClassName;
-          let labelValPrefix = '';
-          if (label.value > 0) {
-            labelValPrefix = '+';
-            labelClassName = 'approved';
-          } else if (label.value < 0) {
-            labelClassName = 'notApproved';
+    Gerrit.awaitPluginsLoaded()
+        .then(() => {
+          this._dynamicTabHeaderEndpoints =
+          Gerrit._endpoints.getDynamicEndpoints('change-view-tab-header');
+          this._dynamicTabContentEndpoints =
+          Gerrit._endpoints.getDynamicEndpoints('change-view-tab-content');
+          if (this._dynamicTabContentEndpoints.length !==
+          this._dynamicTabHeaderEndpoints.length) {
+            console.warn('Different number of tab headers and tab content.');
           }
-          result.push({
-            value: labelValPrefix + label.value,
-            className: labelClassName,
-            account: label,
-          });
-        }
-      }
-      return result;
-    }
+        })
+        .then(() => this._setPrimaryTab());
 
-    _computeReplyButtonLabel(changeRecord, canStartReview) {
-      // Polymer 2: check for undefined
-      if ([changeRecord, canStartReview].some(arg => arg === undefined)) {
-        return 'Reply';
-      }
+    this.addEventListener('comment-save', this._handleCommentSave.bind(this));
+    this.addEventListener('comment-refresh', this._reloadDrafts.bind(this));
+    this.addEventListener('comment-discard',
+        this._handleCommentDiscard.bind(this));
+    this.addEventListener('change-message-deleted',
+        () => this._reload());
+    this.addEventListener('editable-content-save',
+        this._handleCommitMessageSave.bind(this));
+    this.addEventListener('editable-content-cancel',
+        this._handleCommitMessageCancel.bind(this));
+    this.addEventListener('open-fix-preview',
+        this._onOpenFixPreview.bind(this));
+    this.addEventListener('close-fix-preview',
+        this._onCloseFixPreview.bind(this));
+    this.listen(window, 'scroll', '_handleScroll');
+    this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+  }
 
-      if (canStartReview) {
-        return 'Start review';
-      }
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'scroll', '_handleScroll');
+    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
 
-      const drafts = (changeRecord && changeRecord.base) || {};
-      const draftCount = Object.keys(drafts)
-          .reduce((count, file) => count + drafts[file].length, 0);
-
-      let label = 'Reply';
-      if (draftCount > 0) {
-        label += ' (' + draftCount + ')';
-      }
-      return label;
-    }
-
-    _handleOpenReplyDialog(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) {
-        return;
-      }
-      this._getLoggedIn().then(isLoggedIn => {
-        if (!isLoggedIn) {
-          this.fire('show-auth-required');
-          return;
-        }
-
-        e.preventDefault();
-        this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-      });
-    }
-
-    _handleOpenDownloadDialogShortcut(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.$.downloadOverlay.open();
-    }
-
-    _handleEditTopic(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.$.metadata.editTopic();
-    }
-
-    _handleRefreshChange(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      e.preventDefault();
-      Gerrit.Nav.navigateToChange(this._change);
-    }
-
-    _handleToggleChangeStar(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.$.changeStar.toggleStar();
-    }
-
-    _handleUpToDashboard(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this._determinePageBack();
-    }
-
-    _handleExpandAllMessages(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.messagesList.handleExpandCollapse(true);
-    }
-
-    _handleCollapseAllMessages(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.messagesList.handleExpandCollapse(false);
-    }
-
-    _handleOpenDiffPrefsShortcut(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      if (this._diffPrefsDisabled) { return; }
-
-      e.preventDefault();
-      this.$.fileList.openDiffPrefs();
-    }
-
-    _determinePageBack() {
-      // Default backPage to root if user came to change view page
-      // via an email link, etc.
-      Gerrit.Nav.navigateToRelativeUrl(this.backPage ||
-          Gerrit.Nav.getUrlForRoot());
-    }
-
-    _handleLabelRemoved(splices, path) {
-      for (const splice of splices) {
-        for (const removed of splice.removed) {
-          const changePath = path.split('.');
-          const labelPath = changePath.splice(0, changePath.length - 2);
-          const labelDict = this.get(labelPath);
-          if (labelDict.approved &&
-              labelDict.approved._account_id === removed._account_id) {
-            this._reload();
-            return;
-          }
-        }
-      }
-    }
-
-    _labelsChanged(changeRecord) {
-      if (!changeRecord) { return; }
-      if (changeRecord.value && changeRecord.value.indexSplices) {
-        this._handleLabelRemoved(changeRecord.value.indexSplices,
-            changeRecord.path);
-      }
-      this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.LABEL_CHANGE, {
-        change: this._change,
-      });
-    }
-
-    /**
-     * @param {string=} opt_section
-     */
-    _openReplyDialog(opt_section) {
-      this.$.replyOverlay.open().finally(() => {
-        // the following code should be executed no matter open succeed or not
-        this._resetReplyOverlayFocusStops();
-        this.$.replyDialog.open(opt_section);
-        Polymer.dom.flush();
-        this.$.replyOverlay.center();
-      });
-    }
-
-    _handleReloadChange(e) {
-      return this._reload().then(() => {
-        // If the change was rebased or submitted, we need to reload the page
-        // with the latest patch.
-        const action = e.detail.action;
-        if (action === 'rebase' || action === 'submit') {
-          Gerrit.Nav.navigateToChange(this._change);
-        }
-      });
-    }
-
-    _handleGetChangeDetailError(response) {
-      this.fire('page-error', {response});
-    }
-
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    }
-
-    _getServerConfig() {
-      return this.$.restAPI.getConfig();
-    }
-
-    _getProjectConfig() {
-      if (!this._change) return;
-      return this.$.restAPI.getProjectConfig(this._change.project).then(
-          config => {
-            this._projectConfig = config;
-          });
-    }
-
-    _getPreferences() {
-      return this.$.restAPI.getPreferences();
-    }
-
-    _prepareCommitMsgForLinkify(msg) {
-      // TODO(wyatta) switch linkify sequence, see issue 5526.
-      // This is a zero-with space. It is added to prevent the linkify library
-      // from including R= or CC= as part of the email address.
-      return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
-    }
-
-    /**
-     * Utility function to make the necessary modifications to a change in the
-     * case an edit exists.
-     *
-     * @param {!Object} change
-     * @param {?Object} edit
-     */
-    _processEdit(change, edit) {
-      if (!edit) { return; }
-      change.revisions[edit.commit.commit] = {
-        _number: this.EDIT_NAME,
-        basePatchNum: edit.base_patch_set_number,
-        commit: edit.commit,
-        fetch: edit.fetch,
-      };
-      // If the edit is based on the most recent patchset, load it by
-      // default, unless another patch set to load was specified in the URL.
-      if (!this._patchRange.patchNum &&
-          change.current_revision === edit.base_revision) {
-        change.current_revision = edit.commit.commit;
-        this.set('_patchRange.patchNum', this.EDIT_NAME);
-        // Because edits are fibbed as revisions and added to the revisions
-        // array, and revision actions are always derived from the 'latest'
-        // patch set, we must copy over actions from the patch set base.
-        // Context: Issue 7243
-        change.revisions[edit.commit.commit].actions =
-            change.revisions[edit.base_revision].actions;
-      }
-    }
-
-    _getChangeDetail() {
-      const detailCompletes = this.$.restAPI.getChangeDetail(
-          this._changeNum, this._handleGetChangeDetailError.bind(this));
-      const editCompletes = this._getEdit();
-      const prefCompletes = this._getPreferences();
-
-      return Promise.all([detailCompletes, editCompletes, prefCompletes])
-          .then(([change, edit, prefs]) => {
-            this._prefs = prefs;
-
-            if (!change) {
-              return '';
-            }
-            this._processEdit(change, edit);
-            // Issue 4190: Coalesce missing topics to null.
-            if (!change.topic) { change.topic = null; }
-            if (!change.reviewer_updates) {
-              change.reviewer_updates = null;
-            }
-            const latestRevisionSha = this._getLatestRevisionSHA(change);
-            const currentRevision = change.revisions[latestRevisionSha];
-            if (currentRevision.commit && currentRevision.commit.message) {
-              this._latestCommitMessage = this._prepareCommitMsgForLinkify(
-                  currentRevision.commit.message);
-            } else {
-              this._latestCommitMessage = null;
-            }
-
-            const lineHeight = getComputedStyle(this).lineHeight;
-
-            // Slice returns a number as a string, convert to an int.
-            this._lineHeight =
-                parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
-
-            this._change = change;
-            if (!this._patchRange || !this._patchRange.patchNum ||
-                this.patchNumEquals(this._patchRange.patchNum,
-                    currentRevision._number)) {
-              // CommitInfo.commit is optional, and may need patching.
-              if (!currentRevision.commit.commit) {
-                currentRevision.commit.commit = latestRevisionSha;
-              }
-              this._commitInfo = currentRevision.commit;
-              this._selectedRevision = currentRevision;
-              // TODO: Fetch and process files.
-            } else {
-              this._selectedRevision =
-                Object.values(this._change.revisions).find(
-                    revision => {
-                      // edit patchset is a special one
-                      const thePatchNum = this._patchRange.patchNum;
-                      if (thePatchNum === 'edit') {
-                        return revision._number === thePatchNum;
-                      }
-                      return revision._number === parseInt(thePatchNum, 10);
-                    });
-            }
-          });
-    }
-
-    _isSubmitEnabled(revisionActions) {
-      return !!(revisionActions && revisionActions.submit &&
-        revisionActions.submit.enabled);
-    }
-
-    _getEdit() {
-      return this.$.restAPI.getChangeEdit(this._changeNum, true);
-    }
-
-    _getLatestCommitMessage() {
-      return this.$.restAPI.getChangeCommitInfo(this._changeNum,
-          this.computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
-        if (!commitInfo) return Promise.resolve();
-        this._latestCommitMessage =
-                    this._prepareCommitMsgForLinkify(commitInfo.message);
-      });
-    }
-
-    _getLatestRevisionSHA(change) {
-      if (change.current_revision) {
-        return change.current_revision;
-      }
-      // current_revision may not be present in the case where the latest rev is
-      // a draft and the user doesn’t have permission to view that rev.
-      let latestRev = null;
-      let latestPatchNum = -1;
-      for (const rev in change.revisions) {
-        if (!change.revisions.hasOwnProperty(rev)) { continue; }
-
-        if (change.revisions[rev]._number > latestPatchNum) {
-          latestRev = rev;
-          latestPatchNum = change.revisions[rev]._number;
-        }
-      }
-      return latestRev;
-    }
-
-    _getCommitInfo() {
-      return this.$.restAPI.getChangeCommitInfo(
-          this._changeNum, this._patchRange.patchNum).then(
-          commitInfo => {
-            this._commitInfo = commitInfo;
-          });
-    }
-
-    _reloadDraftsWithCallback(e) {
-      return this._reloadDrafts().then(() => e.detail.resolve());
-    }
-
-    /**
-     * Fetches a new changeComment object, and data for all types of comments
-     * (comments, robot comments, draft comments) is requested.
-     */
-    _reloadComments() {
-      return this.$.commentAPI.loadAll(this._changeNum)
-          .then(comments => this._recomputeComments(comments));
-    }
-
-    /**
-     * Fetches a new changeComment object, but only updated data for drafts is
-     * requested.
-     *
-     * TODO(taoalpha): clean up this and _reloadComments, as single comment
-     * can be a thread so it does not make sense to only update drafts
-     * without updating threads
-     */
-    _reloadDrafts() {
-      return this.$.commentAPI.reloadDrafts(this._changeNum)
-          .then(comments => this._recomputeComments(comments));
-    }
-
-    _recomputeComments(comments) {
-      this._changeComments = comments;
-      this._diffDrafts = Object.assign({}, this._changeComments.drafts);
-      this._commentThreads = this._changeComments.getAllThreadsForChange()
-          .map(c => Object.assign({}, c));
-      this._draftCommentThreads = this._commentThreads
-          .filter(c => c.comments[c.comments.length - 1].__draft);
-    }
-
-    /**
-     * Reload the change.
-     *
-     * @param {boolean=} opt_isLocationChange Reloads the related changes
-     *     when true and ends reporting events that started on location change.
-     * @return {Promise} A promise that resolves when the core data has loaded.
-     *     Some non-core data loading may still be in-flight when the core data
-     *     promise resolves.
-     */
-    _reload(opt_isLocationChange) {
-      this._loading = true;
-      this._relatedChangesCollapsed = true;
-      this.$.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
-      this.$.reporting.time(CHANGE_DATA_TIMING_LABEL);
-
-      // Array to house all promises related to data requests.
-      const allDataPromises = [];
-
-      // Resolves when the change detail and the edit patch set (if available)
-      // are loaded.
-      const detailCompletes = this._getChangeDetail();
-      allDataPromises.push(detailCompletes);
-
-      // Resolves when the loading flag is set to false, meaning that some
-      // change content may start appearing.
-      const loadingFlagSet = detailCompletes
-          .then(() => {
-            this._loading = false;
-            this.dispatchEvent(new CustomEvent('change-details-loaded',
-                {bubbles: true, composed: true}));
-          })
-          .then(() => {
-            this.$.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
-            if (opt_isLocationChange) {
-              this.$.reporting.changeDisplayed();
-            }
-          });
-
-      // Resolves when the project config has loaded.
-      const projectConfigLoaded = detailCompletes
-          .then(() => this._getProjectConfig());
-      allDataPromises.push(projectConfigLoaded);
-
-      // Resolves when change comments have loaded (comments, drafts and robot
-      // comments).
-      const commentsLoaded = this._reloadComments();
-      allDataPromises.push(commentsLoaded);
-
-      let coreDataPromise;
-
-      // If the patch number is specified
-      if (this._patchRange && this._patchRange.patchNum) {
-        // Because a specific patchset is specified, reload the resources that
-        // are keyed by patch number or patch range.
-        const patchResourcesLoaded = this._reloadPatchNumDependentResources();
-        allDataPromises.push(patchResourcesLoaded);
-
-        // Promise resolves when the change detail and patch dependent resources
-        // have loaded.
-        const detailAndPatchResourcesLoaded =
-            Promise.all([patchResourcesLoaded, loadingFlagSet]);
-
-        // Promise resolves when mergeability information has loaded.
-        const mergeabilityLoaded = detailAndPatchResourcesLoaded
-            .then(() => this._getMergeability());
-        allDataPromises.push(mergeabilityLoaded);
-
-        // Promise resovles when the change actions have loaded.
-        const actionsLoaded = detailAndPatchResourcesLoaded
-            .then(() => this.$.actions.reload());
-        allDataPromises.push(actionsLoaded);
-
-        // The core data is loaded when both mergeability and actions are known.
-        coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
-      } else {
-        // Resolves when the file list has loaded.
-        const fileListReload = loadingFlagSet
-            .then(() => this.$.fileList.reload());
-        allDataPromises.push(fileListReload);
-
-        const latestCommitMessageLoaded = loadingFlagSet.then(() => {
-          // If the latest commit message is known, there is nothing to do.
-          if (this._latestCommitMessage) { return Promise.resolve(); }
-          return this._getLatestCommitMessage();
-        });
-        allDataPromises.push(latestCommitMessageLoaded);
-
-        // Promise resolves when mergeability information has loaded.
-        const mergeabilityLoaded = loadingFlagSet
-            .then(() => this._getMergeability());
-        allDataPromises.push(mergeabilityLoaded);
-
-        // Core data is loaded when mergeability has been loaded.
-        coreDataPromise = mergeabilityLoaded;
-      }
-
-      if (opt_isLocationChange) {
-        const relatedChangesLoaded = coreDataPromise
-            .then(() => this.$.relatedChanges.reload());
-        allDataPromises.push(relatedChangesLoaded);
-      }
-
-      Promise.all(allDataPromises).then(() => {
-        this.$.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
-        if (opt_isLocationChange) {
-          this.$.reporting.changeFullyLoaded();
-        }
-      });
-
-      return coreDataPromise;
-    }
-
-    /**
-     * Kicks off requests for resources that rely on the patch range
-     * (`this._patchRange`) being defined.
-     */
-    _reloadPatchNumDependentResources() {
-      return Promise.all([
-        this._getCommitInfo(),
-        this.$.fileList.reload(),
-      ]);
-    }
-
-    _getMergeability() {
-      if (!this._change) {
-        this._mergeable = null;
-        return Promise.resolve();
-      }
-      // If the change is closed, it is not mergeable. Note: already merged
-      // changes are obviously not mergeable, but the mergeability API will not
-      // answer for abandoned changes.
-      if (this._change.status === this.ChangeStatus.MERGED ||
-          this._change.status === this.ChangeStatus.ABANDONED) {
-        this._mergeable = false;
-        return Promise.resolve();
-      }
-
-      this._mergeable = null;
-      return this.$.restAPI.getMergeable(this._changeNum).then(m => {
-        this._mergeable = m.mergeable;
-      });
-    }
-
-    _computeCanStartReview(change) {
-      return !!(change.actions && change.actions.ready &&
-          change.actions.ready.enabled);
-    }
-
-    _computeReplyDisabled() { return false; }
-
-    _computeChangePermalinkAriaLabel(changeNum) {
-      return 'Change ' + changeNum;
-    }
-
-    _computeCommitMessageCollapsed(collapsed, collapsible) {
-      return collapsible && collapsed;
-    }
-
-    _computeRelatedChangesClass(collapsed) {
-      return collapsed ? 'collapsed' : '';
-    }
-
-    _computeCollapseText(collapsed) {
-      // Symbols are up and down triangles.
-      return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
-    }
-
-    /**
-     * Returns the text to be copied when
-     * click the copy icon next to change subject
-     *
-     * @param {!Object} change
-     */
-    _computeCopyTextForTitle(change) {
-      return `${change._number}: ${change.subject}` +
-       ` | https://${location.host}${this._computeChangeUrl(change)}`;
-    }
-
-    _toggleCommitCollapsed() {
-      this._commitCollapsed = !this._commitCollapsed;
-      if (this._commitCollapsed) {
-        window.scrollTo(0, 0);
-      }
-    }
-
-    _toggleRelatedChangesCollapsed() {
-      this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
-      if (this._relatedChangesCollapsed) {
-        window.scrollTo(0, 0);
-      }
-    }
-
-    _computeCommitCollapsible(commitMessage) {
-      if (!commitMessage) { return false; }
-      return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
-    }
-
-    _getOffsetHeight(element) {
-      return element.offsetHeight;
-    }
-
-    _getScrollHeight(element) {
-      return element.scrollHeight;
-    }
-
-    /**
-     * Get the line height of an element to the nearest integer.
-     */
-    _getLineHeight(element) {
-      const lineHeightStr = getComputedStyle(element).lineHeight;
-      return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2));
-    }
-
-    /**
-     * New max height for the related changes section, shorter than the existing
-     * change info height.
-     */
-    _updateRelatedChangeMaxHeight() {
-      // Takes into account approximate height for the expand button and
-      // bottom margin.
-      const EXTRA_HEIGHT = 30;
-      let newHeight;
-
-      if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`)
-          .matches) {
-        // In a small (mobile) view, give the relation chain some space.
-        newHeight = SMALL_RELATED_HEIGHT;
-      } else if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`)
-          .matches) {
-        // Since related changes are below the commit message, but still next to
-        // metadata, the height should be the height of the metadata minus the
-        // height of the commit message to reduce jank. However, if that doesn't
-        // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
-        // Note: extraHeight is to take into account margin/padding.
-        const medRelatedHeight = Math.max(
-            this._getOffsetHeight(this.$.mainChangeInfo) -
-            this._getOffsetHeight(this.$.commitMessage) - 2 * EXTRA_HEIGHT,
-            MINIMUM_RELATED_MAX_HEIGHT);
-        newHeight = medRelatedHeight;
-      } else {
-        if (this._commitCollapsible) {
-          // Make sure the content is lined up if both areas have buttons. If
-          // the commit message is not collapsed, instead use the change info
-          // height.
-          newHeight = this._getOffsetHeight(this.$.commitMessage);
-        } else {
-          newHeight = this._getOffsetHeight(this.$.commitAndRelated) -
-              EXTRA_HEIGHT;
-        }
-      }
-      const stylesToUpdate = {};
-
-      // Get the line height of related changes, and convert it to the nearest
-      // integer.
-      const lineHeight = this._getLineHeight(this.$.relatedChanges);
-
-      // Figure out a new height that is divisible by the rounded line height.
-      const remainder = newHeight % lineHeight;
-      newHeight = newHeight - remainder;
-
-      stylesToUpdate['--relation-chain-max-height'] = newHeight + 'px';
-
-      // Update the max-height of the relation chain to this new height.
-      if (this._commitCollapsible) {
-        stylesToUpdate['--related-change-btn-top-padding'] = remainder + 'px';
-      }
-
-      this.updateStyles(stylesToUpdate);
-    }
-
-    _computeShowRelatedToggle() {
-      // Make sure the max height has been applied, since there is now content
-      // to populate.
-      if (!util.getComputedStyleValue('--relation-chain-max-height', this)) {
-        this._updateRelatedChangeMaxHeight();
-      }
-      // Prevents showMore from showing when click on related change, since the
-      // line height would be positive, but related changes height is 0.
-      if (!this._getScrollHeight(this.$.relatedChanges)) {
-        return this._showRelatedToggle = false;
-      }
-
-      if (this._getScrollHeight(this.$.relatedChanges) >
-          (this._getOffsetHeight(this.$.relatedChanges) +
-          this._getLineHeight(this.$.relatedChanges))) {
-        return this._showRelatedToggle = true;
-      }
-      this._showRelatedToggle = false;
-    }
-
-    _updateToggleContainerClass(showRelatedToggle) {
-      if (showRelatedToggle) {
-        this.$.relatedChangesToggle.classList.add('showToggle');
-      } else {
-        this.$.relatedChangesToggle.classList.remove('showToggle');
-      }
-    }
-
-    _startUpdateCheckTimer() {
-      if (!this._serverConfig ||
-          !this._serverConfig.change ||
-          this._serverConfig.change.update_delay === undefined ||
-          this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS) {
-        return;
-      }
-
-      this._updateCheckTimerHandle = this.async(() => {
-        this.fetchChangeUpdates(this._change, this.$.restAPI).then(result => {
-          let toastMessage = null;
-          if (!result.isLatest) {
-            toastMessage = ReloadToastMessage.NEWER_REVISION;
-          } else if (result.newStatus === this.ChangeStatus.MERGED) {
-            toastMessage = ReloadToastMessage.MERGED;
-          } else if (result.newStatus === this.ChangeStatus.ABANDONED) {
-            toastMessage = ReloadToastMessage.ABANDONED;
-          } else if (result.newStatus === this.ChangeStatus.NEW) {
-            toastMessage = ReloadToastMessage.RESTORED;
-          } else if (result.newMessages) {
-            toastMessage = ReloadToastMessage.NEW_MESSAGE;
-          }
-
-          if (!toastMessage) {
-            this._startUpdateCheckTimer();
-            return;
-          }
-
-          this._cancelUpdateCheckTimer();
-          this.fire('show-alert', {
-            message: toastMessage,
-            // Persist this alert.
-            dismissOnNavigation: true,
-            action: 'Reload',
-            callback: function() {
-              // Load the current change without any patch range.
-              Gerrit.Nav.navigateToChange(this._change);
-            }.bind(this),
-          });
-        });
-      }, this._serverConfig.change.update_delay * 1000);
-    }
-
-    _cancelUpdateCheckTimer() {
-      if (this._updateCheckTimerHandle) {
-        this.cancelAsync(this._updateCheckTimerHandle);
-      }
-      this._updateCheckTimerHandle = null;
-    }
-
-    _handleVisibilityChange() {
-      if (document.hidden && this._updateCheckTimerHandle) {
-        this._cancelUpdateCheckTimer();
-      } else if (!this._updateCheckTimerHandle) {
-        this._startUpdateCheckTimer();
-      }
-    }
-
-    _handleTopicChanged() {
-      this.$.relatedChanges.reload();
-    }
-
-    _computeHeaderClass(editMode) {
-      const classes = ['header'];
-      if (editMode) { classes.push('editMode'); }
-      return classes.join(' ');
-    }
-
-    _computeEditMode(patchRangeRecord, paramsRecord) {
-      if ([patchRangeRecord, paramsRecord].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      if (paramsRecord.base && paramsRecord.base.edit) { return true; }
-
-      const patchRange = patchRangeRecord.base || {};
-      return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
-    }
-
-    _handleFileActionTap(e) {
-      e.preventDefault();
-      const controls = this.$.fileListHeader.$.editControls;
-      const path = e.detail.path;
-      switch (e.detail.action) {
-        case GrEditConstants.Actions.DELETE.id:
-          controls.openDeleteDialog(path);
-          break;
-        case GrEditConstants.Actions.OPEN.id:
-          Gerrit.Nav.navigateToRelativeUrl(
-              Gerrit.Nav.getEditUrlForDiff(this._change, path,
-                  this._patchRange.patchNum));
-          break;
-        case GrEditConstants.Actions.RENAME.id:
-          controls.openRenameDialog(path);
-          break;
-        case GrEditConstants.Actions.RESTORE.id:
-          controls.openRestoreDialog(path);
-          break;
-      }
-    }
-
-    _computeCommitMessageKey(number, revision) {
-      return `c${number}_rev${revision}`;
-    }
-
-    _patchNumChanged(patchNumStr) {
-      if (!this._selectedRevision) {
-        return;
-      }
-
-      let patchNum = parseInt(patchNumStr, 10);
-      if (patchNumStr === 'edit') {
-        patchNum = patchNumStr;
-      }
-
-      if (patchNum === this._selectedRevision._number) {
-        return;
-      }
-      this._selectedRevision = Object.values(this._change.revisions).find(
-          revision => revision._number === patchNum);
-    }
-
-    /**
-     * If an edit exists already, load it. Otherwise, toggle edit mode via the
-     * navigation API.
-     */
-    _handleEditTap() {
-      const editInfo = Object.values(this._change.revisions).find(info =>
-        info._number === this.EDIT_NAME);
-
-      if (editInfo) {
-        Gerrit.Nav.navigateToChange(this._change, this.EDIT_NAME);
-        return;
-      }
-
-      // Avoid putting patch set in the URL unless a non-latest patch set is
-      // selected.
-      let patchNum;
-      if (!this.patchNumEquals(this._patchRange.patchNum,
-          this.computeLatestPatchNum(this._allPatchSets))) {
-        patchNum = this._patchRange.patchNum;
-      }
-      Gerrit.Nav.navigateToChange(this._change, patchNum, null, true);
-    }
-
-    _handleStopEditTap() {
-      Gerrit.Nav.navigateToChange(this._change, this._patchRange.patchNum);
-    }
-
-    _resetReplyOverlayFocusStops() {
-      this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
-    }
-
-    _handleToggleStar(e) {
-      this.$.restAPI.saveChangeStarred(e.detail.change._number,
-          e.detail.starred);
-    }
-
-    _getRevisionInfo(change) {
-      return new Gerrit.RevisionInfo(change);
-    }
-
-    _computeCurrentRevision(currentRevision, revisions) {
-      return currentRevision && revisions && revisions[currentRevision];
-    }
-
-    _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
-      return disableDiffPrefs || !loggedIn;
+    if (this._updateCheckTimerHandle) {
+      this._cancelUpdateCheckTimer();
     }
   }
 
-  customElements.define(GrChangeView.is, GrChangeView);
-})();
+  get messagesList() {
+    return this.shadowRoot.querySelector('gr-messages-list');
+  }
+
+  get threadList() {
+    return this.shadowRoot.querySelector('gr-thread-list');
+  }
+
+  /**
+   * @param {boolean=} opt_reset
+   */
+  _setDiffViewMode(opt_reset) {
+    if (!opt_reset && this.viewState.diffViewMode) { return; }
+
+    return this._getPreferences()
+        .then( prefs => {
+          if (!this.viewState.diffMode) {
+            this.set('viewState.diffMode', prefs.default_diff_view);
+          }
+        })
+        .then(() => {
+          if (!this.viewState.diffMode) {
+            this.set('viewState.diffMode', 'SIDE_BY_SIDE');
+          }
+        });
+  }
+
+  _onOpenFixPreview(e) {
+    this.$.applyFixDialog.open(e);
+  }
+
+  _onCloseFixPreview(e) {
+    this._reload();
+  }
+
+  _handleToggleDiffMode(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
+    } else {
+      this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
+    }
+  }
+
+  _handleCommentTabChange() {
+    this._currentView = this.$.commentTabs.selected;
+    const type = Object.keys(CommentTabs).find(key => CommentTabs[key] ===
+        this._currentView);
+    this.$.reporting.reportInteraction('comment-tab-changed', {tabName:
+        type});
+  }
+
+  _isSelectedView(currentView, view) {
+    return currentView === view;
+  }
+
+  _findIfTabMatches(currentTab, tab) {
+    return currentTab === tab;
+  }
+
+  _handleFileTabChange(e) {
+    const selectedIndex = e.target.selected;
+    const tabs = e.target.querySelectorAll('paper-tab');
+    this._currentTabName = tabs[selectedIndex] &&
+      tabs[selectedIndex].dataset.name;
+    const source = e && e.type ? e.type : '';
+    const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
+        this._currentTabName);
+    if (pluginIndex !== -1) {
+      this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
+          pluginIndex];
+      this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
+          pluginIndex];
+    } else {
+      this._selectedTabPluginEndpoint = '';
+      this._selectedTabPluginHeader = '';
+    }
+    this.$.reporting.reportInteraction('tab-changed',
+        {tabName: this._currentTabName, source});
+  }
+
+  _handleShowTab(e) {
+    const primaryTabs = this.shadowRoot.querySelector('#primaryTabs');
+    const tabs = primaryTabs.querySelectorAll('paper-tab');
+    let idx = -1;
+    tabs.forEach((tab, index) => {
+      if (tab.dataset.name === e.detail.tab) idx = index;
+    });
+    if (idx === -1) {
+      console.error(e.detail.tab + ' tab not found');
+      return;
+    }
+    primaryTabs.selected = idx;
+    primaryTabs.scrollIntoView();
+    this.$.reporting.reportInteraction('show-tab', {tabName: e.detail.tab});
+  }
+
+  _handleEditCommitMessage(e) {
+    this._editingCommitMessage = true;
+    this.$.commitMessageEditor.focusTextarea();
+  }
+
+  _handleCommitMessageSave(e) {
+    // Trim trailing whitespace from each line.
+    const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
+
+    this.$.jsAPI.handleCommitMessage(this._change, message);
+
+    this.$.commitMessageEditor.disabled = true;
+    this.$.restAPI.putChangeCommitMessage(
+        this._changeNum, message).then(resp => {
+      this.$.commitMessageEditor.disabled = false;
+      if (!resp.ok) { return; }
+
+      this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+          message);
+      this._editingCommitMessage = false;
+      this._reloadWindow();
+    })
+        .catch(err => {
+          this.$.commitMessageEditor.disabled = false;
+        });
+  }
+
+  _reloadWindow() {
+    window.location.reload();
+  }
+
+  _handleCommitMessageCancel(e) {
+    this._editingCommitMessage = false;
+  }
+
+  _computeChangeStatusChips(change, mergeable, submitEnabled) {
+    // Polymer 2: check for undefined
+    if ([
+      change,
+      mergeable,
+    ].some(arg => arg === undefined)) {
+      // To keep consistent with Polymer 1, we are returning undefined
+      // if not all dependencies are defined
+      return undefined;
+    }
+
+    // Show no chips until mergeability is loaded.
+    if (mergeable === null) {
+      return [];
+    }
+
+    const options = {
+      includeDerived: true,
+      mergeable: !!mergeable,
+      submitEnabled: !!submitEnabled,
+    };
+    return this.changeStatuses(change, options);
+  }
+
+  _computeHideEditCommitMessage(
+      loggedIn, editing, change, editMode, collapsed, collapsible) {
+    if (!loggedIn || editing ||
+        (change && change.status === this.ChangeStatus.MERGED) ||
+        editMode ||
+        (collapsed && collapsible)) {
+      return true;
+    }
+
+    return false;
+  }
+
+  _robotCommentCountPerPatchSet(threads) {
+    return threads.reduce((robotCommentCountMap, thread) => {
+      const comments = thread.comments;
+      const robotCommentsCount = comments.reduce((acc, comment) =>
+        (comment.robot_id ? acc + 1 : acc), 0);
+      robotCommentCountMap[comments[0].patch_set] =
+          (robotCommentCountMap[comments[0].patch_set] || 0) +
+        robotCommentsCount;
+      return robotCommentCountMap;
+    }, {});
+  }
+
+  _computeText(patch, commentThreads) {
+    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})`;
+  }
+
+  _computeRobotCommentsPatchSetDropdownItems(change, commentThreads) {
+    if (!change || !commentThreads || !change.revisions) return [];
+
+    return Object.values(change.revisions)
+        .filter(patch => patch._number !== 'edit')
+        .map(patch => {
+          return {
+            text: this._computeText(patch, commentThreads),
+            value: patch._number,
+          };
+        })
+        .sort((a, b) => b.value - a.value);
+  }
+
+  _handleCurrentRevisionUpdate(currentRevision) {
+    this._currentRobotCommentsPatchSet = currentRevision._number;
+  }
+
+  _handleRobotCommentPatchSetChanged(e) {
+    const patchSet = parseInt(e.detail.value);
+    if (patchSet === this._currentRobotCommentsPatchSet) return;
+    this._currentRobotCommentsPatchSet = patchSet;
+  }
+
+  _computeShowText(showAllRobotComments) {
+    return showAllRobotComments ? 'Show Less' : 'Show more';
+  }
+
+  _toggleShowRobotComments() {
+    this._showAllRobotComments = !this._showAllRobotComments;
+  }
+
+  _computeRobotCommentThreads(commentThreads, currentRobotCommentsPatchSet,
+      showAllRobotComments) {
+    if (!commentThreads || !currentRobotCommentsPatchSet) return [];
+    const threads = commentThreads.filter(thread => {
+      const comments = thread.comments || [];
+      return comments.length && comments[0].robot_id && (comments[0].patch_set
+        === currentRobotCommentsPatchSet);
+    });
+    this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
+    return threads.slice(0, showAllRobotComments ? undefined :
+      ROBOT_COMMENTS_LIMIT);
+  }
+
+  _handleReloadCommentThreads() {
+    // Get any new drafts that have been saved in the diff view and show
+    // in the comment thread view.
+    this._reloadDrafts().then(() => {
+      this._commentThreads = this._changeComments.getAllThreadsForChange()
+          .map(c => Object.assign({}, c));
+      flush();
+    });
+  }
+
+  _handleReloadDiffComments(e) {
+    // Keeps the file list counts updated.
+    this._reloadDrafts().then(() => {
+      // Get any new drafts that have been saved in the thread view and show
+      // in the diff view.
+      this.$.fileList.reloadCommentsForThreadWithRootId(e.detail.rootId,
+          e.detail.path);
+      flush();
+    });
+  }
+
+  _computeTotalCommentCounts(unresolvedCount, changeComments) {
+    if (!changeComments) return undefined;
+    const draftCount = changeComments.computeDraftCount();
+    const unresolvedString = GrCountStringFormatter.computeString(
+        unresolvedCount, 'unresolved');
+    const draftString = GrCountStringFormatter.computePluralString(
+        draftCount, 'draft');
+
+    return unresolvedString +
+        // Add a comma and space if both unresolved and draft comments exist.
+        (unresolvedString && draftString ? ', ' : '') +
+        draftString;
+  }
+
+  _handleCommentSave(e) {
+    const draft = e.detail.comment;
+    if (!draft.__draft) { return; }
+
+    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+    // The use of path-based notification helpers (set, push) can’t be used
+    // because the paths could contain dots in them. A new object must be
+    // created to satisfy Polymer’s dirty checking.
+    // https://github.com/Polymer/polymer/issues/3127
+    const diffDrafts = Object.assign({}, this._diffDrafts);
+    if (!diffDrafts[draft.path]) {
+      diffDrafts[draft.path] = [draft];
+      this._diffDrafts = diffDrafts;
+      return;
+    }
+    for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
+      if (this._diffDrafts[draft.path][i].id === draft.id) {
+        diffDrafts[draft.path][i] = draft;
+        this._diffDrafts = diffDrafts;
+        return;
+      }
+    }
+    diffDrafts[draft.path].push(draft);
+    diffDrafts[draft.path].sort((c1, c2) =>
+      // No line number means that it’s a file comment. Sort it above the
+      // others.
+      (c1.line || -1) - (c2.line || -1)
+    );
+    this._diffDrafts = diffDrafts;
+  }
+
+  _handleCommentDiscard(e) {
+    const draft = e.detail.comment;
+    if (!draft.__draft) { return; }
+
+    if (!this._diffDrafts[draft.path]) {
+      return;
+    }
+    let index = -1;
+    for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
+      if (this._diffDrafts[draft.path][i].id === draft.id) {
+        index = i;
+        break;
+      }
+    }
+    if (index === -1) {
+      // It may be a draft that hasn’t been added to _diffDrafts since it was
+      // never saved.
+      return;
+    }
+
+    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+    // The use of path-based notification helpers (set, push) can’t be used
+    // because the paths could contain dots in them. A new object must be
+    // created to satisfy Polymer’s dirty checking.
+    // https://github.com/Polymer/polymer/issues/3127
+    const diffDrafts = Object.assign({}, this._diffDrafts);
+    diffDrafts[draft.path].splice(index, 1);
+    if (diffDrafts[draft.path].length === 0) {
+      delete diffDrafts[draft.path];
+    }
+    this._diffDrafts = diffDrafts;
+  }
+
+  _handleReplyTap(e) {
+    e.preventDefault();
+    this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+  }
+
+  _handleOpenDiffPrefs() {
+    this.$.fileList.openDiffPrefs();
+  }
+
+  _handleOpenIncludedInDialog() {
+    this.$.includedInDialog.loadData().then(() => {
+      flush();
+      this.$.includedInOverlay.refit();
+    });
+    this.$.includedInOverlay.open();
+  }
+
+  _handleIncludedInDialogClose(e) {
+    this.$.includedInOverlay.close();
+  }
+
+  _handleOpenDownloadDialog() {
+    this.$.downloadOverlay.open().then(() => {
+      this.$.downloadOverlay
+          .setFocusStops(this.$.downloadDialog.getFocusStops());
+      this.$.downloadDialog.focus();
+    });
+  }
+
+  _handleDownloadDialogClose(e) {
+    this.$.downloadOverlay.close();
+  }
+
+  _handleOpenUploadHelpDialog(e) {
+    this.$.uploadHelpOverlay.open();
+  }
+
+  _handleCloseUploadHelpDialog(e) {
+    this.$.uploadHelpOverlay.close();
+  }
+
+  _handleMessageReply(e) {
+    const msg = e.detail.message.message;
+    const quoteStr = msg.split('\n').map(
+        line => '> ' + line)
+        .join('\n') + '\n\n';
+    this.$.replyDialog.quote = quoteStr;
+    this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
+  }
+
+  _handleHideBackgroundContent() {
+    this.$.mainContent.classList.add('overlayOpen');
+  }
+
+  _handleShowBackgroundContent() {
+    this.$.mainContent.classList.remove('overlayOpen');
+  }
+
+  _handleReplySent(e) {
+    this.addEventListener('change-details-loaded',
+        () => {
+          this.$.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
+        }, {once: true});
+    this.$.replyOverlay.close();
+    this._reload();
+  }
+
+  _handleReplyCancel(e) {
+    this.$.replyOverlay.close();
+  }
+
+  _handleReplyAutogrow(e) {
+    // If the textarea resizes, we need to re-fit the overlay.
+    this.debounce('reply-overlay-refit', () => {
+      this.$.replyOverlay.refit();
+    }, REPLY_REFIT_DEBOUNCE_INTERVAL_MS);
+  }
+
+  _handleShowReplyDialog(e) {
+    let target = this.$.replyDialog.FocusTarget.REVIEWERS;
+    if (e.detail.value && e.detail.value.ccsOnly) {
+      target = this.$.replyDialog.FocusTarget.CCS;
+    }
+    this._openReplyDialog(target);
+  }
+
+  _handleScroll() {
+    this.debounce('scroll', () => {
+      this.viewState.scrollTop = document.body.scrollTop;
+    }, 150);
+  }
+
+  _setShownFiles(e) {
+    this._shownFileCount = e.detail.length;
+  }
+
+  _expandAllDiffs() {
+    this.$.fileList.expandAllDiffs();
+  }
+
+  _collapseAllDiffs() {
+    this.$.fileList.collapseAllDiffs();
+  }
+
+  _paramsChanged(value) {
+    this._currentView = CommentTabs.CHANGE_LOG;
+    this._setPrimaryTab();
+    if (value.view !== Gerrit.Nav.View.CHANGE) {
+      this._initialLoadComplete = false;
+      return;
+    }
+
+    if (value.changeNum && value.project) {
+      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+    }
+
+    const patchChanged = this._patchRange &&
+        (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
+        (this._patchRange.patchNum !== value.patchNum ||
+        this._patchRange.basePatchNum !== value.basePatchNum);
+
+    if (this._changeNum !== value.changeNum) {
+      this._initialLoadComplete = false;
+    }
+
+    const patchRange = {
+      patchNum: value.patchNum,
+      basePatchNum: value.basePatchNum || 'PARENT',
+    };
+
+    this.$.fileList.collapseAllDiffs();
+    this._patchRange = patchRange;
+
+    // If the change has already been loaded and the parameter change is only
+    // in the patch range, then don't do a full reload.
+    if (this._initialLoadComplete && patchChanged) {
+      if (patchRange.patchNum == null) {
+        patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets);
+      }
+      this._reloadPatchNumDependentResources().then(() => {
+        this._sendShowChangeEvent();
+      });
+      return;
+    }
+
+    this._changeNum = value.changeNum;
+    this.$.relatedChanges.clear();
+
+    this._reload(true).then(() => {
+      this._performPostLoadTasks();
+    });
+  }
+
+  _sendShowChangeEvent() {
+    this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
+      change: this._change,
+      patchNum: this._patchRange.patchNum,
+      info: {mergeable: this._mergeable},
+    });
+  }
+
+  _setPrimaryTab() {
+    // Selected has to be set after the paper-tabs are visible, because
+    // the selected underline depends on calculations made by the browser.
+    // paper-tabs depends on iron-resizable-behavior, which only fires on
+    // attached() without using RenderStatus.beforeNextRender. Not changing
+    // this when migrating from Polymer 1 to 2 was probably an oversight by
+    // the paper component maintainers.
+    // https://polymer-library.polymer-project.org/2.0/docs/upgrade#attach-time-attached-connectedcallback
+    // By calling _onTabSizingChanged() we are reaching into the private API
+    // of paper-tabs, but we believe this workaround is acceptable for the
+    // time being.
+    beforeNextRender(this, () => {
+      this.$.commentTabs.selected = 0;
+      this.$.commentTabs._onTabSizingChanged();
+      const primaryTabs = this.shadowRoot.querySelector('#primaryTabs');
+      if (primaryTabs) {
+        primaryTabs.selected = 0;
+        primaryTabs._onTabSizingChanged();
+      }
+    });
+  }
+
+  _performPostLoadTasks() {
+    this._maybeShowReplyDialog();
+    this._maybeShowRevertDialog();
+
+    this._sendShowChangeEvent();
+
+    this.async(() => {
+      if (this.viewState.scrollTop) {
+        document.documentElement.scrollTop =
+            document.body.scrollTop = this.viewState.scrollTop;
+      } else {
+        this._maybeScrollToMessage(window.location.hash);
+      }
+      this._initialLoadComplete = true;
+    });
+  }
+
+  _paramsAndChangeChanged(value, change) {
+    // Polymer 2: check for undefined
+    if ([value, change].some(arg => arg === undefined)) {
+      return;
+    }
+
+    // If the change number or patch range is different, then reset the
+    // selected file index.
+    const patchRangeState = this.viewState.patchRange;
+    if (this.viewState.changeNum !== this._changeNum ||
+        patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
+        patchRangeState.patchNum !== this._patchRange.patchNum) {
+      this._resetFileListViewState();
+    }
+  }
+
+  _viewStateChanged(viewState) {
+    this._numFilesShown = viewState.numFilesShown ?
+      viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
+  }
+
+  _numFilesShownChanged(numFilesShown) {
+    this.viewState.numFilesShown = numFilesShown;
+  }
+
+  _handleMessageAnchorTap(e) {
+    const hash = MSG_PREFIX + e.detail.id;
+    const url = Gerrit.Nav.getUrlForChange(this._change,
+        this._patchRange.patchNum, this._patchRange.basePatchNum,
+        this._editMode, hash);
+    history.replaceState(null, '', url);
+  }
+
+  _maybeScrollToMessage(hash) {
+    if (hash.startsWith(MSG_PREFIX)) {
+      this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
+    }
+  }
+
+  _getLocationSearch() {
+    // Not inlining to make it easier to test.
+    return window.location.search;
+  }
+
+  _getUrlParameter(param) {
+    const pageURL = this._getLocationSearch().substring(1);
+    const vars = pageURL.split('&');
+    for (let i = 0; i < vars.length; i++) {
+      const name = vars[i].split('=');
+      if (name[0] == param) {
+        return name[0];
+      }
+    }
+    return null;
+  }
+
+  _maybeShowRevertDialog() {
+    Gerrit.awaitPluginsLoaded()
+        .then(this._getLoggedIn.bind(this))
+        .then(loggedIn => {
+          if (!loggedIn || !this._change ||
+              this._change.status !== this.ChangeStatus.MERGED) {
+          // Do not display dialog if not logged-in or the change is not
+          // merged.
+            return;
+          }
+          if (this._getUrlParameter('revert')) {
+            this.$.actions.showRevertDialog();
+          }
+        });
+  }
+
+  _maybeShowReplyDialog() {
+    this._getLoggedIn().then(loggedIn => {
+      if (!loggedIn) { return; }
+
+      if (this.viewState.showReplyDialog) {
+        this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+        // TODO(kaspern@): Find a better signal for when to call center.
+        this.async(() => { this.$.replyOverlay.center(); }, 100);
+        this.async(() => { this.$.replyOverlay.center(); }, 1000);
+        this.set('viewState.showReplyDialog', false);
+      }
+    });
+  }
+
+  _resetFileListViewState() {
+    this.set('viewState.selectedFileIndex', 0);
+    this.set('viewState.scrollTop', 0);
+    if (!!this.viewState.changeNum &&
+        this.viewState.changeNum !== this._changeNum) {
+      // Reset the diff mode to null when navigating from one change to
+      // another, so that the user's preference is restored.
+      this._setDiffViewMode(true);
+      this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
+    }
+    this.set('viewState.changeNum', this._changeNum);
+    this.set('viewState.patchRange', this._patchRange);
+  }
+
+  _changeChanged(change) {
+    if (!change || !this._patchRange || !this._allPatchSets) { return; }
+
+    // We get the parent first so we keep the original value for basePatchNum
+    // and not the updated value.
+    const parent = this._getBasePatchNum(change, this._patchRange);
+
+    this.set('_patchRange.patchNum', this._patchRange.patchNum ||
+            this.computeLatestPatchNum(this._allPatchSets));
+
+    this.set('_patchRange.basePatchNum', parent);
+
+    const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+    this.fire('title-change', {title});
+  }
+
+  /**
+   * Gets base patch number, if it is a parent try and decide from
+   * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
+   *
+   * @param {Object} change
+   * @param {Object} patchRange
+   * @return {number|string}
+   */
+  _getBasePatchNum(change, patchRange) {
+    if (patchRange.basePatchNum &&
+        patchRange.basePatchNum !== 'PARENT') {
+      return patchRange.basePatchNum;
+    }
+
+    const revisionInfo = this._getRevisionInfo(change);
+    if (!revisionInfo) return 'PARENT';
+
+    const parentCounts = revisionInfo.getParentCountMap();
+    // check that there is at least 2 parents otherwise fall back to 1,
+    // which means there is only one parent.
+    const parentCount = parentCounts.hasOwnProperty(1) ?
+      parentCounts[1] : 1;
+
+    const preferFirst = this._prefs &&
+        this._prefs.default_base_for_merges === 'FIRST_PARENT';
+
+    if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
+      return -1;
+    }
+
+    return 'PARENT';
+  }
+
+  _computeChangeUrl(change) {
+    return Gerrit.Nav.getUrlForChange(change);
+  }
+
+  _computeShowCommitInfo(changeStatus, current_revision) {
+    return changeStatus === 'Merged' && current_revision;
+  }
+
+  _computeMergedCommitInfo(current_revision, revisions) {
+    const rev = revisions[current_revision];
+    if (!rev || !rev.commit) { return {}; }
+    // CommitInfo.commit is optional. Set commit in all cases to avoid error
+    // in <gr-commit-info>. @see Issue 5337
+    if (!rev.commit.commit) { rev.commit.commit = current_revision; }
+    return rev.commit;
+  }
+
+  _computeChangeIdClass(displayChangeId) {
+    return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
+  }
+
+  _computeTitleAttributeWarning(displayChangeId) {
+    if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
+      return 'Change-Id mismatch';
+    } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
+      return 'No Change-Id in commit message';
+    }
+  }
+
+  _computeChangeIdCommitMessageError(commitMessage, change) {
+    // Polymer 2: check for undefined
+    if ([commitMessage, change].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
+
+    // Find the last match in the commit message:
+    let changeId;
+    let changeIdArr;
+
+    while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
+      changeId = changeIdArr[1];
+    }
+
+    if (changeId) {
+      // A change-id is detected in the commit message.
+
+      if (changeId === change.change_id) {
+        // The change-id found matches the real change-id.
+        return null;
+      }
+      // The change-id found does not match the change-id.
+      return CHANGE_ID_ERROR.MISMATCH;
+    }
+    // There is no change-id in the commit message.
+    return CHANGE_ID_ERROR.MISSING;
+  }
+
+  _computeLabelNames(labels) {
+    return Object.keys(labels).sort();
+  }
+
+  _computeLabelValues(labelName, labels) {
+    const result = [];
+    const t = labels[labelName];
+    if (!t) { return result; }
+    const approvals = t.all || [];
+    for (const label of approvals) {
+      if (label.value && label.value != labels[labelName].default_value) {
+        let labelClassName;
+        let labelValPrefix = '';
+        if (label.value > 0) {
+          labelValPrefix = '+';
+          labelClassName = 'approved';
+        } else if (label.value < 0) {
+          labelClassName = 'notApproved';
+        }
+        result.push({
+          value: labelValPrefix + label.value,
+          className: labelClassName,
+          account: label,
+        });
+      }
+    }
+    return result;
+  }
+
+  _computeReplyButtonLabel(changeRecord, canStartReview) {
+    // Polymer 2: check for undefined
+    if ([changeRecord, canStartReview].some(arg => arg === undefined)) {
+      return 'Reply';
+    }
+
+    if (canStartReview) {
+      return 'Start review';
+    }
+
+    const drafts = (changeRecord && changeRecord.base) || {};
+    const draftCount = Object.keys(drafts)
+        .reduce((count, file) => count + drafts[file].length, 0);
+
+    let label = 'Reply';
+    if (draftCount > 0) {
+      label += ' (' + draftCount + ')';
+    }
+    return label;
+  }
+
+  _handleOpenReplyDialog(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) {
+      return;
+    }
+    this._getLoggedIn().then(isLoggedIn => {
+      if (!isLoggedIn) {
+        this.fire('show-auth-required');
+        return;
+      }
+
+      e.preventDefault();
+      this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+    });
+  }
+
+  _handleOpenDownloadDialogShortcut(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this.$.downloadOverlay.open();
+  }
+
+  _handleEditTopic(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this.$.metadata.editTopic();
+  }
+
+  _handleRefreshChange(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    e.preventDefault();
+    Gerrit.Nav.navigateToChange(this._change);
+  }
+
+  _handleToggleChangeStar(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this.$.changeStar.toggleStar();
+  }
+
+  _handleUpToDashboard(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this._determinePageBack();
+  }
+
+  _handleExpandAllMessages(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this.messagesList.handleExpandCollapse(true);
+  }
+
+  _handleCollapseAllMessages(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this.messagesList.handleExpandCollapse(false);
+  }
+
+  _handleOpenDiffPrefsShortcut(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    if (this._diffPrefsDisabled) { return; }
+
+    e.preventDefault();
+    this.$.fileList.openDiffPrefs();
+  }
+
+  _determinePageBack() {
+    // Default backPage to root if user came to change view page
+    // via an email link, etc.
+    Gerrit.Nav.navigateToRelativeUrl(this.backPage ||
+        Gerrit.Nav.getUrlForRoot());
+  }
+
+  _handleLabelRemoved(splices, path) {
+    for (const splice of splices) {
+      for (const removed of splice.removed) {
+        const changePath = path.split('.');
+        const labelPath = changePath.splice(0, changePath.length - 2);
+        const labelDict = this.get(labelPath);
+        if (labelDict.approved &&
+            labelDict.approved._account_id === removed._account_id) {
+          this._reload();
+          return;
+        }
+      }
+    }
+  }
+
+  _labelsChanged(changeRecord) {
+    if (!changeRecord) { return; }
+    if (changeRecord.value && changeRecord.value.indexSplices) {
+      this._handleLabelRemoved(changeRecord.value.indexSplices,
+          changeRecord.path);
+    }
+    this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.LABEL_CHANGE, {
+      change: this._change,
+    });
+  }
+
+  /**
+   * @param {string=} opt_section
+   */
+  _openReplyDialog(opt_section) {
+    this.$.replyOverlay.open().finally(() => {
+      // the following code should be executed no matter open succeed or not
+      this._resetReplyOverlayFocusStops();
+      this.$.replyDialog.open(opt_section);
+      flush();
+      this.$.replyOverlay.center();
+    });
+  }
+
+  _handleReloadChange(e) {
+    return this._reload().then(() => {
+      // If the change was rebased or submitted, we need to reload the page
+      // with the latest patch.
+      const action = e.detail.action;
+      if (action === 'rebase' || action === 'submit') {
+        Gerrit.Nav.navigateToChange(this._change);
+      }
+    });
+  }
+
+  _handleGetChangeDetailError(response) {
+    this.fire('page-error', {response});
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getServerConfig() {
+    return this.$.restAPI.getConfig();
+  }
+
+  _getProjectConfig() {
+    if (!this._change) return;
+    return this.$.restAPI.getProjectConfig(this._change.project).then(
+        config => {
+          this._projectConfig = config;
+        });
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  _prepareCommitMsgForLinkify(msg) {
+    // TODO(wyatta) switch linkify sequence, see issue 5526.
+    // This is a zero-with space. It is added to prevent the linkify library
+    // from including R= or CC= as part of the email address.
+    return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
+  }
+
+  /**
+   * Utility function to make the necessary modifications to a change in the
+   * case an edit exists.
+   *
+   * @param {!Object} change
+   * @param {?Object} edit
+   */
+  _processEdit(change, edit) {
+    if (!edit) { return; }
+    change.revisions[edit.commit.commit] = {
+      _number: this.EDIT_NAME,
+      basePatchNum: edit.base_patch_set_number,
+      commit: edit.commit,
+      fetch: edit.fetch,
+    };
+    // If the edit is based on the most recent patchset, load it by
+    // default, unless another patch set to load was specified in the URL.
+    if (!this._patchRange.patchNum &&
+        change.current_revision === edit.base_revision) {
+      change.current_revision = edit.commit.commit;
+      this.set('_patchRange.patchNum', this.EDIT_NAME);
+      // Because edits are fibbed as revisions and added to the revisions
+      // array, and revision actions are always derived from the 'latest'
+      // patch set, we must copy over actions from the patch set base.
+      // Context: Issue 7243
+      change.revisions[edit.commit.commit].actions =
+          change.revisions[edit.base_revision].actions;
+    }
+  }
+
+  _getChangeDetail() {
+    const detailCompletes = this.$.restAPI.getChangeDetail(
+        this._changeNum, this._handleGetChangeDetailError.bind(this));
+    const editCompletes = this._getEdit();
+    const prefCompletes = this._getPreferences();
+
+    return Promise.all([detailCompletes, editCompletes, prefCompletes])
+        .then(([change, edit, prefs]) => {
+          this._prefs = prefs;
+
+          if (!change) {
+            return '';
+          }
+          this._processEdit(change, edit);
+          // Issue 4190: Coalesce missing topics to null.
+          if (!change.topic) { change.topic = null; }
+          if (!change.reviewer_updates) {
+            change.reviewer_updates = null;
+          }
+          const latestRevisionSha = this._getLatestRevisionSHA(change);
+          const currentRevision = change.revisions[latestRevisionSha];
+          if (currentRevision.commit && currentRevision.commit.message) {
+            this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+                currentRevision.commit.message);
+          } else {
+            this._latestCommitMessage = null;
+          }
+
+          const lineHeight = getComputedStyle(this).lineHeight;
+
+          // Slice returns a number as a string, convert to an int.
+          this._lineHeight =
+              parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
+
+          this._change = change;
+          if (!this._patchRange || !this._patchRange.patchNum ||
+              this.patchNumEquals(this._patchRange.patchNum,
+                  currentRevision._number)) {
+            // CommitInfo.commit is optional, and may need patching.
+            if (!currentRevision.commit.commit) {
+              currentRevision.commit.commit = latestRevisionSha;
+            }
+            this._commitInfo = currentRevision.commit;
+            this._selectedRevision = currentRevision;
+            // TODO: Fetch and process files.
+          } else {
+            this._selectedRevision =
+              Object.values(this._change.revisions).find(
+                  revision => {
+                    // edit patchset is a special one
+                    const thePatchNum = this._patchRange.patchNum;
+                    if (thePatchNum === 'edit') {
+                      return revision._number === thePatchNum;
+                    }
+                    return revision._number === parseInt(thePatchNum, 10);
+                  });
+          }
+        });
+  }
+
+  _isSubmitEnabled(revisionActions) {
+    return !!(revisionActions && revisionActions.submit &&
+      revisionActions.submit.enabled);
+  }
+
+  _getEdit() {
+    return this.$.restAPI.getChangeEdit(this._changeNum, true);
+  }
+
+  _getLatestCommitMessage() {
+    return this.$.restAPI.getChangeCommitInfo(this._changeNum,
+        this.computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
+      if (!commitInfo) return Promise.resolve();
+      this._latestCommitMessage =
+                  this._prepareCommitMsgForLinkify(commitInfo.message);
+    });
+  }
+
+  _getLatestRevisionSHA(change) {
+    if (change.current_revision) {
+      return change.current_revision;
+    }
+    // current_revision may not be present in the case where the latest rev is
+    // a draft and the user doesn’t have permission to view that rev.
+    let latestRev = null;
+    let latestPatchNum = -1;
+    for (const rev in change.revisions) {
+      if (!change.revisions.hasOwnProperty(rev)) { continue; }
+
+      if (change.revisions[rev]._number > latestPatchNum) {
+        latestRev = rev;
+        latestPatchNum = change.revisions[rev]._number;
+      }
+    }
+    return latestRev;
+  }
+
+  _getCommitInfo() {
+    return this.$.restAPI.getChangeCommitInfo(
+        this._changeNum, this._patchRange.patchNum).then(
+        commitInfo => {
+          this._commitInfo = commitInfo;
+        });
+  }
+
+  _reloadDraftsWithCallback(e) {
+    return this._reloadDrafts().then(() => e.detail.resolve());
+  }
+
+  /**
+   * Fetches a new changeComment object, and data for all types of comments
+   * (comments, robot comments, draft comments) is requested.
+   */
+  _reloadComments() {
+    return this.$.commentAPI.loadAll(this._changeNum)
+        .then(comments => this._recomputeComments(comments));
+  }
+
+  /**
+   * Fetches a new changeComment object, but only updated data for drafts is
+   * requested.
+   *
+   * TODO(taoalpha): clean up this and _reloadComments, as single comment
+   * can be a thread so it does not make sense to only update drafts
+   * without updating threads
+   */
+  _reloadDrafts() {
+    return this.$.commentAPI.reloadDrafts(this._changeNum)
+        .then(comments => this._recomputeComments(comments));
+  }
+
+  _recomputeComments(comments) {
+    this._changeComments = comments;
+    this._diffDrafts = Object.assign({}, this._changeComments.drafts);
+    this._commentThreads = this._changeComments.getAllThreadsForChange()
+        .map(c => Object.assign({}, c));
+    this._draftCommentThreads = this._commentThreads
+        .filter(c => c.comments[c.comments.length - 1].__draft);
+  }
+
+  /**
+   * Reload the change.
+   *
+   * @param {boolean=} opt_isLocationChange Reloads the related changes
+   *     when true and ends reporting events that started on location change.
+   * @return {Promise} A promise that resolves when the core data has loaded.
+   *     Some non-core data loading may still be in-flight when the core data
+   *     promise resolves.
+   */
+  _reload(opt_isLocationChange) {
+    this._loading = true;
+    this._relatedChangesCollapsed = true;
+    this.$.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
+    this.$.reporting.time(CHANGE_DATA_TIMING_LABEL);
+
+    // Array to house all promises related to data requests.
+    const allDataPromises = [];
+
+    // Resolves when the change detail and the edit patch set (if available)
+    // are loaded.
+    const detailCompletes = this._getChangeDetail();
+    allDataPromises.push(detailCompletes);
+
+    // Resolves when the loading flag is set to false, meaning that some
+    // change content may start appearing.
+    const loadingFlagSet = detailCompletes
+        .then(() => {
+          this._loading = false;
+          this.dispatchEvent(new CustomEvent('change-details-loaded',
+              {bubbles: true, composed: true}));
+        })
+        .then(() => {
+          this.$.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
+          if (opt_isLocationChange) {
+            this.$.reporting.changeDisplayed();
+          }
+        });
+
+    // Resolves when the project config has loaded.
+    const projectConfigLoaded = detailCompletes
+        .then(() => this._getProjectConfig());
+    allDataPromises.push(projectConfigLoaded);
+
+    // Resolves when change comments have loaded (comments, drafts and robot
+    // comments).
+    const commentsLoaded = this._reloadComments();
+    allDataPromises.push(commentsLoaded);
+
+    let coreDataPromise;
+
+    // If the patch number is specified
+    if (this._patchRange && this._patchRange.patchNum) {
+      // Because a specific patchset is specified, reload the resources that
+      // are keyed by patch number or patch range.
+      const patchResourcesLoaded = this._reloadPatchNumDependentResources();
+      allDataPromises.push(patchResourcesLoaded);
+
+      // Promise resolves when the change detail and patch dependent resources
+      // have loaded.
+      const detailAndPatchResourcesLoaded =
+          Promise.all([patchResourcesLoaded, loadingFlagSet]);
+
+      // Promise resolves when mergeability information has loaded.
+      const mergeabilityLoaded = detailAndPatchResourcesLoaded
+          .then(() => this._getMergeability());
+      allDataPromises.push(mergeabilityLoaded);
+
+      // Promise resovles when the change actions have loaded.
+      const actionsLoaded = detailAndPatchResourcesLoaded
+          .then(() => this.$.actions.reload());
+      allDataPromises.push(actionsLoaded);
+
+      // The core data is loaded when both mergeability and actions are known.
+      coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
+    } else {
+      // Resolves when the file list has loaded.
+      const fileListReload = loadingFlagSet
+          .then(() => this.$.fileList.reload());
+      allDataPromises.push(fileListReload);
+
+      const latestCommitMessageLoaded = loadingFlagSet.then(() => {
+        // If the latest commit message is known, there is nothing to do.
+        if (this._latestCommitMessage) { return Promise.resolve(); }
+        return this._getLatestCommitMessage();
+      });
+      allDataPromises.push(latestCommitMessageLoaded);
+
+      // Promise resolves when mergeability information has loaded.
+      const mergeabilityLoaded = loadingFlagSet
+          .then(() => this._getMergeability());
+      allDataPromises.push(mergeabilityLoaded);
+
+      // Core data is loaded when mergeability has been loaded.
+      coreDataPromise = mergeabilityLoaded;
+    }
+
+    if (opt_isLocationChange) {
+      const relatedChangesLoaded = coreDataPromise
+          .then(() => this.$.relatedChanges.reload());
+      allDataPromises.push(relatedChangesLoaded);
+    }
+
+    Promise.all(allDataPromises).then(() => {
+      this.$.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
+      if (opt_isLocationChange) {
+        this.$.reporting.changeFullyLoaded();
+      }
+    });
+
+    return coreDataPromise;
+  }
+
+  /**
+   * Kicks off requests for resources that rely on the patch range
+   * (`this._patchRange`) being defined.
+   */
+  _reloadPatchNumDependentResources() {
+    return Promise.all([
+      this._getCommitInfo(),
+      this.$.fileList.reload(),
+    ]);
+  }
+
+  _getMergeability() {
+    if (!this._change) {
+      this._mergeable = null;
+      return Promise.resolve();
+    }
+    // If the change is closed, it is not mergeable. Note: already merged
+    // changes are obviously not mergeable, but the mergeability API will not
+    // answer for abandoned changes.
+    if (this._change.status === this.ChangeStatus.MERGED ||
+        this._change.status === this.ChangeStatus.ABANDONED) {
+      this._mergeable = false;
+      return Promise.resolve();
+    }
+
+    this._mergeable = null;
+    return this.$.restAPI.getMergeable(this._changeNum).then(m => {
+      this._mergeable = m.mergeable;
+    });
+  }
+
+  _computeCanStartReview(change) {
+    return !!(change.actions && change.actions.ready &&
+        change.actions.ready.enabled);
+  }
+
+  _computeReplyDisabled() { return false; }
+
+  _computeChangePermalinkAriaLabel(changeNum) {
+    return 'Change ' + changeNum;
+  }
+
+  _computeCommitMessageCollapsed(collapsed, collapsible) {
+    return collapsible && collapsed;
+  }
+
+  _computeRelatedChangesClass(collapsed) {
+    return collapsed ? 'collapsed' : '';
+  }
+
+  _computeCollapseText(collapsed) {
+    // Symbols are up and down triangles.
+    return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
+  }
+
+  /**
+   * Returns the text to be copied when
+   * click the copy icon next to change subject
+   *
+   * @param {!Object} change
+   */
+  _computeCopyTextForTitle(change) {
+    return `${change._number}: ${change.subject}` +
+     ` | https://${location.host}${this._computeChangeUrl(change)}`;
+  }
+
+  _toggleCommitCollapsed() {
+    this._commitCollapsed = !this._commitCollapsed;
+    if (this._commitCollapsed) {
+      window.scrollTo(0, 0);
+    }
+  }
+
+  _toggleRelatedChangesCollapsed() {
+    this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
+    if (this._relatedChangesCollapsed) {
+      window.scrollTo(0, 0);
+    }
+  }
+
+  _computeCommitCollapsible(commitMessage) {
+    if (!commitMessage) { return false; }
+    return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
+  }
+
+  _getOffsetHeight(element) {
+    return element.offsetHeight;
+  }
+
+  _getScrollHeight(element) {
+    return element.scrollHeight;
+  }
+
+  /**
+   * Get the line height of an element to the nearest integer.
+   */
+  _getLineHeight(element) {
+    const lineHeightStr = getComputedStyle(element).lineHeight;
+    return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2));
+  }
+
+  /**
+   * New max height for the related changes section, shorter than the existing
+   * change info height.
+   */
+  _updateRelatedChangeMaxHeight() {
+    // Takes into account approximate height for the expand button and
+    // bottom margin.
+    const EXTRA_HEIGHT = 30;
+    let newHeight;
+
+    if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`)
+        .matches) {
+      // In a small (mobile) view, give the relation chain some space.
+      newHeight = SMALL_RELATED_HEIGHT;
+    } else if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`)
+        .matches) {
+      // Since related changes are below the commit message, but still next to
+      // metadata, the height should be the height of the metadata minus the
+      // height of the commit message to reduce jank. However, if that doesn't
+      // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
+      // Note: extraHeight is to take into account margin/padding.
+      const medRelatedHeight = Math.max(
+          this._getOffsetHeight(this.$.mainChangeInfo) -
+          this._getOffsetHeight(this.$.commitMessage) - 2 * EXTRA_HEIGHT,
+          MINIMUM_RELATED_MAX_HEIGHT);
+      newHeight = medRelatedHeight;
+    } else {
+      if (this._commitCollapsible) {
+        // Make sure the content is lined up if both areas have buttons. If
+        // the commit message is not collapsed, instead use the change info
+        // height.
+        newHeight = this._getOffsetHeight(this.$.commitMessage);
+      } else {
+        newHeight = this._getOffsetHeight(this.$.commitAndRelated) -
+            EXTRA_HEIGHT;
+      }
+    }
+    const stylesToUpdate = {};
+
+    // Get the line height of related changes, and convert it to the nearest
+    // integer.
+    const lineHeight = this._getLineHeight(this.$.relatedChanges);
+
+    // Figure out a new height that is divisible by the rounded line height.
+    const remainder = newHeight % lineHeight;
+    newHeight = newHeight - remainder;
+
+    stylesToUpdate['--relation-chain-max-height'] = newHeight + 'px';
+
+    // Update the max-height of the relation chain to this new height.
+    if (this._commitCollapsible) {
+      stylesToUpdate['--related-change-btn-top-padding'] = remainder + 'px';
+    }
+
+    this.updateStyles(stylesToUpdate);
+  }
+
+  _computeShowRelatedToggle() {
+    // Make sure the max height has been applied, since there is now content
+    // to populate.
+    if (!util.getComputedStyleValue('--relation-chain-max-height', this)) {
+      this._updateRelatedChangeMaxHeight();
+    }
+    // Prevents showMore from showing when click on related change, since the
+    // line height would be positive, but related changes height is 0.
+    if (!this._getScrollHeight(this.$.relatedChanges)) {
+      return this._showRelatedToggle = false;
+    }
+
+    if (this._getScrollHeight(this.$.relatedChanges) >
+        (this._getOffsetHeight(this.$.relatedChanges) +
+        this._getLineHeight(this.$.relatedChanges))) {
+      return this._showRelatedToggle = true;
+    }
+    this._showRelatedToggle = false;
+  }
+
+  _updateToggleContainerClass(showRelatedToggle) {
+    if (showRelatedToggle) {
+      this.$.relatedChangesToggle.classList.add('showToggle');
+    } else {
+      this.$.relatedChangesToggle.classList.remove('showToggle');
+    }
+  }
+
+  _startUpdateCheckTimer() {
+    if (!this._serverConfig ||
+        !this._serverConfig.change ||
+        this._serverConfig.change.update_delay === undefined ||
+        this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS) {
+      return;
+    }
+
+    this._updateCheckTimerHandle = this.async(() => {
+      this.fetchChangeUpdates(this._change, this.$.restAPI).then(result => {
+        let toastMessage = null;
+        if (!result.isLatest) {
+          toastMessage = ReloadToastMessage.NEWER_REVISION;
+        } else if (result.newStatus === this.ChangeStatus.MERGED) {
+          toastMessage = ReloadToastMessage.MERGED;
+        } else if (result.newStatus === this.ChangeStatus.ABANDONED) {
+          toastMessage = ReloadToastMessage.ABANDONED;
+        } else if (result.newStatus === this.ChangeStatus.NEW) {
+          toastMessage = ReloadToastMessage.RESTORED;
+        } else if (result.newMessages) {
+          toastMessage = ReloadToastMessage.NEW_MESSAGE;
+        }
+
+        if (!toastMessage) {
+          this._startUpdateCheckTimer();
+          return;
+        }
+
+        this._cancelUpdateCheckTimer();
+        this.fire('show-alert', {
+          message: toastMessage,
+          // Persist this alert.
+          dismissOnNavigation: true,
+          action: 'Reload',
+          callback: function() {
+            // Load the current change without any patch range.
+            Gerrit.Nav.navigateToChange(this._change);
+          }.bind(this),
+        });
+      });
+    }, this._serverConfig.change.update_delay * 1000);
+  }
+
+  _cancelUpdateCheckTimer() {
+    if (this._updateCheckTimerHandle) {
+      this.cancelAsync(this._updateCheckTimerHandle);
+    }
+    this._updateCheckTimerHandle = null;
+  }
+
+  _handleVisibilityChange() {
+    if (document.hidden && this._updateCheckTimerHandle) {
+      this._cancelUpdateCheckTimer();
+    } else if (!this._updateCheckTimerHandle) {
+      this._startUpdateCheckTimer();
+    }
+  }
+
+  _handleTopicChanged() {
+    this.$.relatedChanges.reload();
+  }
+
+  _computeHeaderClass(editMode) {
+    const classes = ['header'];
+    if (editMode) { classes.push('editMode'); }
+    return classes.join(' ');
+  }
+
+  _computeEditMode(patchRangeRecord, paramsRecord) {
+    if ([patchRangeRecord, paramsRecord].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    if (paramsRecord.base && paramsRecord.base.edit) { return true; }
+
+    const patchRange = patchRangeRecord.base || {};
+    return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
+  }
+
+  _handleFileActionTap(e) {
+    e.preventDefault();
+    const controls = this.$.fileListHeader.$.editControls;
+    const path = e.detail.path;
+    switch (e.detail.action) {
+      case GrEditConstants.Actions.DELETE.id:
+        controls.openDeleteDialog(path);
+        break;
+      case GrEditConstants.Actions.OPEN.id:
+        Gerrit.Nav.navigateToRelativeUrl(
+            Gerrit.Nav.getEditUrlForDiff(this._change, path,
+                this._patchRange.patchNum));
+        break;
+      case GrEditConstants.Actions.RENAME.id:
+        controls.openRenameDialog(path);
+        break;
+      case GrEditConstants.Actions.RESTORE.id:
+        controls.openRestoreDialog(path);
+        break;
+    }
+  }
+
+  _computeCommitMessageKey(number, revision) {
+    return `c${number}_rev${revision}`;
+  }
+
+  _patchNumChanged(patchNumStr) {
+    if (!this._selectedRevision) {
+      return;
+    }
+
+    let patchNum = parseInt(patchNumStr, 10);
+    if (patchNumStr === 'edit') {
+      patchNum = patchNumStr;
+    }
+
+    if (patchNum === this._selectedRevision._number) {
+      return;
+    }
+    this._selectedRevision = Object.values(this._change.revisions).find(
+        revision => revision._number === patchNum);
+  }
+
+  /**
+   * If an edit exists already, load it. Otherwise, toggle edit mode via the
+   * navigation API.
+   */
+  _handleEditTap() {
+    const editInfo = Object.values(this._change.revisions).find(info =>
+      info._number === this.EDIT_NAME);
+
+    if (editInfo) {
+      Gerrit.Nav.navigateToChange(this._change, this.EDIT_NAME);
+      return;
+    }
+
+    // Avoid putting patch set in the URL unless a non-latest patch set is
+    // selected.
+    let patchNum;
+    if (!this.patchNumEquals(this._patchRange.patchNum,
+        this.computeLatestPatchNum(this._allPatchSets))) {
+      patchNum = this._patchRange.patchNum;
+    }
+    Gerrit.Nav.navigateToChange(this._change, patchNum, null, true);
+  }
+
+  _handleStopEditTap() {
+    Gerrit.Nav.navigateToChange(this._change, this._patchRange.patchNum);
+  }
+
+  _resetReplyOverlayFocusStops() {
+    this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
+  }
+
+  _handleToggleStar(e) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number,
+        e.detail.starred);
+  }
+
+  _getRevisionInfo(change) {
+    return new Gerrit.RevisionInfo(change);
+  }
+
+  _computeCurrentRevision(currentRevision, revisions) {
+    return currentRevision && revisions && revisions[currentRevision];
+  }
+
+  _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
+    return disableDiffPrefs || !loggedIn;
+  }
+}
+
+customElements.define(GrChangeView.is, GrChangeView);
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
index 9a53342..b7fdbb7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
@@ -1,63 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="/bower_components/paper-tabs/paper-tabs.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
-<link rel="import" href="../../edit/gr-edit-constants.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
-<link rel="import" href="../../shared/gr-change-status/gr-change-status.html">
-<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-editable-content/gr-editable-content.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../../shared/revision-info/revision-info.html">
-<link rel="import" href="../gr-change-actions/gr-change-actions.html">
-<link rel="import" href="../gr-change-metadata/gr-change-metadata.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../gr-commit-info/gr-commit-info.html">
-<link rel="import" href="../gr-download-dialog/gr-download-dialog.html">
-<link rel="import" href="../gr-file-list-header/gr-file-list-header.html">
-<link rel="import" href="../gr-file-list/gr-file-list.html">
-<link rel="import" href="../gr-included-in-dialog/gr-included-in-dialog.html">
-<link rel="import" href="../gr-messages-list/gr-messages-list.html">
-<link rel="import" href="../gr-related-changes-list/gr-related-changes-list.html">
-<link rel="import" href="../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog.html">
-<link rel="import" href="../gr-reply-dialog/gr-reply-dialog.html">
-<link rel="import" href="../gr-thread-list/gr-thread-list.html">
-<link rel="import" href="../gr-upload-help-dialog/gr-upload-help-dialog.html">
-
-<dom-module id="gr-change-view">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .container:not(.loading) {
         background-color: var(--background-color-tertiary);
@@ -374,136 +333,61 @@
         margin: var(--spacing-m);
       }
     </style>
-    <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
-    <div
-        id="mainContent"
-        class="container"
-        on-show-checks-table="_handleShowTab"
-        hidden$="{{_loading}}">
+    <div class="container loading" hidden\$="[[!_loading]]">Loading...</div>
+    <div id="mainContent" class="container" on-show-checks-table="_handleShowTab" hidden\$="{{_loading}}">
       <section class="changeInfoSection">
-        <div class$="[[_computeHeaderClass(_editMode)]]">
+        <div class\$="[[_computeHeaderClass(_editMode)]]">
           <div class="headerTitle">
             <div class="changeStatuses">
               <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
-                <gr-change-status
-                    max-width="100"
-                    status="[[status]]"></gr-change-status>
+                <gr-change-status max-width="100" status="[[status]]"></gr-change-status>
               </template>
             </div>
             <div class="statusText">
-              <template
-                  is="dom-if"
-                  if="[[_computeShowCommitInfo(_changeStatus, _change.current_revision)]]">
+              <template is="dom-if" if="[[_computeShowCommitInfo(_changeStatus, _change.current_revision)]]">
                 <span class="text"> as </span>
-                <gr-commit-info
-                    change="[[_change]]"
-                    commit-info="[[_computeMergedCommitInfo(_change.current_revision, _change.revisions)]]"
-                    server-config="[[_serverConfig]]"></gr-commit-info>
+                <gr-commit-info change="[[_change]]" commit-info="[[_computeMergedCommitInfo(_change.current_revision, _change.revisions)]]" server-config="[[_serverConfig]]"></gr-commit-info>
               </template>
             </div>
-            <gr-change-star
-                id="changeStar"
-                change="{{_change}}"
-                on-toggle-star="_handleToggleStar"
-                hidden$="[[!_loggedIn]]"></gr-change-star>
+            <gr-change-star id="changeStar" change="{{_change}}" on-toggle-star="_handleToggleStar" hidden\$="[[!_loggedIn]]"></gr-change-star>
 
-            <a aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
-                href$="[[_computeChangeUrl(_change)]]">[[_change._number]]</a>
+            <a aria-label\$="[[_computeChangePermalinkAriaLabel(_change._number)]]" href\$="[[_computeChangeUrl(_change)]]">[[_change._number]]</a>
             <span class="changeNumberColon">:&nbsp;</span>
             <span class="headerSubject">[[_change.subject]]</span>
-            <gr-copy-clipboard
-              class="changeCopyClipboard"
-              hide-input
-              text="[[_computeCopyTextForTitle(_change)]]">
+            <gr-copy-clipboard class="changeCopyClipboard" hide-input="" text="[[_computeCopyTextForTitle(_change)]]">
             </gr-copy-clipboard>
           </div><!-- end headerTitle -->
-          <div class="commitActions" hidden$="[[!_loggedIn]]">
-            <gr-change-actions
-                id="actions"
-                change="[[_change]]"
-                disable-edit="[[disableEdit]]"
-                has-parent="[[hasParent]]"
-                actions="[[_change.actions]]"
-                revision-actions="{{_currentRevisionActions}}"
-                change-num="[[_changeNum]]"
-                change-status="[[_change.status]]"
-                commit-num="[[_commitInfo.commit]]"
-                latest-patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
-                commit-message="[[_latestCommitMessage]]"
-                edit-patchset-loaded="[[hasEditPatchsetLoaded(_patchRange.*)]]"
-                edit-mode="[[_editMode]]"
-                edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
-                private-by-default="[[_projectConfig.private_by_default]]"
-                on-reload-change="_handleReloadChange"
-                on-edit-tap="_handleEditTap"
-                on-stop-edit-tap="_handleStopEditTap"
-                on-download-tap="_handleOpenDownloadDialog"></gr-change-actions>
+          <div class="commitActions" hidden\$="[[!_loggedIn]]">
+            <gr-change-actions id="actions" change="[[_change]]" disable-edit="[[disableEdit]]" has-parent="[[hasParent]]" actions="[[_change.actions]]" revision-actions="{{_currentRevisionActions}}" change-num="[[_changeNum]]" change-status="[[_change.status]]" commit-num="[[_commitInfo.commit]]" latest-patch-num="[[computeLatestPatchNum(_allPatchSets)]]" commit-message="[[_latestCommitMessage]]" edit-patchset-loaded="[[hasEditPatchsetLoaded(_patchRange.*)]]" edit-mode="[[_editMode]]" edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]" private-by-default="[[_projectConfig.private_by_default]]" on-reload-change="_handleReloadChange" on-edit-tap="_handleEditTap" on-stop-edit-tap="_handleStopEditTap" on-download-tap="_handleOpenDownloadDialog"></gr-change-actions>
           </div><!-- end commit actions -->
         </div><!-- end header -->
         <div class="changeInfo">
           <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
-            <gr-change-metadata
-                id="metadata"
-                change="{{_change}}"
-                account="[[_account]]"
-                revision="[[_selectedRevision]]"
-                commit-info="[[_commitInfo]]"
-                server-config="[[_serverConfig]]"
-                parent-is-current="[[_parentIsCurrent]]"
-                on-show-reply-dialog="_handleShowReplyDialog">
+            <gr-change-metadata id="metadata" change="{{_change}}" account="[[_account]]" revision="[[_selectedRevision]]" commit-info="[[_commitInfo]]" server-config="[[_serverConfig]]" parent-is-current="[[_parentIsCurrent]]" on-show-reply-dialog="_handleShowReplyDialog">
             </gr-change-metadata>
           </div>
           <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
             <div id="commitAndRelated" class="hideOnMobileOverlay">
               <div class="commitContainer">
                 <div>
-                  <gr-button
-                      id="replyBtn"
-                      class="reply"
-                      title="[[createTitle(Shortcut.OPEN_REPLY_DIALOG,
-                        ShortcutSection.ACTIONS)]]"
-                      hidden$="[[!_loggedIn]]"
-                      primary
-                      disabled="[[_replyDisabled]]"
-                      on-click="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
+                  <gr-button id="replyBtn" class="reply" title="[[createTitle(Shortcut.OPEN_REPLY_DIALOG,
+                        ShortcutSection.ACTIONS)]]" hidden\$="[[!_loggedIn]]" primary="" disabled="[[_replyDisabled]]" on-click="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
                 </div>
-                <div
-                    id="commitMessage"
-                    class="commitMessage">
-                  <gr-editable-content id="commitMessageEditor"
-                      editing="[[_editingCommitMessage]]"
-                      content="{{_latestCommitMessage}}"
-                      storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
-                      remove-zero-width-space
-                      collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, _commitCollapsible)]]">
-                    <gr-linked-text pre
-                        content="[[_latestCommitMessage]]"
-                        config="[[_projectConfig.commentlinks]]"
-                        remove-zero-width-space></gr-linked-text>
+                <div id="commitMessage" class="commitMessage">
+                  <gr-editable-content id="commitMessageEditor" editing="[[_editingCommitMessage]]" content="{{_latestCommitMessage}}" storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]" remove-zero-width-space="" collapsed\$="[[_computeCommitMessageCollapsed(_commitCollapsed, _commitCollapsible)]]">
+                    <gr-linked-text pre="" content="[[_latestCommitMessage]]" config="[[_projectConfig.commentlinks]]" remove-zero-width-space=""></gr-linked-text>
                   </gr-editable-content>
-                  <gr-button link
-                      class="editCommitMessage"
-                      on-click="_handleEditCommitMessage"
-                      hidden$="[[_hideEditCommitMessage]]">Edit</gr-button>
-                  <div class="changeId" hidden$="[[!_changeIdCommitMessageError]]">
+                  <gr-button link="" class="editCommitMessage" on-click="_handleEditCommitMessage" hidden\$="[[_hideEditCommitMessage]]">Edit</gr-button>
+                  <div class="changeId" hidden\$="[[!_changeIdCommitMessageError]]">
                     <hr>
                     Change-Id:
-                    <span
-                        class$="[[_computeChangeIdClass(_changeIdCommitMessageError)]]"
-                        title$="[[_computeTitleAttributeWarning(_changeIdCommitMessageError)]]">
+                    <span class\$="[[_computeChangeIdClass(_changeIdCommitMessageError)]]" title\$="[[_computeTitleAttributeWarning(_changeIdCommitMessageError)]]">
                       [[_change.change_id]]
                     </span>
                   </div>
                 </div>
-                <div
-                    id="commitCollapseToggle"
-                    class="collapseToggleContainer"
-                    hidden$="[[!_commitCollapsible]]">
-                  <gr-button
-                      link
-                      id="commitCollapseToggleButton"
-                      class="collapseToggleButton"
-                      on-click="_toggleCommitCollapsed">
+                <div id="commitCollapseToggle" class="collapseToggleContainer" hidden\$="[[!_commitCollapsible]]">
+                  <gr-button link="" id="commitCollapseToggleButton" class="collapseToggleButton" on-click="_toggleCommitCollapsed">
                     [[_computeCollapseText(_commitCollapsed)]]
                   </gr-button>
                 </div>
@@ -515,23 +399,10 @@
                 </gr-endpoint-decorator>
               </div>
               <div class="relatedChanges">
-                <gr-related-changes-list id="relatedChanges"
-                    class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]"
-                    change="[[_change]]"
-                    mergeable="[[_mergeable]]"
-                    has-parent="{{hasParent}}"
-                    on-update="_updateRelatedChangeMaxHeight"
-                    patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
-                    on-new-section-loaded="_computeShowRelatedToggle">
+                <gr-related-changes-list id="relatedChanges" class\$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]" change="[[_change]]" mergeable="[[_mergeable]]" has-parent="{{hasParent}}" on-update="_updateRelatedChangeMaxHeight" patch-num="[[computeLatestPatchNum(_allPatchSets)]]" on-new-section-loaded="_computeShowRelatedToggle">
                 </gr-related-changes-list>
-                <div
-                    id="relatedChangesToggle"
-                    class="collapseToggleContainer">
-                  <gr-button
-                      link
-                      id="relatedChangesToggleButton"
-                      class="collapseToggleButton"
-                      on-click="_toggleRelatedChangesCollapsed">
+                <div id="relatedChangesToggle" class="collapseToggleContainer">
+                  <gr-button link="" id="relatedChangesToggleButton" class="collapseToggleButton" on-click="_toggleRelatedChangesCollapsed">
                     [[_computeCollapseText(_relatedChangesCollapsed)]]
                   </gr-button>
                 </div>
@@ -542,11 +413,10 @@
       </section>
 
       <paper-tabs id="primaryTabs" on-selected-changed="_handleFileTabChange">
-        <paper-tab data-name$="[[_files_tab_name]]">Files</paper-tab>
-        <template is="dom-repeat" items="[[_dynamicTabHeaderEndpoints]]"
-          as="tabHeader">
-          <paper-tab data-name$="[[tabHeader]]">
-              <gr-endpoint-decorator name$="[[tabHeader]]">
+        <paper-tab data-name\$="[[_files_tab_name]]">Files</paper-tab>
+        <template is="dom-repeat" items="[[_dynamicTabHeaderEndpoints]]" as="tabHeader">
+          <paper-tab data-name\$="[[tabHeader]]">
+              <gr-endpoint-decorator name\$="[[tabHeader]]">
                   <gr-endpoint-param name="change" value="[[_change]]">
                   </gr-endpoint-param>
                   <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
@@ -554,78 +424,23 @@
               </gr-endpoint-decorator>
           </paper-tab>
         </template>
-        <paper-tab data-name$="[[_findings_tab_name]]">
+        <paper-tab data-name\$="[[_findings_tab_name]]">
           Findings
         </paper-tab>
       </paper-tabs>
 
       <section class="patchInfo">
-        <div hidden$="[[!_findIfTabMatches(_currentTabName, _files_tab_name)]]">
-          <gr-file-list-header
-            id="fileListHeader"
-            account="[[_account]]"
-            all-patch-sets="[[_allPatchSets]]"
-            change="[[_change]]"
-            change-num="[[_changeNum]]"
-            revision-info="[[_revisionInfo]]"
-            change-comments="[[_changeComments]]"
-            commit-info="[[_commitInfo]]"
-            change-url="[[_computeChangeUrl(_change)]]"
-            edit-mode="[[_editMode]]"
-            logged-in="[[_loggedIn]]"
-            server-config="[[_serverConfig]]"
-            shown-file-count="[[_shownFileCount]]"
-            diff-prefs="[[_diffPrefs]]"
-            diff-view-mode="{{viewState.diffMode}}"
-            patch-num="{{_patchRange.patchNum}}"
-            base-patch-num="{{_patchRange.basePatchNum}}"
-            files-expanded="[[_filesExpanded]]"
-            diff-prefs-disabled="[[_diffPrefsDisabled]]"
-            on-open-diff-prefs="_handleOpenDiffPrefs"
-            on-open-download-dialog="_handleOpenDownloadDialog"
-            on-open-upload-help-dialog="_handleOpenUploadHelpDialog"
-            on-open-included-in-dialog="_handleOpenIncludedInDialog"
-            on-expand-diffs="_expandAllDiffs"
-            on-collapse-diffs="_collapseAllDiffs">
+        <div hidden\$="[[!_findIfTabMatches(_currentTabName, _files_tab_name)]]">
+          <gr-file-list-header id="fileListHeader" account="[[_account]]" all-patch-sets="[[_allPatchSets]]" change="[[_change]]" change-num="[[_changeNum]]" revision-info="[[_revisionInfo]]" change-comments="[[_changeComments]]" commit-info="[[_commitInfo]]" change-url="[[_computeChangeUrl(_change)]]" edit-mode="[[_editMode]]" logged-in="[[_loggedIn]]" server-config="[[_serverConfig]]" shown-file-count="[[_shownFileCount]]" diff-prefs="[[_diffPrefs]]" diff-view-mode="{{viewState.diffMode}}" patch-num="{{_patchRange.patchNum}}" base-patch-num="{{_patchRange.basePatchNum}}" files-expanded="[[_filesExpanded]]" diff-prefs-disabled="[[_diffPrefsDisabled]]" on-open-diff-prefs="_handleOpenDiffPrefs" on-open-download-dialog="_handleOpenDownloadDialog" on-open-upload-help-dialog="_handleOpenUploadHelpDialog" on-open-included-in-dialog="_handleOpenIncludedInDialog" on-expand-diffs="_expandAllDiffs" on-collapse-diffs="_collapseAllDiffs">
           </gr-file-list-header>
-          <gr-file-list
-            id="fileList"
-            class="hideOnMobileOverlay"
-            diff-prefs="{{_diffPrefs}}"
-            change="[[_change]]"
-            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]]"
-            num-files-shown="{{_numFilesShown}}"
-            files-expanded="{{_filesExpanded}}"
-            file-list-increment="{{_numFilesShown}}"
-            on-files-shown-changed="_setShownFiles"
-            on-file-action-tap="_handleFileActionTap"
-            on-reload-drafts="_reloadDraftsWithCallback">
+          <gr-file-list id="fileList" class="hideOnMobileOverlay" diff-prefs="{{_diffPrefs}}" change="[[_change]]" 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]]" num-files-shown="{{_numFilesShown}}" files-expanded="{{_filesExpanded}}" file-list-increment="{{_numFilesShown}}" on-files-shown-changed="_setShownFiles" on-file-action-tap="_handleFileActionTap" on-reload-drafts="_reloadDraftsWithCallback">
           </gr-file-list>
         </div>
 
         <template is="dom-if" if="[[_findIfTabMatches(_currentTabName, _findings_tab_name)]]">
-          <gr-dropdown-list
-            class="patch-set-dropdown"
-            items="[[_robotCommentsPatchSetDropdownItems]]"
-            on-value-change="_handleRobotCommentPatchSetChanged"
-            value="[[_currentRobotCommentsPatchSet]]">
+          <gr-dropdown-list class="patch-set-dropdown" items="[[_robotCommentsPatchSetDropdownItems]]" on-value-change="_handleRobotCommentPatchSetChanged" value="[[_currentRobotCommentsPatchSet]]">
           </gr-dropdown-list>
-          <gr-thread-list
-              threads="[[_robotCommentThreads]]"
-              change="[[_change]]"
-              change-num="[[_changeNum]]"
-              logged-in="[[_loggedIn]]"
-              tab="[[_findings_tab_name]]"
-              hide-toggle-buttons
-              on-thread-list-modified="_handleReloadDiffComments"></gr-thread-list>
+          <gr-thread-list threads="[[_robotCommentThreads]]" change="[[_change]]" change-num="[[_changeNum]]" logged-in="[[_loggedIn]]" tab="[[_findings_tab_name]]" hide-toggle-buttons="" on-thread-list-modified="_handleReloadDiffComments"></gr-thread-list>
           <template is="dom-if" if="[[_showRobotCommentsButton]]">
             <gr-button class="show-robot-comments" on-click="_toggleShowRobotComments">
               [[_computeShowText(_showAllRobotComments)]]
@@ -634,7 +449,7 @@
         </template>
 
         <template is="dom-if" if="[[_findIfTabMatches(_currentTabName, _selectedTabPluginHeader)]]">
-          <gr-endpoint-decorator name$="[[_selectedTabPluginEndpoint]]">
+          <gr-endpoint-decorator name\$="[[_selectedTabPluginEndpoint]]">
             <gr-endpoint-param name="change" value="[[_change]]">
             </gr-endpoint-param>
             <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
@@ -650,94 +465,41 @@
         </gr-endpoint-param>
       </gr-endpoint-decorator>
 
-      <paper-tabs
-          id="commentTabs"
-          on-selected-changed="_handleCommentTabChange">
+      <paper-tabs id="commentTabs" on-selected-changed="_handleCommentTabChange">
         <paper-tab class="changeLog">Change Log</paper-tab>
-        <paper-tab
-            class="commentThreads">
-          <gr-tooltip-content
-              has-tooltip
-              title$="[[_computeTotalCommentCounts(_change.unresolved_comment_count, _changeComments)]]">
+        <paper-tab class="commentThreads">
+          <gr-tooltip-content has-tooltip="" title\$="[[_computeTotalCommentCounts(_change.unresolved_comment_count, _changeComments)]]">
             <span>Comment Threads</span></gr-tooltip-content>
         </paper-tab>
       </paper-tabs>
       <section class="changeLog">
         <template is="dom-if" if="[[_isSelectedView(_currentView,
           _commentTabs.CHANGE_LOG)]]">
-          <gr-messages-list
-              class="hideOnMobileOverlay"
-              change-num="[[_changeNum]]"
-              labels="[[_change.labels]]"
-              messages="[[_change.messages]]"
-              reviewer-updates="[[_change.reviewer_updates]]"
-              change-comments="[[_changeComments]]"
-              project-name="[[_change.project]]"
-              show-reply-buttons="[[_loggedIn]]"
-              on-message-anchor-tap="_handleMessageAnchorTap"
-              on-reply="_handleMessageReply"></gr-messages-list>
+          <gr-messages-list class="hideOnMobileOverlay" change-num="[[_changeNum]]" labels="[[_change.labels]]" messages="[[_change.messages]]" reviewer-updates="[[_change.reviewer_updates]]" change-comments="[[_changeComments]]" project-name="[[_change.project]]" show-reply-buttons="[[_loggedIn]]" on-message-anchor-tap="_handleMessageAnchorTap" on-reply="_handleMessageReply"></gr-messages-list>
         </template>
         <template is="dom-if" if="[[_isSelectedView(_currentView,
           _commentTabs.COMMENT_THREADS)]]">
-          <gr-thread-list
-              threads="[[_commentThreads]]"
-              change="[[_change]]"
-              change-num="[[_changeNum]]"
-              logged-in="[[_loggedIn]]"
-              only-show-robot-comments-with-human-reply
-              on-thread-list-modified="_handleReloadDiffComments"></gr-thread-list>
+          <gr-thread-list threads="[[_commentThreads]]" change="[[_change]]" change-num="[[_changeNum]]" logged-in="[[_loggedIn]]" only-show-robot-comments-with-human-reply="" on-thread-list-modified="_handleReloadDiffComments"></gr-thread-list>
         </template>
       </section>
     </div>
 
-    <gr-apply-fix-dialog
-      id="applyFixDialog"
-      prefs="[[_diffPrefs]]"
-      change="[[_change]]"
-      change-num="[[_changeNum]]"></gr-apply-fix-dialog>
-    <gr-overlay id="downloadOverlay" with-backdrop>
-      <gr-download-dialog
-          id="downloadDialog"
-          change="[[_change]]"
-          patch-num="[[_patchRange.patchNum]]"
-          config="[[_serverConfig.download]]"
-          on-close="_handleDownloadDialogClose"></gr-download-dialog>
+    <gr-apply-fix-dialog id="applyFixDialog" prefs="[[_diffPrefs]]" change="[[_change]]" change-num="[[_changeNum]]"></gr-apply-fix-dialog>
+    <gr-overlay id="downloadOverlay" with-backdrop="">
+      <gr-download-dialog id="downloadDialog" change="[[_change]]" patch-num="[[_patchRange.patchNum]]" config="[[_serverConfig.download]]" on-close="_handleDownloadDialogClose"></gr-download-dialog>
     </gr-overlay>
-    <gr-overlay id="uploadHelpOverlay" with-backdrop>
-      <gr-upload-help-dialog
-          revision="[[_currentRevision]]"
-          target-branch="[[_change.branch]]"
-          on-close="_handleCloseUploadHelpDialog"></gr-upload-help-dialog>
+    <gr-overlay id="uploadHelpOverlay" with-backdrop="">
+      <gr-upload-help-dialog revision="[[_currentRevision]]" target-branch="[[_change.branch]]" on-close="_handleCloseUploadHelpDialog"></gr-upload-help-dialog>
     </gr-overlay>
-    <gr-overlay id="includedInOverlay" with-backdrop>
-      <gr-included-in-dialog
-          id="includedInDialog"
-          change-num="[[_changeNum]]"
-          on-close="_handleIncludedInDialogClose"></gr-included-in-dialog>
+    <gr-overlay id="includedInOverlay" with-backdrop="">
+      <gr-included-in-dialog id="includedInDialog" change-num="[[_changeNum]]" on-close="_handleIncludedInDialogClose"></gr-included-in-dialog>
     </gr-overlay>
-    <gr-overlay id="replyOverlay"
-        class="scrollable"
-        no-cancel-on-outside-click
-        no-cancel-on-esc-key
-        with-backdrop>
-      <gr-reply-dialog id="replyDialog"
-          change="{{_change}}"
-          patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
-          permitted-labels="[[_change.permitted_labels]]"
-          draft-comment-threads="[[_draftCommentThreads]]"
-          project-config="[[_projectConfig]]"
-          can-be-started="[[_canStartReview]]"
-          on-send="_handleReplySent"
-          on-cancel="_handleReplyCancel"
-          on-autogrow="_handleReplyAutogrow"
-          on-send-disabled-changed="_resetReplyOverlayFocusStops"
-          hidden$="[[!_loggedIn]]">
+    <gr-overlay id="replyOverlay" class="scrollable" no-cancel-on-outside-click="" no-cancel-on-esc-key="" with-backdrop="">
+      <gr-reply-dialog id="replyDialog" change="{{_change}}" patch-num="[[computeLatestPatchNum(_allPatchSets)]]" permitted-labels="[[_change.permitted_labels]]" draft-comment-threads="[[_draftCommentThreads]]" project-config="[[_projectConfig]]" can-be-started="[[_canStartReview]]" on-send="_handleReplySent" on-cancel="_handleReplyCancel" on-autogrow="_handleReplyAutogrow" on-send-disabled-changed="_resetReplyOverlayFocusStops" hidden\$="[[!_loggedIn]]">
       </gr-reply-dialog>
     </gr-overlay>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-comment-api id="commentAPI"></gr-comment-api>
     <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-change-view.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index 26bcbf2..4dc0e9b3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -19,18 +19,24 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
-<link rel="import" href="../../edit/gr-edit-constants.html">
-<link rel="import" href="gr-change-view.html">
+<script type="module" src="../../edit/gr-edit-constants.js"></script>
+<script type="module" src="./gr-change-view.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../edit/gr-edit-constants.js';
+import './gr-change-view.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -44,887 +50,667 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-view tests', async () => {
-    await readyToTest();
-    const kb = window.Gerrit.KeyboardShortcutBinder;
-    kb.bindShortcut(kb.Shortcut.SEND_REPLY, 'ctrl+enter');
-    kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE, 'shift+r');
-    kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
-    kb.bindShortcut(kb.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
-    kb.bindShortcut(kb.Shortcut.UP_TO_DASHBOARD, 'u');
-    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_MESSAGES, 'x');
-    kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-    kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
-    kb.bindShortcut(kb.Shortcut.EDIT_TOPIC, 't');
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../edit/gr-edit-constants.js';
+import './gr-change-view.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-change-view tests', () => {
+  const kb = window.Gerrit.KeyboardShortcutBinder;
+  kb.bindShortcut(kb.Shortcut.SEND_REPLY, 'ctrl+enter');
+  kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE, 'shift+r');
+  kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
+  kb.bindShortcut(kb.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
+  kb.bindShortcut(kb.Shortcut.UP_TO_DASHBOARD, 'u');
+  kb.bindShortcut(kb.Shortcut.EXPAND_ALL_MESSAGES, 'x');
+  kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+  kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
+  kb.bindShortcut(kb.Shortcut.EDIT_TOPIC, 't');
 
-    let element;
-    let sandbox;
-    let navigateToChangeStub;
-    const TEST_SCROLL_TOP_PX = 100;
+  let element;
+  let sandbox;
+  let navigateToChangeStub;
+  const TEST_SCROLL_TOP_PX = 100;
 
-    const CommentTabs = {
-      CHANGE_LOG: 0,
-      COMMENT_THREADS: 1,
+  const CommentTabs = {
+    CHANGE_LOG: 0,
+    COMMENT_THREADS: 1,
+  };
+  const ROBOT_COMMENTS_LIMIT = 10;
+
+  const THREADS = [
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2,
+          robot_id: 'rb1',
+          id: 'ecf9fa_fe1a5f62',
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'ecf0b9fa_fe1a5f62',
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          id: '503008e2_0ab203ee',
+          path: '/COMMIT_MSG',
+          line: 5,
+          in_reply_to: 'ecf0b9fa_fe1a5f62',
+          updated: '2018-02-13 22:48:48.018000000',
+          message: 'draft',
+          unresolved: false,
+          __draft: true,
+          __draftID: '0.m683trwff68',
+          __editing: false,
+          patch_set: '2',
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'ecf0b9fa_fe1a5f62',
+      start_datetime: '2018-02-08 18:49:18.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 3,
+          id: 'ecf0b9fa_fe5f62',
+          robot_id: 'rb2',
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          __path: 'test.txt',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 3,
+          id: '09a9fb0a_1484e6cf',
+          side: 'PARENT',
+          updated: '2018-02-13 22:47:19.000000000',
+          message: 'Some comment on another patchset.',
+          unresolved: false,
+        },
+      ],
+      patchNum: 3,
+      path: 'test.txt',
+      rootId: '09a9fb0a_1484e6cf',
+      start_datetime: '2018-02-13 22:47:19.000000000',
+      commentSide: 'PARENT',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2,
+          id: '8caddf38_44770ec1',
+          line: 4,
+          updated: '2018-02-13 22:48:40.000000000',
+          message: 'Another unresolved comment',
+          unresolved: true,
+        },
+      ],
+      patchNum: 2,
+      path: '/COMMIT_MSG',
+      line: 4,
+      rootId: '8caddf38_44770ec1',
+      start_datetime: '2018-02-13 22:48:40.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2,
+          id: 'scaddf38_44770ec1',
+          line: 4,
+          updated: '2018-02-14 22:48:40.000000000',
+          message: 'Yet another unresolved comment',
+          unresolved: true,
+        },
+      ],
+      patchNum: 2,
+      path: '/COMMIT_MSG',
+      line: 4,
+      rootId: 'scaddf38_44770ec1',
+      start_datetime: '2018-02-14 22:48:40.000000000',
+    },
+    {
+      comments: [
+        {
+          id: 'zcf0b9fa_fe1a5f62',
+          path: '/COMMIT_MSG',
+          line: 6,
+          updated: '2018-02-15 22:48:48.018000000',
+          message: 'resolved draft',
+          unresolved: false,
+          __draft: true,
+          __draftID: '0.m683trwff68',
+          __editing: false,
+          patch_set: '2',
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 6,
+      rootId: 'zcf0b9fa_fe1a5f62',
+      start_datetime: '2018-02-09 18:49:18.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'rc1',
+          line: 5,
+          updated: '2019-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+          robot_id: 'rc1',
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'rc1',
+      start_datetime: '2019-02-08 18:49:18.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'rc2',
+          line: 5,
+          updated: '2019-03-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+          robot_id: 'rc2',
+        },
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'c2_1',
+          line: 5,
+          updated: '2019-03-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'rc2',
+      start_datetime: '2019-03-08 18:49:18.000000000',
+    },
+  ];
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-endpoint-decorator', {
+      _import: sandbox.stub().returns(Promise.resolve()),
+    });
+    // Since _endpoints are global, must reset state.
+    Gerrit._endpoints = new GrPluginEndpoints();
+    navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({test: 'config'}); },
+      getAccount() { return Promise.resolve(null); },
+      getDiffComments() { return Promise.resolve({}); },
+      getDiffRobotComments() { return Promise.resolve({}); },
+      getDiffDrafts() { return Promise.resolve({}); },
+      _fetchSharedCacheURL() { return Promise.resolve({}); },
+    });
+    element = fixture('basic');
+    sandbox.stub(element.$.actions, 'reload').returns(Promise.resolve());
+    Gerrit._loadPlugins([]);
+    Gerrit.install(
+        plugin => {
+          plugin.registerDynamicCustomComponent(
+              'change-view-tab-header',
+              'gr-checks-change-view-tab-header-view'
+          );
+          plugin.registerDynamicCustomComponent(
+              'change-view-tab-content',
+              'gr-checks-view'
+          );
+        },
+        '0.1',
+        'http://some/plugins/url.html'
+    );
+  });
+
+  teardown(done => {
+    flush(() => {
+      sandbox.restore();
+      done();
+    });
+  });
+
+  const getCustomCssValue =
+      cssParam => util.getComputedStyleValue(cssParam, element);
+
+  test('_handleMessageAnchorTap', () => {
+    element._changeNum = '1';
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
     };
-    const ROBOT_COMMENTS_LIMIT = 10;
+    const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForChange');
+    const replaceStateStub = sandbox.stub(history, 'replaceState');
+    element._handleMessageAnchorTap({detail: {id: 'a12345'}});
 
-    const THREADS = [
-      {
-        comments: [
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            robot_id: 'rb1',
-            id: 'ecf9fa_fe1a5f62',
-            line: 5,
-            updated: '2018-02-08 18:49:18.000000000',
-            message: 'test',
-            unresolved: true,
-          },
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'ecf0b9fa_fe1a5f62',
-            line: 5,
-            updated: '2018-02-08 18:49:18.000000000',
-            message: 'test',
-            unresolved: true,
-          },
-          {
-            id: '503008e2_0ab203ee',
-            path: '/COMMIT_MSG',
-            line: 5,
-            in_reply_to: 'ecf0b9fa_fe1a5f62',
-            updated: '2018-02-13 22:48:48.018000000',
-            message: 'draft',
-            unresolved: false,
-            __draft: true,
-            __draftID: '0.m683trwff68',
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'ecf0b9fa_fe1a5f62',
-        start_datetime: '2018-02-08 18:49:18.000000000',
-      },
-      {
-        comments: [
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 3,
-            id: 'ecf0b9fa_fe5f62',
-            robot_id: 'rb2',
-            line: 5,
-            updated: '2018-02-08 18:49:18.000000000',
-            message: 'test',
-            unresolved: true,
-          },
-          {
-            __path: 'test.txt',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 3,
-            id: '09a9fb0a_1484e6cf',
-            side: 'PARENT',
-            updated: '2018-02-13 22:47:19.000000000',
-            message: 'Some comment on another patchset.',
-            unresolved: false,
-          },
-        ],
-        patchNum: 3,
-        path: 'test.txt',
-        rootId: '09a9fb0a_1484e6cf',
-        start_datetime: '2018-02-13 22:47:19.000000000',
-        commentSide: 'PARENT',
-      },
-      {
-        comments: [
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            id: '8caddf38_44770ec1',
-            line: 4,
-            updated: '2018-02-13 22:48:40.000000000',
-            message: 'Another unresolved comment',
-            unresolved: true,
-          },
-        ],
-        patchNum: 2,
-        path: '/COMMIT_MSG',
-        line: 4,
-        rootId: '8caddf38_44770ec1',
-        start_datetime: '2018-02-13 22:48:40.000000000',
-      },
-      {
-        comments: [
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            id: 'scaddf38_44770ec1',
-            line: 4,
-            updated: '2018-02-14 22:48:40.000000000',
-            message: 'Yet another unresolved comment',
-            unresolved: true,
-          },
-        ],
-        patchNum: 2,
-        path: '/COMMIT_MSG',
-        line: 4,
-        rootId: 'scaddf38_44770ec1',
-        start_datetime: '2018-02-14 22:48:40.000000000',
-      },
-      {
-        comments: [
-          {
-            id: 'zcf0b9fa_fe1a5f62',
-            path: '/COMMIT_MSG',
-            line: 6,
-            updated: '2018-02-15 22:48:48.018000000',
-            message: 'resolved draft',
-            unresolved: false,
-            __draft: true,
-            __draftID: '0.m683trwff68',
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 6,
-        rootId: 'zcf0b9fa_fe1a5f62',
-        start_datetime: '2018-02-09 18:49:18.000000000',
-      },
-      {
-        comments: [
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'rc1',
-            line: 5,
-            updated: '2019-02-08 18:49:18.000000000',
-            message: 'test',
-            unresolved: true,
-            robot_id: 'rc1',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'rc1',
-        start_datetime: '2019-02-08 18:49:18.000000000',
-      },
-      {
-        comments: [
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'rc2',
-            line: 5,
-            updated: '2019-03-08 18:49:18.000000000',
-            message: 'test',
-            unresolved: true,
-            robot_id: 'rc2',
-          },
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'c2_1',
-            line: 5,
-            updated: '2019-03-08 18:49:18.000000000',
-            message: 'test',
-            unresolved: true,
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'rc2',
-        start_datetime: '2019-03-08 18:49:18.000000000',
-      },
-    ];
+    assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+    assert.isTrue(replaceStateStub.called);
+  });
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-endpoint-decorator', {
-        _import: sandbox.stub().returns(Promise.resolve()),
-      });
-      // Since _endpoints are global, must reset state.
-      Gerrit._endpoints = new GrPluginEndpoints();
-      navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({test: 'config'}); },
-        getAccount() { return Promise.resolve(null); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-        _fetchSharedCacheURL() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      sandbox.stub(element.$.actions, 'reload').returns(Promise.resolve());
-      Gerrit._loadPlugins([]);
-      Gerrit.install(
-          plugin => {
-            plugin.registerDynamicCustomComponent(
-                'change-view-tab-header',
-                'gr-checks-change-view-tab-header-view'
-            );
-            plugin.registerDynamicCustomComponent(
-                'change-view-tab-content',
-                'gr-checks-view'
-            );
-          },
-          '0.1',
-          'http://some/plugins/url.html'
-      );
+  suite('plugins adding to file tab', () => {
+    setup(done => {
+      // Resolving it here instead of during setup() as other tests depend
+      // on flush() not being called during setup.
+      flush(() => done());
     });
 
-    teardown(done => {
+    test('plugin added tab shows up as a dynamic endpoint', () => {
+      assert(element._dynamicTabHeaderEndpoints.includes(
+          'change-view-tab-header-url'));
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      // 3 Tabs are : Files, Plugin, Findings
+      assert.equal(paperTabs.querySelectorAll('paper-tab').length, 3);
+      assert.equal(paperTabs.querySelectorAll('paper-tab')[1].dataset.name,
+          'change-view-tab-header-url');
+    });
+
+    test('handleShowTab switched tab correctly', done => {
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      assert.equal(paperTabs.selected, 0);
+      element._handleShowTab({detail:
+          {tab: 'change-view-tab-header-url'}});
       flush(() => {
-        sandbox.restore();
+        assert.equal(paperTabs.selected, 1);
         done();
       });
     });
 
-    const getCustomCssValue =
-        cssParam => util.getComputedStyleValue(cssParam, element);
+    test('switching tab sets _selectedTabPluginEndpoint', done => {
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[1]);
+      flush(() => {
+        assert.equal(element._selectedTabPluginEndpoint,
+            'change-view-tab-content-url');
+        done();
+      });
+    });
+  });
 
-    test('_handleMessageAnchorTap', () => {
+  suite('keyboard shortcuts', () => {
+    test('t to add topic', () => {
+      const editStub = sandbox.stub(element.$.metadata, 'editTopic');
+      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 't');
+      assert(editStub.called);
+    });
+
+    test('S should toggle the CL star', () => {
+      const starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
+      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
+      assert(starStub.called);
+    });
+
+    test('U should navigate to root if no backPage set', () => {
+      const relativeNavStub = sandbox.stub(Gerrit.Nav,
+          'navigateToRelativeUrl');
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert.isTrue(relativeNavStub.called);
+      assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
+          Gerrit.Nav.getUrlForRoot()));
+    });
+
+    test('U should navigate to backPage if set', () => {
+      const relativeNavStub = sandbox.stub(Gerrit.Nav,
+          'navigateToRelativeUrl');
+      element.backPage = '/dashboard/self';
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert.isTrue(relativeNavStub.called);
+      assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
+          '/dashboard/self'));
+    });
+
+    test('A fires an error event when not logged in', done => {
+      sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+      const loggedInErrorSpy = sandbox.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isFalse(element.$.replyOverlay.opened);
+        assert.isTrue(loggedInErrorSpy.called);
+        done();
+      });
+    });
+
+    test('shift A does not open reply overlay', done => {
+      sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+      flush(() => {
+        assert.isFalse(element.$.replyOverlay.opened);
+        done();
+      });
+    });
+
+    test('A toggles overlay when logged in', done => {
+      sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates')
+          .returns(Promise.resolve({isLatest: true}));
+      element._change = {labels: {}};
+      const openSpy = sandbox.spy(element, '_openReplyDialog');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isTrue(element.$.replyOverlay.opened);
+        element.$.replyOverlay.close();
+        assert.isFalse(element.$.replyOverlay.opened);
+        assert(openSpy.lastCall.calledWithExactly(
+            element.$.replyDialog.FocusTarget.ANY),
+        '_openReplyDialog should have been passed ANY');
+        assert.equal(openSpy.callCount, 1);
+        done();
+      });
+    });
+
+    test('fullscreen-overlay-opened hides content', () => {
+      element._loggedIn = true;
+      element._loading = false;
+      element._change = {
+        owner: {_account_id: 1},
+        labels: {},
+        actions: {
+          abandon: {
+            enabled: true,
+            label: 'Abandon',
+            method: 'POST',
+            title: 'Abandon',
+          },
+        },
+      };
+      sandbox.spy(element, '_handleHideBackgroundContent');
+      element.$.replyDialog.fire('fullscreen-overlay-opened');
+      assert.isTrue(element._handleHideBackgroundContent.called);
+      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+      assert.equal(getComputedStyle(element.$.actions).display, 'flex');
+    });
+
+    test('fullscreen-overlay-closed shows content', () => {
+      element._loggedIn = true;
+      element._loading = false;
+      element._change = {
+        owner: {_account_id: 1},
+        labels: {},
+        actions: {
+          abandon: {
+            enabled: true,
+            label: 'Abandon',
+            method: 'POST',
+            title: 'Abandon',
+          },
+        },
+      };
+      sandbox.spy(element, '_handleShowBackgroundContent');
+      element.$.replyDialog.fire('fullscreen-overlay-closed');
+      assert.isTrue(element._handleShowBackgroundContent.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('expand all messages when expand-diffs fired', () => {
+      const handleExpand =
+          sandbox.stub(element.$.fileList, 'expandAllDiffs');
+      element.$.fileListHeader.fire('expand-diffs');
+      assert.isTrue(handleExpand.called);
+    });
+
+    test('collapse all messages when collapse-diffs fired', () => {
+      const handleCollapse =
+      sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+      element.$.fileListHeader.fire('collapse-diffs');
+      assert.isTrue(handleCollapse.called);
+    });
+
+    test('X should expand all messages', done => {
+      flush(() => {
+        const handleExpand = sandbox.stub(element.messagesList,
+            'handleExpandCollapse');
+        MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'x');
+        assert(handleExpand.calledWith(true));
+        done();
+      });
+    });
+
+    test('Z should collapse all messages', done => {
+      flush(() => {
+        const handleExpand = sandbox.stub(element.messagesList,
+            'handleExpandCollapse');
+        MockInteractions.pressAndReleaseKeyOn(element, 90, null, 'z');
+        assert(handleExpand.calledWith(false));
+        done();
+      });
+    });
+
+    test('shift + R should fetch and navigate to the latest patch set',
+        done => {
+          element._changeNum = '42';
+          element._patchRange = {
+            basePatchNum: 'PARENT',
+            patchNum: 1,
+          };
+          element._change = {
+            change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+            _number: 42,
+            revisions: {
+              rev1: {_number: 1, commit: {parents: []}},
+            },
+            current_revision: 'rev1',
+            status: 'NEW',
+            labels: {},
+            actions: {},
+          };
+
+          navigateToChangeStub.restore();
+          navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange',
+              (change, patchNum, basePatchNum) => {
+                assert.equal(change, element._change);
+                assert.isUndefined(patchNum);
+                assert.isUndefined(basePatchNum);
+                done();
+              });
+
+          MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+        });
+
+    test('d should open download overlay', () => {
+      const stub = sandbox.stub(element.$.downloadOverlay, 'open');
+      MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+      assert.isTrue(stub.called);
+    });
+
+    test(', should open diff preferences', () => {
+      const stub = sandbox.stub(
+          element.$.fileList.$.diffPreferencesDialog, 'open');
+      element._loggedIn = false;
+      element.disableDiffPrefs = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isFalse(stub.called);
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isFalse(stub.called);
+
+      element.disableDiffPrefs = false;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isTrue(stub.called);
+    });
+
+    test('m should toggle diff mode', () => {
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const setModeStub = sandbox.stub(element.$.fileListHeader,
+          'setDiffViewMode');
+      const e = {preventDefault: () => {}};
+      flushAsynchronousOperations();
+
+      element.viewState.diffMode = 'SIDE_BY_SIDE';
+      element._handleToggleDiffMode(e);
+      assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF'));
+
+      element.viewState.diffMode = 'UNIFIED_DIFF';
+      element._handleToggleDiffMode(e);
+      assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE'));
+    });
+  });
+
+  suite('reloading drafts', () => {
+    let reloadStub;
+    const drafts = {
+      'testfile.txt': [
+        {
+          patch_set: 5,
+          id: 'dd2982f5_c01c9e6a',
+          line: 1,
+          updated: '2017-11-08 18:47:45.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+      ],
+    };
+    setup(() => {
+      // Fake computeDraftCount as its required for ChangeComments,
+      // see gr-comment-api#reloadDrafts.
+      reloadStub = sandbox.stub(element.$.commentAPI, 'reloadDrafts')
+          .returns(Promise.resolve({
+            drafts,
+            getAllThreadsForChange: () => ([]),
+            computeDraftCount: () => 1,
+          }));
+    });
+
+    test('drafts are reloaded when reload-drafts fired', done => {
+      element.$.fileList.fire('reload-drafts', {
+        resolve: () => {
+          assert.isTrue(reloadStub.called);
+          assert.deepEqual(element._diffDrafts, drafts);
+          done();
+        },
+      });
+    });
+
+    test('drafts are reloaded when comment-refresh fired', () => {
+      element.fire('comment-refresh');
+      assert.isTrue(reloadStub.called);
+    });
+  });
+
+  test('diff comments modified', () => {
+    sandbox.spy(element, '_handleReloadCommentThreads');
+    return element._reloadComments().then(() => {
+      element.fire('diff-comments-modified');
+      assert.isTrue(element._handleReloadCommentThreads.called);
+    });
+  });
+
+  test('thread list modified', () => {
+    sandbox.spy(element, '_handleReloadDiffComments');
+    element._currentView = CommentTabs.COMMENT_THREADS;
+    flushAsynchronousOperations();
+
+    return element._reloadComments().then(() => {
+      element.threadList.fire('thread-list-modified');
+      assert.isTrue(element._handleReloadDiffComments.called);
+
+      let draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+          .returns(1);
+      assert.equal(element._computeTotalCommentCounts(5,
+          element._changeComments), '5 unresolved, 1 draft');
+      assert.equal(element._computeTotalCommentCounts(0,
+          element._changeComments), '1 draft');
+      draftStub.restore();
+      draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+          .returns(0);
+      assert.equal(element._computeTotalCommentCounts(0,
+          element._changeComments), '');
+      assert.equal(element._computeTotalCommentCounts(1,
+          element._changeComments), '1 unresolved');
+      draftStub.restore();
+      draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+          .returns(2);
+      assert.equal(element._computeTotalCommentCounts(1,
+          element._changeComments), '1 unresolved, 2 drafts');
+      draftStub.restore();
+    });
+  });
+
+  suite('thread list and change log tabs', () => {
+    setup(() => {
       element._changeNum = '1';
       element._patchRange = {
         basePatchNum: 'PARENT',
         patchNum: 1,
       };
-      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForChange');
-      const replaceStateStub = sandbox.stub(history, 'replaceState');
-      element._handleMessageAnchorTap({detail: {id: 'a12345'}});
-
-      assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
-      assert.isTrue(replaceStateStub.called);
-    });
-
-    suite('plugins adding to file tab', () => {
-      setup(done => {
-        // Resolving it here instead of during setup() as other tests depend
-        // on flush() not being called during setup.
-        flush(() => done());
-      });
-
-      test('plugin added tab shows up as a dynamic endpoint', () => {
-        assert(element._dynamicTabHeaderEndpoints.includes(
-            'change-view-tab-header-url'));
-        const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
-        // 3 Tabs are : Files, Plugin, Findings
-        assert.equal(paperTabs.querySelectorAll('paper-tab').length, 3);
-        assert.equal(paperTabs.querySelectorAll('paper-tab')[1].dataset.name,
-            'change-view-tab-header-url');
-      });
-
-      test('handleShowTab switched tab correctly', done => {
-        const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
-        assert.equal(paperTabs.selected, 0);
-        element._handleShowTab({detail:
-            {tab: 'change-view-tab-header-url'}});
-        flush(() => {
-          assert.equal(paperTabs.selected, 1);
-          done();
-        });
-      });
-
-      test('switching tab sets _selectedTabPluginEndpoint', done => {
-        const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
-        MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[1]);
-        flush(() => {
-          assert.equal(element._selectedTabPluginEndpoint,
-              'change-view-tab-content-url');
-          done();
-        });
-      });
-    });
-
-    suite('keyboard shortcuts', () => {
-      test('t to add topic', () => {
-        const editStub = sandbox.stub(element.$.metadata, 'editTopic');
-        MockInteractions.pressAndReleaseKeyOn(element, 83, null, 't');
-        assert(editStub.called);
-      });
-
-      test('S should toggle the CL star', () => {
-        const starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
-        MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
-        assert(starStub.called);
-      });
-
-      test('U should navigate to root if no backPage set', () => {
-        const relativeNavStub = sandbox.stub(Gerrit.Nav,
-            'navigateToRelativeUrl');
-        MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-        assert.isTrue(relativeNavStub.called);
-        assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
-            Gerrit.Nav.getUrlForRoot()));
-      });
-
-      test('U should navigate to backPage if set', () => {
-        const relativeNavStub = sandbox.stub(Gerrit.Nav,
-            'navigateToRelativeUrl');
-        element.backPage = '/dashboard/self';
-        MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-        assert.isTrue(relativeNavStub.called);
-        assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
-            '/dashboard/self'));
-      });
-
-      test('A fires an error event when not logged in', done => {
-        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
-        const loggedInErrorSpy = sandbox.spy();
-        element.addEventListener('show-auth-required', loggedInErrorSpy);
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        flush(() => {
-          assert.isFalse(element.$.replyOverlay.opened);
-          assert.isTrue(loggedInErrorSpy.called);
-          done();
-        });
-      });
-
-      test('shift A does not open reply overlay', done => {
-        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-        flush(() => {
-          assert.isFalse(element.$.replyOverlay.opened);
-          done();
-        });
-      });
-
-      test('A toggles overlay when logged in', done => {
-        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates')
-            .returns(Promise.resolve({isLatest: true}));
-        element._change = {labels: {}};
-        const openSpy = sandbox.spy(element, '_openReplyDialog');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        flush(() => {
-          assert.isTrue(element.$.replyOverlay.opened);
-          element.$.replyOverlay.close();
-          assert.isFalse(element.$.replyOverlay.opened);
-          assert(openSpy.lastCall.calledWithExactly(
-              element.$.replyDialog.FocusTarget.ANY),
-          '_openReplyDialog should have been passed ANY');
-          assert.equal(openSpy.callCount, 1);
-          done();
-        });
-      });
-
-      test('fullscreen-overlay-opened hides content', () => {
-        element._loggedIn = true;
-        element._loading = false;
-        element._change = {
-          owner: {_account_id: 1},
-          labels: {},
-          actions: {
-            abandon: {
-              enabled: true,
-              label: 'Abandon',
-              method: 'POST',
-              title: 'Abandon',
-            },
-          },
-        };
-        sandbox.spy(element, '_handleHideBackgroundContent');
-        element.$.replyDialog.fire('fullscreen-overlay-opened');
-        assert.isTrue(element._handleHideBackgroundContent.called);
-        assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-        assert.equal(getComputedStyle(element.$.actions).display, 'flex');
-      });
-
-      test('fullscreen-overlay-closed shows content', () => {
-        element._loggedIn = true;
-        element._loading = false;
-        element._change = {
-          owner: {_account_id: 1},
-          labels: {},
-          actions: {
-            abandon: {
-              enabled: true,
-              label: 'Abandon',
-              method: 'POST',
-              title: 'Abandon',
-            },
-          },
-        };
-        sandbox.spy(element, '_handleShowBackgroundContent');
-        element.$.replyDialog.fire('fullscreen-overlay-closed');
-        assert.isTrue(element._handleShowBackgroundContent.called);
-        assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
-      });
-
-      test('expand all messages when expand-diffs fired', () => {
-        const handleExpand =
-            sandbox.stub(element.$.fileList, 'expandAllDiffs');
-        element.$.fileListHeader.fire('expand-diffs');
-        assert.isTrue(handleExpand.called);
-      });
-
-      test('collapse all messages when collapse-diffs fired', () => {
-        const handleCollapse =
-        sandbox.stub(element.$.fileList, 'collapseAllDiffs');
-        element.$.fileListHeader.fire('collapse-diffs');
-        assert.isTrue(handleCollapse.called);
-      });
-
-      test('X should expand all messages', done => {
-        flush(() => {
-          const handleExpand = sandbox.stub(element.messagesList,
-              'handleExpandCollapse');
-          MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'x');
-          assert(handleExpand.calledWith(true));
-          done();
-        });
-      });
-
-      test('Z should collapse all messages', done => {
-        flush(() => {
-          const handleExpand = sandbox.stub(element.messagesList,
-              'handleExpandCollapse');
-          MockInteractions.pressAndReleaseKeyOn(element, 90, null, 'z');
-          assert(handleExpand.calledWith(false));
-          done();
-        });
-      });
-
-      test('shift + R should fetch and navigate to the latest patch set',
-          done => {
-            element._changeNum = '42';
-            element._patchRange = {
-              basePatchNum: 'PARENT',
-              patchNum: 1,
-            };
-            element._change = {
-              change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-              _number: 42,
-              revisions: {
-                rev1: {_number: 1, commit: {parents: []}},
-              },
-              current_revision: 'rev1',
-              status: 'NEW',
-              labels: {},
-              actions: {},
-            };
-
-            navigateToChangeStub.restore();
-            navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange',
-                (change, patchNum, basePatchNum) => {
-                  assert.equal(change, element._change);
-                  assert.isUndefined(patchNum);
-                  assert.isUndefined(basePatchNum);
-                  done();
-                });
-
-            MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-          });
-
-      test('d should open download overlay', () => {
-        const stub = sandbox.stub(element.$.downloadOverlay, 'open');
-        MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
-        assert.isTrue(stub.called);
-      });
-
-      test(', should open diff preferences', () => {
-        const stub = sandbox.stub(
-            element.$.fileList.$.diffPreferencesDialog, 'open');
-        element._loggedIn = false;
-        element.disableDiffPrefs = true;
-        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-        assert.isFalse(stub.called);
-
-        element._loggedIn = true;
-        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-        assert.isFalse(stub.called);
-
-        element.disableDiffPrefs = false;
-        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-        assert.isTrue(stub.called);
-      });
-
-      test('m should toggle diff mode', () => {
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        const setModeStub = sandbox.stub(element.$.fileListHeader,
-            'setDiffViewMode');
-        const e = {preventDefault: () => {}};
-        flushAsynchronousOperations();
-
-        element.viewState.diffMode = 'SIDE_BY_SIDE';
-        element._handleToggleDiffMode(e);
-        assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF'));
-
-        element.viewState.diffMode = 'UNIFIED_DIFF';
-        element._handleToggleDiffMode(e);
-        assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE'));
-      });
-    });
-
-    suite('reloading drafts', () => {
-      let reloadStub;
-      const drafts = {
-        'testfile.txt': [
-          {
-            patch_set: 5,
-            id: 'dd2982f5_c01c9e6a',
-            line: 1,
-            updated: '2017-11-08 18:47:45.000000000',
-            message: 'test',
-            unresolved: true,
-          },
-        ],
-      };
-      setup(() => {
-        // Fake computeDraftCount as its required for ChangeComments,
-        // see gr-comment-api#reloadDrafts.
-        reloadStub = sandbox.stub(element.$.commentAPI, 'reloadDrafts')
-            .returns(Promise.resolve({
-              drafts,
-              getAllThreadsForChange: () => ([]),
-              computeDraftCount: () => 1,
-            }));
-      });
-
-      test('drafts are reloaded when reload-drafts fired', done => {
-        element.$.fileList.fire('reload-drafts', {
-          resolve: () => {
-            assert.isTrue(reloadStub.called);
-            assert.deepEqual(element._diffDrafts, drafts);
-            done();
-          },
-        });
-      });
-
-      test('drafts are reloaded when comment-refresh fired', () => {
-        element.fire('comment-refresh');
-        assert.isTrue(reloadStub.called);
-      });
-    });
-
-    test('diff comments modified', () => {
-      sandbox.spy(element, '_handleReloadCommentThreads');
-      return element._reloadComments().then(() => {
-        element.fire('diff-comments-modified');
-        assert.isTrue(element._handleReloadCommentThreads.called);
-      });
-    });
-
-    test('thread list modified', () => {
-      sandbox.spy(element, '_handleReloadDiffComments');
-      element._currentView = CommentTabs.COMMENT_THREADS;
-      flushAsynchronousOperations();
-
-      return element._reloadComments().then(() => {
-        element.threadList.fire('thread-list-modified');
-        assert.isTrue(element._handleReloadDiffComments.called);
-
-        let draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
-            .returns(1);
-        assert.equal(element._computeTotalCommentCounts(5,
-            element._changeComments), '5 unresolved, 1 draft');
-        assert.equal(element._computeTotalCommentCounts(0,
-            element._changeComments), '1 draft');
-        draftStub.restore();
-        draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
-            .returns(0);
-        assert.equal(element._computeTotalCommentCounts(0,
-            element._changeComments), '');
-        assert.equal(element._computeTotalCommentCounts(1,
-            element._changeComments), '1 unresolved');
-        draftStub.restore();
-        draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
-            .returns(2);
-        assert.equal(element._computeTotalCommentCounts(1,
-            element._changeComments), '1 unresolved, 2 drafts');
-        draftStub.restore();
-      });
-    });
-
-    suite('thread list and change log tabs', () => {
-      setup(() => {
-        element._changeNum = '1';
-        element._patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: 1,
-        };
-        element._change = {
-          change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-          revisions: {
-            rev2: {_number: 2, commit: {parents: []}},
-            rev1: {_number: 1, commit: {parents: []}},
-            rev13: {_number: 13, commit: {parents: []}},
-            rev3: {_number: 3, commit: {parents: []}},
-          },
-          current_revision: 'rev3',
-          status: 'NEW',
-          labels: {
-            test: {
-              all: [],
-              default_value: 0,
-              values: [],
-              approved: {},
-            },
-          },
-        };
-        sandbox.stub(element.$.relatedChanges, 'reload');
-        sandbox.stub(element, '_reload').returns(Promise.resolve());
-        sandbox.spy(element, '_paramsChanged');
-        element.params = {view: 'change', changeNum: '1'};
-      });
-
-      test('tab switch works correctly', done => {
-        assert.isTrue(element._paramsChanged.called);
-        assert.equal(element.$.commentTabs.selected, CommentTabs.CHANGE_LOG);
-        assert.equal(element._currentView, CommentTabs.CHANGE_LOG);
-
-        const commentTab = element.shadowRoot.querySelector(
-            'paper-tab.commentThreads'
-        );
-        // Switch to comment thread tab
-        MockInteractions.tap(commentTab);
-        const commentTabs = element.$.commentTabs;
-        assert.equal(commentTabs.selected,
-            CommentTabs.COMMENT_THREADS);
-        assert.equal(element._currentView, CommentTabs.COMMENT_THREADS);
-
-        // Switch back to 'Change Log' tab
-        element._paramsChanged(element.params);
-        flush(() => {
-          assert.equal(commentTabs.selected,
-              CommentTabs.CHANGE_LOG);
-          assert.equal(element._currentView, CommentTabs.CHANGE_LOG);
-          done();
-        });
-      });
-    });
-
-    suite('Findings comment tab', () => {
-      setup(done => {
-        element._change = {
-          change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-          revisions: {
-            rev2: {_number: 2, commit: {parents: []}},
-            rev1: {_number: 1, commit: {parents: []}},
-            rev13: {_number: 13, commit: {parents: []}},
-            rev3: {_number: 3, commit: {parents: []}},
-            rev4: {_number: 4, commit: {parents: []}},
-          },
-          current_revision: 'rev4',
-        };
-        element._commentThreads = THREADS;
-        const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
-        MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[2]);
-        flush(() => {
-          done();
-        });
-      });
-
-      test('robot comments count per patchset', () => {
-        const count = element._robotCommentCountPerPatchSet(THREADS);
-        const expectedCount = {
-          2: 1,
-          3: 1,
-          4: 2,
-        };
-        assert.deepEqual(count, expectedCount);
-        assert.equal(element._computeText({_number: 2}, THREADS),
-            'Patchset 2 (1 finding)');
-        assert.equal(element._computeText({_number: 4}, THREADS),
-            'Patchset 4 (2 findings)');
-        assert.equal(element._computeText({_number: 5}, THREADS),
-            'Patchset 5');
-      });
-
-      test('only robot comments are rendered', () => {
-        assert.equal(element._robotCommentThreads.length, 2);
-        assert.equal(element._robotCommentThreads[0].comments[0].robot_id,
-            'rc1');
-        assert.equal(element._robotCommentThreads[1].comments[0].robot_id,
-            'rc2');
-      });
-
-      test('changing patchsets resets robot comments', done => {
-        element.set('_change.current_revision', 'rev3');
-        flush(() => {
-          assert.equal(element._robotCommentThreads.length, 1);
-          done();
-        });
-      });
-
-      test('Show more button is hidden', () => {
-        assert.isNull(element.shadowRoot.querySelector('.show-robot-comments'));
-      });
-
-      suite('robot comments show more button', () => {
-        setup(done => {
-          const arr = [];
-          for (let i = 0; i <= 30; i++) {
-            arr.push(...THREADS);
-          }
-          element._commentThreads = arr;
-          flush(() => {
-            done();
-          });
-        });
-
-        test('Show more button is rendered', () => {
-          assert.isOk(element.shadowRoot.querySelector('.show-robot-comments'));
-          assert.equal(element._robotCommentThreads.length,
-              ROBOT_COMMENTS_LIMIT);
-        });
-
-        test('Clicking show more button renders all comments', done => {
-          MockInteractions.tap(element.shadowRoot.querySelector(
-              '.show-robot-comments'));
-          flush(() => {
-            assert.equal(element._robotCommentThreads.length, 62);
-            done();
-          });
-        });
-      });
-    });
-
-    test('reply button is not visible when logged out', () => {
-      assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
-      element._loggedIn = true;
-      assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
-    });
-
-    test('download tap calls _handleOpenDownloadDialog', () => {
-      sandbox.stub(element, '_handleOpenDownloadDialog');
-      element.$.actions.fire('download-tap');
-      assert.isTrue(element._handleOpenDownloadDialog.called);
-    });
-
-    test('fetches the server config on attached', done => {
-      flush(() => {
-        assert.equal(element._serverConfig.test, 'config');
-        done();
-      });
-    });
-
-    test('_changeStatuses', () => {
-      sandbox.stub(element, 'changeStatuses').returns(
-          ['Merged', 'WIP']);
-      element._loading = false;
       element._change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
         revisions: {
-          rev2: {_number: 2},
-          rev1: {_number: 1},
-          rev13: {_number: 13},
-          rev3: {_number: 3},
-        },
-        current_revision: 'rev3',
-        labels: {
-          test: {
-            all: [],
-            default_value: 0,
-            values: [],
-            approved: {},
-          },
-        },
-      };
-      element._mergeable = true;
-      const expectedStatuses = ['Merged', 'WIP'];
-      assert.deepEqual(element._changeStatuses, expectedStatuses);
-      assert.equal(element._changeStatus, expectedStatuses.join(', '));
-      flushAsynchronousOperations();
-      const statusChips = Polymer.dom(element.root)
-          .querySelectorAll('gr-change-status');
-      assert.equal(statusChips.length, 2);
-    });
-
-    test('diff preferences open when open-diff-prefs is fired', () => {
-      const overlayOpenStub = sandbox.stub(element.$.fileList,
-          'openDiffPrefs');
-      element.$.fileListHeader.fire('open-diff-prefs');
-      assert.isTrue(overlayOpenStub.called);
-    });
-
-    test('_prepareCommitMsgForLinkify', () => {
-      let commitMessage = 'R=test@google.com';
-      let result = element._prepareCommitMsgForLinkify(commitMessage);
-      assert.equal(result, 'R=\u200Btest@google.com');
-
-      commitMessage = 'R=test@google.com\nR=test@google.com';
-      result = element._prepareCommitMsgForLinkify(commitMessage);
-      assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
-
-      commitMessage = 'CC=test@google.com';
-      result = element._prepareCommitMsgForLinkify(commitMessage);
-      assert.equal(result, 'CC=\u200Btest@google.com');
-    }),
-
-    test('_isSubmitEnabled', () => {
-      assert.isFalse(element._isSubmitEnabled({}));
-      assert.isFalse(element._isSubmitEnabled({submit: {}}));
-      assert.isTrue(element._isSubmitEnabled(
-          {submit: {enabled: true}}));
-    });
-
-    test('_reload is called when an approved label is removed', () => {
-      const vote = {_account_id: 1, name: 'bojack', value: 1};
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {email: 'abc@def'},
-        revisions: {
           rev2: {_number: 2, commit: {parents: []}},
           rev1: {_number: 1, commit: {parents: []}},
           rev13: {_number: 13, commit: {parents: []}},
@@ -934,1312 +720,1536 @@
         status: 'NEW',
         labels: {
           test: {
-            all: [vote],
+            all: [],
             default_value: 0,
             values: [],
             approved: {},
           },
         },
       };
-      flushAsynchronousOperations();
-      const reloadStub = sandbox.stub(element, '_reload');
-      element.splice('_change.labels.test.all', 0, 1);
-      assert.isFalse(reloadStub.called);
-      element._change.labels.test.all.push(vote);
-      element._change.labels.test.all.push(vote);
-      element._change.labels.test.approved = vote;
-      flushAsynchronousOperations();
-      element.splice('_change.labels.test.all', 0, 2);
-      assert.isTrue(reloadStub.called);
-      assert.isTrue(reloadStub.calledOnce);
-    });
-
-    test('reply button has updated count when there are drafts', () => {
-      const getLabel = element._computeReplyButtonLabel;
-
-      assert.equal(getLabel(null, false), 'Reply');
-      assert.equal(getLabel(null, true), 'Start review');
-
-      const changeRecord = {base: null};
-      assert.equal(getLabel(changeRecord, false), 'Reply');
-
-      changeRecord.base = {};
-      assert.equal(getLabel(changeRecord, false), 'Reply');
-
-      changeRecord.base = {
-        'file1.txt': [{}],
-        'file2.txt': [{}, {}],
-      };
-      assert.equal(getLabel(changeRecord, false), 'Reply (3)');
-    });
-
-    test('start review button when owner of WIP change', () => {
-      assert.equal(
-          element._computeReplyButtonLabel(null, true),
-          'Start review');
-    });
-
-    test('comment events properly update diff drafts', () => {
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      const draft = {
-        __draft: true,
-        id: 'id1',
-        path: '/foo/bar.txt',
-        text: 'hello',
-      };
-      element._handleCommentSave({detail: {comment: draft}});
-      draft.patch_set = 2;
-      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-      draft.patch_set = null;
-      draft.text = 'hello, there';
-      element._handleCommentSave({detail: {comment: draft}});
-      draft.patch_set = 2;
-      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-      const draft2 = {
-        __draft: true,
-        id: 'id2',
-        path: '/foo/bar.txt',
-        text: 'hola',
-      };
-      element._handleCommentSave({detail: {comment: draft2}});
-      draft2.patch_set = 2;
-      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
-      draft.patch_set = null;
-      element._handleCommentDiscard({detail: {comment: draft}});
-      draft.patch_set = 2;
-      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
-      element._handleCommentDiscard({detail: {comment: draft2}});
-      assert.deepEqual(element._diffDrafts, {});
-    });
-
-    test('change num change', () => {
-      element._changeNum = null;
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        labels: {},
-      };
-      element.viewState.changeNum = null;
-      element.viewState.diffMode = 'UNIFIED';
-      assert.equal(element.viewState.numFilesShown, 200);
-      assert.equal(element._numFilesShown, 200);
-      element._numFilesShown = 150;
-      flushAsynchronousOperations();
-      assert.equal(element.viewState.diffMode, 'UNIFIED');
-      assert.equal(element.viewState.numFilesShown, 150);
-
-      element._changeNum = '1';
-      element.params = {changeNum: '1'};
-      element._change.newProp = '1';
-      flushAsynchronousOperations();
-      assert.equal(element.viewState.diffMode, 'UNIFIED');
-      assert.equal(element.viewState.changeNum, '1');
-
-      element._changeNum = '2';
-      element.params = {changeNum: '2'};
-      element._change.newProp = '2';
-      flushAsynchronousOperations();
-      assert.equal(element.viewState.diffMode, 'UNIFIED');
-      assert.equal(element.viewState.changeNum, '2');
-      assert.equal(element.viewState.numFilesShown, 200);
-      assert.equal(element._numFilesShown, 200);
-    });
-
-    test('_setDiffViewMode is called with reset when new change is loaded',
-        () => {
-          sandbox.stub(element, '_setDiffViewMode');
-          element.viewState = {changeNum: 1};
-          element._changeNum = 2;
-          element._resetFileListViewState();
-          assert.isTrue(
-              element._setDiffViewMode.lastCall.calledWithExactly(true));
-        });
-
-    test('diffViewMode is propagated from file list header', () => {
-      element.viewState = {diffMode: 'UNIFIED'};
-      element.$.fileListHeader.diffViewMode = 'SIDE_BY_SIDE';
-      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
-    });
-
-    test('diffMode defaults to side by side without preferences', done => {
-      sandbox.stub(element.$.restAPI, 'getPreferences').returns(
-          Promise.resolve({}));
-      // No user prefs or diff view mode set.
-
-      element._setDiffViewMode().then(() => {
-        assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
-        done();
-      });
-    });
-
-    test('diffMode defaults to preference when not already set', done => {
-      sandbox.stub(element.$.restAPI, 'getPreferences').returns(
-          Promise.resolve({default_diff_view: 'UNIFIED'}));
-
-      element._setDiffViewMode().then(() => {
-        assert.equal(element.viewState.diffMode, 'UNIFIED');
-        done();
-      });
-    });
-
-    test('existing diffMode overrides preference', done => {
-      element.viewState.diffMode = 'SIDE_BY_SIDE';
-      sandbox.stub(element.$.restAPI, 'getPreferences').returns(
-          Promise.resolve({default_diff_view: 'UNIFIED'}));
-      element._setDiffViewMode().then(() => {
-        assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
-        done();
-      });
-    });
-
-    test('don’t reload entire page when patchRange changes', () => {
-      const reloadStub = sandbox.stub(element, '_reload',
-          () => Promise.resolve());
-      const reloadPatchDependentStub = sandbox.stub(element,
-          '_reloadPatchNumDependentResources',
-          () => Promise.resolve());
-      const relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
-      const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
-
-      const value = {
-        view: Gerrit.Nav.View.CHANGE,
-        patchNum: '1',
-      };
-      element._paramsChanged(value);
-      assert.isTrue(reloadStub.calledOnce);
-      assert.isTrue(relatedClearSpy.calledOnce);
-
-      element._initialLoadComplete = true;
-
-      value.basePatchNum = '1';
-      value.patchNum = '2';
-      element._paramsChanged(value);
-      assert.isFalse(reloadStub.calledTwice);
-      assert.isTrue(reloadPatchDependentStub.calledOnce);
-      assert.isTrue(relatedClearSpy.calledOnce);
-      assert.isTrue(collapseStub.calledTwice);
-    });
-
-    test('reload entire page when patchRange doesnt change', () => {
-      const reloadStub = sandbox.stub(element, '_reload',
-          () => Promise.resolve());
-      const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
-      const value = {
-        view: Gerrit.Nav.View.CHANGE,
-      };
-      element._paramsChanged(value);
-      assert.isTrue(reloadStub.calledOnce);
-      element._initialLoadComplete = true;
-      element._paramsChanged(value);
-      assert.isTrue(reloadStub.calledTwice);
-      assert.isTrue(collapseStub.calledTwice);
-    });
-
-    test('related changes are updated and new patch selected after rebase',
-        done => {
-          element._changeNum = '42';
-          sandbox.stub(element, 'computeLatestPatchNum', () => 1);
-          sandbox.stub(element, '_reload',
-              () => Promise.resolve());
-          const e = {detail: {action: 'rebase'}};
-          element._handleReloadChange(e).then(() => {
-            assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
-                element._change));
-            done();
-          });
-        });
-
-    test('related changes are not updated after other action', done => {
-      sandbox.stub(element, '_reload', () => Promise.resolve());
-      sandbox.stub(element.$.relatedChanges, 'reload');
-      const e = {detail: {action: 'abandon'}};
-      element._handleReloadChange(e).then(() => {
-        assert.isFalse(navigateToChangeStub.called);
-        done();
-      });
-    });
-
-    test('_computeMergedCommitInfo', () => {
-      const dummyRevs = {
-        1: {commit: {commit: 1}},
-        2: {commit: {}},
-      };
-      assert.deepEqual(element._computeMergedCommitInfo(0, dummyRevs), {});
-      assert.deepEqual(element._computeMergedCommitInfo(1, dummyRevs),
-          dummyRevs[1].commit);
-
-      // Regression test for issue 5337.
-      const commit = element._computeMergedCommitInfo(2, dummyRevs);
-      assert.notDeepEqual(commit, dummyRevs[2]);
-      assert.deepEqual(commit, {commit: 2});
-    });
-
-    test('_computeCopyTextForTitle', () => {
-      const change = {
-        _number: 123,
-        subject: 'test subject',
-        revisions: {
-          rev1: {_number: 1},
-          rev3: {_number: 3},
-        },
-        current_revision: 'rev3',
-      };
-      sandbox.stub(Gerrit.Nav, 'getUrlForChange')
-          .returns('/change/123');
-      assert.equal(
-          element._computeCopyTextForTitle(change),
-          '123: test subject | https://localhost:8081/change/123'
-      );
-    });
-
-    test('get latest revision', () => {
-      let change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev3: {_number: 3},
-        },
-        current_revision: 'rev3',
-      };
-      assert.equal(element._getLatestRevisionSHA(change), 'rev3');
-      change = {
-        revisions: {
-          rev1: {_number: 1},
-        },
-      };
-      assert.equal(element._getLatestRevisionSHA(change), 'rev1');
-    });
-
-    test('show commit message edit button', () => {
-      const _change = {
-        status: element.ChangeStatus.MERGED,
-      };
-      assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
-      assert.isTrue(element._computeHideEditCommitMessage(true, true, {}));
-      assert.isTrue(element._computeHideEditCommitMessage(false, true, {}));
-      assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
-      assert.isTrue(element._computeHideEditCommitMessage(true, false,
-          _change));
-      assert.isTrue(element._computeHideEditCommitMessage(true, false, {},
-          true));
-      assert.isFalse(element._computeHideEditCommitMessage(true, false, {},
-          false));
-    });
-
-    test('_handleCommitMessageSave trims trailing whitespace', () => {
-      const putStub = sandbox.stub(element.$.restAPI, 'putChangeCommitMessage')
-          .returns(Promise.resolve({}));
-
-      const mockEvent = content => { return {detail: {content}}; };
-
-      element._handleCommitMessageSave(mockEvent('test \n  test '));
-      assert.equal(putStub.lastCall.args[1], 'test\n  test');
-
-      element._handleCommitMessageSave(mockEvent('  test\ntest'));
-      assert.equal(putStub.lastCall.args[1], '  test\ntest');
-
-      element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
-      assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
-    });
-
-    test('_computeChangeIdCommitMessageError', () => {
-      let commitMessage =
-        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
-      let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          null);
-
-      change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          'mismatch');
-
-      commitMessage = 'This is the greatest change.';
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          'missing');
-    });
-
-    test('multiple change Ids in commit message picks last', () => {
-      const commitMessage = [
-        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
-      ].join('\n');
-      let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          null);
-      change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          'mismatch');
-    });
-
-    test('does not count change Id that starts mid line', () => {
-      const commitMessage = [
-        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
-      ].join(' and ');
-      let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          null);
-      change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          'mismatch');
-    });
-
-    test('_computeTitleAttributeWarning', () => {
-      let changeIdCommitMessageError = 'missing';
-      assert.equal(
-          element._computeTitleAttributeWarning(changeIdCommitMessageError),
-          'No Change-Id in commit message');
-
-      changeIdCommitMessageError = 'mismatch';
-      assert.equal(
-          element._computeTitleAttributeWarning(changeIdCommitMessageError),
-          'Change-Id mismatch');
-    });
-
-    test('_computeChangeIdClass', () => {
-      let changeIdCommitMessageError = 'missing';
-      assert.equal(
-          element._computeChangeIdClass(changeIdCommitMessageError), '');
-
-      changeIdCommitMessageError = 'mismatch';
-      assert.equal(
-          element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
-    });
-
-    test('topic is coalesced to null', done => {
-      sandbox.stub(element, '_changeChanged');
-      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
-        id: '123456789',
-        labels: {},
-        current_revision: 'foo',
-        revisions: {foo: {commit: {}}},
-      }));
-
-      element._getChangeDetail().then(() => {
-        assert.isNull(element._change.topic);
-        done();
-      });
-    });
-
-    test('commit sha is populated from getChangeDetail', done => {
-      sandbox.stub(element, '_changeChanged');
-      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
-        id: '123456789',
-        labels: {},
-        current_revision: 'foo',
-        revisions: {foo: {commit: {}}},
-      }));
-
-      element._getChangeDetail().then(() => {
-        assert.equal('foo', element._commitInfo.commit);
-        done();
-      });
-    });
-
-    test('edit is added to change', () => {
-      sandbox.stub(element, '_changeChanged');
-      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
-        id: '123456789',
-        labels: {},
-        current_revision: 'foo',
-        revisions: {foo: {commit: {}}},
-      }));
-      sandbox.stub(element, '_getEdit', () => Promise.resolve({
-        base_patch_set_number: 1,
-        commit: {commit: 'bar'},
-      }));
-      element._patchRange = {};
-
-      return element._getChangeDetail().then(() => {
-        const revs = element._change.revisions;
-        assert.equal(Object.keys(revs).length, 2);
-        assert.deepEqual(revs['foo'], {commit: {commit: 'foo'}});
-        assert.deepEqual(revs['bar'], {
-          _number: element.EDIT_NAME,
-          basePatchNum: 1,
-          commit: {commit: 'bar'},
-          fetch: undefined,
-        });
-      });
-    });
-
-    test('_getBasePatchNum', () => {
-      const _change = {
-        _number: 42,
-        revisions: {
-          '98da160735fb81604b4c40e93c368f380539dd0e': {
-            _number: 1,
-            commit: {
-              parents: [],
-            },
-          },
-        },
-      };
-      const _patchRange = {
-        basePatchNum: 'PARENT',
-      };
-      assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
-
-      element._prefs = {
-        default_base_for_merges: 'FIRST_PARENT',
-      };
-
-      const _change2 = {
-        _number: 42,
-        revisions: {
-          '98da160735fb81604b4c40e93c368f380539dd0e': {
-            _number: 1,
-            commit: {
-              parents: [
-                {
-                  commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8',
-                  subject: 'test',
-                },
-                {
-                  commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841',
-                  subject: 'test3',
-                },
-              ],
-            },
-          },
-        },
-      };
-      assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
-
-      _patchRange.patchNum = 1;
-      assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
-    });
-
-    test('_openReplyDialog called with `ANY` when coming from tap event',
-        () => {
-          const openStub = sandbox.stub(element, '_openReplyDialog');
-          element._serverConfig = {};
-          MockInteractions.tap(element.$.replyBtn);
-          assert(openStub.lastCall.calledWithExactly(
-              element.$.replyDialog.FocusTarget.ANY),
-          '_openReplyDialog should have been passed ANY');
-          assert.equal(openStub.callCount, 1);
-        });
-
-    test('_openReplyDialog called with `BODY` when coming from message reply' +
-        'event', done => {
-      flush(() => {
-        const openStub = sandbox.stub(element, '_openReplyDialog');
-        element.messagesList.fire('reply',
-            {message: {message: 'text'}});
-        assert(openStub.lastCall.calledWithExactly(
-            element.$.replyDialog.FocusTarget.BODY),
-        '_openReplyDialog should have been passed BODY');
-        assert.equal(openStub.callCount, 1);
-        done();
-      });
-    });
-
-    test('reply dialog focus can be controlled', () => {
-      const FocusTarget = element.$.replyDialog.FocusTarget;
-      const openStub = sandbox.stub(element, '_openReplyDialog');
-
-      const e = {detail: {}};
-      element._handleShowReplyDialog(e);
-      assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
-          '_openReplyDialog should have been passed REVIEWERS');
-      assert.equal(openStub.callCount, 1);
-
-      e.detail.value = {ccsOnly: true};
-      element._handleShowReplyDialog(e);
-      assert(openStub.lastCall.calledWithExactly(FocusTarget.CCS),
-          '_openReplyDialog should have been passed CCS');
-      assert.equal(openStub.callCount, 2);
-    });
-
-    test('getUrlParameter functionality', () => {
-      const locationStub = sandbox.stub(element, '_getLocationSearch');
-
-      locationStub.returns('?test');
-      assert.equal(element._getUrlParameter('test'), 'test');
-      locationStub.returns('?test2=12&test=3');
-      assert.equal(element._getUrlParameter('test'), 'test');
-      locationStub.returns('');
-      assert.isNull(element._getUrlParameter('test'));
-      locationStub.returns('?');
-      assert.isNull(element._getUrlParameter('test'));
-      locationStub.returns('?test2');
-      assert.isNull(element._getUrlParameter('test'));
-    });
-
-    test('revert dialog opened with revert param', done => {
-      sandbox.stub(element.$.restAPI, 'getLoggedIn', () => Promise.resolve(true));
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded', () => Promise.resolve());
-
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1, commit: {parents: []}},
-          rev2: {_number: 2, commit: {parents: []}},
-        },
-        current_revision: 'rev1',
-        status: element.ChangeStatus.MERGED,
-        labels: {},
-        actions: {},
-      };
-
-      sandbox.stub(element, '_getUrlParameter',
-          param => {
-            assert.equal(param, 'revert');
-            return param;
-          });
-
-      sandbox.stub(element.$.actions, 'showRevertDialog',
-          done);
-
-      element._maybeShowRevertDialog();
-      assert.isTrue(Gerrit.awaitPluginsLoaded.called);
-    });
-
-    suite('scroll related tests', () => {
-      test('document scrolling calls function to set scroll height', done => {
-        const originalHeight = document.body.scrollHeight;
-        const scrollStub = sandbox.stub(element, '_handleScroll',
-            () => {
-              assert.isTrue(scrollStub.called);
-              document.body.style.height = originalHeight + 'px';
-              scrollStub.restore();
-              done();
-            });
-        document.body.style.height = '10000px';
-        element._handleScroll();
-      });
-
-      test('scrollTop is set correctly', () => {
-        element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
-
-        sandbox.stub(element, '_reload', () => {
-          // When element is reloaded, ensure that the history
-          // state has the scrollTop set earlier. This will then
-          // be reset.
-          assert.isTrue(element.viewState.scrollTop == TEST_SCROLL_TOP_PX);
-          return Promise.resolve({});
-        });
-
-        // simulate reloading component, which is done when route
-        // changes to match a regex of change view type.
-        element._paramsChanged({view: Gerrit.Nav.View.CHANGE});
-      });
-
-      test('scrollTop is reset when new change is loaded', () => {
-        element._resetFileListViewState();
-        assert.equal(element.viewState.scrollTop, 0);
-      });
-    });
-
-    suite('reply dialog tests', () => {
-      setup(() => {
-        sandbox.stub(element.$.replyDialog, '_draftChanged');
-        sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates',
-            () => Promise.resolve({isLatest: true}));
-        element._change = {labels: {}};
-      });
-
-      test('reply from comment adds quote text', () => {
-        const e = {detail: {message: {message: 'quote text'}}};
-        element._handleMessageReply(e);
-        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-      });
-
-      test('reply from comment replaces quote text', () => {
-        element.$.replyDialog.draft = '> old quote text\n\n some draft text';
-        element.$.replyDialog.quote = '> old quote text\n\n';
-        const e = {detail: {message: {message: 'quote text'}}};
-        element._handleMessageReply(e);
-        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-      });
-
-      test('reply from same comment preserves quote text', () => {
-        element.$.replyDialog.draft = '> quote text\n\n some draft text';
-        element.$.replyDialog.quote = '> quote text\n\n';
-        const e = {detail: {message: {message: 'quote text'}}};
-        element._handleMessageReply(e);
-        assert.equal(element.$.replyDialog.draft,
-            '> quote text\n\n some draft text');
-        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-      });
-
-      test('reply from top of page contains previous draft', () => {
-        const div = document.createElement('div');
-        element.$.replyDialog.draft = '> quote text\n\n some draft text';
-        element.$.replyDialog.quote = '> quote text\n\n';
-        const e = {target: div, preventDefault: sandbox.spy()};
-        element._handleReplyTap(e);
-        assert.equal(element.$.replyDialog.draft,
-            '> quote text\n\n some draft text');
-        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-      });
-    });
-
-    test('reply button is disabled until server config is loaded', () => {
-      assert.isTrue(element._replyDisabled);
-      element._serverConfig = {};
-      assert.isFalse(element._replyDisabled);
-    });
-
-    suite('commit message expand/collapse', () => {
-      setup(() => {
-        sandbox.stub(element, 'fetchChangeUpdates',
-            () => Promise.resolve({isLatest: false}));
-      });
-
-      test('commitCollapseToggle hidden for short commit message', () => {
-        element._latestCommitMessage = '';
-        assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
-      });
-
-      test('commitCollapseToggle shown for long commit message', () => {
-        element._latestCommitMessage = _.times(31, String).join('\n');
-        assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
-      });
-
-      test('commitCollapseToggle functions', () => {
-        element._latestCommitMessage = _.times(35, String).join('\n');
-        assert.isTrue(element._commitCollapsed);
-        assert.isTrue(element._commitCollapsible);
-        assert.isTrue(
-            element.$.commitMessageEditor.hasAttribute('collapsed'));
-        MockInteractions.tap(element.$.commitCollapseToggleButton);
-        assert.isFalse(element._commitCollapsed);
-        assert.isTrue(element._commitCollapsible);
-        assert.isFalse(
-            element.$.commitMessageEditor.hasAttribute('collapsed'));
-      });
-    });
-
-    suite('related changes expand/collapse', () => {
-      let updateHeightSpy;
-      setup(() => {
-        updateHeightSpy = sandbox.spy(element, '_updateRelatedChangeMaxHeight');
-      });
-
-      test('relatedChangesToggle shown height greater than changeInfo height',
-          () => {
-            assert.isFalse(element.$.relatedChangesToggle.classList
-                .contains('showToggle'));
-            sandbox.stub(element, '_getOffsetHeight', () => 50);
-            sandbox.stub(element, '_getScrollHeight', () => 60);
-            sandbox.stub(element, '_getLineHeight', () => 5);
-            sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
-            element.$.relatedChanges.dispatchEvent(
-                new CustomEvent('new-section-loaded'));
-            assert.isTrue(element.$.relatedChangesToggle.classList
-                .contains('showToggle'));
-            assert.equal(updateHeightSpy.callCount, 1);
-          });
-
-      test('relatedChangesToggle hidden height less than changeInfo height',
-          () => {
-            assert.isFalse(element.$.relatedChangesToggle.classList
-                .contains('showToggle'));
-            sandbox.stub(element, '_getOffsetHeight', () => 50);
-            sandbox.stub(element, '_getScrollHeight', () => 40);
-            sandbox.stub(element, '_getLineHeight', () => 5);
-            sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
-            element.$.relatedChanges.dispatchEvent(
-                new CustomEvent('new-section-loaded'));
-            assert.isFalse(element.$.relatedChangesToggle.classList
-                .contains('showToggle'));
-            assert.equal(updateHeightSpy.callCount, 1);
-          });
-
-      test('relatedChangesToggle functions', () => {
-        sandbox.stub(element, '_getOffsetHeight', () => 50);
-        sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
-        element._relatedChangesLoading = false;
-        assert.isTrue(element._relatedChangesCollapsed);
-        assert.isTrue(
-            element.$.relatedChanges.classList.contains('collapsed'));
-        MockInteractions.tap(element.$.relatedChangesToggleButton);
-        assert.isFalse(element._relatedChangesCollapsed);
-        assert.isFalse(
-            element.$.relatedChanges.classList.contains('collapsed'));
-      });
-
-      test('_updateRelatedChangeMaxHeight without commit toggle', () => {
-        sandbox.stub(element, '_getOffsetHeight', () => 50);
-        sandbox.stub(element, '_getLineHeight', () => 12);
-        sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
-
-        // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
-        // 20 (max existing height)  % 12 (line height) = 6 (remainder).
-        // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
-
-        element._updateRelatedChangeMaxHeight();
-        assert.equal(getCustomCssValue('--relation-chain-max-height'),
-            '12px');
-        assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
-            '');
-      });
-
-      test('_updateRelatedChangeMaxHeight with commit toggle', () => {
-        element._latestCommitMessage = _.times(31, String).join('\n');
-        sandbox.stub(element, '_getOffsetHeight', () => 50);
-        sandbox.stub(element, '_getLineHeight', () => 12);
-        sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
-
-        // 50 (existing height) % 12 (line height) = 2 (remainder).
-        // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
-
-        element._updateRelatedChangeMaxHeight();
-        assert.equal(getCustomCssValue('--relation-chain-max-height'),
-            '48px');
-        assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
-            '2px');
-      });
-
-      test('_updateRelatedChangeMaxHeight in small screen mode', () => {
-        element._latestCommitMessage = _.times(31, String).join('\n');
-        sandbox.stub(element, '_getOffsetHeight', () => 50);
-        sandbox.stub(element, '_getLineHeight', () => 12);
-        sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
-
-        element._updateRelatedChangeMaxHeight();
-
-        // 400 (new height) % 12 (line height) = 4 (remainder).
-        // 400 (new height) - 4 (remainder) = 396.
-
-        assert.equal(getCustomCssValue('--relation-chain-max-height'),
-            '396px');
-      });
-
-      test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
-        element._latestCommitMessage = _.times(31, String).join('\n');
-        sandbox.stub(element, '_getOffsetHeight', () => 50);
-        sandbox.stub(element, '_getLineHeight', () => 12);
-        sandbox.stub(window, 'matchMedia', () => {
-          if (window.matchMedia.lastCall.args[0] === '(max-width: 75em)') {
-            return {matches: true};
-          } else {
-            return {matches: false};
-          }
-        });
-
-        // 100 (new height) % 12 (line height) = 4 (remainder).
-        // 100 (new height) - 4 (remainder) = 96.
-        element._updateRelatedChangeMaxHeight();
-        assert.equal(getCustomCssValue('--relation-chain-max-height'),
-            '96px');
-      });
-
-      suite('update checks', () => {
-        setup(() => {
-          sandbox.spy(element, '_startUpdateCheckTimer');
-          sandbox.stub(element, 'async', f => {
-            // Only fire the async callback one time.
-            if (element.async.callCount > 1) { return; }
-            f.call(element);
-          });
-        });
-
-        test('_startUpdateCheckTimer negative delay', () => {
-          sandbox.stub(element, 'fetchChangeUpdates');
-
-          element._serverConfig = {change: {update_delay: -1}};
-
-          assert.isTrue(element._startUpdateCheckTimer.called);
-          assert.isFalse(element.fetchChangeUpdates.called);
-        });
-
-        test('_startUpdateCheckTimer up-to-date', () => {
-          sandbox.stub(element, 'fetchChangeUpdates',
-              () => Promise.resolve({isLatest: true}));
-
-          element._serverConfig = {change: {update_delay: 12345}};
-
-          assert.isTrue(element._startUpdateCheckTimer.called);
-          assert.isTrue(element.fetchChangeUpdates.called);
-          assert.equal(element.async.lastCall.args[1], 12345 * 1000);
-        });
-
-        test('_startUpdateCheckTimer out-of-date shows an alert', done => {
-          sandbox.stub(element, 'fetchChangeUpdates',
-              () => Promise.resolve({isLatest: false}));
-          element.addEventListener('show-alert', e => {
-            assert.equal(e.detail.message,
-                'A newer patch set has been uploaded');
-            done();
-          });
-          element._serverConfig = {change: {update_delay: 12345}};
-        });
-
-        test('_startUpdateCheckTimer new status shows an alert', done => {
-          sandbox.stub(element, 'fetchChangeUpdates')
-              .returns(Promise.resolve({
-                isLatest: true,
-                newStatus: element.ChangeStatus.MERGED,
-              }));
-          element.addEventListener('show-alert', e => {
-            assert.equal(e.detail.message, 'This change has been merged');
-            done();
-          });
-          element._serverConfig = {change: {update_delay: 12345}};
-        });
-
-        test('_startUpdateCheckTimer new messages shows an alert', done => {
-          sandbox.stub(element, 'fetchChangeUpdates')
-              .returns(Promise.resolve({
-                isLatest: true,
-                newMessages: true,
-              }));
-          element.addEventListener('show-alert', e => {
-            assert.equal(e.detail.message,
-                'There are new messages on this change');
-            done();
-          });
-          element._serverConfig = {change: {update_delay: 12345}};
-        });
-      });
-
-      test('canStartReview computation', () => {
-        const change1 = {};
-        const change2 = {
-          actions: {
-            ready: {
-              enabled: true,
-            },
-          },
-        };
-        const change3 = {
-          actions: {
-            ready: {
-              label: 'Ready for Review',
-            },
-          },
-        };
-        assert.isFalse(element._computeCanStartReview(change1));
-        assert.isTrue(element._computeCanStartReview(change2));
-        assert.isFalse(element._computeCanStartReview(change3));
-      });
-    });
-
-    test('header class computation', () => {
-      assert.equal(element._computeHeaderClass(), 'header');
-      assert.equal(element._computeHeaderClass(true), 'header editMode');
-    });
-
-    test('_maybeScrollToMessage', done => {
-      flush(() => {
-        const scrollStub = sandbox.stub(element.messagesList,
-            'scrollToMessage');
-
-        element._maybeScrollToMessage('');
-        assert.isFalse(scrollStub.called);
-        element._maybeScrollToMessage('message');
-        assert.isFalse(scrollStub.called);
-        element._maybeScrollToMessage('#message-TEST');
-        assert.isTrue(scrollStub.called);
-        assert.equal(scrollStub.lastCall.args[0], 'TEST');
-        done();
-      });
-    });
-
-    test('topic update reloads related changes', () => {
-      sandbox.stub(element.$.relatedChanges, 'reload');
-      element.dispatchEvent(new CustomEvent('topic-changed'));
-      assert.isTrue(element.$.relatedChanges.reload.calledOnce);
-    });
-
-    test('_computeEditMode', () => {
-      const callCompute = (range, params) =>
-        element._computeEditMode({base: range}, {base: params});
-      assert.isFalse(callCompute({}, {}));
-      assert.isTrue(callCompute({}, {edit: true}));
-      assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}, {}));
-      assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}, {}));
-      assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}, {}));
-    });
-
-    test('_processEdit', () => {
-      element._patchRange = {};
-      const change = {
-        current_revision: 'foo',
-        revisions: {foo: {commit: {}, actions: {cherrypick: {enabled: true}}}},
-      };
-      let mockChange;
-
-      // With no edit, mockChange should be unmodified.
-      element._processEdit(mockChange = _.cloneDeep(change), null);
-      assert.deepEqual(mockChange, change);
-
-      // When edit is not based on the latest PS, current_revision should be
-      // unmodified.
-      const edit = {
-        base_patch_set_number: 1,
-        commit: {commit: 'bar'},
-        fetch: true,
-      };
-      element._processEdit(mockChange = _.cloneDeep(change), edit);
-      assert.notDeepEqual(mockChange, change);
-      assert.equal(mockChange.revisions.bar._number, element.EDIT_NAME);
-      assert.equal(mockChange.current_revision, change.current_revision);
-      assert.deepEqual(mockChange.revisions.bar.commit, {commit: 'bar'});
-      assert.notOk(mockChange.revisions.bar.actions);
-
-      edit.base_revision = 'foo';
-      element._processEdit(mockChange = _.cloneDeep(change), edit);
-      assert.notDeepEqual(mockChange, change);
-      assert.equal(mockChange.current_revision, 'bar');
-      assert.deepEqual(mockChange.revisions.bar.actions,
-          mockChange.revisions.foo.actions);
-
-      // If _patchRange.patchNum is defined, do not load edit.
-      element._patchRange.patchNum = 'baz';
-      change.current_revision = 'baz';
-      element._processEdit(mockChange = _.cloneDeep(change), edit);
-      assert.equal(element._patchRange.patchNum, 'baz');
-      assert.notOk(mockChange.revisions.bar.actions);
-    });
-
-    test('file-action-tap handling', () => {
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      const fileList = element.$.fileList;
-      const Actions = GrEditConstants.Actions;
-      const controls = element.$.fileListHeader.$.editControls;
-      sandbox.stub(controls, 'openDeleteDialog');
-      sandbox.stub(controls, 'openRenameDialog');
-      sandbox.stub(controls, 'openRestoreDialog');
-      sandbox.stub(Gerrit.Nav, 'getEditUrlForDiff');
-      sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
-
-      // Delete
-      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-        detail: {action: Actions.DELETE.id, path: 'foo'},
-        bubbles: true,
-        composed: true,
-      }));
-      flushAsynchronousOperations();
-
-      assert.isTrue(controls.openDeleteDialog.called);
-      assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
-
-      // Restore
-      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-        detail: {action: Actions.RESTORE.id, path: 'foo'},
-        bubbles: true,
-        composed: true,
-      }));
-      flushAsynchronousOperations();
-
-      assert.isTrue(controls.openRestoreDialog.called);
-      assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
-
-      // Rename
-      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-        detail: {action: Actions.RENAME.id, path: 'foo'},
-        bubbles: true,
-        composed: true,
-      }));
-      flushAsynchronousOperations();
-
-      assert.isTrue(controls.openRenameDialog.called);
-      assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
-
-      // Open
-      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-        detail: {action: Actions.OPEN.id, path: 'foo'},
-        bubbles: true,
-        composed: true,
-      }));
-      flushAsynchronousOperations();
-
-      assert.isTrue(Gerrit.Nav.getEditUrlForDiff.called);
-      assert.equal(Gerrit.Nav.getEditUrlForDiff.lastCall.args[1], 'foo');
-      assert.equal(Gerrit.Nav.getEditUrlForDiff.lastCall.args[2], '1');
-      assert.isTrue(Gerrit.Nav.navigateToRelativeUrl.called);
-    });
-
-    test('_selectedRevision updates when patchNum is changed', () => {
-      const revision1 = {_number: 1, commit: {parents: []}};
-      const revision2 = {_number: 2, commit: {parents: []}};
-      sandbox.stub(element.$.restAPI, 'getChangeDetail').returns(
-          Promise.resolve({
-            revisions: {
-              aaa: revision1,
-              bbb: revision2,
-            },
-            labels: {},
-            actions: {},
-            current_revision: 'bbb',
-            change_id: 'loremipsumdolorsitamet',
-          }));
-      sandbox.stub(element, '_getEdit').returns(Promise.resolve());
-      sandbox.stub(element, '_getPreferences').returns(Promise.resolve({}));
-      element._patchRange = {patchNum: '2'};
-      return element._getChangeDetail().then(() => {
-        assert.strictEqual(element._selectedRevision, revision2);
-
-        element.set('_patchRange.patchNum', '1');
-        assert.strictEqual(element._selectedRevision, revision1);
-      });
-    });
-
-    test('_selectedRevision is assigned when patchNum is edit', () => {
-      const revision1 = {_number: 1, commit: {parents: []}};
-      const revision2 = {_number: 2, commit: {parents: []}};
-      const revision3 = {_number: 'edit', commit: {parents: []}};
-      sandbox.stub(element.$.restAPI, 'getChangeDetail').returns(
-          Promise.resolve({
-            revisions: {
-              aaa: revision1,
-              bbb: revision2,
-              ccc: revision3,
-            },
-            labels: {},
-            actions: {},
-            current_revision: 'ccc',
-            change_id: 'loremipsumdolorsitamet',
-          }));
-      sandbox.stub(element, '_getEdit').returns(Promise.resolve());
-      sandbox.stub(element, '_getPreferences').returns(Promise.resolve({}));
-      element._patchRange = {patchNum: 'edit'};
-      return element._getChangeDetail().then(() => {
-        assert.strictEqual(element._selectedRevision, revision3);
-      });
-    });
-
-    test('_sendShowChangeEvent', () => {
-      element._change = {labels: {}};
-      element._patchRange = {patchNum: 4};
-      element._mergeable = true;
-      const showStub = sandbox.stub(element.$.jsAPI, 'handleEvent');
-      element._sendShowChangeEvent();
-      assert.isTrue(showStub.calledOnce);
-      assert.equal(
-          showStub.lastCall.args[0], element.$.jsAPI.EventType.SHOW_CHANGE);
-      assert.deepEqual(showStub.lastCall.args[1], {
-        change: {labels: {}},
-        patchNum: 4,
-        info: {mergeable: true},
-      });
-    });
-
-    suite('_handleEditTap', () => {
-      let fireEdit;
-
-      setup(() => {
-        fireEdit = () => {
-          element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
-        };
-        navigateToChangeStub.restore();
-
-        element._change = {revisions: {rev1: {_number: 1}}};
-      });
-
-      test('edit exists in revisions', done => {
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
-          assert.equal(args.length, 2);
-          assert.equal(args[1], element.EDIT_NAME); // patchNum
-          done();
-        });
-
-        element.set('_change.revisions.rev2', {_number: element.EDIT_NAME});
-        flushAsynchronousOperations();
-
-        fireEdit();
-      });
-
-      test('no edit exists in revisions, non-latest patchset', done => {
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
-          assert.equal(args.length, 4);
-          assert.equal(args[1], 1); // patchNum
-          assert.equal(args[3], true); // opt_isEdit
-          done();
-        });
-
-        element.set('_change.revisions.rev2', {_number: 2});
-        element._patchRange = {patchNum: 1};
-        flushAsynchronousOperations();
-
-        fireEdit();
-      });
-
-      test('no edit exists in revisions, latest patchset', done => {
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
-          assert.equal(args.length, 4);
-          // No patch should be specified when patchNum == latest.
-          assert.isNotOk(args[1]); // patchNum
-          assert.equal(args[3], true); // opt_isEdit
-          done();
-        });
-
-        element.set('_change.revisions.rev2', {_number: 2});
-        element._patchRange = {patchNum: 2};
-        flushAsynchronousOperations();
-
-        fireEdit();
-      });
-    });
-
-    test('_handleStopEditTap', done => {
-      sandbox.stub(element.$.metadata, '_computeLabelNames');
-      navigateToChangeStub.restore();
-      sandbox.stub(element, 'computeLatestPatchNum').returns(1);
-      sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
-        assert.equal(args.length, 2);
-        assert.equal(args[1], 1); // patchNum
-        done();
-      });
-
-      element._patchRange = {patchNum: 1};
-      element.$.actions.dispatchEvent(new CustomEvent('stop-edit-tap',
-          {bubbles: false}));
-    });
-
-    suite('plugin endpoints', () => {
-      test('endpoint params', done => {
-        element._change = {labels: {}};
-        element._selectedRevision = {};
-        let hookEl;
-        let plugin;
-        Gerrit.install(
-            p => {
-              plugin = p;
-              plugin.hook('change-view-integration').getLastAttached()
-                  .then(
-                      el => hookEl = el);
-            },
-            '0.1',
-            'http://some/plugins/url.html');
-        flush(() => {
-          assert.strictEqual(hookEl.plugin, plugin);
-          assert.strictEqual(hookEl.change, element._change);
-          assert.strictEqual(hookEl.revision, element._selectedRevision);
-          done();
-        });
-      });
-    });
-
-    suite('_getMergeability', () => {
-      let getMergeableStub;
-
-      setup(() => {
-        element._change = {labels: {}};
-        getMergeableStub = sandbox.stub(element.$.restAPI, 'getMergeable')
-            .returns(Promise.resolve({mergeable: true}));
-      });
-
-      test('merged change', () => {
-        element._mergeable = null;
-        element._change.status = element.ChangeStatus.MERGED;
-        return element._getMergeability().then(() => {
-          assert.isFalse(element._mergeable);
-          assert.isFalse(getMergeableStub.called);
-        });
-      });
-
-      test('abandoned change', () => {
-        element._mergeable = null;
-        element._change.status = element.ChangeStatus.ABANDONED;
-        return element._getMergeability().then(() => {
-          assert.isFalse(element._mergeable);
-          assert.isFalse(getMergeableStub.called);
-        });
-      });
-
-      test('open change', () => {
-        element._mergeable = null;
-        return element._getMergeability().then(() => {
-          assert.isTrue(element._mergeable);
-          assert.isTrue(getMergeableStub.called);
-        });
-      });
-    });
-
-    test('_paramsChanged sets in projectLookup', () => {
       sandbox.stub(element.$.relatedChanges, 'reload');
       sandbox.stub(element, '_reload').returns(Promise.resolve());
-      const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-      element._paramsChanged({
-        view: Gerrit.Nav.View.CHANGE,
-        changeNum: 101,
-        project: 'test-project',
-      });
-      assert.isTrue(setStub.calledOnce);
-      assert.isTrue(setStub.calledWith(101, 'test-project'));
+      sandbox.spy(element, '_paramsChanged');
+      element.params = {view: 'change', changeNum: '1'};
     });
 
-    test('_handleToggleStar called when star is tapped', () => {
+    test('tab switch works correctly', done => {
+      assert.isTrue(element._paramsChanged.called);
+      assert.equal(element.$.commentTabs.selected, CommentTabs.CHANGE_LOG);
+      assert.equal(element._currentView, CommentTabs.CHANGE_LOG);
+
+      const commentTab = element.shadowRoot.querySelector(
+          'paper-tab.commentThreads'
+      );
+      // Switch to comment thread tab
+      MockInteractions.tap(commentTab);
+      const commentTabs = element.$.commentTabs;
+      assert.equal(commentTabs.selected,
+          CommentTabs.COMMENT_THREADS);
+      assert.equal(element._currentView, CommentTabs.COMMENT_THREADS);
+
+      // Switch back to 'Change Log' tab
+      element._paramsChanged(element.params);
+      flush(() => {
+        assert.equal(commentTabs.selected,
+            CommentTabs.CHANGE_LOG);
+        assert.equal(element._currentView, CommentTabs.CHANGE_LOG);
+        done();
+      });
+    });
+  });
+
+  suite('Findings comment tab', () => {
+    setup(done => {
       element._change = {
-        owner: {_account_id: 1},
-        starred: false,
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2, commit: {parents: []}},
+          rev1: {_number: 1, commit: {parents: []}},
+          rev13: {_number: 13, commit: {parents: []}},
+          rev3: {_number: 3, commit: {parents: []}},
+          rev4: {_number: 4, commit: {parents: []}},
+        },
+        current_revision: 'rev4',
       };
-      element._loggedIn = true;
-      const stub = sandbox.stub(element, '_handleToggleStar');
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.$.changeStar.shadowRoot
-          .querySelector('button'));
-      assert.isTrue(stub.called);
+      element._commentThreads = THREADS;
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[2]);
+      flush(() => {
+        done();
+      });
     });
 
-    suite('gr-reporting tests', () => {
-      setup(() => {
-        element._patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: 1,
-        };
-        sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve());
-        sandbox.stub(element, '_getProjectConfig').returns(Promise.resolve());
-        sandbox.stub(element, '_reloadComments').returns(Promise.resolve());
-        sandbox.stub(element, '_getMergeability').returns(Promise.resolve());
-        sandbox.stub(element, '_getLatestCommitMessage')
-            .returns(Promise.resolve());
-      });
+    test('robot comments count per patchset', () => {
+      const count = element._robotCommentCountPerPatchSet(THREADS);
+      const expectedCount = {
+        2: 1,
+        3: 1,
+        4: 2,
+      };
+      assert.deepEqual(count, expectedCount);
+      assert.equal(element._computeText({_number: 2}, THREADS),
+          'Patchset 2 (1 finding)');
+      assert.equal(element._computeText({_number: 4}, THREADS),
+          'Patchset 4 (2 findings)');
+      assert.equal(element._computeText({_number: 5}, THREADS),
+          'Patchset 5');
+    });
 
-      test('don\'t report changedDisplayed on reply', done => {
-        const changeDisplayStub =
-          sandbox.stub(element.$.reporting, 'changeDisplayed');
-        const changeFullyLoadedStub =
-          sandbox.stub(element.$.reporting, 'changeFullyLoaded');
-        element._handleReplySent();
+    test('only robot comments are rendered', () => {
+      assert.equal(element._robotCommentThreads.length, 2);
+      assert.equal(element._robotCommentThreads[0].comments[0].robot_id,
+          'rc1');
+      assert.equal(element._robotCommentThreads[1].comments[0].robot_id,
+          'rc2');
+    });
+
+    test('changing patchsets resets robot comments', done => {
+      element.set('_change.current_revision', 'rev3');
+      flush(() => {
+        assert.equal(element._robotCommentThreads.length, 1);
+        done();
+      });
+    });
+
+    test('Show more button is hidden', () => {
+      assert.isNull(element.shadowRoot.querySelector('.show-robot-comments'));
+    });
+
+    suite('robot comments show more button', () => {
+      setup(done => {
+        const arr = [];
+        for (let i = 0; i <= 30; i++) {
+          arr.push(...THREADS);
+        }
+        element._commentThreads = arr;
         flush(() => {
-          assert.isFalse(changeDisplayStub.called);
-          assert.isFalse(changeFullyLoadedStub.called);
           done();
         });
       });
 
-      test('report changedDisplayed on _paramsChanged', done => {
-        const changeDisplayStub =
-          sandbox.stub(element.$.reporting, 'changeDisplayed');
-        const changeFullyLoadedStub =
-          sandbox.stub(element.$.reporting, 'changeFullyLoaded');
-        element._paramsChanged({
-          view: Gerrit.Nav.View.CHANGE,
-          changeNum: 101,
-          project: 'test-project',
-        });
+      test('Show more button is rendered', () => {
+        assert.isOk(element.shadowRoot.querySelector('.show-robot-comments'));
+        assert.equal(element._robotCommentThreads.length,
+            ROBOT_COMMENTS_LIMIT);
+      });
+
+      test('Clicking show more button renders all comments', done => {
+        MockInteractions.tap(element.shadowRoot.querySelector(
+            '.show-robot-comments'));
         flush(() => {
-          assert.isTrue(changeDisplayStub.called);
-          assert.isTrue(changeFullyLoadedStub.called);
+          assert.equal(element._robotCommentThreads.length, 62);
           done();
         });
       });
     });
   });
+
+  test('reply button is not visible when logged out', () => {
+    assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
+    element._loggedIn = true;
+    assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
+  });
+
+  test('download tap calls _handleOpenDownloadDialog', () => {
+    sandbox.stub(element, '_handleOpenDownloadDialog');
+    element.$.actions.fire('download-tap');
+    assert.isTrue(element._handleOpenDownloadDialog.called);
+  });
+
+  test('fetches the server config on attached', done => {
+    flush(() => {
+      assert.equal(element._serverConfig.test, 'config');
+      done();
+    });
+  });
+
+  test('_changeStatuses', () => {
+    sandbox.stub(element, 'changeStatuses').returns(
+        ['Merged', 'WIP']);
+    element._loading = false;
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev2: {_number: 2},
+        rev1: {_number: 1},
+        rev13: {_number: 13},
+        rev3: {_number: 3},
+      },
+      current_revision: 'rev3',
+      labels: {
+        test: {
+          all: [],
+          default_value: 0,
+          values: [],
+          approved: {},
+        },
+      },
+    };
+    element._mergeable = true;
+    const expectedStatuses = ['Merged', 'WIP'];
+    assert.deepEqual(element._changeStatuses, expectedStatuses);
+    assert.equal(element._changeStatus, expectedStatuses.join(', '));
+    flushAsynchronousOperations();
+    const statusChips = dom(element.root)
+        .querySelectorAll('gr-change-status');
+    assert.equal(statusChips.length, 2);
+  });
+
+  test('diff preferences open when open-diff-prefs is fired', () => {
+    const overlayOpenStub = sandbox.stub(element.$.fileList,
+        'openDiffPrefs');
+    element.$.fileListHeader.fire('open-diff-prefs');
+    assert.isTrue(overlayOpenStub.called);
+  });
+
+  test('_prepareCommitMsgForLinkify', () => {
+    let commitMessage = 'R=test@google.com';
+    let result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'R=\u200Btest@google.com');
+
+    commitMessage = 'R=test@google.com\nR=test@google.com';
+    result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
+
+    commitMessage = 'CC=test@google.com';
+    result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'CC=\u200Btest@google.com');
+  }),
+
+  test('_isSubmitEnabled', () => {
+    assert.isFalse(element._isSubmitEnabled({}));
+    assert.isFalse(element._isSubmitEnabled({submit: {}}));
+    assert.isTrue(element._isSubmitEnabled(
+        {submit: {enabled: true}}));
+  });
+
+  test('_reload is called when an approved label is removed', () => {
+    const vote = {_account_id: 1, name: 'bojack', value: 1};
+    element._changeNum = '42';
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
+    };
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {email: 'abc@def'},
+      revisions: {
+        rev2: {_number: 2, commit: {parents: []}},
+        rev1: {_number: 1, commit: {parents: []}},
+        rev13: {_number: 13, commit: {parents: []}},
+        rev3: {_number: 3, commit: {parents: []}},
+      },
+      current_revision: 'rev3',
+      status: 'NEW',
+      labels: {
+        test: {
+          all: [vote],
+          default_value: 0,
+          values: [],
+          approved: {},
+        },
+      },
+    };
+    flushAsynchronousOperations();
+    const reloadStub = sandbox.stub(element, '_reload');
+    element.splice('_change.labels.test.all', 0, 1);
+    assert.isFalse(reloadStub.called);
+    element._change.labels.test.all.push(vote);
+    element._change.labels.test.all.push(vote);
+    element._change.labels.test.approved = vote;
+    flushAsynchronousOperations();
+    element.splice('_change.labels.test.all', 0, 2);
+    assert.isTrue(reloadStub.called);
+    assert.isTrue(reloadStub.calledOnce);
+  });
+
+  test('reply button has updated count when there are drafts', () => {
+    const getLabel = element._computeReplyButtonLabel;
+
+    assert.equal(getLabel(null, false), 'Reply');
+    assert.equal(getLabel(null, true), 'Start review');
+
+    const changeRecord = {base: null};
+    assert.equal(getLabel(changeRecord, false), 'Reply');
+
+    changeRecord.base = {};
+    assert.equal(getLabel(changeRecord, false), 'Reply');
+
+    changeRecord.base = {
+      'file1.txt': [{}],
+      'file2.txt': [{}, {}],
+    };
+    assert.equal(getLabel(changeRecord, false), 'Reply (3)');
+  });
+
+  test('start review button when owner of WIP change', () => {
+    assert.equal(
+        element._computeReplyButtonLabel(null, true),
+        'Start review');
+  });
+
+  test('comment events properly update diff drafts', () => {
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    const draft = {
+      __draft: true,
+      id: 'id1',
+      path: '/foo/bar.txt',
+      text: 'hello',
+    };
+    element._handleCommentSave({detail: {comment: draft}});
+    draft.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+    draft.patch_set = null;
+    draft.text = 'hello, there';
+    element._handleCommentSave({detail: {comment: draft}});
+    draft.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+    const draft2 = {
+      __draft: true,
+      id: 'id2',
+      path: '/foo/bar.txt',
+      text: 'hola',
+    };
+    element._handleCommentSave({detail: {comment: draft2}});
+    draft2.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
+    draft.patch_set = null;
+    element._handleCommentDiscard({detail: {comment: draft}});
+    draft.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
+    element._handleCommentDiscard({detail: {comment: draft2}});
+    assert.deepEqual(element._diffDrafts, {});
+  });
+
+  test('change num change', () => {
+    element._changeNum = null;
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      labels: {},
+    };
+    element.viewState.changeNum = null;
+    element.viewState.diffMode = 'UNIFIED';
+    assert.equal(element.viewState.numFilesShown, 200);
+    assert.equal(element._numFilesShown, 200);
+    element._numFilesShown = 150;
+    flushAsynchronousOperations();
+    assert.equal(element.viewState.diffMode, 'UNIFIED');
+    assert.equal(element.viewState.numFilesShown, 150);
+
+    element._changeNum = '1';
+    element.params = {changeNum: '1'};
+    element._change.newProp = '1';
+    flushAsynchronousOperations();
+    assert.equal(element.viewState.diffMode, 'UNIFIED');
+    assert.equal(element.viewState.changeNum, '1');
+
+    element._changeNum = '2';
+    element.params = {changeNum: '2'};
+    element._change.newProp = '2';
+    flushAsynchronousOperations();
+    assert.equal(element.viewState.diffMode, 'UNIFIED');
+    assert.equal(element.viewState.changeNum, '2');
+    assert.equal(element.viewState.numFilesShown, 200);
+    assert.equal(element._numFilesShown, 200);
+  });
+
+  test('_setDiffViewMode is called with reset when new change is loaded',
+      () => {
+        sandbox.stub(element, '_setDiffViewMode');
+        element.viewState = {changeNum: 1};
+        element._changeNum = 2;
+        element._resetFileListViewState();
+        assert.isTrue(
+            element._setDiffViewMode.lastCall.calledWithExactly(true));
+      });
+
+  test('diffViewMode is propagated from file list header', () => {
+    element.viewState = {diffMode: 'UNIFIED'};
+    element.$.fileListHeader.diffViewMode = 'SIDE_BY_SIDE';
+    assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+  });
+
+  test('diffMode defaults to side by side without preferences', done => {
+    sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+        Promise.resolve({}));
+    // No user prefs or diff view mode set.
+
+    element._setDiffViewMode().then(() => {
+      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+      done();
+    });
+  });
+
+  test('diffMode defaults to preference when not already set', done => {
+    sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+        Promise.resolve({default_diff_view: 'UNIFIED'}));
+
+    element._setDiffViewMode().then(() => {
+      assert.equal(element.viewState.diffMode, 'UNIFIED');
+      done();
+    });
+  });
+
+  test('existing diffMode overrides preference', done => {
+    element.viewState.diffMode = 'SIDE_BY_SIDE';
+    sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+        Promise.resolve({default_diff_view: 'UNIFIED'}));
+    element._setDiffViewMode().then(() => {
+      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+      done();
+    });
+  });
+
+  test('don’t reload entire page when patchRange changes', () => {
+    const reloadStub = sandbox.stub(element, '_reload',
+        () => Promise.resolve());
+    const reloadPatchDependentStub = sandbox.stub(element,
+        '_reloadPatchNumDependentResources',
+        () => Promise.resolve());
+    const relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
+    const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+
+    const value = {
+      view: Gerrit.Nav.View.CHANGE,
+      patchNum: '1',
+    };
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledOnce);
+    assert.isTrue(relatedClearSpy.calledOnce);
+
+    element._initialLoadComplete = true;
+
+    value.basePatchNum = '1';
+    value.patchNum = '2';
+    element._paramsChanged(value);
+    assert.isFalse(reloadStub.calledTwice);
+    assert.isTrue(reloadPatchDependentStub.calledOnce);
+    assert.isTrue(relatedClearSpy.calledOnce);
+    assert.isTrue(collapseStub.calledTwice);
+  });
+
+  test('reload entire page when patchRange doesnt change', () => {
+    const reloadStub = sandbox.stub(element, '_reload',
+        () => Promise.resolve());
+    const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+    const value = {
+      view: Gerrit.Nav.View.CHANGE,
+    };
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledOnce);
+    element._initialLoadComplete = true;
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledTwice);
+    assert.isTrue(collapseStub.calledTwice);
+  });
+
+  test('related changes are updated and new patch selected after rebase',
+      done => {
+        element._changeNum = '42';
+        sandbox.stub(element, 'computeLatestPatchNum', () => 1);
+        sandbox.stub(element, '_reload',
+            () => Promise.resolve());
+        const e = {detail: {action: 'rebase'}};
+        element._handleReloadChange(e).then(() => {
+          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
+              element._change));
+          done();
+        });
+      });
+
+  test('related changes are not updated after other action', done => {
+    sandbox.stub(element, '_reload', () => Promise.resolve());
+    sandbox.stub(element.$.relatedChanges, 'reload');
+    const e = {detail: {action: 'abandon'}};
+    element._handleReloadChange(e).then(() => {
+      assert.isFalse(navigateToChangeStub.called);
+      done();
+    });
+  });
+
+  test('_computeMergedCommitInfo', () => {
+    const dummyRevs = {
+      1: {commit: {commit: 1}},
+      2: {commit: {}},
+    };
+    assert.deepEqual(element._computeMergedCommitInfo(0, dummyRevs), {});
+    assert.deepEqual(element._computeMergedCommitInfo(1, dummyRevs),
+        dummyRevs[1].commit);
+
+    // Regression test for issue 5337.
+    const commit = element._computeMergedCommitInfo(2, dummyRevs);
+    assert.notDeepEqual(commit, dummyRevs[2]);
+    assert.deepEqual(commit, {commit: 2});
+  });
+
+  test('_computeCopyTextForTitle', () => {
+    const change = {
+      _number: 123,
+      subject: 'test subject',
+      revisions: {
+        rev1: {_number: 1},
+        rev3: {_number: 3},
+      },
+      current_revision: 'rev3',
+    };
+    sandbox.stub(Gerrit.Nav, 'getUrlForChange')
+        .returns('/change/123');
+    assert.equal(
+        element._computeCopyTextForTitle(change),
+        '123: test subject | https://localhost:8081/change/123'
+    );
+  });
+
+  test('get latest revision', () => {
+    let change = {
+      revisions: {
+        rev1: {_number: 1},
+        rev3: {_number: 3},
+      },
+      current_revision: 'rev3',
+    };
+    assert.equal(element._getLatestRevisionSHA(change), 'rev3');
+    change = {
+      revisions: {
+        rev1: {_number: 1},
+      },
+    };
+    assert.equal(element._getLatestRevisionSHA(change), 'rev1');
+  });
+
+  test('show commit message edit button', () => {
+    const _change = {
+      status: element.ChangeStatus.MERGED,
+    };
+    assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
+    assert.isTrue(element._computeHideEditCommitMessage(true, true, {}));
+    assert.isTrue(element._computeHideEditCommitMessage(false, true, {}));
+    assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
+    assert.isTrue(element._computeHideEditCommitMessage(true, false,
+        _change));
+    assert.isTrue(element._computeHideEditCommitMessage(true, false, {},
+        true));
+    assert.isFalse(element._computeHideEditCommitMessage(true, false, {},
+        false));
+  });
+
+  test('_handleCommitMessageSave trims trailing whitespace', () => {
+    const putStub = sandbox.stub(element.$.restAPI, 'putChangeCommitMessage')
+        .returns(Promise.resolve({}));
+
+    const mockEvent = content => { return {detail: {content}}; };
+
+    element._handleCommitMessageSave(mockEvent('test \n  test '));
+    assert.equal(putStub.lastCall.args[1], 'test\n  test');
+
+    element._handleCommitMessageSave(mockEvent('  test\ntest'));
+    assert.equal(putStub.lastCall.args[1], '  test\ntest');
+
+    element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
+    assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
+  });
+
+  test('_computeChangeIdCommitMessageError', () => {
+    let commitMessage =
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
+    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+
+    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+
+    commitMessage = 'This is the greatest change.';
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'missing');
+  });
+
+  test('multiple change Ids in commit message picks last', () => {
+    const commitMessage = [
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    ].join('\n');
+    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+  });
+
+  test('does not count change Id that starts mid line', () => {
+    const commitMessage = [
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    ].join(' and ');
+    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+  });
+
+  test('_computeTitleAttributeWarning', () => {
+    let changeIdCommitMessageError = 'missing';
+    assert.equal(
+        element._computeTitleAttributeWarning(changeIdCommitMessageError),
+        'No Change-Id in commit message');
+
+    changeIdCommitMessageError = 'mismatch';
+    assert.equal(
+        element._computeTitleAttributeWarning(changeIdCommitMessageError),
+        'Change-Id mismatch');
+  });
+
+  test('_computeChangeIdClass', () => {
+    let changeIdCommitMessageError = 'missing';
+    assert.equal(
+        element._computeChangeIdClass(changeIdCommitMessageError), '');
+
+    changeIdCommitMessageError = 'mismatch';
+    assert.equal(
+        element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
+  });
+
+  test('topic is coalesced to null', done => {
+    sandbox.stub(element, '_changeChanged');
+    sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
+      id: '123456789',
+      labels: {},
+      current_revision: 'foo',
+      revisions: {foo: {commit: {}}},
+    }));
+
+    element._getChangeDetail().then(() => {
+      assert.isNull(element._change.topic);
+      done();
+    });
+  });
+
+  test('commit sha is populated from getChangeDetail', done => {
+    sandbox.stub(element, '_changeChanged');
+    sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
+      id: '123456789',
+      labels: {},
+      current_revision: 'foo',
+      revisions: {foo: {commit: {}}},
+    }));
+
+    element._getChangeDetail().then(() => {
+      assert.equal('foo', element._commitInfo.commit);
+      done();
+    });
+  });
+
+  test('edit is added to change', () => {
+    sandbox.stub(element, '_changeChanged');
+    sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
+      id: '123456789',
+      labels: {},
+      current_revision: 'foo',
+      revisions: {foo: {commit: {}}},
+    }));
+    sandbox.stub(element, '_getEdit', () => Promise.resolve({
+      base_patch_set_number: 1,
+      commit: {commit: 'bar'},
+    }));
+    element._patchRange = {};
+
+    return element._getChangeDetail().then(() => {
+      const revs = element._change.revisions;
+      assert.equal(Object.keys(revs).length, 2);
+      assert.deepEqual(revs['foo'], {commit: {commit: 'foo'}});
+      assert.deepEqual(revs['bar'], {
+        _number: element.EDIT_NAME,
+        basePatchNum: 1,
+        commit: {commit: 'bar'},
+        fetch: undefined,
+      });
+    });
+  });
+
+  test('_getBasePatchNum', () => {
+    const _change = {
+      _number: 42,
+      revisions: {
+        '98da160735fb81604b4c40e93c368f380539dd0e': {
+          _number: 1,
+          commit: {
+            parents: [],
+          },
+        },
+      },
+    };
+    const _patchRange = {
+      basePatchNum: 'PARENT',
+    };
+    assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
+
+    element._prefs = {
+      default_base_for_merges: 'FIRST_PARENT',
+    };
+
+    const _change2 = {
+      _number: 42,
+      revisions: {
+        '98da160735fb81604b4c40e93c368f380539dd0e': {
+          _number: 1,
+          commit: {
+            parents: [
+              {
+                commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8',
+                subject: 'test',
+              },
+              {
+                commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841',
+                subject: 'test3',
+              },
+            ],
+          },
+        },
+      },
+    };
+    assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
+
+    _patchRange.patchNum = 1;
+    assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
+  });
+
+  test('_openReplyDialog called with `ANY` when coming from tap event',
+      () => {
+        const openStub = sandbox.stub(element, '_openReplyDialog');
+        element._serverConfig = {};
+        MockInteractions.tap(element.$.replyBtn);
+        assert(openStub.lastCall.calledWithExactly(
+            element.$.replyDialog.FocusTarget.ANY),
+        '_openReplyDialog should have been passed ANY');
+        assert.equal(openStub.callCount, 1);
+      });
+
+  test('_openReplyDialog called with `BODY` when coming from message reply' +
+      'event', done => {
+    flush(() => {
+      const openStub = sandbox.stub(element, '_openReplyDialog');
+      element.messagesList.fire('reply',
+          {message: {message: 'text'}});
+      assert(openStub.lastCall.calledWithExactly(
+          element.$.replyDialog.FocusTarget.BODY),
+      '_openReplyDialog should have been passed BODY');
+      assert.equal(openStub.callCount, 1);
+      done();
+    });
+  });
+
+  test('reply dialog focus can be controlled', () => {
+    const FocusTarget = element.$.replyDialog.FocusTarget;
+    const openStub = sandbox.stub(element, '_openReplyDialog');
+
+    const e = {detail: {}};
+    element._handleShowReplyDialog(e);
+    assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
+        '_openReplyDialog should have been passed REVIEWERS');
+    assert.equal(openStub.callCount, 1);
+
+    e.detail.value = {ccsOnly: true};
+    element._handleShowReplyDialog(e);
+    assert(openStub.lastCall.calledWithExactly(FocusTarget.CCS),
+        '_openReplyDialog should have been passed CCS');
+    assert.equal(openStub.callCount, 2);
+  });
+
+  test('getUrlParameter functionality', () => {
+    const locationStub = sandbox.stub(element, '_getLocationSearch');
+
+    locationStub.returns('?test');
+    assert.equal(element._getUrlParameter('test'), 'test');
+    locationStub.returns('?test2=12&test=3');
+    assert.equal(element._getUrlParameter('test'), 'test');
+    locationStub.returns('');
+    assert.isNull(element._getUrlParameter('test'));
+    locationStub.returns('?');
+    assert.isNull(element._getUrlParameter('test'));
+    locationStub.returns('?test2');
+    assert.isNull(element._getUrlParameter('test'));
+  });
+
+  test('revert dialog opened with revert param', done => {
+    sandbox.stub(element.$.restAPI, 'getLoggedIn', () => Promise.resolve(true));
+    sandbox.stub(Gerrit, 'awaitPluginsLoaded', () => Promise.resolve());
+
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1, commit: {parents: []}},
+        rev2: {_number: 2, commit: {parents: []}},
+      },
+      current_revision: 'rev1',
+      status: element.ChangeStatus.MERGED,
+      labels: {},
+      actions: {},
+    };
+
+    sandbox.stub(element, '_getUrlParameter',
+        param => {
+          assert.equal(param, 'revert');
+          return param;
+        });
+
+    sandbox.stub(element.$.actions, 'showRevertDialog',
+        done);
+
+    element._maybeShowRevertDialog();
+    assert.isTrue(Gerrit.awaitPluginsLoaded.called);
+  });
+
+  suite('scroll related tests', () => {
+    test('document scrolling calls function to set scroll height', done => {
+      const originalHeight = document.body.scrollHeight;
+      const scrollStub = sandbox.stub(element, '_handleScroll',
+          () => {
+            assert.isTrue(scrollStub.called);
+            document.body.style.height = originalHeight + 'px';
+            scrollStub.restore();
+            done();
+          });
+      document.body.style.height = '10000px';
+      element._handleScroll();
+    });
+
+    test('scrollTop is set correctly', () => {
+      element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
+
+      sandbox.stub(element, '_reload', () => {
+        // When element is reloaded, ensure that the history
+        // state has the scrollTop set earlier. This will then
+        // be reset.
+        assert.isTrue(element.viewState.scrollTop == TEST_SCROLL_TOP_PX);
+        return Promise.resolve({});
+      });
+
+      // simulate reloading component, which is done when route
+      // changes to match a regex of change view type.
+      element._paramsChanged({view: Gerrit.Nav.View.CHANGE});
+    });
+
+    test('scrollTop is reset when new change is loaded', () => {
+      element._resetFileListViewState();
+      assert.equal(element.viewState.scrollTop, 0);
+    });
+  });
+
+  suite('reply dialog tests', () => {
+    setup(() => {
+      sandbox.stub(element.$.replyDialog, '_draftChanged');
+      sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates',
+          () => Promise.resolve({isLatest: true}));
+      element._change = {labels: {}};
+    });
+
+    test('reply from comment adds quote text', () => {
+      const e = {detail: {message: {message: 'quote text'}}};
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    test('reply from comment replaces quote text', () => {
+      element.$.replyDialog.draft = '> old quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> old quote text\n\n';
+      const e = {detail: {message: {message: 'quote text'}}};
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    test('reply from same comment preserves quote text', () => {
+      element.$.replyDialog.draft = '> quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> quote text\n\n';
+      const e = {detail: {message: {message: 'quote text'}}};
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.draft,
+          '> quote text\n\n some draft text');
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    test('reply from top of page contains previous draft', () => {
+      const div = document.createElement('div');
+      element.$.replyDialog.draft = '> quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> quote text\n\n';
+      const e = {target: div, preventDefault: sandbox.spy()};
+      element._handleReplyTap(e);
+      assert.equal(element.$.replyDialog.draft,
+          '> quote text\n\n some draft text');
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+  });
+
+  test('reply button is disabled until server config is loaded', () => {
+    assert.isTrue(element._replyDisabled);
+    element._serverConfig = {};
+    assert.isFalse(element._replyDisabled);
+  });
+
+  suite('commit message expand/collapse', () => {
+    setup(() => {
+      sandbox.stub(element, 'fetchChangeUpdates',
+          () => Promise.resolve({isLatest: false}));
+    });
+
+    test('commitCollapseToggle hidden for short commit message', () => {
+      element._latestCommitMessage = '';
+      assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
+    });
+
+    test('commitCollapseToggle shown for long commit message', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
+    });
+
+    test('commitCollapseToggle functions', () => {
+      element._latestCommitMessage = _.times(35, String).join('\n');
+      assert.isTrue(element._commitCollapsed);
+      assert.isTrue(element._commitCollapsible);
+      assert.isTrue(
+          element.$.commitMessageEditor.hasAttribute('collapsed'));
+      MockInteractions.tap(element.$.commitCollapseToggleButton);
+      assert.isFalse(element._commitCollapsed);
+      assert.isTrue(element._commitCollapsible);
+      assert.isFalse(
+          element.$.commitMessageEditor.hasAttribute('collapsed'));
+    });
+  });
+
+  suite('related changes expand/collapse', () => {
+    let updateHeightSpy;
+    setup(() => {
+      updateHeightSpy = sandbox.spy(element, '_updateRelatedChangeMaxHeight');
+    });
+
+    test('relatedChangesToggle shown height greater than changeInfo height',
+        () => {
+          assert.isFalse(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          sandbox.stub(element, '_getOffsetHeight', () => 50);
+          sandbox.stub(element, '_getScrollHeight', () => 60);
+          sandbox.stub(element, '_getLineHeight', () => 5);
+          sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
+          element.$.relatedChanges.dispatchEvent(
+              new CustomEvent('new-section-loaded'));
+          assert.isTrue(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          assert.equal(updateHeightSpy.callCount, 1);
+        });
+
+    test('relatedChangesToggle hidden height less than changeInfo height',
+        () => {
+          assert.isFalse(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          sandbox.stub(element, '_getOffsetHeight', () => 50);
+          sandbox.stub(element, '_getScrollHeight', () => 40);
+          sandbox.stub(element, '_getLineHeight', () => 5);
+          sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
+          element.$.relatedChanges.dispatchEvent(
+              new CustomEvent('new-section-loaded'));
+          assert.isFalse(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          assert.equal(updateHeightSpy.callCount, 1);
+        });
+
+    test('relatedChangesToggle functions', () => {
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
+      element._relatedChangesLoading = false;
+      assert.isTrue(element._relatedChangesCollapsed);
+      assert.isTrue(
+          element.$.relatedChanges.classList.contains('collapsed'));
+      MockInteractions.tap(element.$.relatedChangesToggleButton);
+      assert.isFalse(element._relatedChangesCollapsed);
+      assert.isFalse(
+          element.$.relatedChanges.classList.contains('collapsed'));
+    });
+
+    test('_updateRelatedChangeMaxHeight without commit toggle', () => {
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(element, '_getLineHeight', () => 12);
+      sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
+
+      // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
+      // 20 (max existing height)  % 12 (line height) = 6 (remainder).
+      // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
+
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '12px');
+      assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
+          '');
+    });
+
+    test('_updateRelatedChangeMaxHeight with commit toggle', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(element, '_getLineHeight', () => 12);
+      sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
+
+      // 50 (existing height) % 12 (line height) = 2 (remainder).
+      // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
+
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '48px');
+      assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
+          '2px');
+    });
+
+    test('_updateRelatedChangeMaxHeight in small screen mode', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(element, '_getLineHeight', () => 12);
+      sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
+
+      element._updateRelatedChangeMaxHeight();
+
+      // 400 (new height) % 12 (line height) = 4 (remainder).
+      // 400 (new height) - 4 (remainder) = 396.
+
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '396px');
+    });
+
+    test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(element, '_getLineHeight', () => 12);
+      sandbox.stub(window, 'matchMedia', () => {
+        if (window.matchMedia.lastCall.args[0] === '(max-width: 75em)') {
+          return {matches: true};
+        } else {
+          return {matches: false};
+        }
+      });
+
+      // 100 (new height) % 12 (line height) = 4 (remainder).
+      // 100 (new height) - 4 (remainder) = 96.
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '96px');
+    });
+
+    suite('update checks', () => {
+      setup(() => {
+        sandbox.spy(element, '_startUpdateCheckTimer');
+        sandbox.stub(element, 'async', f => {
+          // Only fire the async callback one time.
+          if (element.async.callCount > 1) { return; }
+          f.call(element);
+        });
+      });
+
+      test('_startUpdateCheckTimer negative delay', () => {
+        sandbox.stub(element, 'fetchChangeUpdates');
+
+        element._serverConfig = {change: {update_delay: -1}};
+
+        assert.isTrue(element._startUpdateCheckTimer.called);
+        assert.isFalse(element.fetchChangeUpdates.called);
+      });
+
+      test('_startUpdateCheckTimer up-to-date', () => {
+        sandbox.stub(element, 'fetchChangeUpdates',
+            () => Promise.resolve({isLatest: true}));
+
+        element._serverConfig = {change: {update_delay: 12345}};
+
+        assert.isTrue(element._startUpdateCheckTimer.called);
+        assert.isTrue(element.fetchChangeUpdates.called);
+        assert.equal(element.async.lastCall.args[1], 12345 * 1000);
+      });
+
+      test('_startUpdateCheckTimer out-of-date shows an alert', done => {
+        sandbox.stub(element, 'fetchChangeUpdates',
+            () => Promise.resolve({isLatest: false}));
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message,
+              'A newer patch set has been uploaded');
+          done();
+        });
+        element._serverConfig = {change: {update_delay: 12345}};
+      });
+
+      test('_startUpdateCheckTimer new status shows an alert', done => {
+        sandbox.stub(element, 'fetchChangeUpdates')
+            .returns(Promise.resolve({
+              isLatest: true,
+              newStatus: element.ChangeStatus.MERGED,
+            }));
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message, 'This change has been merged');
+          done();
+        });
+        element._serverConfig = {change: {update_delay: 12345}};
+      });
+
+      test('_startUpdateCheckTimer new messages shows an alert', done => {
+        sandbox.stub(element, 'fetchChangeUpdates')
+            .returns(Promise.resolve({
+              isLatest: true,
+              newMessages: true,
+            }));
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message,
+              'There are new messages on this change');
+          done();
+        });
+        element._serverConfig = {change: {update_delay: 12345}};
+      });
+    });
+
+    test('canStartReview computation', () => {
+      const change1 = {};
+      const change2 = {
+        actions: {
+          ready: {
+            enabled: true,
+          },
+        },
+      };
+      const change3 = {
+        actions: {
+          ready: {
+            label: 'Ready for Review',
+          },
+        },
+      };
+      assert.isFalse(element._computeCanStartReview(change1));
+      assert.isTrue(element._computeCanStartReview(change2));
+      assert.isFalse(element._computeCanStartReview(change3));
+    });
+  });
+
+  test('header class computation', () => {
+    assert.equal(element._computeHeaderClass(), 'header');
+    assert.equal(element._computeHeaderClass(true), 'header editMode');
+  });
+
+  test('_maybeScrollToMessage', done => {
+    flush(() => {
+      const scrollStub = sandbox.stub(element.messagesList,
+          'scrollToMessage');
+
+      element._maybeScrollToMessage('');
+      assert.isFalse(scrollStub.called);
+      element._maybeScrollToMessage('message');
+      assert.isFalse(scrollStub.called);
+      element._maybeScrollToMessage('#message-TEST');
+      assert.isTrue(scrollStub.called);
+      assert.equal(scrollStub.lastCall.args[0], 'TEST');
+      done();
+    });
+  });
+
+  test('topic update reloads related changes', () => {
+    sandbox.stub(element.$.relatedChanges, 'reload');
+    element.dispatchEvent(new CustomEvent('topic-changed'));
+    assert.isTrue(element.$.relatedChanges.reload.calledOnce);
+  });
+
+  test('_computeEditMode', () => {
+    const callCompute = (range, params) =>
+      element._computeEditMode({base: range}, {base: params});
+    assert.isFalse(callCompute({}, {}));
+    assert.isTrue(callCompute({}, {edit: true}));
+    assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}, {}));
+    assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}, {}));
+    assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}, {}));
+  });
+
+  test('_processEdit', () => {
+    element._patchRange = {};
+    const change = {
+      current_revision: 'foo',
+      revisions: {foo: {commit: {}, actions: {cherrypick: {enabled: true}}}},
+    };
+    let mockChange;
+
+    // With no edit, mockChange should be unmodified.
+    element._processEdit(mockChange = _.cloneDeep(change), null);
+    assert.deepEqual(mockChange, change);
+
+    // When edit is not based on the latest PS, current_revision should be
+    // unmodified.
+    const edit = {
+      base_patch_set_number: 1,
+      commit: {commit: 'bar'},
+      fetch: true,
+    };
+    element._processEdit(mockChange = _.cloneDeep(change), edit);
+    assert.notDeepEqual(mockChange, change);
+    assert.equal(mockChange.revisions.bar._number, element.EDIT_NAME);
+    assert.equal(mockChange.current_revision, change.current_revision);
+    assert.deepEqual(mockChange.revisions.bar.commit, {commit: 'bar'});
+    assert.notOk(mockChange.revisions.bar.actions);
+
+    edit.base_revision = 'foo';
+    element._processEdit(mockChange = _.cloneDeep(change), edit);
+    assert.notDeepEqual(mockChange, change);
+    assert.equal(mockChange.current_revision, 'bar');
+    assert.deepEqual(mockChange.revisions.bar.actions,
+        mockChange.revisions.foo.actions);
+
+    // If _patchRange.patchNum is defined, do not load edit.
+    element._patchRange.patchNum = 'baz';
+    change.current_revision = 'baz';
+    element._processEdit(mockChange = _.cloneDeep(change), edit);
+    assert.equal(element._patchRange.patchNum, 'baz');
+    assert.notOk(mockChange.revisions.bar.actions);
+  });
+
+  test('file-action-tap handling', () => {
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
+    };
+    const fileList = element.$.fileList;
+    const Actions = GrEditConstants.Actions;
+    const controls = element.$.fileListHeader.$.editControls;
+    sandbox.stub(controls, 'openDeleteDialog');
+    sandbox.stub(controls, 'openRenameDialog');
+    sandbox.stub(controls, 'openRestoreDialog');
+    sandbox.stub(Gerrit.Nav, 'getEditUrlForDiff');
+    sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
+
+    // Delete
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.DELETE.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(controls.openDeleteDialog.called);
+    assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
+
+    // Restore
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.RESTORE.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(controls.openRestoreDialog.called);
+    assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
+
+    // Rename
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.RENAME.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(controls.openRenameDialog.called);
+    assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
+
+    // Open
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.OPEN.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(Gerrit.Nav.getEditUrlForDiff.called);
+    assert.equal(Gerrit.Nav.getEditUrlForDiff.lastCall.args[1], 'foo');
+    assert.equal(Gerrit.Nav.getEditUrlForDiff.lastCall.args[2], '1');
+    assert.isTrue(Gerrit.Nav.navigateToRelativeUrl.called);
+  });
+
+  test('_selectedRevision updates when patchNum is changed', () => {
+    const revision1 = {_number: 1, commit: {parents: []}};
+    const revision2 = {_number: 2, commit: {parents: []}};
+    sandbox.stub(element.$.restAPI, 'getChangeDetail').returns(
+        Promise.resolve({
+          revisions: {
+            aaa: revision1,
+            bbb: revision2,
+          },
+          labels: {},
+          actions: {},
+          current_revision: 'bbb',
+          change_id: 'loremipsumdolorsitamet',
+        }));
+    sandbox.stub(element, '_getEdit').returns(Promise.resolve());
+    sandbox.stub(element, '_getPreferences').returns(Promise.resolve({}));
+    element._patchRange = {patchNum: '2'};
+    return element._getChangeDetail().then(() => {
+      assert.strictEqual(element._selectedRevision, revision2);
+
+      element.set('_patchRange.patchNum', '1');
+      assert.strictEqual(element._selectedRevision, revision1);
+    });
+  });
+
+  test('_selectedRevision is assigned when patchNum is edit', () => {
+    const revision1 = {_number: 1, commit: {parents: []}};
+    const revision2 = {_number: 2, commit: {parents: []}};
+    const revision3 = {_number: 'edit', commit: {parents: []}};
+    sandbox.stub(element.$.restAPI, 'getChangeDetail').returns(
+        Promise.resolve({
+          revisions: {
+            aaa: revision1,
+            bbb: revision2,
+            ccc: revision3,
+          },
+          labels: {},
+          actions: {},
+          current_revision: 'ccc',
+          change_id: 'loremipsumdolorsitamet',
+        }));
+    sandbox.stub(element, '_getEdit').returns(Promise.resolve());
+    sandbox.stub(element, '_getPreferences').returns(Promise.resolve({}));
+    element._patchRange = {patchNum: 'edit'};
+    return element._getChangeDetail().then(() => {
+      assert.strictEqual(element._selectedRevision, revision3);
+    });
+  });
+
+  test('_sendShowChangeEvent', () => {
+    element._change = {labels: {}};
+    element._patchRange = {patchNum: 4};
+    element._mergeable = true;
+    const showStub = sandbox.stub(element.$.jsAPI, 'handleEvent');
+    element._sendShowChangeEvent();
+    assert.isTrue(showStub.calledOnce);
+    assert.equal(
+        showStub.lastCall.args[0], element.$.jsAPI.EventType.SHOW_CHANGE);
+    assert.deepEqual(showStub.lastCall.args[1], {
+      change: {labels: {}},
+      patchNum: 4,
+      info: {mergeable: true},
+    });
+  });
+
+  suite('_handleEditTap', () => {
+    let fireEdit;
+
+    setup(() => {
+      fireEdit = () => {
+        element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
+      };
+      navigateToChangeStub.restore();
+
+      element._change = {revisions: {rev1: {_number: 1}}};
+    });
+
+    test('edit exists in revisions', done => {
+      sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
+        assert.equal(args.length, 2);
+        assert.equal(args[1], element.EDIT_NAME); // patchNum
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: element.EDIT_NAME});
+      flushAsynchronousOperations();
+
+      fireEdit();
+    });
+
+    test('no edit exists in revisions, non-latest patchset', done => {
+      sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
+        assert.equal(args.length, 4);
+        assert.equal(args[1], 1); // patchNum
+        assert.equal(args[3], true); // opt_isEdit
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: 2});
+      element._patchRange = {patchNum: 1};
+      flushAsynchronousOperations();
+
+      fireEdit();
+    });
+
+    test('no edit exists in revisions, latest patchset', done => {
+      sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
+        assert.equal(args.length, 4);
+        // No patch should be specified when patchNum == latest.
+        assert.isNotOk(args[1]); // patchNum
+        assert.equal(args[3], true); // opt_isEdit
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: 2});
+      element._patchRange = {patchNum: 2};
+      flushAsynchronousOperations();
+
+      fireEdit();
+    });
+  });
+
+  test('_handleStopEditTap', done => {
+    sandbox.stub(element.$.metadata, '_computeLabelNames');
+    navigateToChangeStub.restore();
+    sandbox.stub(element, 'computeLatestPatchNum').returns(1);
+    sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
+      assert.equal(args.length, 2);
+      assert.equal(args[1], 1); // patchNum
+      done();
+    });
+
+    element._patchRange = {patchNum: 1};
+    element.$.actions.dispatchEvent(new CustomEvent('stop-edit-tap',
+        {bubbles: false}));
+  });
+
+  suite('plugin endpoints', () => {
+    test('endpoint params', done => {
+      element._change = {labels: {}};
+      element._selectedRevision = {};
+      let hookEl;
+      let plugin;
+      Gerrit.install(
+          p => {
+            plugin = p;
+            plugin.hook('change-view-integration').getLastAttached()
+                .then(
+                    el => hookEl = el);
+          },
+          '0.1',
+          'http://some/plugins/url.html');
+      flush(() => {
+        assert.strictEqual(hookEl.plugin, plugin);
+        assert.strictEqual(hookEl.change, element._change);
+        assert.strictEqual(hookEl.revision, element._selectedRevision);
+        done();
+      });
+    });
+  });
+
+  suite('_getMergeability', () => {
+    let getMergeableStub;
+
+    setup(() => {
+      element._change = {labels: {}};
+      getMergeableStub = sandbox.stub(element.$.restAPI, 'getMergeable')
+          .returns(Promise.resolve({mergeable: true}));
+    });
+
+    test('merged change', () => {
+      element._mergeable = null;
+      element._change.status = element.ChangeStatus.MERGED;
+      return element._getMergeability().then(() => {
+        assert.isFalse(element._mergeable);
+        assert.isFalse(getMergeableStub.called);
+      });
+    });
+
+    test('abandoned change', () => {
+      element._mergeable = null;
+      element._change.status = element.ChangeStatus.ABANDONED;
+      return element._getMergeability().then(() => {
+        assert.isFalse(element._mergeable);
+        assert.isFalse(getMergeableStub.called);
+      });
+    });
+
+    test('open change', () => {
+      element._mergeable = null;
+      return element._getMergeability().then(() => {
+        assert.isTrue(element._mergeable);
+        assert.isTrue(getMergeableStub.called);
+      });
+    });
+  });
+
+  test('_paramsChanged sets in projectLookup', () => {
+    sandbox.stub(element.$.relatedChanges, 'reload');
+    sandbox.stub(element, '_reload').returns(Promise.resolve());
+    const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+    element._paramsChanged({
+      view: Gerrit.Nav.View.CHANGE,
+      changeNum: 101,
+      project: 'test-project',
+    });
+    assert.isTrue(setStub.calledOnce);
+    assert.isTrue(setStub.calledWith(101, 'test-project'));
+  });
+
+  test('_handleToggleStar called when star is tapped', () => {
+    element._change = {
+      owner: {_account_id: 1},
+      starred: false,
+    };
+    element._loggedIn = true;
+    const stub = sandbox.stub(element, '_handleToggleStar');
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(element.$.changeStar.shadowRoot
+        .querySelector('button'));
+    assert.isTrue(stub.called);
+  });
+
+  suite('gr-reporting tests', () => {
+    setup(() => {
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve());
+      sandbox.stub(element, '_getProjectConfig').returns(Promise.resolve());
+      sandbox.stub(element, '_reloadComments').returns(Promise.resolve());
+      sandbox.stub(element, '_getMergeability').returns(Promise.resolve());
+      sandbox.stub(element, '_getLatestCommitMessage')
+          .returns(Promise.resolve());
+    });
+
+    test('don\'t report changedDisplayed on reply', done => {
+      const changeDisplayStub =
+        sandbox.stub(element.$.reporting, 'changeDisplayed');
+      const changeFullyLoadedStub =
+        sandbox.stub(element.$.reporting, 'changeFullyLoaded');
+      element._handleReplySent();
+      flush(() => {
+        assert.isFalse(changeDisplayStub.called);
+        assert.isFalse(changeFullyLoadedStub.called);
+        done();
+      });
+    });
+
+    test('report changedDisplayed on _paramsChanged', done => {
+      const changeDisplayStub =
+        sandbox.stub(element.$.reporting, 'changeDisplayed');
+      const changeFullyLoadedStub =
+        sandbox.stub(element.$.reporting, 'changeFullyLoaded');
+      element._paramsChanged({
+        view: Gerrit.Nav.View.CHANGE,
+        changeNum: 101,
+        project: 'test-project',
+      });
+      flush(() => {
+        assert.isTrue(changeDisplayStub.called);
+        assert.isTrue(changeFullyLoadedStub.called);
+        done();
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
index 16c55cd..2649733 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,79 +14,100 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+/*
+  The custom CSS property `--gr-formatted-text-prose-max-width` controls the max
+  width of formatted text blocks that are not code.
+*/
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.PathListMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
-   */
-  class GrCommentList extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.PathListBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-comment-list'; }
+import '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '../../../scripts/bundled-polymer.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-formatted-text/gr-formatted-text.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-comment-list_html.js';
 
-    static get properties() {
-      return {
-        changeNum: Number,
-        comments: Object,
-        patchNum: Number,
-        projectName: String,
-        /** @type {?} */
-        projectConfig: Object,
-      };
-    }
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.PathListMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrCommentList extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.PathListBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    _computeFilesFromComments(comments) {
-      const arr = Object.keys(comments || {});
-      return arr.sort(this.specialFilePathCompare);
-    }
+  static get is() { return 'gr-comment-list'; }
 
-    _isOnParent(comment) {
-      return comment.side === 'PARENT';
-    }
-
-    _computeDiffURL(filePath, changeNum, allComments) {
-      if ([filePath, changeNum, allComments].some(arg => arg === undefined)) {
-        return;
-      }
-      const fileComments = this._computeCommentsForFile(allComments, filePath);
-      // This can happen for files that don't exist anymore in the current ps.
-      if (fileComments.length === 0) return;
-      return Gerrit.Nav.getUrlForDiffById(changeNum, this.projectName,
-          filePath, fileComments[0].patch_set);
-    }
-
-    _computeDiffLineURL(filePath, changeNum, patchNum, comment) {
-      const basePatchNum = comment.hasOwnProperty('parent') ?
-        -comment.parent : null;
-      return Gerrit.Nav.getUrlForDiffById(changeNum, this.projectName,
-          filePath, patchNum, basePatchNum, comment.line,
-          this._isOnParent(comment));
-    }
-
-    _computeCommentsForFile(comments, filePath) {
-      // Changes are not picked up by the dom-repeat due to the array instance
-      // identity not changing even when it has elements added/removed from it.
-      return (comments[filePath] || []).slice();
-    }
-
-    _computePatchDisplayName(comment) {
-      if (this._isOnParent(comment)) {
-        return 'Base, ';
-      }
-      if (comment.patch_set != this.patchNum) {
-        return `PS${comment.patch_set}, `;
-      }
-      return '';
-    }
+  static get properties() {
+    return {
+      changeNum: Number,
+      comments: Object,
+      patchNum: Number,
+      projectName: String,
+      /** @type {?} */
+      projectConfig: Object,
+    };
   }
 
-  customElements.define(GrCommentList.is, GrCommentList);
-})();
+  _computeFilesFromComments(comments) {
+    const arr = Object.keys(comments || {});
+    return arr.sort(this.specialFilePathCompare);
+  }
+
+  _isOnParent(comment) {
+    return comment.side === 'PARENT';
+  }
+
+  _computeDiffURL(filePath, changeNum, allComments) {
+    if ([filePath, changeNum, allComments].some(arg => arg === undefined)) {
+      return;
+    }
+    const fileComments = this._computeCommentsForFile(allComments, filePath);
+    // This can happen for files that don't exist anymore in the current ps.
+    if (fileComments.length === 0) return;
+    return Gerrit.Nav.getUrlForDiffById(changeNum, this.projectName,
+        filePath, fileComments[0].patch_set);
+  }
+
+  _computeDiffLineURL(filePath, changeNum, patchNum, comment) {
+    const basePatchNum = comment.hasOwnProperty('parent') ?
+      -comment.parent : null;
+    return Gerrit.Nav.getUrlForDiffById(changeNum, this.projectName,
+        filePath, patchNum, basePatchNum, comment.line,
+        this._isOnParent(comment));
+  }
+
+  _computeCommentsForFile(comments, filePath) {
+    // Changes are not picked up by the dom-repeat due to the array instance
+    // identity not changing even when it has elements added/removed from it.
+    return (comments[filePath] || []).slice();
+  }
+
+  _computePatchDisplayName(comment) {
+    if (this._isOnParent(comment)) {
+      return 'Base, ';
+    }
+    if (comment.patch_set != this.patchNum) {
+      return `PS${comment.patch_set}, `;
+    }
+    return '';
+  }
+}
+
+customElements.define(GrCommentList.is, GrCommentList);
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_html.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_html.js
index 52c0c89..d50ba6a 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_html.js
@@ -1,35 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<!--
-  The custom CSS property `--gr-formatted-text-prose-max-width` controls the max
-  width of formatted text blocks that are not code.
--->
-
-<dom-module id="gr-comment-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -64,27 +51,19 @@
     </style>
     <template is="dom-repeat" items="[[_computeFilesFromComments(comments)]]" as="file">
       <div class="file"><a class="fileLink" href="[[_computeDiffURL(file, changeNum, comments)]]">[[computeDisplayPath(file)]]</a></div>
-      <template is="dom-repeat"
-                items="[[_computeCommentsForFile(comments, file)]]" as="comment">
+      <template is="dom-repeat" items="[[_computeCommentsForFile(comments, file)]]" as="comment">
         <div class="container">
-          <a class="lineNum"
-             href$="[[_computeDiffLineURL(file, changeNum, comment.patch_set, comment)]]">
-             <span hidden$="[[!comment.line]]">
+          <a class="lineNum" href\$="[[_computeDiffLineURL(file, changeNum, comment.patch_set, comment)]]">
+             <span hidden\$="[[!comment.line]]">
                <span>[[_computePatchDisplayName(comment)]]</span>
                Line <span>[[comment.line]]</span>
              </span>
-             <span hidden$="[[comment.line]]">
+             <span hidden\$="[[comment.line]]">
                File comment:
              </span>
           </a>
-          <gr-formatted-text
-              class="message"
-              no-trailing-margin
-              content="[[comment.message]]"
-              config="[[projectConfig.commentlinks]]"></gr-formatted-text>
+          <gr-formatted-text class="message" no-trailing-margin="" content="[[comment.message]]" config="[[projectConfig.commentlinks]]"></gr-formatted-text>
         </div>
       </template>
     </template>
-  </template>
-  <script src="gr-comment-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
index a91ec0e..5e8c3ab 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-comment-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-comment-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-comment-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,98 +40,101 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-comment-list tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-comment-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-comment-list tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(Gerrit.Nav, 'mapCommentlinks', x => x);
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('_computeFilesFromComments w/ special file path sorting', () => {
-      const comments = {
-        'file_b.html': [],
-        'file_c.css': [],
-        'file_a.js': [],
-        'test.cc': [],
-        'test.h': [],
-      };
-      const expected = [
-        'file_a.js',
-        'file_b.html',
-        'file_c.css',
-        'test.h',
-        'test.cc',
-      ];
-      const actual = element._computeFilesFromComments(comments);
-      assert.deepEqual(actual, expected);
-
-      assert.deepEqual(element._computeFilesFromComments(null), []);
-    });
-
-    test('_computePatchDisplayName', () => {
-      const comment = {line: 123, side: 'REVISION', patch_set: 10};
-
-      element.patchNum = 10;
-      assert.equal(element._computePatchDisplayName(comment), '');
-
-      element.patchNum = 9;
-      assert.equal(element._computePatchDisplayName(comment), 'PS10, ');
-
-      comment.side = 'PARENT';
-      assert.equal(element._computePatchDisplayName(comment), 'Base, ');
-    });
-
-    test('config commentlinks propagate to formatted text', () => {
-      element.comments = {
-        'test.h': [{
-          author: {name: 'foo'},
-          patch_set: 4,
-          line: 10,
-          updated: '2017-10-30 20:48:40.000000000',
-          message: 'Ideadbeefdeadbeef',
-          unresolved: true,
-        }],
-      };
-      element.projectConfig = {
-        commentlinks: {foo: {link: '#/q/$1', match: '(I[0-9a-f]{8,40})'}},
-      };
-      flushAsynchronousOperations();
-      const formattedText = Polymer.dom(element.root).querySelector(
-          'gr-formatted-text.message');
-      assert.isOk(formattedText.config);
-      assert.deepEqual(formattedText.config,
-          element.projectConfig.commentlinks);
-    });
-
-    test('_computeDiffLineURL', () => {
-      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
-      element.projectName = 'proj';
-      element.changeNum = 123;
-
-      const comment = {line: 456};
-      element._computeDiffLineURL('foo.cc', 123, 4, comment);
-      assert.isTrue(getUrlStub.calledOnce);
-      assert.deepEqual(getUrlStub.lastCall.args,
-          [123, 'proj', 'foo.cc', 4, null, 456, false]);
-
-      comment.side = 'PARENT';
-      element._computeDiffLineURL('foo.cc', 123, 4, comment);
-      assert.isTrue(getUrlStub.calledTwice);
-      assert.deepEqual(getUrlStub.lastCall.args,
-          [123, 'proj', 'foo.cc', 4, null, 456, true]);
-
-      comment.parent = 12;
-      element._computeDiffLineURL('foo.cc', 123, 4, comment);
-      assert.isTrue(getUrlStub.calledThrice);
-      assert.deepEqual(getUrlStub.lastCall.args,
-          [123, 'proj', 'foo.cc', 4, -12, 456, true]);
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(Gerrit.Nav, 'mapCommentlinks', x => x);
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('_computeFilesFromComments w/ special file path sorting', () => {
+    const comments = {
+      'file_b.html': [],
+      'file_c.css': [],
+      'file_a.js': [],
+      'test.cc': [],
+      'test.h': [],
+    };
+    const expected = [
+      'file_a.js',
+      'file_b.html',
+      'file_c.css',
+      'test.h',
+      'test.cc',
+    ];
+    const actual = element._computeFilesFromComments(comments);
+    assert.deepEqual(actual, expected);
+
+    assert.deepEqual(element._computeFilesFromComments(null), []);
+  });
+
+  test('_computePatchDisplayName', () => {
+    const comment = {line: 123, side: 'REVISION', patch_set: 10};
+
+    element.patchNum = 10;
+    assert.equal(element._computePatchDisplayName(comment), '');
+
+    element.patchNum = 9;
+    assert.equal(element._computePatchDisplayName(comment), 'PS10, ');
+
+    comment.side = 'PARENT';
+    assert.equal(element._computePatchDisplayName(comment), 'Base, ');
+  });
+
+  test('config commentlinks propagate to formatted text', () => {
+    element.comments = {
+      'test.h': [{
+        author: {name: 'foo'},
+        patch_set: 4,
+        line: 10,
+        updated: '2017-10-30 20:48:40.000000000',
+        message: 'Ideadbeefdeadbeef',
+        unresolved: true,
+      }],
+    };
+    element.projectConfig = {
+      commentlinks: {foo: {link: '#/q/$1', match: '(I[0-9a-f]{8,40})'}},
+    };
+    flushAsynchronousOperations();
+    const formattedText = dom(element.root).querySelector(
+        'gr-formatted-text.message');
+    assert.isOk(formattedText.config);
+    assert.deepEqual(formattedText.config,
+        element.projectConfig.commentlinks);
+  });
+
+  test('_computeDiffLineURL', () => {
+    const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
+    element.projectName = 'proj';
+    element.changeNum = 123;
+
+    const comment = {line: 456};
+    element._computeDiffLineURL('foo.cc', 123, 4, comment);
+    assert.isTrue(getUrlStub.calledOnce);
+    assert.deepEqual(getUrlStub.lastCall.args,
+        [123, 'proj', 'foo.cc', 4, null, 456, false]);
+
+    comment.side = 'PARENT';
+    element._computeDiffLineURL('foo.cc', 123, 4, comment);
+    assert.isTrue(getUrlStub.calledTwice);
+    assert.deepEqual(getUrlStub.lastCall.args,
+        [123, 'proj', 'foo.cc', 4, null, 456, true]);
+
+    comment.parent = 12;
+    element._computeDiffLineURL('foo.cc', 123, 4, comment);
+    assert.isTrue(getUrlStub.calledThrice);
+    assert.deepEqual(getUrlStub.lastCall.args,
+        [123, 'proj', 'foo.cc', 4, -12, 456, true]);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
index a339865..79a3692 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
@@ -14,68 +14,75 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrCommitInfo extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-commit-info'; }
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-commit-info_html.js';
 
-    static get properties() {
-      return {
-        change: Object,
-        /** @type {?} */
-        commitInfo: Object,
-        serverConfig: Object,
-        _showWebLink: {
-          type: Boolean,
-          computed: '_computeShowWebLink(change, commitInfo, serverConfig)',
-        },
-        _webLink: {
-          type: String,
-          computed: '_computeWebLink(change, commitInfo, serverConfig)',
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrCommitInfo extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _getWeblink(change, commitInfo, config) {
-      return Gerrit.Nav.getPatchSetWeblink(
-          change.project,
-          commitInfo.commit,
-          {
-            weblinks: commitInfo.web_links,
-            config,
-          });
-    }
+  static get is() { return 'gr-commit-info'; }
 
-    _computeShowWebLink(change, commitInfo, serverConfig) {
-      // Polymer 2: check for undefined
-      if ([change, commitInfo, serverConfig].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      const weblink = this._getWeblink(change, commitInfo, serverConfig);
-      return !!weblink && !!weblink.url;
-    }
-
-    _computeWebLink(change, commitInfo, serverConfig) {
-      // Polymer 2: check for undefined
-      if ([change, commitInfo, serverConfig].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      const {url} = this._getWeblink(change, commitInfo, serverConfig) || {};
-      return url;
-    }
-
-    _computeShortHash(commitInfo) {
-      const {name} =
-            this._getWeblink(this.change, commitInfo, this.serverConfig) || {};
-      return name;
-    }
+  static get properties() {
+    return {
+      change: Object,
+      /** @type {?} */
+      commitInfo: Object,
+      serverConfig: Object,
+      _showWebLink: {
+        type: Boolean,
+        computed: '_computeShowWebLink(change, commitInfo, serverConfig)',
+      },
+      _webLink: {
+        type: String,
+        computed: '_computeWebLink(change, commitInfo, serverConfig)',
+      },
+    };
   }
 
-  customElements.define(GrCommitInfo.is, GrCommitInfo);
-})();
+  _getWeblink(change, commitInfo, config) {
+    return Gerrit.Nav.getPatchSetWeblink(
+        change.project,
+        commitInfo.commit,
+        {
+          weblinks: commitInfo.web_links,
+          config,
+        });
+  }
+
+  _computeShowWebLink(change, commitInfo, serverConfig) {
+    // Polymer 2: check for undefined
+    if ([change, commitInfo, serverConfig].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const weblink = this._getWeblink(change, commitInfo, serverConfig);
+    return !!weblink && !!weblink.url;
+  }
+
+  _computeWebLink(change, commitInfo, serverConfig) {
+    // Polymer 2: check for undefined
+    if ([change, commitInfo, serverConfig].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const {url} = this._getWeblink(change, commitInfo, serverConfig) || {};
+    return url;
+  }
+
+  _computeShortHash(commitInfo) {
+    const {name} =
+          this._getWeblink(this.change, commitInfo, this.serverConfig) || {};
+    return name;
+  }
+}
+
+customElements.define(GrCommitInfo.is, GrCommitInfo);
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.js
index 902bf41..ffd36f4 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.js
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.js
@@ -1,26 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-
-<dom-module id="gr-commit-info">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .container {
         align-items: center;
@@ -29,19 +25,12 @@
     </style>
     <div class="container">
       <template is="dom-if" if="[[_showWebLink]]">
-        <a target="_blank" rel="noopener"
-            href$="[[_webLink]]">[[_computeShortHash(commitInfo)]]</a>
+        <a target="_blank" rel="noopener" href\$="[[_webLink]]">[[_computeShortHash(commitInfo)]]</a>
       </template>
       <template is="dom-if" if="[[!_showWebLink]]">
         [[_computeShortHash(commitInfo)]]
       </template>
-      <gr-copy-clipboard
-          has-tooltip
-          button-title="Copy full SHA to clipboard"
-          hide-input
-          text="[[commitInfo.commit]]">
+      <gr-copy-clipboard has-tooltip="" button-title="Copy full SHA to clipboard" hide-input="" text="[[commitInfo.commit]]">
       </gr-copy-clipboard>
     </div>
-  </template>
-  <script src="gr-commit-info.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
index b063561..f2fdbe0 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
@@ -19,16 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-commit-info</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../core/gr-router/gr-router.html">
-<link rel="import" href="gr-commit-info.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../core/gr-router/gr-router.js"></script>
+<script type="module" src="./gr-commit-info.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../core/gr-router/gr-router.js';
+import './gr-commit-info.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,105 +42,108 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-commit-info tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../core/gr-router/gr-router.js';
+import './gr-commit-info.js';
+suite('gr-commit-info tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('weblinks use Gerrit.Nav interface', () => {
-      const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
-          .returns([{name: 'stubb', url: '#s'}]);
-      element.change = {};
-      element.commitInfo = {};
-      element.serverConfig = {};
-      assert.isTrue(weblinksStub.called);
-    });
-
-    test('no web link when unavailable', () => {
-      element.commitInfo = {};
-      element.serverConfig = {};
-      element.change = {labels: [], project: ''};
-
-      assert.isNotOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-    });
-
-    test('use web link when available', () => {
-      const router = document.createElement('gr-router');
-      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
-          router._generateWeblinks.bind(router));
-
-      element.change = {labels: [], project: ''};
-      element.commitInfo =
-          {commit: 'commitsha', web_links: [{name: 'gitweb', url: 'link-url'}]};
-      element.serverConfig = {};
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), 'link-url');
-    });
-
-    test('does not relativize web links that begin with scheme', () => {
-      const router = document.createElement('gr-router');
-      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
-          router._generateWeblinks.bind(router));
-
-      element.change = {labels: [], project: ''};
-      element.commitInfo = {
-        commit: 'commitsha',
-        web_links: [{name: 'gitweb', url: 'https://link-url'}],
-      };
-      element.serverConfig = {};
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), 'https://link-url');
-    });
-
-    test('ignore web links that are neither gitweb nor gitiles', () => {
-      const router = document.createElement('gr-router');
-      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
-          router._generateWeblinks.bind(router));
-
-      element.change = {project: 'project-name'};
-      element.commitInfo = {
-        commit: 'commit-sha',
-        web_links: [
-          {
-            name: 'ignore',
-            url: 'ignore',
-          },
-          {
-            name: 'gitiles',
-            url: 'https://link-url',
-          },
-        ],
-      };
-      element.serverConfig = {};
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), 'https://link-url');
-
-      // Remove gitiles link.
-      element.commitInfo.web_links.splice(1, 1);
-      assert.isNotOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-      assert.isNotOk(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig));
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('weblinks use Gerrit.Nav interface', () => {
+    const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
+        .returns([{name: 'stubb', url: '#s'}]);
+    element.change = {};
+    element.commitInfo = {};
+    element.serverConfig = {};
+    assert.isTrue(weblinksStub.called);
+  });
+
+  test('no web link when unavailable', () => {
+    element.commitInfo = {};
+    element.serverConfig = {};
+    element.change = {labels: [], project: ''};
+
+    assert.isNotOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+  });
+
+  test('use web link when available', () => {
+    const router = document.createElement('gr-router');
+    sandbox.stub(Gerrit.Nav, '_generateWeblinks',
+        router._generateWeblinks.bind(router));
+
+    element.change = {labels: [], project: ''};
+    element.commitInfo =
+        {commit: 'commitsha', web_links: [{name: 'gitweb', url: 'link-url'}]};
+    element.serverConfig = {};
+
+    assert.isOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+    assert.equal(element._computeWebLink(element.change, element.commitInfo,
+        element.serverConfig), 'link-url');
+  });
+
+  test('does not relativize web links that begin with scheme', () => {
+    const router = document.createElement('gr-router');
+    sandbox.stub(Gerrit.Nav, '_generateWeblinks',
+        router._generateWeblinks.bind(router));
+
+    element.change = {labels: [], project: ''};
+    element.commitInfo = {
+      commit: 'commitsha',
+      web_links: [{name: 'gitweb', url: 'https://link-url'}],
+    };
+    element.serverConfig = {};
+
+    assert.isOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+    assert.equal(element._computeWebLink(element.change, element.commitInfo,
+        element.serverConfig), 'https://link-url');
+  });
+
+  test('ignore web links that are neither gitweb nor gitiles', () => {
+    const router = document.createElement('gr-router');
+    sandbox.stub(Gerrit.Nav, '_generateWeblinks',
+        router._generateWeblinks.bind(router));
+
+    element.change = {project: 'project-name'};
+    element.commitInfo = {
+      commit: 'commit-sha',
+      web_links: [
+        {
+          name: 'ignore',
+          url: 'ignore',
+        },
+        {
+          name: 'gitiles',
+          url: 'https://link-url',
+        },
+      ],
+    };
+    element.serverConfig = {};
+
+    assert.isOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+    assert.equal(element._computeWebLink(element.change, element.commitInfo,
+        element.serverConfig), 'https://link-url');
+
+    // Remove gitiles link.
+    element.commitInfo.web_links.splice(1, 1);
+    assert.isNotOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+    assert.isNotOk(element._computeWebLink(element.change, element.commitInfo,
+        element.serverConfig));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
index 555c605..d950988 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
@@ -14,69 +14,79 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+
+import '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-abandon-dialog_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @extends Polymer.Element
+ */
+class GrConfirmAbandonDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-confirm-abandon-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @extends Polymer.Element
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
    */
-  class GrConfirmAbandonDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-confirm-abandon-dialog'; }
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
-
-    static get properties() {
-      return {
-        message: String,
-      };
-    }
-
-    get keyBindings() {
-      return {
-        'ctrl+enter meta+enter': '_handleEnterKey',
-      };
-    }
-
-    resetFocus() {
-      this.$.messageInput.textarea.focus();
-    }
-
-    _handleEnterKey(e) {
-      this._confirm();
-    }
-
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this._confirm();
-    }
-
-    _confirm() {
-      this.fire('confirm', {reason: this.message}, {bubbles: false});
-    }
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    }
+  static get properties() {
+    return {
+      message: String,
+    };
   }
 
-  customElements.define(GrConfirmAbandonDialog.is, GrConfirmAbandonDialog);
-})();
+  get keyBindings() {
+    return {
+      'ctrl+enter meta+enter': '_handleEnterKey',
+    };
+  }
+
+  resetFocus() {
+    this.$.messageInput.textarea.focus();
+  }
+
+  _handleEnterKey(e) {
+    this._confirm();
+  }
+
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this._confirm();
+  }
+
+  _confirm() {
+    this.fire('confirm', {reason: this.message}, {bubbles: false});
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('cancel', null, {bubbles: false});
+  }
+}
+
+customElements.define(GrConfirmAbandonDialog.is, GrConfirmAbandonDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.js
index 9e7857c4..e8b530b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-confirm-abandon-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -48,21 +42,11 @@
         width: 73ch; /* Add a char to account for the border. */
       }
     </style>
-    <gr-dialog
-        confirm-label="Abandon"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
+    <gr-dialog confirm-label="Abandon" on-confirm="_handleConfirmTap" on-cancel="_handleCancelTap">
       <div class="header" slot="header">Abandon Change</div>
       <div class="main" slot="main">
         <label for="messageInput">Abandon Message</label>
-        <iron-autogrow-textarea
-            id="messageInput"
-            class="message"
-            autocomplete="on"
-            placeholder="<Insert reasoning here>"
-            bind-value="{{message}}"></iron-autogrow-textarea>
+        <iron-autogrow-textarea id="messageInput" class="message" autocomplete="on" placeholder="<Insert reasoning here>" bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
     </gr-dialog>
-  </template>
-  <script src="gr-confirm-abandon-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
index 3786174..522b290 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-abandon-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-confirm-abandon-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-confirm-abandon-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-abandon-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,46 +40,48 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-abandon-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-abandon-dialog.js';
+suite('gr-confirm-abandon-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_handleConfirmTap', () => {
-      const confirmHandler = sandbox.stub();
-      element.addEventListener('confirm', confirmHandler);
-      sandbox.spy(element, '_handleConfirmTap');
-      sandbox.spy(element, '_confirm');
-      element.shadowRoot
-          .querySelector('gr-dialog').fire('confirm');
-      assert.isTrue(confirmHandler.called);
-      assert.isTrue(confirmHandler.calledOnce);
-      assert.isTrue(element._handleConfirmTap.called);
-      assert.isTrue(element._confirm.called);
-      assert.isTrue(element._confirm.called);
-      assert.isTrue(element._confirm.calledOnce);
-    });
-
-    test('_handleCancelTap', () => {
-      const cancelHandler = sandbox.stub();
-      element.addEventListener('cancel', cancelHandler);
-      sandbox.spy(element, '_handleCancelTap');
-      element.shadowRoot
-          .querySelector('gr-dialog').fire('cancel');
-      assert.isTrue(cancelHandler.called);
-      assert.isTrue(cancelHandler.calledOnce);
-      assert.isTrue(element._handleCancelTap.called);
-      assert.isTrue(element._handleCancelTap.calledOnce);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sandbox.stub();
+    element.addEventListener('confirm', confirmHandler);
+    sandbox.spy(element, '_handleConfirmTap');
+    sandbox.spy(element, '_confirm');
+    element.shadowRoot
+        .querySelector('gr-dialog').fire('confirm');
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(element._handleConfirmTap.called);
+    assert.isTrue(element._confirm.called);
+    assert.isTrue(element._confirm.called);
+    assert.isTrue(element._confirm.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sandbox.stub();
+    element.addEventListener('cancel', cancelHandler);
+    sandbox.spy(element, '_handleCancelTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').fire('cancel');
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(element._handleCancelTap.called);
+    assert.isTrue(element._handleCancelTap.calledOnce);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
index 35e9afb..9c5ddf4 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
@@ -14,45 +14,54 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-cherrypick-conflict-dialog_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrConfirmCherrypickConflictDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-confirm-cherrypick-conflict-dialog'; }
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
    */
-  class GrConfirmCherrypickConflictDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-confirm-cherrypick-conflict-dialog'; }
 
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
-
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    }
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    }
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('confirm', null, {bubbles: false});
   }
 
-  customElements.define(GrConfirmCherrypickConflictDialog.is,
-      GrConfirmCherrypickConflictDialog);
-})();
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('cancel', null, {bubbles: false});
+  }
+}
+
+customElements.define(GrConfirmCherrypickConflictDialog.is,
+    GrConfirmCherrypickConflictDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.js
index b9e9155..c03c246 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.js
@@ -1,27 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-
-<dom-module id="gr-confirm-cherrypick-conflict-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -36,10 +31,7 @@
         width: 100%;
       }
     </style>
-    <gr-dialog
-        confirm-label="Continue"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
+    <gr-dialog confirm-label="Continue" on-confirm="_handleConfirmTap" on-cancel="_handleCancelTap">
       <div class="header" slot="header">Cherry Pick Conflict!</div>
       <div class="main" slot="main">
         <span>Cherry Pick failed! (merge conflicts)</span>
@@ -47,6 +39,4 @@
         <span>Please select "Continue" to continue with conflicts or select "cancel" to close the dialog.</span>
       </div>
     </gr-dialog>
-  </template>
-  <script src="gr-confirm-cherrypick-conflict-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
index 7c9896a..58b1182 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-cherrypick-conflict-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-confirm-cherrypick-conflict-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-confirm-cherrypick-conflict-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-cherrypick-conflict-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,41 +40,43 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-cherrypick-conflict-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-cherrypick-conflict-dialog.js';
+suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('_handleConfirmTap', () => {
-      const confirmHandler = sandbox.stub();
-      element.addEventListener('confirm', confirmHandler);
-      sandbox.spy(element, '_handleConfirmTap');
-      element.shadowRoot
-          .querySelector('gr-dialog').fire('confirm');
-      assert.isTrue(confirmHandler.called);
-      assert.isTrue(confirmHandler.calledOnce);
-      assert.isTrue(element._handleConfirmTap.called);
-      assert.isTrue(element._handleConfirmTap.calledOnce);
-    });
-
-    test('_handleCancelTap', () => {
-      const cancelHandler = sandbox.stub();
-      element.addEventListener('cancel', cancelHandler);
-      sandbox.spy(element, '_handleCancelTap');
-      element.shadowRoot
-          .querySelector('gr-dialog').fire('cancel');
-      assert.isTrue(cancelHandler.called);
-      assert.isTrue(cancelHandler.calledOnce);
-      assert.isTrue(element._handleCancelTap.called);
-      assert.isTrue(element._handleCancelTap.calledOnce);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sandbox.stub();
+    element.addEventListener('confirm', confirmHandler);
+    sandbox.spy(element, '_handleConfirmTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').fire('confirm');
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(element._handleConfirmTap.called);
+    assert.isTrue(element._handleConfirmTap.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sandbox.stub();
+    element.addEventListener('cancel', cancelHandler);
+    sandbox.spy(element, '_handleCancelTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').fire('cancel');
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(element._handleCancelTap.called);
+    assert.isTrue(element._handleCancelTap.calledOnce);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
index 2b10a97..7405a30 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
@@ -14,115 +14,128 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const SUGGESTIONS_LIMIT = 15;
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-cherrypick-dialog_html.js';
+
+const SUGGESTIONS_LIMIT = 15;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrConfirmCherrypickDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-confirm-cherrypick-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
    */
-  class GrConfirmCherrypickDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-confirm-cherrypick-dialog'; }
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
-
-    static get properties() {
-      return {
-        branch: String,
-        baseCommit: String,
-        changeStatus: String,
-        commitMessage: String,
-        commitNum: String,
-        message: String,
-        project: String,
-        _query: {
-          type: Function,
-          value() {
-            return this._getProjectBranchesSuggestions.bind(this);
-          },
+  static get properties() {
+    return {
+      branch: String,
+      baseCommit: String,
+      changeStatus: String,
+      commitMessage: String,
+      commitNum: String,
+      message: String,
+      project: String,
+      _query: {
+        type: Function,
+        value() {
+          return this._getProjectBranchesSuggestions.bind(this);
         },
-      };
-    }
-
-    static get observers() {
-      return [
-        '_computeMessage(changeStatus, commitNum, commitMessage)',
-      ];
-    }
-
-    _computeMessage(changeStatus, commitNum, commitMessage) {
-      // Polymer 2: check for undefined
-      if ([
-        changeStatus,
-        commitNum,
-        commitMessage,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      let newMessage = commitMessage;
-
-      if (changeStatus === 'MERGED') {
-        newMessage += '(cherry picked from commit ' + commitNum + ')';
-      }
-      this.message = newMessage;
-    }
-
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    }
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    }
-
-    resetFocus() {
-      this.$.branchInput.focus();
-    }
-
-    _getProjectBranchesSuggestions(input) {
-      if (input.startsWith('refs/heads/')) {
-        input = input.substring('refs/heads/'.length);
-      }
-      return this.$.restAPI.getRepoBranches(
-          input, this.project, SUGGESTIONS_LIMIT).then(response => {
-        const branches = [];
-        let branch;
-        for (const key in response) {
-          if (!response.hasOwnProperty(key)) { continue; }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
-          } else {
-            branch = response[key].ref;
-          }
-          branches.push({
-            name: branch,
-          });
-        }
-        return branches;
-      });
-    }
+      },
+    };
   }
 
-  customElements.define(GrConfirmCherrypickDialog.is,
-      GrConfirmCherrypickDialog);
-})();
+  static get observers() {
+    return [
+      '_computeMessage(changeStatus, commitNum, commitMessage)',
+    ];
+  }
+
+  _computeMessage(changeStatus, commitNum, commitMessage) {
+    // Polymer 2: check for undefined
+    if ([
+      changeStatus,
+      commitNum,
+      commitMessage,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    let newMessage = commitMessage;
+
+    if (changeStatus === 'MERGED') {
+      newMessage += '(cherry picked from commit ' + commitNum + ')';
+    }
+    this.message = newMessage;
+  }
+
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('confirm', null, {bubbles: false});
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('cancel', null, {bubbles: false});
+  }
+
+  resetFocus() {
+    this.$.branchInput.focus();
+  }
+
+  _getProjectBranchesSuggestions(input) {
+    if (input.startsWith('refs/heads/')) {
+      input = input.substring('refs/heads/'.length);
+    }
+    return this.$.restAPI.getRepoBranches(
+        input, this.project, SUGGESTIONS_LIMIT).then(response => {
+      const branches = [];
+      let branch;
+      for (const key in response) {
+        if (!response.hasOwnProperty(key)) { continue; }
+        if (response[key].ref.startsWith('refs/heads/')) {
+          branch = response[key].ref.substring('refs/heads/'.length);
+        } else {
+          branch = response[key].ref;
+        }
+        branches.push({
+          name: branch,
+        });
+      }
+      return branches;
+    });
+  }
+}
+
+customElements.define(GrConfirmCherrypickDialog.is,
+    GrConfirmCherrypickDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.js
index cab9fd6..ee6e55d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.js
@@ -1,31 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-confirm-cherrypick-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -54,48 +45,25 @@
         width: 73ch; /* Add a char to account for the border. */
       }
     </style>
-    <gr-dialog
-        confirm-label="Cherry Pick"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
+    <gr-dialog confirm-label="Cherry Pick" on-confirm="_handleConfirmTap" on-cancel="_handleCancelTap">
       <div class="header" slot="header">Cherry Pick Change to Another Branch</div>
       <div class="main" slot="main">
         <label for="branchInput">
           Cherry Pick to branch
         </label>
-        <gr-autocomplete
-            id="branchInput"
-            text="{{branch}}"
-            query="[[_query]]"
-            placeholder="Destination branch">
+        <gr-autocomplete id="branchInput" text="{{branch}}" query="[[_query]]" placeholder="Destination branch">
         </gr-autocomplete>
         <label for="baseInput">
           Provide base commit sha1 for cherry-pick
         </label>
-        <iron-input
-            maxlength="40"
-            placeholder="(optional)"
-            bind-value="{{baseCommit}}">
-          <input
-              is="iron-input"
-              id="baseCommitInput"
-              maxlength="40"
-              placeholder="(optional)"
-              bind-value="{{baseCommit}}">
+        <iron-input maxlength="40" placeholder="(optional)" bind-value="{{baseCommit}}">
+          <input is="iron-input" id="baseCommitInput" maxlength="40" placeholder="(optional)" bind-value="{{baseCommit}}">
         </iron-input>
         <label for="messageInput">
           Cherry Pick Commit Message
         </label>
-        <iron-autogrow-textarea
-            id="messageInput"
-            class="message"
-            autocomplete="on"
-            rows="4"
-            max-rows="15"
-            bind-value="{{message}}"></iron-autogrow-textarea>
+        <iron-autogrow-textarea id="messageInput" class="message" autocomplete="on" rows="4" max-rows="15" bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
     </gr-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-confirm-cherrypick-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
index 12e6252..fa9531f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-cherrypick-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-confirm-cherrypick-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-confirm-cherrypick-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-cherrypick-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,85 +40,87 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-cherrypick-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-cherrypick-dialog.js';
+suite('gr-confirm-cherrypick-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getRepoBranches(input) {
-          if (input.startsWith('test')) {
-            return Promise.resolve([
-              {
-                ref: 'refs/heads/test-branch',
-                revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-                can_delete: true,
-              },
-            ]);
-          } else {
-            return Promise.resolve({});
-          }
-        },
-      });
-      element = fixture('basic');
-      element.project = 'test-project';
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getRepoBranches(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              ref: 'refs/heads/test-branch',
+              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+              can_delete: true,
+            },
+          ]);
+        } else {
+          return Promise.resolve({});
+        }
+      },
     });
+    element = fixture('basic');
+    element.project = 'test-project';
+  });
 
-    teardown(() => { sandbox.restore(); });
+  teardown(() => { sandbox.restore(); });
 
-    test('with merged change', () => {
-      element.changeStatus = 'MERGED';
-      element.commitMessage = 'message\n';
-      element.commitNum = '123';
-      element.branch = 'master';
-      flushAsynchronousOperations();
-      const expectedMessage = 'message\n(cherry picked from commit 123)';
-      assert.equal(element.message, expectedMessage);
-    });
+  test('with merged change', () => {
+    element.changeStatus = 'MERGED';
+    element.commitMessage = 'message\n';
+    element.commitNum = '123';
+    element.branch = 'master';
+    flushAsynchronousOperations();
+    const expectedMessage = 'message\n(cherry picked from commit 123)';
+    assert.equal(element.message, expectedMessage);
+  });
 
-    test('with unmerged change', () => {
-      element.changeStatus = 'OPEN';
-      element.commitMessage = 'message\n';
-      element.commitNum = '123';
-      element.branch = 'master';
-      flushAsynchronousOperations();
-      const expectedMessage = 'message\n';
-      assert.equal(element.message, expectedMessage);
-    });
+  test('with unmerged change', () => {
+    element.changeStatus = 'OPEN';
+    element.commitMessage = 'message\n';
+    element.commitNum = '123';
+    element.branch = 'master';
+    flushAsynchronousOperations();
+    const expectedMessage = 'message\n';
+    assert.equal(element.message, expectedMessage);
+  });
 
-    test('with updated commit message', () => {
-      element.changeStatus = 'OPEN';
-      element.commitMessage = 'message\n';
-      element.commitNum = '123';
-      element.branch = 'master';
-      const myNewMessage = 'updated commit message';
-      element.message = myNewMessage;
-      flushAsynchronousOperations();
-      assert.equal(element.message, myNewMessage);
-    });
+  test('with updated commit message', () => {
+    element.changeStatus = 'OPEN';
+    element.commitMessage = 'message\n';
+    element.commitNum = '123';
+    element.branch = 'master';
+    const myNewMessage = 'updated commit message';
+    element.message = myNewMessage;
+    flushAsynchronousOperations();
+    assert.equal(element.message, myNewMessage);
+  });
 
-    test('_getProjectBranchesSuggestions empty', done => {
-      element._getProjectBranchesSuggestions('nonexistent').then(branches => {
-        assert.equal(branches.length, 0);
-        done();
-      });
-    });
-
-    test('resetFocus', () => {
-      const focusStub = sandbox.stub(element.$.branchInput, 'focus');
-      element.resetFocus();
-      assert.isTrue(focusStub.called);
-    });
-
-    test('_getProjectBranchesSuggestions non-empty', done => {
-      element._getProjectBranchesSuggestions('test-branch').then(branches => {
-        assert.equal(branches.length, 1);
-        assert.equal(branches[0].name, 'test-branch');
-        done();
-      });
+  test('_getProjectBranchesSuggestions empty', done => {
+    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
+      assert.equal(branches.length, 0);
+      done();
     });
   });
+
+  test('resetFocus', () => {
+    const focusStub = sandbox.stub(element.$.branchInput, 'focus');
+    element.resetFocus();
+    assert.isTrue(focusStub.called);
+  });
+
+  test('_getProjectBranchesSuggestions non-empty', done => {
+    element._getProjectBranchesSuggestions('test-branch').then(branches => {
+      assert.equal(branches.length, 1);
+      assert.equal(branches[0].name, 'test-branch');
+      done();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
index 8932af7..8316951 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
@@ -14,90 +14,102 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 
-  const SUGGESTIONS_LIMIT = 15;
+import '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-move-dialog_html.js';
+
+const SUGGESTIONS_LIMIT = 15;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @extends Polymer.Element
+ */
+class GrConfirmMoveDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-confirm-move-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @extends Polymer.Element
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
    */
-  class GrConfirmMoveDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-confirm-move-dialog'; }
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
-
-    static get properties() {
-      return {
-        branch: String,
-        message: String,
-        project: String,
-        _query: {
-          type: Function,
-          value() {
-            return this._getProjectBranchesSuggestions.bind(this);
-          },
+  static get properties() {
+    return {
+      branch: String,
+      message: String,
+      project: String,
+      _query: {
+        type: Function,
+        value() {
+          return this._getProjectBranchesSuggestions.bind(this);
         },
-      };
-    }
-
-    get keyBindings() {
-      return {
-        'ctrl+enter meta+enter': '_handleConfirmTap',
-      };
-    }
-
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    }
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    }
-
-    _getProjectBranchesSuggestions(input) {
-      if (input.startsWith('refs/heads/')) {
-        input = input.substring('refs/heads/'.length);
-      }
-      return this.$.restAPI.getRepoBranches(
-          input, this.project, SUGGESTIONS_LIMIT).then(response => {
-        const branches = [];
-        let branch;
-        for (const key in response) {
-          if (!response.hasOwnProperty(key)) { continue; }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
-          } else {
-            branch = response[key].ref;
-          }
-          branches.push({
-            name: branch,
-          });
-        }
-        return branches;
-      });
-    }
+      },
+    };
   }
 
-  customElements.define(GrConfirmMoveDialog.is, GrConfirmMoveDialog);
-})();
+  get keyBindings() {
+    return {
+      'ctrl+enter meta+enter': '_handleConfirmTap',
+    };
+  }
+
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('confirm', null, {bubbles: false});
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('cancel', null, {bubbles: false});
+  }
+
+  _getProjectBranchesSuggestions(input) {
+    if (input.startsWith('refs/heads/')) {
+      input = input.substring('refs/heads/'.length);
+    }
+    return this.$.restAPI.getRepoBranches(
+        input, this.project, SUGGESTIONS_LIMIT).then(response => {
+      const branches = [];
+      let branch;
+      for (const key in response) {
+        if (!response.hasOwnProperty(key)) { continue; }
+        if (response[key].ref.startsWith('refs/heads/')) {
+          branch = response[key].ref.substring('refs/heads/'.length);
+        } else {
+          branch = response[key].ref;
+        }
+        branches.push({
+          name: branch,
+        });
+      }
+      return branches;
+    });
+  }
+}
+
+customElements.define(GrConfirmMoveDialog.is, GrConfirmMoveDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.js
index f65ec03..b8f3336 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-confirm-move-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -54,10 +46,7 @@
         color: var(--error-text-color);
       }
     </style>
-    <gr-dialog
-        confirm-label="Move Change"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
+    <gr-dialog confirm-label="Move Change" on-confirm="_handleConfirmTap" on-cancel="_handleCancelTap">
       <div class="header" slot="header">Move Change to Another Branch</div>
       <div class="main" slot="main">
         <p class="warning">
@@ -66,25 +55,13 @@
         <label for="branchInput">
           Move change to branch
         </label>
-        <gr-autocomplete
-            id="branchInput"
-            text="{{branch}}"
-            query="[[_query]]"
-            placeholder="Destination branch">
+        <gr-autocomplete id="branchInput" text="{{branch}}" query="[[_query]]" placeholder="Destination branch">
         </gr-autocomplete>
         <label for="messageInput">
           Move Change Message
         </label>
-        <iron-autogrow-textarea
-            id="messageInput"
-            class="message"
-            autocomplete="on"
-            rows="4"
-            max-rows="15"
-            bind-value="{{message}}"></iron-autogrow-textarea>
+        <iron-autogrow-textarea id="messageInput" class="message" autocomplete="on" rows="4" max-rows="15" bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
     </gr-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-confirm-move-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
index 465ce73..27c9934 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-move-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-confirm-move-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-confirm-move-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-move-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,52 +40,54 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-move-dialog tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-move-dialog.js';
+suite('gr-confirm-move-dialog tests', () => {
+  let element;
 
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getRepoBranches(input) {
-          if (input.startsWith('test')) {
-            return Promise.resolve([
-              {
-                ref: 'refs/heads/test-branch',
-                revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-                can_delete: true,
-              },
-            ]);
-          } else {
-            return Promise.resolve({});
-          }
-        },
-      });
-      element = fixture('basic');
-      element.project = 'test-project';
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getRepoBranches(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              ref: 'refs/heads/test-branch',
+              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+              can_delete: true,
+            },
+          ]);
+        } else {
+          return Promise.resolve({});
+        }
+      },
     });
+    element = fixture('basic');
+    element.project = 'test-project';
+  });
 
-    test('with updated commit message', () => {
-      element.branch = 'master';
-      const myNewMessage = 'updated commit message';
-      element.message = myNewMessage;
-      flushAsynchronousOperations();
-      assert.equal(element.message, myNewMessage);
-    });
+  test('with updated commit message', () => {
+    element.branch = 'master';
+    const myNewMessage = 'updated commit message';
+    element.message = myNewMessage;
+    flushAsynchronousOperations();
+    assert.equal(element.message, myNewMessage);
+  });
 
-    test('_getProjectBranchesSuggestions empty', done => {
-      element._getProjectBranchesSuggestions('nonexistent').then(branches => {
-        assert.equal(branches.length, 0);
-        done();
-      });
-    });
-
-    test('_getProjectBranchesSuggestions non-empty', done => {
-      element._getProjectBranchesSuggestions('test-branch').then(branches => {
-        assert.equal(branches.length, 1);
-        assert.equal(branches[0].name, 'test-branch');
-        done();
-      });
+  test('_getProjectBranchesSuggestions empty', done => {
+    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
+      assert.equal(branches.length, 0);
+      done();
     });
   });
+
+  test('_getProjectBranchesSuggestions non-empty', done => {
+    element._getProjectBranchesSuggestions('test-branch').then(branches => {
+      assert.equal(branches.length, 1);
+      assert.equal(branches[0].name, 'test-branch');
+      done();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
index 607f587..e451034 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
@@ -14,157 +14,166 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrConfirmRebaseDialog extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-confirm-rebase-dialog'; }
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-rebase-dialog_html.js';
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
+/** @extends Polymer.Element */
+class GrConfirmRebaseDialog extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    static get properties() {
-      return {
-        branch: String,
-        changeNumber: Number,
-        hasParent: Boolean,
-        rebaseOnCurrent: Boolean,
-        _text: String,
-        _query: {
-          type: Function,
-          value() {
-            return this._getChangeSuggestions.bind(this);
-          },
+  static get is() { return 'gr-confirm-rebase-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  static get properties() {
+    return {
+      branch: String,
+      changeNumber: Number,
+      hasParent: Boolean,
+      rebaseOnCurrent: Boolean,
+      _text: String,
+      _query: {
+        type: Function,
+        value() {
+          return this._getChangeSuggestions.bind(this);
         },
-        _recentChanges: Array,
-      };
-    }
-
-    static get observers() {
-      return [
-        '_updateSelectedOption(rebaseOnCurrent, hasParent)',
-      ];
-    }
-
-    // This is called by gr-change-actions every time the rebase dialog is
-    // re-opened. Unlike other autocompletes that make a request with each
-    // updated input, this one gets all recent changes once and then filters
-    // them by the input. The query is re-run each time the dialog is opened
-    // in case there are new/updated changes in the generic query since the
-    // last time it was run.
-    fetchRecentChanges() {
-      return this.$.restAPI.getChanges(null, `is:open -age:90d`)
-          .then(response => {
-            const changes = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              changes.push({
-                name: `${response[key]._number}: ${response[key].subject}`,
-                value: response[key]._number,
-              });
-            }
-            this._recentChanges = changes;
-            return this._recentChanges;
-          });
-    }
-
-    _getRecentChanges() {
-      if (this._recentChanges) {
-        return Promise.resolve(this._recentChanges);
-      }
-      return this.fetchRecentChanges();
-    }
-
-    _getChangeSuggestions(input) {
-      return this._getRecentChanges().then(changes =>
-        this._filterChanges(input, changes));
-    }
-
-    _filterChanges(input, changes) {
-      return changes.filter(change => change.name.includes(input) &&
-          change.value !== this.changeNumber);
-    }
-
-    _displayParentOption(rebaseOnCurrent, hasParent) {
-      return hasParent && rebaseOnCurrent;
-    }
-
-    _displayParentUpToDateMsg(rebaseOnCurrent, hasParent) {
-      return hasParent && !rebaseOnCurrent;
-    }
-
-    _displayTipOption(rebaseOnCurrent, hasParent) {
-      return !(!rebaseOnCurrent && !hasParent);
-    }
-
-    /**
-     * There is a subtle but important difference between setting the base to an
-     * empty string and omitting it entirely from the payload. An empty string
-     * implies that the parent should be cleared and the change should be
-     * rebased on top of the target branch. Leaving out the base implies that it
-     * should be rebased on top of its current parent.
-     */
-    _getSelectedBase() {
-      if (this.$.rebaseOnParentInput.checked) { return null; }
-      if (this.$.rebaseOnTipInput.checked) { return ''; }
-      // Change numbers will have their description appended by the
-      // autocomplete.
-      return this._text.split(':')[0];
-    }
-
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(new CustomEvent('confirm',
-          {detail: {base: this._getSelectedBase()}}));
-      this._text = '';
-    }
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(new CustomEvent('cancel'));
-      this._text = '';
-    }
-
-    _handleRebaseOnOther() {
-      this.$.parentInput.focus();
-    }
-
-    _handleEnterChangeNumberClick() {
-      this.$.rebaseOnOtherInput.checked = true;
-    }
-
-    /**
-     * Sets the default radio button based on the state of the app and
-     * the corresponding value to be submitted.
-     */
-    _updateSelectedOption(rebaseOnCurrent, hasParent) {
-      // Polymer 2: check for undefined
-      if ([rebaseOnCurrent, hasParent].some(arg => arg === undefined)) {
-        return;
-      }
-
-      if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
-        this.$.rebaseOnParentInput.checked = true;
-      } else if (this._displayTipOption(rebaseOnCurrent, hasParent)) {
-        this.$.rebaseOnTipInput.checked = true;
-      } else {
-        this.$.rebaseOnOtherInput.checked = true;
-      }
-    }
+      },
+      _recentChanges: Array,
+    };
   }
 
-  customElements.define(GrConfirmRebaseDialog.is, GrConfirmRebaseDialog);
-})();
+  static get observers() {
+    return [
+      '_updateSelectedOption(rebaseOnCurrent, hasParent)',
+    ];
+  }
+
+  // This is called by gr-change-actions every time the rebase dialog is
+  // re-opened. Unlike other autocompletes that make a request with each
+  // updated input, this one gets all recent changes once and then filters
+  // them by the input. The query is re-run each time the dialog is opened
+  // in case there are new/updated changes in the generic query since the
+  // last time it was run.
+  fetchRecentChanges() {
+    return this.$.restAPI.getChanges(null, `is:open -age:90d`)
+        .then(response => {
+          const changes = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            changes.push({
+              name: `${response[key]._number}: ${response[key].subject}`,
+              value: response[key]._number,
+            });
+          }
+          this._recentChanges = changes;
+          return this._recentChanges;
+        });
+  }
+
+  _getRecentChanges() {
+    if (this._recentChanges) {
+      return Promise.resolve(this._recentChanges);
+    }
+    return this.fetchRecentChanges();
+  }
+
+  _getChangeSuggestions(input) {
+    return this._getRecentChanges().then(changes =>
+      this._filterChanges(input, changes));
+  }
+
+  _filterChanges(input, changes) {
+    return changes.filter(change => change.name.includes(input) &&
+        change.value !== this.changeNumber);
+  }
+
+  _displayParentOption(rebaseOnCurrent, hasParent) {
+    return hasParent && rebaseOnCurrent;
+  }
+
+  _displayParentUpToDateMsg(rebaseOnCurrent, hasParent) {
+    return hasParent && !rebaseOnCurrent;
+  }
+
+  _displayTipOption(rebaseOnCurrent, hasParent) {
+    return !(!rebaseOnCurrent && !hasParent);
+  }
+
+  /**
+   * There is a subtle but important difference between setting the base to an
+   * empty string and omitting it entirely from the payload. An empty string
+   * implies that the parent should be cleared and the change should be
+   * rebased on top of the target branch. Leaving out the base implies that it
+   * should be rebased on top of its current parent.
+   */
+  _getSelectedBase() {
+    if (this.$.rebaseOnParentInput.checked) { return null; }
+    if (this.$.rebaseOnTipInput.checked) { return ''; }
+    // Change numbers will have their description appended by the
+    // autocomplete.
+    return this._text.split(':')[0];
+  }
+
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('confirm',
+        {detail: {base: this._getSelectedBase()}}));
+    this._text = '';
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel'));
+    this._text = '';
+  }
+
+  _handleRebaseOnOther() {
+    this.$.parentInput.focus();
+  }
+
+  _handleEnterChangeNumberClick() {
+    this.$.rebaseOnOtherInput.checked = true;
+  }
+
+  /**
+   * Sets the default radio button based on the state of the app and
+   * the corresponding value to be submitted.
+   */
+  _updateSelectedOption(rebaseOnCurrent, hasParent) {
+    // Polymer 2: check for undefined
+    if ([rebaseOnCurrent, hasParent].some(arg => arg === undefined)) {
+      return;
+    }
+
+    if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
+      this.$.rebaseOnParentInput.checked = true;
+    } else if (this._displayTipOption(rebaseOnCurrent, hasParent)) {
+      this.$.rebaseOnTipInput.checked = true;
+    } else {
+      this.$.rebaseOnOtherInput.checked = true;
+    }
+  }
+}
+
+customElements.define(GrConfirmRebaseDialog.is, GrConfirmRebaseDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.js
index cf2721a..20872bc 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-confirm-rebase-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -50,70 +44,43 @@
         margin: var(--spacing-m) 0;
       }
     </style>
-    <gr-dialog
-        id="confirmDialog"
-        confirm-label="Rebase"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
+    <gr-dialog id="confirmDialog" confirm-label="Rebase" on-confirm="_handleConfirmTap" on-cancel="_handleCancelTap">
       <div class="header" slot="header">Confirm rebase</div>
       <div class="main" slot="main">
-        <div id="rebaseOnParent" class="rebaseOption"
-            hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]">
-          <input id="rebaseOnParentInput"
-              name="rebaseOptions"
-              type="radio"
-              on-click="_handleRebaseOnParent">
+        <div id="rebaseOnParent" class="rebaseOption" hidden\$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]">
+          <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" on-click="_handleRebaseOnParent">
           <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
             Rebase on parent change
           </label>
         </div>
-        <div id="parentUpToDateMsg" class="message"
-            hidden$="[[!_displayParentUpToDateMsg(rebaseOnCurrent, hasParent)]]">
+        <div id="parentUpToDateMsg" class="message" hidden\$="[[!_displayParentUpToDateMsg(rebaseOnCurrent, hasParent)]]">
           This change is up to date with its parent.
         </div>
-        <div id="rebaseOnTip" class="rebaseOption"
-            hidden$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]">
-          <input id="rebaseOnTipInput"
-              name="rebaseOptions"
-              type="radio"
-              disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-              on-click="_handleRebaseOnTip">
+        <div id="rebaseOnTip" class="rebaseOption" hidden\$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]">
+          <input id="rebaseOnTipInput" name="rebaseOptions" type="radio" disabled\$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]" on-click="_handleRebaseOnTip">
           <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
             Rebase on top of the [[branch]]
-            branch<span hidden$="[[!hasParent]]">
+            branch<span hidden\$="[[!hasParent]]">
               (breaks relation chain)
             </span>
           </label>
         </div>
-        <div id="tipUpToDateMsg" class="message"
-            hidden$="[[_displayTipOption(rebaseOnCurrent, hasParent)]]">
+        <div id="tipUpToDateMsg" class="message" hidden\$="[[_displayTipOption(rebaseOnCurrent, hasParent)]]">
           Change is up to date with the target branch already ([[branch]])
         </div>
         <div id="rebaseOnOther" class="rebaseOption">
-          <input id="rebaseOnOtherInput"
-              name="rebaseOptions"
-              type="radio"
-              on-click="_handleRebaseOnOther">
+          <input id="rebaseOnOtherInput" name="rebaseOptions" type="radio" on-click="_handleRebaseOnOther">
           <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
-            Rebase on a specific change, ref, or commit <span hidden$="[[!hasParent]]">
+            Rebase on a specific change, ref, or commit <span hidden\$="[[!hasParent]]">
               (breaks relation chain)
             </span>
           </label>
         </div>
         <div class="parentRevisionContainer">
-          <gr-autocomplete
-              id="parentInput"
-              query="[[_query]]"
-              no-debounce
-              text="{{_text}}"
-              on-click="_handleEnterChangeNumberClick"
-              allow-non-suggested-values
-              placeholder="Change number, ref, or commit hash">
+          <gr-autocomplete id="parentInput" query="[[_query]]" no-debounce="" text="{{_text}}" on-click="_handleEnterChangeNumberClick" allow-non-suggested-values="" placeholder="Change number, ref, or commit hash">
           </gr-autocomplete>
         </div>
       </div>
     </gr-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-confirm-rebase-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
index fe42cac..d715fbe3 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-rebase-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-confirm-rebase-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-confirm-rebase-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-rebase-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,167 +40,169 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-rebase-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-rebase-dialog.js';
+suite('gr-confirm-rebase-dialog tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('controls with parent and rebase on current available', () => {
+    element.rebaseOnCurrent = true;
+    element.hasParent = true;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.rebaseOnParentInput.checked);
+    assert.isFalse(element.$.rebaseOnParent.hasAttribute('hidden'));
+    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
+    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+  });
+
+  test('controls with parent rebase on current not available', () => {
+    element.rebaseOnCurrent = false;
+    element.hasParent = true;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.rebaseOnTipInput.checked);
+    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
+    assert.isFalse(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
+    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+  });
+
+  test('controls without parent and rebase on current available', () => {
+    element.rebaseOnCurrent = true;
+    element.hasParent = false;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.rebaseOnTipInput.checked);
+    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
+    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
+    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+  });
+
+  test('controls without parent rebase on current not available', () => {
+    element.rebaseOnCurrent = false;
+    element.hasParent = false;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.rebaseOnOtherInput.checked);
+    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
+    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+    assert.isTrue(element.$.rebaseOnTip.hasAttribute('hidden'));
+    assert.isFalse(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+  });
+
+  test('input cleared on cancel or submit', () => {
+    element._text = '123';
+    element.$.confirmDialog.fire('confirm');
+    assert.equal(element._text, '');
+
+    element._text = '123';
+    element.$.confirmDialog.fire('cancel');
+    assert.equal(element._text, '');
+  });
+
+  test('_getSelectedBase', () => {
+    element._text = '5fab321c';
+    element.$.rebaseOnParentInput.checked = true;
+    assert.equal(element._getSelectedBase(), null);
+    element.$.rebaseOnParentInput.checked = false;
+    element.$.rebaseOnTipInput.checked = true;
+    assert.equal(element._getSelectedBase(), '');
+    element.$.rebaseOnTipInput.checked = false;
+    assert.equal(element._getSelectedBase(), element._text);
+    element._text = '101: Test';
+    assert.equal(element._getSelectedBase(), '101');
+  });
+
+  suite('parent suggestions', () => {
+    let recentChanges;
     setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
+      recentChanges = [
+        {
+          name: '123: my first awesome change',
+          value: 123,
+        },
+        {
+          name: '124: my second awesome change',
+          value: 124,
+        },
+        {
+          name: '245: my third awesome change',
+          value: 245,
+        },
+      ];
+
+      sandbox.stub(element.$.restAPI, 'getChanges').returns(Promise.resolve(
+          [
+            {
+              _number: 123,
+              subject: 'my first awesome change',
+            },
+            {
+              _number: 124,
+              subject: 'my second awesome change',
+            },
+            {
+              _number: 245,
+              subject: 'my third awesome change',
+            },
+          ]
+      ));
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('_getRecentChanges', () => {
+      sandbox.spy(element, '_getRecentChanges');
+      return element._getRecentChanges()
+          .then(() => {
+            assert.deepEqual(element._recentChanges, recentChanges);
+            assert.equal(element.$.restAPI.getChanges.callCount, 1);
+            // When called a second time, should not re-request recent changes.
+            element._getRecentChanges();
+          })
+          .then(() => {
+            assert.equal(element._getRecentChanges.callCount, 2);
+            assert.equal(element.$.restAPI.getChanges.callCount, 1);
+          });
     });
 
-    test('controls with parent and rebase on current available', () => {
-      element.rebaseOnCurrent = true;
-      element.hasParent = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.rebaseOnParentInput.checked);
-      assert.isFalse(element.$.rebaseOnParent.hasAttribute('hidden'));
-      assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-      assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-      assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    test('_filterChanges', () => {
+      assert.equal(element._filterChanges('123', recentChanges).length, 1);
+      assert.equal(element._filterChanges('12', recentChanges).length, 2);
+      assert.equal(element._filterChanges('awesome', recentChanges).length,
+          3);
+      assert.equal(element._filterChanges('third', recentChanges).length,
+          1);
+
+      element.changeNumber = 123;
+      assert.equal(element._filterChanges('123', recentChanges).length, 0);
+      assert.equal(element._filterChanges('124', recentChanges).length, 1);
+      assert.equal(element._filterChanges('awesome', recentChanges).length,
+          2);
     });
 
-    test('controls with parent rebase on current not available', () => {
-      element.rebaseOnCurrent = false;
-      element.hasParent = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.rebaseOnTipInput.checked);
-      assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-      assert.isFalse(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-      assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-      assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
-    });
-
-    test('controls without parent and rebase on current available', () => {
-      element.rebaseOnCurrent = true;
-      element.hasParent = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.rebaseOnTipInput.checked);
-      assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-      assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-      assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-      assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
-    });
-
-    test('controls without parent rebase on current not available', () => {
-      element.rebaseOnCurrent = false;
-      element.hasParent = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.rebaseOnOtherInput.checked);
-      assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-      assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-      assert.isTrue(element.$.rebaseOnTip.hasAttribute('hidden'));
-      assert.isFalse(element.$.tipUpToDateMsg.hasAttribute('hidden'));
-    });
-
-    test('input cleared on cancel or submit', () => {
-      element._text = '123';
-      element.$.confirmDialog.fire('confirm');
-      assert.equal(element._text, '');
-
-      element._text = '123';
-      element.$.confirmDialog.fire('cancel');
-      assert.equal(element._text, '');
-    });
-
-    test('_getSelectedBase', () => {
-      element._text = '5fab321c';
-      element.$.rebaseOnParentInput.checked = true;
-      assert.equal(element._getSelectedBase(), null);
-      element.$.rebaseOnParentInput.checked = false;
-      element.$.rebaseOnTipInput.checked = true;
-      assert.equal(element._getSelectedBase(), '');
-      element.$.rebaseOnTipInput.checked = false;
-      assert.equal(element._getSelectedBase(), element._text);
-      element._text = '101: Test';
-      assert.equal(element._getSelectedBase(), '101');
-    });
-
-    suite('parent suggestions', () => {
-      let recentChanges;
-      setup(() => {
-        recentChanges = [
-          {
-            name: '123: my first awesome change',
-            value: 123,
-          },
-          {
-            name: '124: my second awesome change',
-            value: 124,
-          },
-          {
-            name: '245: my third awesome change',
-            value: 245,
-          },
-        ];
-
-        sandbox.stub(element.$.restAPI, 'getChanges').returns(Promise.resolve(
-            [
-              {
-                _number: 123,
-                subject: 'my first awesome change',
-              },
-              {
-                _number: 124,
-                subject: 'my second awesome change',
-              },
-              {
-                _number: 245,
-                subject: 'my third awesome change',
-              },
-            ]
-        ));
-      });
-
-      test('_getRecentChanges', () => {
-        sandbox.spy(element, '_getRecentChanges');
-        return element._getRecentChanges()
-            .then(() => {
-              assert.deepEqual(element._recentChanges, recentChanges);
-              assert.equal(element.$.restAPI.getChanges.callCount, 1);
-              // When called a second time, should not re-request recent changes.
-              element._getRecentChanges();
-            })
-            .then(() => {
-              assert.equal(element._getRecentChanges.callCount, 2);
-              assert.equal(element.$.restAPI.getChanges.callCount, 1);
-            });
-      });
-
-      test('_filterChanges', () => {
-        assert.equal(element._filterChanges('123', recentChanges).length, 1);
-        assert.equal(element._filterChanges('12', recentChanges).length, 2);
-        assert.equal(element._filterChanges('awesome', recentChanges).length,
-            3);
-        assert.equal(element._filterChanges('third', recentChanges).length,
-            1);
-
-        element.changeNumber = 123;
-        assert.equal(element._filterChanges('123', recentChanges).length, 0);
-        assert.equal(element._filterChanges('124', recentChanges).length, 1);
-        assert.equal(element._filterChanges('awesome', recentChanges).length,
-            2);
-      });
-
-      test('input text change triggers function', () => {
-        sandbox.spy(element, '_getRecentChanges');
-        element.$.parentInput.noDebounce = true;
-        MockInteractions.pressAndReleaseKeyOn(
-            element.$.parentInput.$.input,
-            13,
-            null,
-            'enter');
-        element._text = '1';
-        assert.isTrue(element._getRecentChanges.calledOnce);
-        element._text = '12';
-        assert.isTrue(element._getRecentChanges.calledTwice);
-      });
+    test('input text change triggers function', () => {
+      sandbox.spy(element, '_getRecentChanges');
+      element.$.parentInput.noDebounce = true;
+      MockInteractions.pressAndReleaseKeyOn(
+          element.$.parentInput.$.input,
+          13,
+          null,
+          'enter');
+      element._text = '1';
+      assert.isTrue(element._getRecentChanges.calledOnce);
+      element._text = '12';
+      assert.isTrue(element._getRecentChanges.calledTwice);
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
index 05660bf..9bc0f8d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
@@ -14,184 +14,196 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 
-  const ERR_COMMIT_NOT_FOUND =
-      'Unable to find the commit hash of this change.';
-  const CHANGE_SUBJECT_LIMIT = 50;
+import '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../../styles/shared-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-revert-dialog_html.js';
 
-  // TODO(dhruvsri): clean up repeated definitions after moving to js modules
-  const REVERT_TYPES = {
-    REVERT_SINGLE_CHANGE: 1,
-    REVERT_SUBMISSION: 2,
-  };
+const ERR_COMMIT_NOT_FOUND =
+    'Unable to find the commit hash of this change.';
+const CHANGE_SUBJECT_LIMIT = 50;
+
+// TODO(dhruvsri): clean up repeated definitions after moving to js modules
+const REVERT_TYPES = {
+  REVERT_SINGLE_CHANGE: 1,
+  REVERT_SUBMISSION: 2,
+};
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrConfirmRevertDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-confirm-revert-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
    */
-  class GrConfirmRevertDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-confirm-revert-dialog'; }
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
-
-    static get properties() {
-      return {
-        /* The revert message updated by the user
-        The default value is set by the dialog */
-        _message: String,
-        _revertType: {
-          type: Number,
-          value: REVERT_TYPES.REVERT_SINGLE_CHANGE,
-        },
-        _showRevertSubmission: {
-          type: Boolean,
-          value: false,
-        },
-        _changesCount: Number,
-        _showErrorMessage: {
-          type: Boolean,
-          value: false,
-        },
-        /* store the default revert messages per revert type so that we can
-        check if user has edited the revert message or not
-        Set when populate() is called */
-        _originalRevertMessages: {
-          type: Array,
-          value() { return []; },
-        },
-        // Store the actual messages that the user has edited
-        _revertMessages: {
-          type: Array,
-          value() { return []; },
-        },
-      };
-    }
-
-    _computeIfSingleRevert(revertType) {
-      return revertType === REVERT_TYPES.REVERT_SINGLE_CHANGE;
-    }
-
-    _computeIfRevertSubmission(revertType) {
-      return revertType === REVERT_TYPES.REVERT_SUBMISSION;
-    }
-
-    _modifyRevertMsg(change, commitMessage, message) {
-      return this.$.jsAPI.modifyRevertMsg(change,
-          message, commitMessage);
-    }
-
-    populate(change, commitMessage, changes) {
-      this._changesCount = changes.length;
-      // The option to revert a single change is always available
-      this._populateRevertSingleChangeMessage(
-          change, commitMessage, change.current_revision);
-      this._populateRevertSubmissionMessage(change, changes, commitMessage);
-    }
-
-    _populateRevertSingleChangeMessage(change, commitMessage, commitHash) {
-      // Figure out what the revert title should be.
-      const originalTitle = (commitMessage || '').split('\n')[0];
-      const revertTitle = `Revert "${originalTitle}"`;
-      if (!commitHash) {
-        this.fire('show-alert', {message: ERR_COMMIT_NOT_FOUND});
-        return;
-      }
-      const revertCommitText = `This reverts commit ${commitHash}.`;
-
-      this._message = `${revertTitle}\n\n${revertCommitText}\n\n` +
-          `Reason for revert: <INSERT REASONING HERE>\n`;
-      // This is to give plugins a chance to update message
-      this._message = this._modifyRevertMsg(change, commitMessage,
-          this._message);
-      this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
-      this._showRevertSubmission = false;
-      this._revertMessages[this._revertType] = this._message;
-      this._originalRevertMessages[this._revertType] = this._message;
-    }
-
-    _getTrimmedChangeSubject(subject) {
-      if (!subject) return '';
-      if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
-      return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
-    }
-
-    _modifyRevertSubmissionMsg(change, msg, commitMessage) {
-      return this.$.jsAPI.modifyRevertSubmissionMsg(change, msg,
-          commitMessage);
-    }
-
-    _populateRevertSubmissionMessage(change, changes, commitMessage) {
-      // Follow the same convention of the revert
-      const commitHash = change.current_revision;
-      if (!commitHash) {
-        this.fire('show-alert', {message: ERR_COMMIT_NOT_FOUND});
-        return;
-      }
-      if (!changes || changes.length <= 1) return;
-      const submissionId = change.submission_id;
-      const revertTitle = 'Revert submission ' + submissionId;
-      this._message = revertTitle + '\n\n' + 'Reason for revert: <INSERT ' +
-        'REASONING HERE>\n';
-      this._message += 'Reverted Changes:\n';
-      changes.forEach(change => {
-        this._message += change.change_id.substring(0, 10) + ':'
-          + this._getTrimmedChangeSubject(change.subject) + '\n';
-      });
-      this._message = this._modifyRevertSubmissionMsg(change, this._message,
-          commitMessage);
-      this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
-      this._revertMessages[this._revertType] = this._message;
-      this._originalRevertMessages[this._revertType] = this._message;
-      this._showRevertSubmission = true;
-    }
-
-    _handleRevertSingleChangeClicked() {
-      this._showErrorMessage = false;
-      this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION] = this._message;
-      this._message = this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE];
-      this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
-    }
-
-    _handleRevertSubmissionClicked() {
-      this._showErrorMessage = false;
-      this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
-      this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE] = this._message;
-      this._message = this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION];
-    }
-
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      if (this._message === this._originalRevertMessages[this._revertType]) {
-        this._showErrorMessage = true;
-        return;
-      }
-      this.fire('confirm', {revertType: this._revertType,
-        message: this._message}, {bubbles: false});
-    }
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', {revertType: this._revertType},
-          {bubbles: false});
-    }
+  static get properties() {
+    return {
+      /* The revert message updated by the user
+      The default value is set by the dialog */
+      _message: String,
+      _revertType: {
+        type: Number,
+        value: REVERT_TYPES.REVERT_SINGLE_CHANGE,
+      },
+      _showRevertSubmission: {
+        type: Boolean,
+        value: false,
+      },
+      _changesCount: Number,
+      _showErrorMessage: {
+        type: Boolean,
+        value: false,
+      },
+      /* store the default revert messages per revert type so that we can
+      check if user has edited the revert message or not
+      Set when populate() is called */
+      _originalRevertMessages: {
+        type: Array,
+        value() { return []; },
+      },
+      // Store the actual messages that the user has edited
+      _revertMessages: {
+        type: Array,
+        value() { return []; },
+      },
+    };
   }
 
-  customElements.define(GrConfirmRevertDialog.is, GrConfirmRevertDialog);
-})();
+  _computeIfSingleRevert(revertType) {
+    return revertType === REVERT_TYPES.REVERT_SINGLE_CHANGE;
+  }
+
+  _computeIfRevertSubmission(revertType) {
+    return revertType === REVERT_TYPES.REVERT_SUBMISSION;
+  }
+
+  _modifyRevertMsg(change, commitMessage, message) {
+    return this.$.jsAPI.modifyRevertMsg(change,
+        message, commitMessage);
+  }
+
+  populate(change, commitMessage, changes) {
+    this._changesCount = changes.length;
+    // The option to revert a single change is always available
+    this._populateRevertSingleChangeMessage(
+        change, commitMessage, change.current_revision);
+    this._populateRevertSubmissionMessage(change, changes, commitMessage);
+  }
+
+  _populateRevertSingleChangeMessage(change, commitMessage, commitHash) {
+    // Figure out what the revert title should be.
+    const originalTitle = (commitMessage || '').split('\n')[0];
+    const revertTitle = `Revert "${originalTitle}"`;
+    if (!commitHash) {
+      this.fire('show-alert', {message: ERR_COMMIT_NOT_FOUND});
+      return;
+    }
+    const revertCommitText = `This reverts commit ${commitHash}.`;
+
+    this._message = `${revertTitle}\n\n${revertCommitText}\n\n` +
+        `Reason for revert: <INSERT REASONING HERE>\n`;
+    // This is to give plugins a chance to update message
+    this._message = this._modifyRevertMsg(change, commitMessage,
+        this._message);
+    this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+    this._showRevertSubmission = false;
+    this._revertMessages[this._revertType] = this._message;
+    this._originalRevertMessages[this._revertType] = this._message;
+  }
+
+  _getTrimmedChangeSubject(subject) {
+    if (!subject) return '';
+    if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
+    return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
+  }
+
+  _modifyRevertSubmissionMsg(change, msg, commitMessage) {
+    return this.$.jsAPI.modifyRevertSubmissionMsg(change, msg,
+        commitMessage);
+  }
+
+  _populateRevertSubmissionMessage(change, changes, commitMessage) {
+    // Follow the same convention of the revert
+    const commitHash = change.current_revision;
+    if (!commitHash) {
+      this.fire('show-alert', {message: ERR_COMMIT_NOT_FOUND});
+      return;
+    }
+    if (!changes || changes.length <= 1) return;
+    const submissionId = change.submission_id;
+    const revertTitle = 'Revert submission ' + submissionId;
+    this._message = revertTitle + '\n\n' + 'Reason for revert: <INSERT ' +
+      'REASONING HERE>\n';
+    this._message += 'Reverted Changes:\n';
+    changes.forEach(change => {
+      this._message += change.change_id.substring(0, 10) + ':'
+        + this._getTrimmedChangeSubject(change.subject) + '\n';
+    });
+    this._message = this._modifyRevertSubmissionMsg(change, this._message,
+        commitMessage);
+    this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
+    this._revertMessages[this._revertType] = this._message;
+    this._originalRevertMessages[this._revertType] = this._message;
+    this._showRevertSubmission = true;
+  }
+
+  _handleRevertSingleChangeClicked() {
+    this._showErrorMessage = false;
+    this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION] = this._message;
+    this._message = this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE];
+    this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+  }
+
+  _handleRevertSubmissionClicked() {
+    this._showErrorMessage = false;
+    this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
+    this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE] = this._message;
+    this._message = this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION];
+  }
+
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    if (this._message === this._originalRevertMessages[this._revertType]) {
+      this._showErrorMessage = true;
+      return;
+    }
+    this.fire('confirm', {revertType: this._revertType,
+      message: this._message}, {bubbles: false});
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('cancel', {revertType: this._revertType},
+        {bubbles: false});
+  }
+}
+
+customElements.define(GrConfirmRevertDialog.is, GrConfirmRevertDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.js
index 144cf20..3f293cf 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-
-<dom-module id="gr-confirm-revert-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -56,54 +48,34 @@
         margin-bottom: var(--spacing-m);
       }
     </style>
-    <gr-dialog
-        confirm-label="Revert"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
+    <gr-dialog confirm-label="Revert" on-confirm="_handleConfirmTap" on-cancel="_handleCancelTap">
       <div class="header" slot="header">
         Revert Merged Change
       </div>
       <div class="main" slot="main">
-        <div class="error" hidden$="[[!_showErrorMessage]]">
+        <div class="error" hidden\$="[[!_showErrorMessage]]">
           <span> A reason is required </span>
         </div>
         <template is="dom-if" if="[[_showRevertSubmission]]">
           <div class="revertSubmissionLayout">
-            <input
-              name="revertOptions"
-              type="radio"
-              id="revertSingleChange"
-              on-change="_handleRevertSingleChangeClicked"
-              checked="[[_computeIfSingleRevert(_revertType)]]">
+            <input name="revertOptions" type="radio" id="revertSingleChange" on-change="_handleRevertSingleChangeClicked" checked="[[_computeIfSingleRevert(_revertType)]]">
             <label for="revertSingleChange" class="label revertSingleChange">
               Revert single change
             </label>
           </div>
           <div class="revertSubmissionLayout">
-            <input
-              name="revertOptions"
-              type="radio"
-              id="revertSubmission"
-              on-change="_handleRevertSubmissionClicked"
-              checked="[[_computeIfRevertSubmission(_revertType)]]">
+            <input name="revertOptions" type="radio" id="revertSubmission" on-change="_handleRevertSubmissionClicked" checked="[[_computeIfRevertSubmission(_revertType)]]">
             <label for="revertSubmission" class="label revertSubmission">
               Revert entire submission ([[_changesCount]] Changes)
             </label>
-        </template>
+        </div></template>
         <gr-endpoint-decorator name="confirm-revert-change">
           <label for="messageInput">
             Revert Commit Message
           </label>
-          <iron-autogrow-textarea
-            id="messageInput"
-            class="message"
-            autocomplete="on"
-            max-rows="15"
-            bind-value="{{_message}}"></iron-autogrow-textarea>
+          <iron-autogrow-textarea id="messageInput" class="message" autocomplete="on" max-rows="15" bind-value="{{_message}}"></iron-autogrow-textarea>
         </gr-endpoint-decorator>
       </div>
     </gr-dialog>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  </template>
-  <script src="gr-confirm-revert-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
index 29886dd..1f8837b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-revert-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-confirm-revert-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-confirm-revert-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-revert-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,70 +40,72 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-revert-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-revert-dialog.js';
+suite('gr-confirm-revert-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox =sinon.sandbox.create();
-    });
-
-    teardown(() => sandbox.restore());
-
-    test('no match', () => {
-      assert.isNotOk(element._message);
-      const alertStub = sandbox.stub();
-      element.addEventListener('show-alert', alertStub);
-      element._populateRevertSingleChangeMessage({},
-          'not a commitHash in sight', undefined);
-      assert.isTrue(alertStub.calledOnce);
-    });
-
-    test('single line', () => {
-      assert.isNotOk(element._message);
-      element._populateRevertSingleChangeMessage({},
-          'one line commit\n\nChange-Id: abcdefg\n',
-          'abcd123');
-      const expected = 'Revert "one line commit"\n\n' +
-          'This reverts commit abcd123.\n\n' +
-          'Reason for revert: <INSERT REASONING HERE>\n';
-      assert.equal(element._message, expected);
-    });
-
-    test('multi line', () => {
-      assert.isNotOk(element._message);
-      element._populateRevertSingleChangeMessage({},
-          'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
-          'abcd123');
-      const expected = 'Revert "many lines"\n\n' +
-          'This reverts commit abcd123.\n\n' +
-          'Reason for revert: <INSERT REASONING HERE>\n';
-      assert.equal(element._message, expected);
-    });
-
-    test('issue above change id', () => {
-      assert.isNotOk(element._message);
-      element._populateRevertSingleChangeMessage({},
-          'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
-          'abcd123');
-      const expected = 'Revert "much lines"\n\n' +
-          'This reverts commit abcd123.\n\n' +
-          'Reason for revert: <INSERT REASONING HERE>\n';
-      assert.equal(element._message, expected);
-    });
-
-    test('revert a revert', () => {
-      assert.isNotOk(element._message);
-      element._populateRevertSingleChangeMessage({},
-          'Revert "one line commit"\n\nChange-Id: abcdefg\n',
-          'abcd123');
-      const expected = 'Revert "Revert "one line commit""\n\n' +
-          'This reverts commit abcd123.\n\n' +
-          'Reason for revert: <INSERT REASONING HERE>\n';
-      assert.equal(element._message, expected);
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox =sinon.sandbox.create();
   });
+
+  teardown(() => sandbox.restore());
+
+  test('no match', () => {
+    assert.isNotOk(element._message);
+    const alertStub = sandbox.stub();
+    element.addEventListener('show-alert', alertStub);
+    element._populateRevertSingleChangeMessage({},
+        'not a commitHash in sight', undefined);
+    assert.isTrue(alertStub.calledOnce);
+  });
+
+  test('single line', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage({},
+        'one line commit\n\nChange-Id: abcdefg\n',
+        'abcd123');
+    const expected = 'Revert "one line commit"\n\n' +
+        'This reverts commit abcd123.\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+
+  test('multi line', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage({},
+        'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
+        'abcd123');
+    const expected = 'Revert "many lines"\n\n' +
+        'This reverts commit abcd123.\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+
+  test('issue above change id', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage({},
+        'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
+        'abcd123');
+    const expected = 'Revert "much lines"\n\n' +
+        'This reverts commit abcd123.\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+
+  test('revert a revert', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage({},
+        'Revert "one line commit"\n\nChange-Id: abcdefg\n',
+        'abcd123');
+    const expected = 'Revert "Revert "one line commit""\n\n' +
+        'This reverts commit abcd123.\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
index ae8dfa5..3cae44a 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
@@ -14,87 +14,98 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 
-  const ERR_COMMIT_NOT_FOUND =
-      'Unable to find the commit hash of this change.';
-  const CHANGE_SUBJECT_LIMIT = 50;
+import '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-revert-submission-dialog_html.js';
+
+const ERR_COMMIT_NOT_FOUND =
+    'Unable to find the commit hash of this change.';
+const CHANGE_SUBJECT_LIMIT = 50;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrConfirmRevertSubmissionDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-confirm-revert-submission-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
    */
-  class GrConfirmRevertSubmissionDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-confirm-revert-submission-dialog'; }
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
-
-    static get properties() {
-      return {
-        message: String,
-        commitMessage: String,
-      };
-    }
-
-    _getTrimmedChangeSubject(subject) {
-      if (!subject) return '';
-      if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
-      return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
-    }
-
-    _modifyRevertSubmissionMsg(change) {
-      return this.$.jsAPI.modifyRevertSubmissionMsg(change,
-          this.message, this.commitMessage);
-    }
-
-    _populateRevertSubmissionMessage(message, change, changes) {
-      // Follow the same convention of the revert
-      const commitHash = change.current_revision;
-      if (!commitHash) {
-        this.fire('show-alert', {message: ERR_COMMIT_NOT_FOUND});
-        return;
-      }
-      const submissionId = change.submission_id;
-      const revertTitle = 'Revert submission ' + submissionId;
-      this.changes = changes;
-      this.message = revertTitle + '\n\n' +
-          'Reason for revert: <INSERT REASONING HERE>\n';
-      this.message += 'Reverted Changes:\n';
-      changes = changes || [];
-      changes.forEach(change => {
-        this.message += change.change_id.substring(0, 10) + ': ' +
-          this._getTrimmedChangeSubject(change.subject) + '\n';
-      });
-      this.message = this._modifyRevertSubmissionMsg(change);
-    }
-
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    }
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    }
+  static get properties() {
+    return {
+      message: String,
+      commitMessage: String,
+    };
   }
 
-  customElements.define(GrConfirmRevertSubmissionDialog.is,
-      GrConfirmRevertSubmissionDialog);
-})();
+  _getTrimmedChangeSubject(subject) {
+    if (!subject) return '';
+    if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
+    return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
+  }
+
+  _modifyRevertSubmissionMsg(change) {
+    return this.$.jsAPI.modifyRevertSubmissionMsg(change,
+        this.message, this.commitMessage);
+  }
+
+  _populateRevertSubmissionMessage(message, change, changes) {
+    // Follow the same convention of the revert
+    const commitHash = change.current_revision;
+    if (!commitHash) {
+      this.fire('show-alert', {message: ERR_COMMIT_NOT_FOUND});
+      return;
+    }
+    const submissionId = change.submission_id;
+    const revertTitle = 'Revert submission ' + submissionId;
+    this.changes = changes;
+    this.message = revertTitle + '\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    this.message += 'Reverted Changes:\n';
+    changes = changes || [];
+    changes.forEach(change => {
+      this.message += change.change_id.substring(0, 10) + ': ' +
+        this._getTrimmedChangeSubject(change.subject) + '\n';
+    });
+    this.message = this._modifyRevertSubmissionMsg(change);
+  }
+
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('confirm', null, {bubbles: false});
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('cancel', null, {bubbles: false});
+  }
+}
+
+customElements.define(GrConfirmRevertSubmissionDialog.is,
+    GrConfirmRevertSubmissionDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.js
index f2cfef8..a68920c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-
-<dom-module id="gr-confirm-revert-submission-dialog">
-  <template>
+export const htmlTemplate = html`
     <!-- TODO(taoalpha): move all shared styles to a style module. -->
     <style include="shared-styles">
       :host {
@@ -45,24 +38,14 @@
         width: 73ch; /* Add a char to account for the border. */
       }
     </style>
-    <gr-dialog
-        confirm-label="Revert Submission"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
+    <gr-dialog confirm-label="Revert Submission" on-confirm="_handleConfirmTap" on-cancel="_handleCancelTap">
       <div class="header" slot="header">Revert Submission</div>
       <div class="main" slot="main">
         <label for="messageInput">
           Revert Commit Message
         </label>
-        <iron-autogrow-textarea
-            id="messageInput"
-            class="message"
-            autocomplete="on"
-            max-rows="15"
-            bind-value="{{message}}"></iron-autogrow-textarea>
+        <iron-autogrow-textarea id="messageInput" class="message" autocomplete="on" max-rows="15" bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
     </gr-dialog>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  </template>
-  <script src="gr-confirm-revert-submission-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html
index 2513986..d84aa4d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-revert-submission-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-confirm-revert-submission-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-confirm-revert-submission-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-revert-submission-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,67 +41,69 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-revert-submission-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-revert-submission-dialog.js';
+suite('gr-confirm-revert-submission-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox =sinon.sandbox.create();
-    });
-
-    teardown(() => sandbox.restore());
-
-    test('no match', () => {
-      assert.isNotOk(element.message);
-      const alertStub = sandbox.stub();
-      element.addEventListener('show-alert', alertStub);
-      element._populateRevertSubmissionMessage(
-          'not a commitHash in sight'
-      );
-      assert.isTrue(alertStub.calledOnce);
-    });
-
-    test('single line', () => {
-      assert.isNotOk(element.message);
-      element._populateRevertSubmissionMessage(
-          'one line commit\n\nChange-Id: abcdefg\n',
-          'abcd123');
-      const expected = 'Revert submission\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-      assert.equal(element.message, expected);
-    });
-
-    test('multi line', () => {
-      assert.isNotOk(element.message);
-      element._populateRevertSubmissionMessage(
-          'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
-          'abcd123');
-      const expected = 'Revert submission\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-      assert.equal(element.message, expected);
-    });
-
-    test('issue above change id', () => {
-      assert.isNotOk(element.message);
-      element._populateRevertSubmissionMessage(
-          'test \nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
-          'abcd123');
-      const expected = 'Revert submission\n\n' +
-          'Reason for revert: <INSERT REASONING HERE>\n';
-      assert.equal(element.message, expected);
-    });
-
-    test('revert a revert', () => {
-      assert.isNotOk(element.message);
-      element._populateRevertSubmissionMessage(
-          'Revert "one line commit"\n\nChange-Id: abcdefg\n',
-          'abcd123');
-      const expected = 'Revert submission\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-      assert.equal(element.message, expected);
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox =sinon.sandbox.create();
   });
+
+  teardown(() => sandbox.restore());
+
+  test('no match', () => {
+    assert.isNotOk(element.message);
+    const alertStub = sandbox.stub();
+    element.addEventListener('show-alert', alertStub);
+    element._populateRevertSubmissionMessage(
+        'not a commitHash in sight'
+    );
+    assert.isTrue(alertStub.calledOnce);
+  });
+
+  test('single line', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        'one line commit\n\nChange-Id: abcdefg\n',
+        'abcd123');
+    const expected = 'Revert submission\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('multi line', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
+        'abcd123');
+    const expected = 'Revert submission\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('issue above change id', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        'test \nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
+        'abcd123');
+    const expected = 'Revert submission\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('revert a revert', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        'Revert "one line commit"\n\nChange-Id: abcdefg\n',
+        'abcd123');
+    const expected = 'Revert submission\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
index aa26681..037d53d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
@@ -14,67 +14,80 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrConfirmSubmitDialog extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-confirm-submit-dialog'; }
+import '@polymer/iron-icon/iron-icon.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-submit-dialog_html.js';
+
+/** @extends Polymer.Element */
+class GrConfirmSubmitDialog extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-confirm-submit-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  static get properties() {
+    return {
     /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
+     * @type {{
+     *    is_private: boolean,
+     *    subject: string,
+     *  }}
      */
+      change: Object,
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
-
-    static get properties() {
-      return {
       /**
        * @type {{
-       *    is_private: boolean,
-       *    subject: string,
+       *    label: string,
        *  }}
        */
-        change: Object,
-
-        /**
-         * @type {{
-         *    label: string,
-         *  }}
-         */
-        action: Object,
-      };
-    }
-
-    resetFocus(e) {
-      this.$.dialog.resetFocus();
-    }
-
-    _computeUnresolvedCommentsWarning(change) {
-      const unresolvedCount = change.unresolved_comment_count;
-      const plural = unresolvedCount > 1 ? 's' : '';
-      return `Heads Up! ${unresolvedCount} unresolved comment${plural}.`;
-    }
-
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
-    }
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
-    }
+      action: Object,
+    };
   }
 
-  customElements.define(GrConfirmSubmitDialog.is, GrConfirmSubmitDialog);
-})();
+  resetFocus(e) {
+    this.$.dialog.resetFocus();
+  }
+
+  _computeUnresolvedCommentsWarning(change) {
+    const unresolvedCount = change.unresolved_comment_count;
+    const plural = unresolvedCount > 1 ? 's' : '';
+    return `Heads Up! ${unresolvedCount} unresolved comment${plural}.`;
+  }
+
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
+  }
+}
+
+customElements.define(GrConfirmSubmitDialog.is, GrConfirmSubmitDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.js
index a845ed4..03e0f17 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.js
@@ -1,34 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
-
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-confirm-submit-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       #dialog {
         min-width: 40em;
@@ -48,18 +36,13 @@
         }
       }
     </style>
-    <gr-dialog
-        id="dialog"
-        confirm-label="Continue"
-        confirm-on-enter
-        on-cancel="_handleCancelTap"
-        on-confirm="_handleConfirmTap">
+    <gr-dialog id="dialog" confirm-label="Continue" confirm-on-enter="" on-cancel="_handleCancelTap" on-confirm="_handleConfirmTap">
       <div class="header" slot="header">
         [[action.label]]
       </div>
       <div class="main" slot="main">
         <gr-endpoint-decorator name="confirm-submit-change">
-          <p>Ready to submit &ldquo;<strong>[[change.subject]]</strong>&rdquo;?</p>
+          <p>Ready to submit “<strong>[[change.subject]]</strong>”?</p>
           <template is="dom-if" if="[[change.is_private]]">
             <p>
               <iron-icon icon="gr-icons:error" class="warningBeforeSubmit"></iron-icon>
@@ -79,6 +62,4 @@
       </div>
     </gr-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-confirm-submit-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
index a5dffa8..5acbbde 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
@@ -19,17 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-submit-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
-<link rel="import" href="gr-confirm-submit-dialog.html">
+<script type="module" src="./gr-confirm-submit-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-submit-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,43 +42,45 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-file-list-header tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-confirm-submit-dialog.js';
+suite('gr-file-list-header tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('display', () => {
-      element.action = {label: 'my-label'};
-      element.change = {subject: 'my-subject'};
-      flushAsynchronousOperations();
-      const header = element.shadowRoot
-          .querySelector('.header');
-      assert.equal(header.textContent.trim(), 'my-label');
-
-      const message = element.shadowRoot
-          .querySelector('.main p');
-      assert.notEqual(message.textContent.length, 0);
-      assert.notEqual(message.textContent.indexOf('my-subject'), -1);
-    });
-
-    test('_computeUnresolvedCommentsWarning', () => {
-      const change = {unresolved_comment_count: 1};
-      assert.equal(element._computeUnresolvedCommentsWarning(change),
-          'Heads Up! 1 unresolved comment.');
-
-      const change2 = {unresolved_comment_count: 2};
-      assert.equal(element._computeUnresolvedCommentsWarning(change2),
-          'Heads Up! 2 unresolved comments.');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('display', () => {
+    element.action = {label: 'my-label'};
+    element.change = {subject: 'my-subject'};
+    flushAsynchronousOperations();
+    const header = element.shadowRoot
+        .querySelector('.header');
+    assert.equal(header.textContent.trim(), 'my-label');
+
+    const message = element.shadowRoot
+        .querySelector('.main p');
+    assert.notEqual(message.textContent.length, 0);
+    assert.notEqual(message.textContent.indexOf('my-subject'), -1);
+  });
+
+  test('_computeUnresolvedCommentsWarning', () => {
+    const change = {unresolved_comment_count: 1};
+    assert.equal(element._computeUnresolvedCommentsWarning(change),
+        'Heads Up! 1 unresolved comment.');
+
+    const change2 = {unresolved_comment_count: 2};
+    assert.equal(element._computeUnresolvedCommentsWarning(change2),
+        'Heads Up! 2 unresolved comments.');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index 17c6f50..1b6e521 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -14,214 +14,225 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-download-commands/gr-download-commands.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-download-dialog_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @extends Polymer.Element
+ */
+class GrDownloadDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.PatchSetBehavior,
+  Gerrit.RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-download-dialog'; }
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @extends Polymer.Element
+   * Fired when the user presses the close button.
+   *
+   * @event close
    */
-  class GrDownloadDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.PatchSetBehavior,
-    Gerrit.RESTClientBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-download-dialog'; }
-    /**
-     * Fired when the user presses the close button.
-     *
-     * @event close
-     */
 
-    static get properties() {
-      return {
-      /** @type {{ revisions: Array }} */
-        change: Object,
-        patchNum: String,
-        /** @type {?} */
-        config: Object,
+  static get properties() {
+    return {
+    /** @type {{ revisions: Array }} */
+      change: Object,
+      patchNum: String,
+      /** @type {?} */
+      config: Object,
 
-        _schemes: {
-          type: Array,
-          value() { return []; },
-          computed: '_computeSchemes(change, patchNum)',
-          observer: '_schemesChanged',
-        },
-        _selectedScheme: String,
-      };
-    }
+      _schemes: {
+        type: Array,
+        value() { return []; },
+        computed: '_computeSchemes(change, patchNum)',
+        observer: '_schemesChanged',
+      },
+      _selectedScheme: String,
+    };
+  }
 
-    /** @override */
-    ready() {
-      super.ready();
-      this._ensureAttribute('role', 'dialog');
-    }
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'dialog');
+  }
 
-    focus() {
-      if (this._schemes.length) {
-        this.$.downloadCommands.focusOnCopy();
-      } else {
-        this.$.download.focus();
-      }
-    }
-
-    getFocusStops() {
-      const links = this.shadowRoot
-          .querySelector('#archives').querySelectorAll('a');
-      return {
-        start: this.$.closeButton,
-        end: links[links.length - 1],
-      };
-    }
-
-    _computeDownloadCommands(change, patchNum, _selectedScheme) {
-      let commandObj;
-      if (!change) return [];
-      for (const rev of Object.values(change.revisions || {})) {
-        if (this.patchNumEquals(rev._number, patchNum) &&
-            rev && rev.fetch && rev.fetch.hasOwnProperty(_selectedScheme)) {
-          commandObj = rev.fetch[_selectedScheme].commands;
-          break;
-        }
-      }
-      const commands = [];
-      for (const title in commandObj) {
-        if (!commandObj || !commandObj.hasOwnProperty(title)) { continue; }
-        commands.push({
-          title,
-          command: commandObj[title],
-        });
-      }
-      return commands;
-    }
-
-    /**
-     * @param {!Object} change
-     * @param {number|string} patchNum
-     *
-     * @return {string}
-     */
-    _computeZipDownloadLink(change, patchNum) {
-      return this._computeDownloadLink(change, patchNum, true);
-    }
-
-    /**
-     * @param {!Object} change
-     * @param {number|string} patchNum
-     *
-     * @return {string}
-     */
-    _computeZipDownloadFilename(change, patchNum) {
-      return this._computeDownloadFilename(change, patchNum, true);
-    }
-
-    /**
-     * @param {!Object} change
-     * @param {number|string} patchNum
-     * @param {boolean=} opt_zip
-     *
-     * @return {string} Not sure why there was a mismatch
-     */
-    _computeDownloadLink(change, patchNum, opt_zip) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum].some(arg => arg === undefined)) {
-        return '';
-      }
-      return this.changeBaseURL(change.project, change._number, patchNum) +
-          '/patch?' + (opt_zip ? 'zip' : 'download');
-    }
-
-    /**
-     * @param {!Object} change
-     * @param {number|string} patchNum
-     * @param {boolean=} opt_zip
-     *
-     * @return {string}
-     */
-    _computeDownloadFilename(change, patchNum, opt_zip) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum].some(arg => arg === undefined)) {
-        return '';
-      }
-
-      let shortRev = '';
-      for (const rev in change.revisions) {
-        if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
-          shortRev = rev.substr(0, 7);
-          break;
-        }
-      }
-      return shortRev + '.diff.' + (opt_zip ? 'zip' : 'base64');
-    }
-
-    _computeHidePatchFile(change, patchNum) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum].some(arg => arg === undefined)) {
-        return false;
-      }
-      for (const rev of Object.values(change.revisions || {})) {
-        if (this.patchNumEquals(rev._number, patchNum)) {
-          const parentLength = rev.commit && rev.commit.parents ?
-            rev.commit.parents.length : 0;
-          return parentLength == 0;
-        }
-      }
-      return false;
-    }
-
-    _computeArchiveDownloadLink(change, patchNum, format) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum, format].some(arg => arg === undefined)) {
-        return '';
-      }
-      return this.changeBaseURL(change.project, change._number, patchNum) +
-          '/archive?format=' + format;
-    }
-
-    _computeSchemes(change, patchNum) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum].some(arg => arg === undefined)) {
-        return [];
-      }
-
-      for (const rev of Object.values(change.revisions || {})) {
-        if (this.patchNumEquals(rev._number, patchNum)) {
-          const fetch = rev.fetch;
-          if (fetch) {
-            return Object.keys(fetch).sort();
-          }
-          break;
-        }
-      }
-      return [];
-    }
-
-    _computePatchSetQuantity(revisions) {
-      if (!revisions) { return 0; }
-      return Object.keys(revisions).length;
-    }
-
-    _handleCloseTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('close', null, {bubbles: false});
-    }
-
-    _schemesChanged(schemes) {
-      if (schemes.length === 0) { return; }
-      if (!schemes.includes(this._selectedScheme)) {
-        this._selectedScheme = schemes.sort()[0];
-      }
-    }
-
-    _computeShowDownloadCommands(schemes) {
-      return schemes.length ? '' : 'hidden';
+  focus() {
+    if (this._schemes.length) {
+      this.$.downloadCommands.focusOnCopy();
+    } else {
+      this.$.download.focus();
     }
   }
 
-  customElements.define(GrDownloadDialog.is, GrDownloadDialog);
-})();
+  getFocusStops() {
+    const links = this.shadowRoot
+        .querySelector('#archives').querySelectorAll('a');
+    return {
+      start: this.$.closeButton,
+      end: links[links.length - 1],
+    };
+  }
+
+  _computeDownloadCommands(change, patchNum, _selectedScheme) {
+    let commandObj;
+    if (!change) return [];
+    for (const rev of Object.values(change.revisions || {})) {
+      if (this.patchNumEquals(rev._number, patchNum) &&
+          rev && rev.fetch && rev.fetch.hasOwnProperty(_selectedScheme)) {
+        commandObj = rev.fetch[_selectedScheme].commands;
+        break;
+      }
+    }
+    const commands = [];
+    for (const title in commandObj) {
+      if (!commandObj || !commandObj.hasOwnProperty(title)) { continue; }
+      commands.push({
+        title,
+        command: commandObj[title],
+      });
+    }
+    return commands;
+  }
+
+  /**
+   * @param {!Object} change
+   * @param {number|string} patchNum
+   *
+   * @return {string}
+   */
+  _computeZipDownloadLink(change, patchNum) {
+    return this._computeDownloadLink(change, patchNum, true);
+  }
+
+  /**
+   * @param {!Object} change
+   * @param {number|string} patchNum
+   *
+   * @return {string}
+   */
+  _computeZipDownloadFilename(change, patchNum) {
+    return this._computeDownloadFilename(change, patchNum, true);
+  }
+
+  /**
+   * @param {!Object} change
+   * @param {number|string} patchNum
+   * @param {boolean=} opt_zip
+   *
+   * @return {string} Not sure why there was a mismatch
+   */
+  _computeDownloadLink(change, patchNum, opt_zip) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum].some(arg => arg === undefined)) {
+      return '';
+    }
+    return this.changeBaseURL(change.project, change._number, patchNum) +
+        '/patch?' + (opt_zip ? 'zip' : 'download');
+  }
+
+  /**
+   * @param {!Object} change
+   * @param {number|string} patchNum
+   * @param {boolean=} opt_zip
+   *
+   * @return {string}
+   */
+  _computeDownloadFilename(change, patchNum, opt_zip) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum].some(arg => arg === undefined)) {
+      return '';
+    }
+
+    let shortRev = '';
+    for (const rev in change.revisions) {
+      if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
+        shortRev = rev.substr(0, 7);
+        break;
+      }
+    }
+    return shortRev + '.diff.' + (opt_zip ? 'zip' : 'base64');
+  }
+
+  _computeHidePatchFile(change, patchNum) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum].some(arg => arg === undefined)) {
+      return false;
+    }
+    for (const rev of Object.values(change.revisions || {})) {
+      if (this.patchNumEquals(rev._number, patchNum)) {
+        const parentLength = rev.commit && rev.commit.parents ?
+          rev.commit.parents.length : 0;
+        return parentLength == 0;
+      }
+    }
+    return false;
+  }
+
+  _computeArchiveDownloadLink(change, patchNum, format) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum, format].some(arg => arg === undefined)) {
+      return '';
+    }
+    return this.changeBaseURL(change.project, change._number, patchNum) +
+        '/archive?format=' + format;
+  }
+
+  _computeSchemes(change, patchNum) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum].some(arg => arg === undefined)) {
+      return [];
+    }
+
+    for (const rev of Object.values(change.revisions || {})) {
+      if (this.patchNumEquals(rev._number, patchNum)) {
+        const fetch = rev.fetch;
+        if (fetch) {
+          return Object.keys(fetch).sort();
+        }
+        break;
+      }
+    }
+    return [];
+  }
+
+  _computePatchSetQuantity(revisions) {
+    if (!revisions) { return 0; }
+    return Object.keys(revisions).length;
+  }
+
+  _handleCloseTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('close', null, {bubbles: false});
+  }
+
+  _schemesChanged(schemes) {
+    if (schemes.length === 0) { return; }
+    if (!schemes.includes(this._selectedScheme)) {
+      this._selectedScheme = schemes.sort()[0];
+    }
+  }
+
+  _computeShowDownloadCommands(schemes) {
+    return schemes.length ? '' : 'hidden';
+  }
+}
+
+customElements.define(GrDownloadDialog.is, GrDownloadDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js
index 4ddc876..324f9f4 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
-
-<dom-module id="gr-download-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -77,37 +69,26 @@
         Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
       </h3>
     </section>
-    <section class$="[[_computeShowDownloadCommands(_schemes)]]">
-      <gr-download-commands
-          id="downloadCommands"
-          commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
-          schemes="[[_schemes]]"
-          selected-scheme="{{_selectedScheme}}"></gr-download-commands>
+    <section class\$="[[_computeShowDownloadCommands(_schemes)]]">
+      <gr-download-commands id="downloadCommands" commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]" schemes="[[_schemes]]" selected-scheme="{{_selectedScheme}}"></gr-download-commands>
     </section>
     <section class="flexContainer">
-      <div class="patchFiles" hidden="[[_computeHidePatchFile(change, patchNum)]]" hidden>
+      <div class="patchFiles" hidden="[[_computeHidePatchFile(change, patchNum)]]">
         <label>Patch file</label>
         <div>
-          <a
-              id="download"
-              href$="[[_computeDownloadLink(change, patchNum)]]"
-              download>
+          <a id="download" href\$="[[_computeDownloadLink(change, patchNum)]]" download="">
             [[_computeDownloadFilename(change, patchNum)]]
           </a>
-          <a
-              href$="[[_computeZipDownloadLink(change, patchNum)]]"
-              download>
+          <a href\$="[[_computeZipDownloadLink(change, patchNum)]]" download="">
             [[_computeZipDownloadFilename(change, patchNum)]]
           </a>
         </div>
       </div>
-      <div class="archivesContainer" hidden$="[[!config.archives.length]]" hidden>
+      <div class="archivesContainer" hidden\$="[[!config.archives.length]]" hidden="">
         <label>Archive</label>
         <div id="archives" class="archives">
           <template is="dom-repeat" items="[[config.archives]]" as="format">
-            <a
-                href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]"
-                download>
+            <a href\$="[[_computeArchiveDownloadLink(change, patchNum, format)]]" download="">
               [[format]]
             </a>
           </template>
@@ -116,11 +97,7 @@
     </section>
     <section class="footer">
       <span class="closeButtonContainer">
-        <gr-button id="closeButton"
-            link
-            on-click="_handleCloseTap">Close</gr-button>
+        <gr-button id="closeButton" link="" on-click="_handleCloseTap">Close</gr-button>
       </span>
     </section>
-  </template>
-  <script src="gr-download-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index a3755ba..8a55c21 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-download-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-download-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-download-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-download-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -41,184 +46,187 @@
   </template>
 </test-fixture>
 
-<script>
-  function getChangeObject() {
-    return {
-      current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
-      revisions: {
-        '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-          _number: 1,
-          commit: {
-            parents: [],
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-download-dialog.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+function getChangeObject() {
+  return {
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+    revisions: {
+      '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
+        _number: 1,
+        commit: {
+          parents: [],
+        },
+        fetch: {
+          repo: {
+            commands: {
+              repo: 'repo download test-project 5/1',
+            },
           },
-          fetch: {
-            repo: {
-              commands: {
-                repo: 'repo download test-project 5/1',
-              },
+          ssh: {
+            commands: {
+              'Checkout':
+                'git fetch ' +
+                'ssh://andybons@localhost:29418/test-project ' +
+                'refs/changes/05/5/1 && git checkout FETCH_HEAD',
+              'Cherry Pick':
+                'git fetch ' +
+                'ssh://andybons@localhost:29418/test-project ' +
+                'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
+              'Format Patch':
+                'git fetch ' +
+                'ssh://andybons@localhost:29418/test-project ' +
+                'refs/changes/05/5/1 ' +
+                '&& git format-patch -1 --stdout FETCH_HEAD',
+              'Pull':
+                'git pull ' +
+                'ssh://andybons@localhost:29418/test-project ' +
+                'refs/changes/05/5/1',
             },
-            ssh: {
-              commands: {
-                'Checkout':
-                  'git fetch ' +
-                  'ssh://andybons@localhost:29418/test-project ' +
-                  'refs/changes/05/5/1 && git checkout FETCH_HEAD',
-                'Cherry Pick':
-                  'git fetch ' +
-                  'ssh://andybons@localhost:29418/test-project ' +
-                  'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
-                'Format Patch':
-                  'git fetch ' +
-                  'ssh://andybons@localhost:29418/test-project ' +
-                  'refs/changes/05/5/1 ' +
-                  '&& git format-patch -1 --stdout FETCH_HEAD',
-                'Pull':
-                  'git pull ' +
-                  'ssh://andybons@localhost:29418/test-project ' +
-                  'refs/changes/05/5/1',
-              },
-            },
-            http: {
-              commands: {
-                'Checkout':
-                  'git fetch ' +
-                  'http://andybons@localhost:8080/a/test-project ' +
-                  'refs/changes/05/5/1 && git checkout FETCH_HEAD',
-                'Cherry Pick':
-                  'git fetch ' +
-                  'http://andybons@localhost:8080/a/test-project ' +
-                  'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
-                'Format Patch':
-                  'git fetch ' +
-                  'http://andybons@localhost:8080/a/test-project ' +
-                  'refs/changes/05/5/1 && ' +
-                  'git format-patch -1 --stdout FETCH_HEAD',
-                'Pull':
-                  'git pull ' +
-                  'http://andybons@localhost:8080/a/test-project ' +
-                  'refs/changes/05/5/1',
-              },
+          },
+          http: {
+            commands: {
+              'Checkout':
+                'git fetch ' +
+                'http://andybons@localhost:8080/a/test-project ' +
+                'refs/changes/05/5/1 && git checkout FETCH_HEAD',
+              'Cherry Pick':
+                'git fetch ' +
+                'http://andybons@localhost:8080/a/test-project ' +
+                'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
+              'Format Patch':
+                'git fetch ' +
+                'http://andybons@localhost:8080/a/test-project ' +
+                'refs/changes/05/5/1 && ' +
+                'git format-patch -1 --stdout FETCH_HEAD',
+              'Pull':
+                'git pull ' +
+                'http://andybons@localhost:8080/a/test-project ' +
+                'refs/changes/05/5/1',
             },
           },
         },
       },
-    };
-  }
+    },
+  };
+}
 
-  function getChangeObjectNoFetch() {
-    return {
-      current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
-      revisions: {
-        '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-          _number: 1,
-          commit: {
-            parents: [],
-          },
-          fetch: {},
+function getChangeObjectNoFetch() {
+  return {
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+    revisions: {
+      '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
+        _number: 1,
+        commit: {
+          parents: [],
         },
+        fetch: {},
       },
+    },
+  };
+}
+
+suite('gr-download-dialog', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+
+    element = fixture('basic');
+    element.patchNum = '1';
+    element.config = {
+      schemes: {
+        'anonymous http': {},
+        'http': {},
+        'repo': {},
+        'ssh': {},
+      },
+      archives: ['tgz', 'tar', 'tbz2', 'txz'],
     };
-  }
 
-  suite('gr-download-dialog', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+    flushAsynchronousOperations();
+  });
 
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('anchors use download attribute', () => {
+    const anchors = Array.from(
+        dom(element.root).querySelectorAll('a'));
+    assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
+  });
+
+  suite('gr-download-dialog tests with no fetch options', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-
-      element = fixture('basic');
-      element.patchNum = '1';
-      element.config = {
-        schemes: {
-          'anonymous http': {},
-          'http': {},
-          'repo': {},
-          'ssh': {},
-        },
-        archives: ['tgz', 'tar', 'tbz2', 'txz'],
-      };
-
+      element.change = getChangeObjectNoFetch();
       flushAsynchronousOperations();
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('anchors use download attribute', () => {
-      const anchors = Array.from(
-          Polymer.dom(element.root).querySelectorAll('a'));
-      assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
-    });
-
-    suite('gr-download-dialog tests with no fetch options', () => {
-      setup(() => {
-        element.change = getChangeObjectNoFetch();
-        flushAsynchronousOperations();
-      });
-
-      test('focuses on first download link if no copy links', () => {
-        const focusStub = sandbox.stub(element.$.download, 'focus');
-        element.focus();
-        assert.isTrue(focusStub.called);
-        focusStub.restore();
-      });
-    });
-
-    suite('gr-download-dialog with fetch options', () => {
-      setup(() => {
-        element.change = getChangeObject();
-        flushAsynchronousOperations();
-      });
-
-      test('focuses on first copy link', () => {
-        const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
-        element.focus();
-        flushAsynchronousOperations();
-        assert.isTrue(focusStub.called);
-        focusStub.restore();
-      });
-
-      test('computed fields', () => {
-        assert.equal(element._computeArchiveDownloadLink(
-            {project: 'test/project', _number: 123}, 2, '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'));
-      });
-    });
-
-    test('_computeShowDownloadCommands', () => {
-      assert.equal(element._computeShowDownloadCommands([]), 'hidden');
-      assert.equal(element._computeShowDownloadCommands(['test']), '');
-    });
-
-    test('_computeHidePatchFile', () => {
-      const patchNum = '1';
-
-      const change1 = {
-        revisions: {
-          r1: {_number: 1, commit: {parents: []}},
-        },
-      };
-      assert.isTrue(element._computeHidePatchFile(change1, patchNum));
-
-      const change2 = {
-        revisions: {
-          r1: {_number: 1, commit: {parents: [
-            {commit: 'p1'},
-          ]}},
-        },
-      };
-      assert.isFalse(element._computeHidePatchFile(change2, patchNum));
+    test('focuses on first download link if no copy links', () => {
+      const focusStub = sandbox.stub(element.$.download, 'focus');
+      element.focus();
+      assert.isTrue(focusStub.called);
+      focusStub.restore();
     });
   });
+
+  suite('gr-download-dialog with fetch options', () => {
+    setup(() => {
+      element.change = getChangeObject();
+      flushAsynchronousOperations();
+    });
+
+    test('focuses on first copy link', () => {
+      const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
+      element.focus();
+      flushAsynchronousOperations();
+      assert.isTrue(focusStub.called);
+      focusStub.restore();
+    });
+
+    test('computed fields', () => {
+      assert.equal(element._computeArchiveDownloadLink(
+          {project: 'test/project', _number: 123}, 2, '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'));
+    });
+  });
+
+  test('_computeShowDownloadCommands', () => {
+    assert.equal(element._computeShowDownloadCommands([]), 'hidden');
+    assert.equal(element._computeShowDownloadCommands(['test']), '');
+  });
+
+  test('_computeHidePatchFile', () => {
+    const patchNum = '1';
+
+    const change1 = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: []}},
+      },
+    };
+    assert.isTrue(element._computeHidePatchFile(change1, patchNum));
+
+    const change2 = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: [
+          {commit: 'p1'},
+        ]}},
+      },
+    };
+    assert.isFalse(element._computeHidePatchFile(change2, patchNum));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.js b/polygerrit-ui/app/elements/change/gr-file-list-constants.js
index 8bdcf7a..0f93b52 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-constants.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-constants.js
@@ -1,31 +1,29 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.
+ */
+(function(window) {
+  'use strict';
 
-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
+  const GrFileListConstants = window.GrFileListConstants || {};
 
-http://www.apache.org/licenses/LICENSE-2.0
+  GrFileListConstants.FilesExpandedState = {
+    ALL: 'all',
+    NONE: 'none',
+    SOME: 'some',
+  };
 
-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.
--->
-<script>
-  (function(window) {
-    'use strict';
-
-    const GrFileListConstants = window.GrFileListConstants || {};
-
-    GrFileListConstants.FilesExpandedState = {
-      ALL: 'all',
-      NONE: 'none',
-      SOME: 'some',
-    };
-
-    window.GrFileListConstants = GrFileListConstants;
-  })(window);
-</script>
+  window.GrFileListConstants = GrFileListConstants;
+})(window);
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
index 34e1cfa..eef436b 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
@@ -14,264 +14,286 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Maximum length for patch set descriptions.
-  const PATCH_DESC_MAX_LENGTH = 500;
-  const MERGED_STATUS = 'MERGED';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector.js';
+import '../../diff/gr-patch-range-select/gr-patch-range-select.js';
+import '../../edit/gr-edit-controls/gr-edit-controls.js';
+import '../../shared/gr-editable-label/gr-editable-label.js';
+import '../../shared/gr-linked-chip/gr-linked-chip.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../gr-file-list-constants.js';
+import '../gr-commit-info/gr-commit-info.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-file-list-header_html.js';
+
+// Maximum length for patch set descriptions.
+const PATCH_DESC_MAX_LENGTH = 500;
+const MERGED_STATUS = 'MERGED';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @extends Polymer.Element
+ */
+class GrFileListHeader extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.PatchSetBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-file-list-header'; }
+  /**
+   * @event expand-diffs
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @extends Polymer.Element
+   * @event collapse-diffs
    */
-  class GrFileListHeader extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.PatchSetBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-file-list-header'; }
-    /**
-     * @event expand-diffs
-     */
 
-    /**
-     * @event collapse-diffs
-     */
+  /**
+   * @event open-diff-prefs
+   */
 
-    /**
-     * @event open-diff-prefs
-     */
+  /**
+   * @event open-included-in-dialog
+   */
 
-    /**
-     * @event open-included-in-dialog
-     */
+  /**
+   * @event open-download-dialog
+   */
 
-    /**
-     * @event open-download-dialog
-     */
+  /**
+   * @event open-upload-help-dialog
+   */
 
-    /**
-     * @event open-upload-help-dialog
-     */
+  static get properties() {
+    return {
+      account: Object,
+      allPatchSets: Array,
+      /** @type {?} */
+      change: Object,
+      changeNum: String,
+      changeUrl: String,
+      changeComments: Object,
+      commitInfo: Object,
+      editMode: Boolean,
+      loggedIn: Boolean,
+      serverConfig: Object,
+      shownFileCount: Number,
+      diffPrefs: Object,
+      diffPrefsDisabled: Boolean,
+      diffViewMode: {
+        type: String,
+        notify: true,
+      },
+      patchNum: String,
+      basePatchNum: String,
+      filesExpanded: String,
+      // Caps the number of files that can be shown and have the 'show diffs' /
+      // 'hide diffs' buttons still be functional.
+      _maxFilesForBulkActions: {
+        type: Number,
+        readOnly: true,
+        value: 225,
+      },
+      _patchsetDescription: {
+        type: String,
+        value: '',
+      },
+      _descriptionReadOnly: {
+        type: Boolean,
+        computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
+      },
+      revisionInfo: Object,
+    };
+  }
 
-    static get properties() {
-      return {
-        account: Object,
-        allPatchSets: Array,
-        /** @type {?} */
-        change: Object,
-        changeNum: String,
-        changeUrl: String,
-        changeComments: Object,
-        commitInfo: Object,
-        editMode: Boolean,
-        loggedIn: Boolean,
-        serverConfig: Object,
-        shownFileCount: Number,
-        diffPrefs: Object,
-        diffPrefsDisabled: Boolean,
-        diffViewMode: {
-          type: String,
-          notify: true,
-        },
-        patchNum: String,
-        basePatchNum: String,
-        filesExpanded: String,
-        // Caps the number of files that can be shown and have the 'show diffs' /
-        // 'hide diffs' buttons still be functional.
-        _maxFilesForBulkActions: {
-          type: Number,
-          readOnly: true,
-          value: 225,
-        },
-        _patchsetDescription: {
-          type: String,
-          value: '',
-        },
-        _descriptionReadOnly: {
-          type: Boolean,
-          computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
-        },
-        revisionInfo: Object,
-      };
+  static get observers() {
+    return [
+      '_computePatchSetDescription(change, patchNum)',
+    ];
+  }
+
+  setDiffViewMode(mode) {
+    this.$.modeSelect.setMode(mode);
+  }
+
+  _expandAllDiffs() {
+    this._expanded = true;
+    this.fire('expand-diffs');
+  }
+
+  _collapseAllDiffs() {
+    this._expanded = false;
+    this.fire('collapse-diffs');
+  }
+
+  _computeExpandedClass(filesExpanded) {
+    const classes = [];
+    if (filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
+      classes.push('expanded');
+    }
+    if (filesExpanded === GrFileListConstants.FilesExpandedState.SOME ||
+          filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
+      classes.push('openFile');
+    }
+    return classes.join(' ');
+  }
+
+  _computeDescriptionPlaceholder(readOnly) {
+    return (readOnly ? 'No' : 'Add') + ' patchset description';
+  }
+
+  _computeDescriptionReadOnly(loggedIn, change, account) {
+    // Polymer 2: check for undefined
+    if ([loggedIn, change, account].some(arg => arg === undefined)) {
+      return undefined;
     }
 
-    static get observers() {
-      return [
-        '_computePatchSetDescription(change, patchNum)',
-      ];
+    return !(loggedIn && (account._account_id === change.owner._account_id));
+  }
+
+  _computePatchSetDescription(change, patchNum) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum].some(arg => arg === undefined)) {
+      return;
     }
 
-    setDiffViewMode(mode) {
-      this.$.modeSelect.setMode(mode);
-    }
+    const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
+    this._patchsetDescription = (rev && rev.description) ?
+      rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
+  }
 
-    _expandAllDiffs() {
-      this._expanded = true;
-      this.fire('expand-diffs');
-    }
+  _handleDescriptionRemoved(e) {
+    return this._updateDescription('', e);
+  }
 
-    _collapseAllDiffs() {
-      this._expanded = false;
-      this.fire('collapse-diffs');
-    }
-
-    _computeExpandedClass(filesExpanded) {
-      const classes = [];
-      if (filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
-        classes.push('expanded');
+  /**
+   * @param {!Object} revisions The revisions object keyed by revision hashes
+   * @param {?Object} patchSet A revision already fetched from {revisions}
+   * @return {string|undefined} the SHA hash corresponding to the revision.
+   */
+  _getPatchsetHash(revisions, patchSet) {
+    for (const rev in revisions) {
+      if (revisions.hasOwnProperty(rev) &&
+          revisions[rev] === patchSet) {
+        return rev;
       }
-      if (filesExpanded === GrFileListConstants.FilesExpandedState.SOME ||
-            filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
-        classes.push('openFile');
-      }
-      return classes.join(' ');
-    }
-
-    _computeDescriptionPlaceholder(readOnly) {
-      return (readOnly ? 'No' : 'Add') + ' patchset description';
-    }
-
-    _computeDescriptionReadOnly(loggedIn, change, account) {
-      // Polymer 2: check for undefined
-      if ([loggedIn, change, account].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      return !(loggedIn && (account._account_id === change.owner._account_id));
-    }
-
-    _computePatchSetDescription(change, patchNum) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum].some(arg => arg === undefined)) {
-        return;
-      }
-
-      const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
-      this._patchsetDescription = (rev && rev.description) ?
-        rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
-    }
-
-    _handleDescriptionRemoved(e) {
-      return this._updateDescription('', e);
-    }
-
-    /**
-     * @param {!Object} revisions The revisions object keyed by revision hashes
-     * @param {?Object} patchSet A revision already fetched from {revisions}
-     * @return {string|undefined} the SHA hash corresponding to the revision.
-     */
-    _getPatchsetHash(revisions, patchSet) {
-      for (const rev in revisions) {
-        if (revisions.hasOwnProperty(rev) &&
-            revisions[rev] === patchSet) {
-          return rev;
-        }
-      }
-    }
-
-    _handleDescriptionChanged(e) {
-      const desc = e.detail.trim();
-      this._updateDescription(desc, e);
-    }
-
-    /**
-     * Update the patchset description with the rest API.
-     *
-     * @param {string} desc
-     * @param {?(Event|Node)} e
-     * @return {!Promise}
-     */
-    _updateDescription(desc, e) {
-      const target = Polymer.dom(e).rootTarget;
-      if (target) { target.disabled = true; }
-      const rev = this.getRevisionByPatchNum(this.change.revisions,
-          this.patchNum);
-      const sha = this._getPatchsetHash(this.change.revisions, rev);
-      return this.$.restAPI.setDescription(this.changeNum, this.patchNum, desc)
-          .then(res => {
-            if (res.ok) {
-              if (target) { target.disabled = false; }
-              this.set(['change', 'revisions', sha, 'description'], desc);
-              this._patchsetDescription = desc;
-            }
-          })
-          .catch(err => {
-            if (target) { target.disabled = false; }
-            return;
-          });
-    }
-
-    _computePrefsButtonHidden(prefs, diffPrefsDisabled) {
-      return diffPrefsDisabled || !prefs;
-    }
-
-    _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
-      return shownFileCount <= maxFilesForBulkActions;
-    }
-
-    _handlePatchChange(e) {
-      const {basePatchNum, patchNum} = e.detail;
-      if (this.patchNumEquals(basePatchNum, this.basePatchNum) &&
-          this.patchNumEquals(patchNum, this.patchNum)) { return; }
-      Gerrit.Nav.navigateToChange(this.change, patchNum, basePatchNum);
-    }
-
-    _handlePrefsTap(e) {
-      e.preventDefault();
-      this.fire('open-diff-prefs');
-    }
-
-    _handleIncludedInTap(e) {
-      e.preventDefault();
-      this.fire('open-included-in-dialog');
-    }
-
-    _handleDownloadTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(
-          new CustomEvent('open-download-dialog', {bubbles: false}));
-    }
-
-    _computeEditModeClass(editMode) {
-      return editMode ? 'editMode' : '';
-    }
-
-    _computePatchInfoClass(patchNum, allPatchSets) {
-      const latestNum = this.computeLatestPatchNum(allPatchSets);
-      if (this.patchNumEquals(patchNum, latestNum)) {
-        return '';
-      }
-      return 'patchInfoOldPatchSet';
-    }
-
-    _hideIncludedIn(change) {
-      return change && change.status === MERGED_STATUS ? '' : 'hide';
-    }
-
-    _handleUploadTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(
-          new CustomEvent('open-upload-help-dialog', {bubbles: false}));
-    }
-
-    _computeUploadHelpContainerClass(change, account) {
-      const changeIsMerged = change && change.status === MERGED_STATUS;
-      const ownerId = change && change.owner && change.owner._account_id ?
-        change.owner._account_id : null;
-      const userId = account && account._account_id;
-      const userIsOwner = ownerId && userId && ownerId === userId;
-      const hideContainer = !userIsOwner || changeIsMerged;
-      return 'uploadContainer desktop' + (hideContainer ? ' hide' : '');
     }
   }
 
-  customElements.define(GrFileListHeader.is, GrFileListHeader);
-})();
+  _handleDescriptionChanged(e) {
+    const desc = e.detail.trim();
+    this._updateDescription(desc, e);
+  }
+
+  /**
+   * Update the patchset description with the rest API.
+   *
+   * @param {string} desc
+   * @param {?(Event|Node)} e
+   * @return {!Promise}
+   */
+  _updateDescription(desc, e) {
+    const target = dom(e).rootTarget;
+    if (target) { target.disabled = true; }
+    const rev = this.getRevisionByPatchNum(this.change.revisions,
+        this.patchNum);
+    const sha = this._getPatchsetHash(this.change.revisions, rev);
+    return this.$.restAPI.setDescription(this.changeNum, this.patchNum, desc)
+        .then(res => {
+          if (res.ok) {
+            if (target) { target.disabled = false; }
+            this.set(['change', 'revisions', sha, 'description'], desc);
+            this._patchsetDescription = desc;
+          }
+        })
+        .catch(err => {
+          if (target) { target.disabled = false; }
+          return;
+        });
+  }
+
+  _computePrefsButtonHidden(prefs, diffPrefsDisabled) {
+    return diffPrefsDisabled || !prefs;
+  }
+
+  _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
+    return shownFileCount <= maxFilesForBulkActions;
+  }
+
+  _handlePatchChange(e) {
+    const {basePatchNum, patchNum} = e.detail;
+    if (this.patchNumEquals(basePatchNum, this.basePatchNum) &&
+        this.patchNumEquals(patchNum, this.patchNum)) { return; }
+    Gerrit.Nav.navigateToChange(this.change, patchNum, basePatchNum);
+  }
+
+  _handlePrefsTap(e) {
+    e.preventDefault();
+    this.fire('open-diff-prefs');
+  }
+
+  _handleIncludedInTap(e) {
+    e.preventDefault();
+    this.fire('open-included-in-dialog');
+  }
+
+  _handleDownloadTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+        new CustomEvent('open-download-dialog', {bubbles: false}));
+  }
+
+  _computeEditModeClass(editMode) {
+    return editMode ? 'editMode' : '';
+  }
+
+  _computePatchInfoClass(patchNum, allPatchSets) {
+    const latestNum = this.computeLatestPatchNum(allPatchSets);
+    if (this.patchNumEquals(patchNum, latestNum)) {
+      return '';
+    }
+    return 'patchInfoOldPatchSet';
+  }
+
+  _hideIncludedIn(change) {
+    return change && change.status === MERGED_STATUS ? '' : 'hide';
+  }
+
+  _handleUploadTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+        new CustomEvent('open-upload-help-dialog', {bubbles: false}));
+  }
+
+  _computeUploadHelpContainerClass(change, account) {
+    const changeIsMerged = change && change.status === MERGED_STATUS;
+    const ownerId = change && change.owner && change.owner._account_id ?
+      change.owner._account_id : null;
+    const userId = account && account._account_id;
+    const userIsOwner = ownerId && userId && ownerId === userId;
+    const hideContainer = !userIsOwner || changeIsMerged;
+    return 'uploadContainer desktop' + (hideContainer ? ' hide' : '');
+  }
+}
+
+customElements.define(GrFileListHeader.is, GrFileListHeader);
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js
index 79f6c50..28cd645 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js
@@ -1,39 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../diff/gr-diff-mode-selector/gr-diff-mode-selector.html">
-<link rel="import" href="../../diff/gr-patch-range-select/gr-patch-range-select.html">
-<link rel="import" href="../../edit/gr-edit-controls/gr-edit-controls.html">
-<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
-<link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../gr-file-list-constants.html">
-<link rel="import" href="../gr-commit-info/gr-commit-info.html">
-
-<dom-module id="gr-file-list-header">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .prefsButton {
         float: right;
@@ -152,96 +135,49 @@
         }
       }
     </style>
-    <div class$="patchInfo-header [[_computeEditModeClass(editMode)]] [[_computePatchInfoClass(patchNum, allPatchSets)]]">
+    <div class\$="patchInfo-header [[_computeEditModeClass(editMode)]] [[_computePatchInfoClass(patchNum, allPatchSets)]]">
       <div class="patchInfo-left">
         <div class="patchInfoContent">
-          <gr-patch-range-select
-              id="rangeSelect"
-              change-comments="[[changeComments]]"
-              change-num="[[changeNum]]"
-              patch-num="[[patchNum]]"
-              base-patch-num="[[basePatchNum]]"
-              available-patches="[[allPatchSets]]"
-              revisions="[[change.revisions]]"
-              revision-info="[[revisionInfo]]"
-              on-patch-range-change="_handlePatchChange">
+          <gr-patch-range-select id="rangeSelect" change-comments="[[changeComments]]" change-num="[[changeNum]]" patch-num="[[patchNum]]" base-patch-num="[[basePatchNum]]" available-patches="[[allPatchSets]]" revisions="[[change.revisions]]" revision-info="[[revisionInfo]]" on-patch-range-change="_handlePatchChange">
           </gr-patch-range-select>
           <span class="separator"></span>
-          <gr-commit-info
-              change="[[change]]"
-              server-config="[[serverConfig]]"
-              commit-info="[[commitInfo]]"></gr-commit-info>
+          <gr-commit-info change="[[change]]" server-config="[[serverConfig]]" commit-info="[[commitInfo]]"></gr-commit-info>
           <span class="container latestPatchContainer">
             <span class="separator"></span>
-            <a href$="[[changeUrl]]">Go to latest patch set</a>
+            <a href\$="[[changeUrl]]">Go to latest patch set</a>
           </span>
           <span class="container descriptionContainer hideOnEdit">
             <span class="separator"></span>
-            <template
-                is="dom-if"
-                if="[[_patchsetDescription]]">
-              <gr-linked-chip
-                  id="descriptionChip"
-                  text="[[_patchsetDescription]]"
-                  removable="[[!_descriptionReadOnly]]"
-                  on-remove="_handleDescriptionRemoved"></gr-linked-chip>
+            <template is="dom-if" if="[[_patchsetDescription]]">
+              <gr-linked-chip id="descriptionChip" text="[[_patchsetDescription]]" removable="[[!_descriptionReadOnly]]" on-remove="_handleDescriptionRemoved"></gr-linked-chip>
             </template>
-            <template
-                is="dom-if"
-                if="[[!_patchsetDescription]]">
-              <gr-editable-label
-                  id="descriptionLabel"
-                  uppercase
-                  class="descriptionLabel"
-                  label-text="Add patchset description"
-                  value="[[_patchsetDescription]]"
-                  placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
-                  read-only="[[_descriptionReadOnly]]"
-                  on-changed="_handleDescriptionChanged"></gr-editable-label>
+            <template is="dom-if" if="[[!_patchsetDescription]]">
+              <gr-editable-label id="descriptionLabel" uppercase="" class="descriptionLabel" label-text="Add patchset description" value="[[_patchsetDescription]]" placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]" read-only="[[_descriptionReadOnly]]" on-changed="_handleDescriptionChanged"></gr-editable-label>
             </template>
           </span>
         </div>
       </div>
-      <div class$="rightControls [[_computeExpandedClass(filesExpanded)]]">
+      <div class\$="rightControls [[_computeExpandedClass(filesExpanded)]]">
         <span class="showOnEdit flexContainer">
-          <gr-edit-controls
-              id="editControls"
-              patch-num="[[patchNum]]"
-              change="[[change]]"></gr-edit-controls>
+          <gr-edit-controls id="editControls" patch-num="[[patchNum]]" change="[[change]]"></gr-edit-controls>
           <span class="separator"></span>
         </span>
-        <span class$="[[_computeUploadHelpContainerClass(change, account)]]">
-          <gr-button link
-              class="upload"
-              on-click="_handleUploadTap">Update Change</gr-button>
+        <span class\$="[[_computeUploadHelpContainerClass(change, account)]]">
+          <gr-button link="" class="upload" on-click="_handleUploadTap">Update Change</gr-button>
         </span>
         <span class="downloadContainer desktop">
-          <gr-button link
-              class="download"
-              title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
-                ShortcutSection.ACTIONS)]]"
-              on-click="_handleDownloadTap">Download</gr-button>
+          <gr-button link="" class="download" title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
+                ShortcutSection.ACTIONS)]]" on-click="_handleDownloadTap">Download</gr-button>
         </span>
-        <span class$="includedInContainer [[_hideIncludedIn(change)]] desktop">
-          <gr-button link
-              class="includedIn"
-              on-click="_handleIncludedInTap">Included In</gr-button>
+        <span class\$="includedInContainer [[_hideIncludedIn(change)]] desktop">
+          <gr-button link="" class="includedIn" on-click="_handleIncludedInTap">Included In</gr-button>
         </span>
-        <template is="dom-if"
-            if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
-          <gr-button
-              id="expandBtn"
-              link
-              title="[[createTitle(Shortcut.EXPAND_ALL_DIFF_CONTEXT,
-                ShortcutSection.DIFFS)]]"
-              on-click="_expandAllDiffs">Expand All</gr-button>
-          <gr-button
-              id="collapseBtn"
-              link
-              on-click="_collapseAllDiffs">Collapse All</gr-button>
+        <template is="dom-if" if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
+          <gr-button id="expandBtn" link="" title="[[createTitle(Shortcut.EXPAND_ALL_DIFF_CONTEXT,
+                ShortcutSection.DIFFS)]]" on-click="_expandAllDiffs">Expand All</gr-button>
+          <gr-button id="collapseBtn" link="" on-click="_collapseAllDiffs">Collapse All</gr-button>
         </template>
-        <template is="dom-if"
-            if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
+        <template is="dom-if" if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
           <div class="warning">
             Bulk actions disabled because there are too many files.
           </div>
@@ -249,25 +185,12 @@
         <div class="fileViewActions">
           <span class="separator"></span>
           <span class="fileViewActionsLabel">Diff view:</span>
-          <gr-diff-mode-selector
-              id="modeSelect"
-              mode="{{diffViewMode}}"
-              save-on-change="[[!diffPrefsDisabled]]"></gr-diff-mode-selector>
-          <span id="diffPrefsContainer"
-              class="hideOnEdit"
-              hidden$="[[_computePrefsButtonHidden(diffPrefs, diffPrefsDisabled)]]"
-              hidden>
-            <gr-button
-                link
-                has-tooltip
-                title="Diff preferences"
-                class="prefsButton desktop"
-                on-click="_handlePrefsTap"><iron-icon icon="gr-icons:settings"></iron-icon></gr-button>
+          <gr-diff-mode-selector id="modeSelect" mode="{{diffViewMode}}" save-on-change="[[!diffPrefsDisabled]]"></gr-diff-mode-selector>
+          <span id="diffPrefsContainer" class="hideOnEdit" hidden\$="[[_computePrefsButtonHidden(diffPrefs, diffPrefsDisabled)]]" hidden="">
+            <gr-button link="" has-tooltip="" title="Diff preferences" class="prefsButton desktop" on-click="_handlePrefsTap"><iron-icon icon="gr-icons:settings"></iron-icon></gr-button>
           </span>
         </div>
       </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-file-list-header.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
index f8a4e87..32ecb14 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
@@ -19,17 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-file-list-header</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
-<link rel="import" href="gr-file-list-header.html">
+<script type="module" src="./gr-file-list-header.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-file-list-header.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -43,281 +48,284 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-file-list-header tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-file-list-header.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-file-list-header tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({test: 'config'}); },
-        getAccount() { return Promise.resolve(null); },
-        _fetchSharedCacheURL() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({test: 'config'}); },
+      getAccount() { return Promise.resolve(null); },
+      _fetchSharedCacheURL() { return Promise.resolve({}); },
     });
+    element = fixture('basic');
+  });
 
-    teardown(done => {
-      flush(() => {
-        sandbox.restore();
-        done();
-      });
-    });
-
-    test('Diff preferences hidden when no prefs or diffPrefsDisabled', () => {
-      element.diffPrefsDisabled = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-      element.diffPrefsDisabled = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-      element.diffPrefsDisabled = true;
-      element.diffPrefs = {font_size: '12'};
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-      element.diffPrefsDisabled = false;
-      flushAsynchronousOperations();
-      assert.isFalse(element.$.diffPrefsContainer.hidden);
-    });
-
-    test('_computeDescriptionReadOnly', () => {
-      assert.equal(element._computeDescriptionReadOnly(false,
-          {owner: {_account_id: 1}}, {_account_id: 1}), true);
-      assert.equal(element._computeDescriptionReadOnly(true,
-          {owner: {_account_id: 0}}, {_account_id: 1}), true);
-      assert.equal(element._computeDescriptionReadOnly(true,
-          {owner: {_account_id: 1}}, {_account_id: 1}), false);
-    });
-
-    test('_computeDescriptionPlaceholder', () => {
-      assert.equal(element._computeDescriptionPlaceholder(true),
-          'No patchset description');
-      assert.equal(element._computeDescriptionPlaceholder(false),
-          'Add patchset description');
-    });
-
-    test('description editing', () => {
-      const putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
-          .returns(Promise.resolve({ok: true}));
-
-      element.changeNum = '42';
-      element.basePatchNum = 'PARENT';
-      element.patchNum = 1;
-
-      element.change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        actions: {},
-        owner: {_account_id: 1},
-      };
-      element.account = {_account_id: 1};
-      element.loggedIn = true;
-
-      flushAsynchronousOperations();
-
-      // The element has a description, so the account chip should be visible
-      // and the description label should not exist.
-      const chip = Polymer.dom(element.root).querySelector('#descriptionChip');
-      let label = Polymer.dom(element.root).querySelector('#descriptionLabel');
-
-      assert.equal(chip.text, 'test');
-      assert.isNotOk(label);
-
-      // Simulate tapping the remove button, but call function directly so that
-      // can determine what happens after the promise is resolved.
-      return element._handleDescriptionRemoved()
-          .then(() => {
-            // The API stub should be called with an empty string for the new
-            // description.
-            assert.equal(putDescStub.lastCall.args[2], '');
-            assert.equal(element.change.revisions.rev1.description, '');
-
-            flushAsynchronousOperations();
-            // The editable label should now be visible and the chip hidden.
-            label = Polymer.dom(element.root).querySelector('#descriptionLabel');
-            assert.isOk(label);
-            assert.equal(getComputedStyle(chip).display, 'none');
-            assert.notEqual(getComputedStyle(label).display, 'none');
-            assert.isFalse(label.readOnly);
-            // Edit the label to have a new value of test2, and save.
-            label.editing = true;
-            label._inputText = 'test2';
-            label._save();
-            flushAsynchronousOperations();
-            // The API stub should be called with an `test2` for the new
-            // description.
-            assert.equal(putDescStub.callCount, 2);
-            assert.equal(putDescStub.lastCall.args[2], 'test2');
-          })
-          .then(() => {
-            flushAsynchronousOperations();
-            // The chip should be visible again, and the label hidden.
-            assert.equal(element.change.revisions.rev1.description, 'test2');
-            assert.equal(getComputedStyle(label).display, 'none');
-            assert.notEqual(getComputedStyle(chip).display, 'none');
-          });
-    });
-
-    test('expandAllDiffs called when expand button clicked', () => {
-      element.shownFileCount = 1;
-      flushAsynchronousOperations();
-      sandbox.stub(element, '_expandAllDiffs');
-      MockInteractions.tap(Polymer.dom(element.root).querySelector(
-          '#expandBtn'));
-      assert.isTrue(element._expandAllDiffs.called);
-    });
-
-    test('collapseAllDiffs called when expand button clicked', () => {
-      element.shownFileCount = 1;
-      flushAsynchronousOperations();
-      sandbox.stub(element, '_collapseAllDiffs');
-      MockInteractions.tap(Polymer.dom(element.root).querySelector(
-          '#collapseBtn'));
-      assert.isTrue(element._collapseAllDiffs.called);
-    });
-
-    test('show/hide diffs disabled for large amounts of files', done => {
-      const computeSpy = sandbox.spy(element, '_fileListActionsVisible');
-      element._files = [];
-      element.changeNum = '42';
-      element.basePatchNum = 'PARENT';
-      element.patchNum = '2';
-      element.shownFileCount = 1;
-      flush(() => {
-        assert.isTrue(computeSpy.lastCall.returnValue);
-        _.times(element._maxFilesForBulkActions + 1, () => {
-          element.shownFileCount = element.shownFileCount + 1;
-        });
-        assert.isFalse(computeSpy.lastCall.returnValue);
-        done();
-      });
-    });
-
-    test('fileViewActions are properly hidden', () => {
-      const actions = element.shadowRoot
-          .querySelector('.fileViewActions');
-      assert.equal(getComputedStyle(actions).display, 'none');
-      element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
-      flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(actions).display, 'none');
-      element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
-      flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(actions).display, 'none');
-      element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(actions).display, 'none');
-    });
-
-    test('expand/collapse buttons are toggled correctly', () => {
-      element.shownFileCount = 10;
-      flushAsynchronousOperations();
-      const expandBtn = element.shadowRoot.querySelector('#expandBtn');
-      const collapseBtn = element.shadowRoot.querySelector('#collapseBtn');
-      assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-      assert.equal(getComputedStyle(collapseBtn).display, 'none');
-      element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
-      flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-      assert.equal(getComputedStyle(collapseBtn).display, 'none');
-      element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(expandBtn).display, 'none');
-      assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
-      element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
-      flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-      assert.equal(getComputedStyle(collapseBtn).display, 'none');
-    });
-
-    test('navigateToChange called when range select changes', () => {
-      const navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-      element.change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev2: {_number: 2},
-          rev1: {_number: 1},
-          rev13: {_number: 13},
-          rev3: {_number: 3},
-        },
-        status: 'NEW',
-        labels: {},
-      };
-      element.basePatchNum = 1;
-      element.patchNum = 2;
-
-      element._handlePatchChange({detail: {basePatchNum: 1, patchNum: 3}});
-      assert.equal(navigateToChangeStub.callCount, 1);
-      assert.isTrue(navigateToChangeStub.lastCall
-          .calledWithExactly(element.change, 3, 1));
-    });
-
-    test('class is applied to file list on old patch set', () => {
-      const allPatchSets = [{num: 4}, {num: 2}, {num: 1}];
-      assert.equal(element._computePatchInfoClass('1', allPatchSets),
-          'patchInfoOldPatchSet');
-      assert.equal(element._computePatchInfoClass('2', allPatchSets),
-          'patchInfoOldPatchSet');
-      assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
-    });
-
-    suite('editMode behavior', () => {
-      setup(() => {
-        element.diffPrefsDisabled = false;
-        element.diffPrefs = {};
-      });
-
-      const isVisible = el => {
-        assert.ok(el);
-        return getComputedStyle(el).getPropertyValue('display') !== 'none';
-      };
-
-      test('patch specific elements', () => {
-        element.editMode = true;
-        sandbox.stub(element, 'computeLatestPatchNum').returns('2');
-        flushAsynchronousOperations();
-
-        assert.isFalse(isVisible(element.$.diffPrefsContainer));
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.descriptionContainer')));
-
-        element.editMode = false;
-        flushAsynchronousOperations();
-
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.descriptionContainer')));
-        assert.isTrue(isVisible(element.$.diffPrefsContainer));
-      });
-
-      test('edit-controls visibility', () => {
-        element.editMode = true;
-        flushAsynchronousOperations();
-        assert.isTrue(isVisible(element.$.editControls.parentElement));
-
-        element.editMode = false;
-        flushAsynchronousOperations();
-        assert.isFalse(isVisible(element.$.editControls.parentElement));
-      });
-
-      test('_computeUploadHelpContainerClass', () => {
-        // Only show the upload helper button when an unmerged change is viewed
-        // by its owner.
-        const accountA = {_account_id: 1};
-        const accountB = {_account_id: 2};
-        assert.notInclude(element._computeUploadHelpContainerClass(
-            {owner: accountA}, accountA), 'hide');
-        assert.include(element._computeUploadHelpContainerClass(
-            {owner: accountA}, accountB), 'hide');
-      });
+  teardown(done => {
+    flush(() => {
+      sandbox.restore();
+      done();
     });
   });
+
+  test('Diff preferences hidden when no prefs or diffPrefsDisabled', () => {
+    element.diffPrefsDisabled = true;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+    element.diffPrefsDisabled = false;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+    element.diffPrefsDisabled = true;
+    element.diffPrefs = {font_size: '12'};
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+    element.diffPrefsDisabled = false;
+    flushAsynchronousOperations();
+    assert.isFalse(element.$.diffPrefsContainer.hidden);
+  });
+
+  test('_computeDescriptionReadOnly', () => {
+    assert.equal(element._computeDescriptionReadOnly(false,
+        {owner: {_account_id: 1}}, {_account_id: 1}), true);
+    assert.equal(element._computeDescriptionReadOnly(true,
+        {owner: {_account_id: 0}}, {_account_id: 1}), true);
+    assert.equal(element._computeDescriptionReadOnly(true,
+        {owner: {_account_id: 1}}, {_account_id: 1}), false);
+  });
+
+  test('_computeDescriptionPlaceholder', () => {
+    assert.equal(element._computeDescriptionPlaceholder(true),
+        'No patchset description');
+    assert.equal(element._computeDescriptionPlaceholder(false),
+        'Add patchset description');
+  });
+
+  test('description editing', () => {
+    const putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
+        .returns(Promise.resolve({ok: true}));
+
+    element.changeNum = '42';
+    element.basePatchNum = 'PARENT';
+    element.patchNum = 1;
+
+    element.change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      actions: {},
+      owner: {_account_id: 1},
+    };
+    element.account = {_account_id: 1};
+    element.loggedIn = true;
+
+    flushAsynchronousOperations();
+
+    // The element has a description, so the account chip should be visible
+    // and the description label should not exist.
+    const chip = dom(element.root).querySelector('#descriptionChip');
+    let label = dom(element.root).querySelector('#descriptionLabel');
+
+    assert.equal(chip.text, 'test');
+    assert.isNotOk(label);
+
+    // Simulate tapping the remove button, but call function directly so that
+    // can determine what happens after the promise is resolved.
+    return element._handleDescriptionRemoved()
+        .then(() => {
+          // The API stub should be called with an empty string for the new
+          // description.
+          assert.equal(putDescStub.lastCall.args[2], '');
+          assert.equal(element.change.revisions.rev1.description, '');
+
+          flushAsynchronousOperations();
+          // The editable label should now be visible and the chip hidden.
+          label = dom(element.root).querySelector('#descriptionLabel');
+          assert.isOk(label);
+          assert.equal(getComputedStyle(chip).display, 'none');
+          assert.notEqual(getComputedStyle(label).display, 'none');
+          assert.isFalse(label.readOnly);
+          // Edit the label to have a new value of test2, and save.
+          label.editing = true;
+          label._inputText = 'test2';
+          label._save();
+          flushAsynchronousOperations();
+          // The API stub should be called with an `test2` for the new
+          // description.
+          assert.equal(putDescStub.callCount, 2);
+          assert.equal(putDescStub.lastCall.args[2], 'test2');
+        })
+        .then(() => {
+          flushAsynchronousOperations();
+          // The chip should be visible again, and the label hidden.
+          assert.equal(element.change.revisions.rev1.description, 'test2');
+          assert.equal(getComputedStyle(label).display, 'none');
+          assert.notEqual(getComputedStyle(chip).display, 'none');
+        });
+  });
+
+  test('expandAllDiffs called when expand button clicked', () => {
+    element.shownFileCount = 1;
+    flushAsynchronousOperations();
+    sandbox.stub(element, '_expandAllDiffs');
+    MockInteractions.tap(dom(element.root).querySelector(
+        '#expandBtn'));
+    assert.isTrue(element._expandAllDiffs.called);
+  });
+
+  test('collapseAllDiffs called when expand button clicked', () => {
+    element.shownFileCount = 1;
+    flushAsynchronousOperations();
+    sandbox.stub(element, '_collapseAllDiffs');
+    MockInteractions.tap(dom(element.root).querySelector(
+        '#collapseBtn'));
+    assert.isTrue(element._collapseAllDiffs.called);
+  });
+
+  test('show/hide diffs disabled for large amounts of files', done => {
+    const computeSpy = sandbox.spy(element, '_fileListActionsVisible');
+    element._files = [];
+    element.changeNum = '42';
+    element.basePatchNum = 'PARENT';
+    element.patchNum = '2';
+    element.shownFileCount = 1;
+    flush(() => {
+      assert.isTrue(computeSpy.lastCall.returnValue);
+      _.times(element._maxFilesForBulkActions + 1, () => {
+        element.shownFileCount = element.shownFileCount + 1;
+      });
+      assert.isFalse(computeSpy.lastCall.returnValue);
+      done();
+    });
+  });
+
+  test('fileViewActions are properly hidden', () => {
+    const actions = element.shadowRoot
+        .querySelector('.fileViewActions');
+    assert.equal(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
+    flushAsynchronousOperations();
+    assert.equal(getComputedStyle(actions).display, 'none');
+  });
+
+  test('expand/collapse buttons are toggled correctly', () => {
+    element.shownFileCount = 10;
+    flushAsynchronousOperations();
+    const expandBtn = element.shadowRoot.querySelector('#expandBtn');
+    const collapseBtn = element.shadowRoot.querySelector('#collapseBtn');
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
+    flushAsynchronousOperations();
+    assert.equal(getComputedStyle(expandBtn).display, 'none');
+    assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+  });
+
+  test('navigateToChange called when range select changes', () => {
+    const navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+    element.change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev2: {_number: 2},
+        rev1: {_number: 1},
+        rev13: {_number: 13},
+        rev3: {_number: 3},
+      },
+      status: 'NEW',
+      labels: {},
+    };
+    element.basePatchNum = 1;
+    element.patchNum = 2;
+
+    element._handlePatchChange({detail: {basePatchNum: 1, patchNum: 3}});
+    assert.equal(navigateToChangeStub.callCount, 1);
+    assert.isTrue(navigateToChangeStub.lastCall
+        .calledWithExactly(element.change, 3, 1));
+  });
+
+  test('class is applied to file list on old patch set', () => {
+    const allPatchSets = [{num: 4}, {num: 2}, {num: 1}];
+    assert.equal(element._computePatchInfoClass('1', allPatchSets),
+        'patchInfoOldPatchSet');
+    assert.equal(element._computePatchInfoClass('2', allPatchSets),
+        'patchInfoOldPatchSet');
+    assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
+  });
+
+  suite('editMode behavior', () => {
+    setup(() => {
+      element.diffPrefsDisabled = false;
+      element.diffPrefs = {};
+    });
+
+    const isVisible = el => {
+      assert.ok(el);
+      return getComputedStyle(el).getPropertyValue('display') !== 'none';
+    };
+
+    test('patch specific elements', () => {
+      element.editMode = true;
+      sandbox.stub(element, 'computeLatestPatchNum').returns('2');
+      flushAsynchronousOperations();
+
+      assert.isFalse(isVisible(element.$.diffPrefsContainer));
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.descriptionContainer')));
+
+      element.editMode = false;
+      flushAsynchronousOperations();
+
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.descriptionContainer')));
+      assert.isTrue(isVisible(element.$.diffPrefsContainer));
+    });
+
+    test('edit-controls visibility', () => {
+      element.editMode = true;
+      flushAsynchronousOperations();
+      assert.isTrue(isVisible(element.$.editControls.parentElement));
+
+      element.editMode = false;
+      flushAsynchronousOperations();
+      assert.isFalse(isVisible(element.$.editControls.parentElement));
+    });
+
+    test('_computeUploadHelpContainerClass', () => {
+      // Only show the upload helper button when an unmerged change is viewed
+      // by its owner.
+      const accountA = {_account_id: 1};
+      const accountB = {_account_id: 2};
+      assert.notInclude(element._computeUploadHelpContainerClass(
+          {owner: accountA}, accountA), 'hide');
+      assert.include(element._computeUploadHelpContainerClass(
+          {owner: accountA}, accountB), 'hide');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index d2f11c2..858fc05 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,1359 +14,1388 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Maximum length for patch set descriptions.
-  const PATCH_DESC_MAX_LENGTH = 500;
-  const WARN_SHOW_ALL_THRESHOLD = 1000;
-  const LOADING_DEBOUNCE_INTERVAL = 100;
+import '../../../behaviors/async-foreach-behavior/async-foreach-behavior.js';
+import '../../../behaviors/dom-util-behavior/dom-util-behavior.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../diff/gr-diff-cursor/gr-diff-cursor.js';
+import '../../diff/gr-diff-host/gr-diff-host.js';
+import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
+import '../../edit/gr-edit-file-controls/gr-edit-file-controls.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-linked-text/gr-linked-text.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
+import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
+import '../gr-file-list-constants.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-file-list_html.js';
 
-  const SIZE_BAR_MAX_WIDTH = 61;
-  const SIZE_BAR_GAP_WIDTH = 1;
-  const SIZE_BAR_MIN_WIDTH = 1.5;
+// Maximum length for patch set descriptions.
+const PATCH_DESC_MAX_LENGTH = 500;
+const WARN_SHOW_ALL_THRESHOLD = 1000;
+const LOADING_DEBOUNCE_INTERVAL = 100;
 
-  const RENDER_TIMING_LABEL = 'FileListRenderTime';
-  const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
-  const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
-  const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
+const SIZE_BAR_MAX_WIDTH = 61;
+const SIZE_BAR_GAP_WIDTH = 1;
+const SIZE_BAR_MIN_WIDTH = 1.5;
 
-  const FileStatus = {
-    A: 'Added',
-    C: 'Copied',
-    D: 'Deleted',
-    M: 'Modified',
-    R: 'Renamed',
-    W: 'Rewritten',
-    U: 'Unchanged',
-  };
+const RENDER_TIMING_LABEL = 'FileListRenderTime';
+const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
+const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
+const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
+
+const FileStatus = {
+  A: 'Added',
+  C: 'Copied',
+  D: 'Deleted',
+  M: 'Modified',
+  R: 'Renamed',
+  W: 'Rewritten',
+  U: 'Unchanged',
+};
+
+/**
+ * @appliesMixin Gerrit.AsyncForeachMixin
+ * @appliesMixin Gerrit.DomUtilMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @appliesMixin Gerrit.PathListMixin
+ * @extends Polymer.Element
+ */
+class GrFileList extends mixinBehaviors( [
+  Gerrit.AsyncForeachBehavior,
+  Gerrit.DomUtilBehavior,
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+  Gerrit.PatchSetBehavior,
+  Gerrit.PathListBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-file-list'; }
+  /**
+   * Fired when a draft refresh should get triggered
+   *
+   * @event reload-drafts
+   */
+
+  static get properties() {
+    return {
+    /** @type {?} */
+      patchRange: Object,
+      patchNum: String,
+      changeNum: String,
+      /** @type {?} */
+      changeComments: Object,
+      drafts: Object,
+      revisions: Array,
+      projectConfig: Object,
+      selectedIndex: {
+        type: Number,
+        notify: true,
+      },
+      keyEventTarget: {
+        type: Object,
+        value() { return document.body; },
+      },
+      /** @type {?} */
+      change: Object,
+      diffViewMode: {
+        type: String,
+        notify: true,
+        observer: '_updateDiffPreferences',
+      },
+      editMode: {
+        type: Boolean,
+        observer: '_editModeChanged',
+      },
+      filesExpanded: {
+        type: String,
+        value: GrFileListConstants.FilesExpandedState.NONE,
+        notify: true,
+      },
+      _filesByPath: Object,
+      _files: {
+        type: Array,
+        observer: '_filesChanged',
+        value() { return []; },
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _reviewed: {
+        type: Array,
+        value() { return []; },
+      },
+      diffPrefs: {
+        type: Object,
+        notify: true,
+        observer: '_updateDiffPreferences',
+      },
+      /** @type {?} */
+      _userPrefs: Object,
+      _showInlineDiffs: Boolean,
+      numFilesShown: {
+        type: Number,
+        notify: true,
+      },
+      /** @type {?} */
+      _patchChange: {
+        type: Object,
+        computed: '_calculatePatchChange(_files)',
+      },
+      fileListIncrement: Number,
+      _hideChangeTotals: {
+        type: Boolean,
+        computed: '_shouldHideChangeTotals(_patchChange)',
+      },
+      _hideBinaryChangeTotals: {
+        type: Boolean,
+        computed: '_shouldHideBinaryChangeTotals(_patchChange)',
+      },
+
+      _shownFiles: {
+        type: Array,
+        computed: '_computeFilesShown(numFilesShown, _files)',
+      },
+
+      /**
+       * The amount of files added to the shown files list the last time it was
+       * updated. This is used for reporting the average render time.
+       */
+      _reportinShownFilesIncrement: Number,
+
+      _expandedFilePaths: {
+        type: Array,
+        value() { return []; },
+      },
+      _displayLine: Boolean,
+      _loading: {
+        type: Boolean,
+        observer: '_loadingChanged',
+      },
+      /** @type {Gerrit.LayoutStats|undefined} */
+      _sizeBarLayout: {
+        type: Object,
+        computed: '_computeSizeBarLayout(_shownFiles.*)',
+      },
+
+      _showSizeBars: {
+        type: Boolean,
+        value: true,
+        computed: '_computeShowSizeBars(_userPrefs)',
+      },
+
+      /** @type {Function} */
+      _cancelForEachDiff: Function,
+
+      _showDynamicColumns: {
+        type: Boolean,
+        computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
+                '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
+      },
+      /** @type {Array<string>} */
+      _dynamicHeaderEndpoints: {
+        type: Array,
+      },
+      /** @type {Array<string>} */
+      _dynamicContentEndpoints: {
+        type: Array,
+      },
+      /** @type {Array<string>} */
+      _dynamicSummaryEndpoints: {
+        type: Array,
+      },
+    };
+  }
+
+  static get observers() {
+    return [
+      '_expandedPathsChanged(_expandedFilePaths.splices)',
+      '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
+        '_loading)',
+    ];
+  }
+
+  get keyBindings() {
+    return {
+      esc: '_handleEscKey',
+    };
+  }
+
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [this.Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
+      [this.Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
+      [this.Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
+      [this.Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
+      [this.Shortcut.NEXT_LINE]: '_handleCursorNext',
+      [this.Shortcut.PREV_LINE]: '_handleCursorPrev',
+      [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [this.Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
+      [this.Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
+      [this.Shortcut.OPEN_FILE]: '_handleOpenFile',
+      [this.Shortcut.NEXT_CHUNK]: '_handleNextChunk',
+      [this.Shortcut.PREV_CHUNK]: '_handlePrevChunk',
+      [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+      [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+
+      // Final two are actually handled by gr-comment-thread.
+      [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('keydown',
+        e => this._scopedKeydownHandler(e));
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    Gerrit.awaitPluginsLoaded().then(() => {
+      this._dynamicHeaderEndpoints = Gerrit._endpoints.getDynamicEndpoints(
+          'change-view-file-list-header');
+      this._dynamicContentEndpoints = Gerrit._endpoints.getDynamicEndpoints(
+          'change-view-file-list-content');
+      this._dynamicSummaryEndpoints = Gerrit._endpoints.getDynamicEndpoints(
+          'change-view-file-list-summary');
+
+      if (this._dynamicHeaderEndpoints.length !==
+          this._dynamicContentEndpoints.length) {
+        console.warn(
+            'Different number of dynamic file-list header and content.');
+      }
+      if (this._dynamicHeaderEndpoints.length !==
+          this._dynamicSummaryEndpoints.length) {
+        console.warn(
+            'Different number of dynamic file-list headers and summary.');
+      }
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this._cancelDiffs();
+  }
 
   /**
-   * @appliesMixin Gerrit.AsyncForeachMixin
-   * @appliesMixin Gerrit.DomUtilMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @appliesMixin Gerrit.PathListMixin
-   * @extends Polymer.Element
+   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+   * events must be scoped to a component level (e.g. `enter`) in order to not
+   * override native browser functionality.
+   *
+   * Context: Issue 7277
    */
-  class GrFileList extends Polymer.mixinBehaviors( [
-    Gerrit.AsyncForeachBehavior,
-    Gerrit.DomUtilBehavior,
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-    Gerrit.PatchSetBehavior,
-    Gerrit.PathListBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-file-list'; }
-    /**
-     * Fired when a draft refresh should get triggered
-     *
-     * @event reload-drafts
-     */
-
-    static get properties() {
-      return {
-      /** @type {?} */
-        patchRange: Object,
-        patchNum: String,
-        changeNum: String,
-        /** @type {?} */
-        changeComments: Object,
-        drafts: Object,
-        revisions: Array,
-        projectConfig: Object,
-        selectedIndex: {
-          type: Number,
-          notify: true,
-        },
-        keyEventTarget: {
-          type: Object,
-          value() { return document.body; },
-        },
-        /** @type {?} */
-        change: Object,
-        diffViewMode: {
-          type: String,
-          notify: true,
-          observer: '_updateDiffPreferences',
-        },
-        editMode: {
-          type: Boolean,
-          observer: '_editModeChanged',
-        },
-        filesExpanded: {
-          type: String,
-          value: GrFileListConstants.FilesExpandedState.NONE,
-          notify: true,
-        },
-        _filesByPath: Object,
-        _files: {
-          type: Array,
-          observer: '_filesChanged',
-          value() { return []; },
-        },
-        _loggedIn: {
-          type: Boolean,
-          value: false,
-        },
-        _reviewed: {
-          type: Array,
-          value() { return []; },
-        },
-        diffPrefs: {
-          type: Object,
-          notify: true,
-          observer: '_updateDiffPreferences',
-        },
-        /** @type {?} */
-        _userPrefs: Object,
-        _showInlineDiffs: Boolean,
-        numFilesShown: {
-          type: Number,
-          notify: true,
-        },
-        /** @type {?} */
-        _patchChange: {
-          type: Object,
-          computed: '_calculatePatchChange(_files)',
-        },
-        fileListIncrement: Number,
-        _hideChangeTotals: {
-          type: Boolean,
-          computed: '_shouldHideChangeTotals(_patchChange)',
-        },
-        _hideBinaryChangeTotals: {
-          type: Boolean,
-          computed: '_shouldHideBinaryChangeTotals(_patchChange)',
-        },
-
-        _shownFiles: {
-          type: Array,
-          computed: '_computeFilesShown(numFilesShown, _files)',
-        },
-
-        /**
-         * The amount of files added to the shown files list the last time it was
-         * updated. This is used for reporting the average render time.
-         */
-        _reportinShownFilesIncrement: Number,
-
-        _expandedFilePaths: {
-          type: Array,
-          value() { return []; },
-        },
-        _displayLine: Boolean,
-        _loading: {
-          type: Boolean,
-          observer: '_loadingChanged',
-        },
-        /** @type {Gerrit.LayoutStats|undefined} */
-        _sizeBarLayout: {
-          type: Object,
-          computed: '_computeSizeBarLayout(_shownFiles.*)',
-        },
-
-        _showSizeBars: {
-          type: Boolean,
-          value: true,
-          computed: '_computeShowSizeBars(_userPrefs)',
-        },
-
-        /** @type {Function} */
-        _cancelForEachDiff: Function,
-
-        _showDynamicColumns: {
-          type: Boolean,
-          computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
-                  '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
-        },
-        /** @type {Array<string>} */
-        _dynamicHeaderEndpoints: {
-          type: Array,
-        },
-        /** @type {Array<string>} */
-        _dynamicContentEndpoints: {
-          type: Array,
-        },
-        /** @type {Array<string>} */
-        _dynamicSummaryEndpoints: {
-          type: Array,
-        },
-      };
-    }
-
-    static get observers() {
-      return [
-        '_expandedPathsChanged(_expandedFilePaths.splices)',
-        '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
-          '_loading)',
-      ];
-    }
-
-    get keyBindings() {
-      return {
-        esc: '_handleEscKey',
-      };
-    }
-
-    keyboardShortcuts() {
-      return {
-        [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
-        [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
-        [this.Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
-        [this.Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
-        [this.Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
-        [this.Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
-        [this.Shortcut.NEXT_LINE]: '_handleCursorNext',
-        [this.Shortcut.PREV_LINE]: '_handleCursorPrev',
-        [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
-        [this.Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
-        [this.Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
-        [this.Shortcut.OPEN_FILE]: '_handleOpenFile',
-        [this.Shortcut.NEXT_CHUNK]: '_handleNextChunk',
-        [this.Shortcut.PREV_CHUNK]: '_handlePrevChunk',
-        [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
-        [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-
-        // Final two are actually handled by gr-comment-thread.
-        [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-        [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-      };
-    }
-
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('keydown',
-          e => this._scopedKeydownHandler(e));
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      Gerrit.awaitPluginsLoaded().then(() => {
-        this._dynamicHeaderEndpoints = Gerrit._endpoints.getDynamicEndpoints(
-            'change-view-file-list-header');
-        this._dynamicContentEndpoints = Gerrit._endpoints.getDynamicEndpoints(
-            'change-view-file-list-content');
-        this._dynamicSummaryEndpoints = Gerrit._endpoints.getDynamicEndpoints(
-            'change-view-file-list-summary');
-
-        if (this._dynamicHeaderEndpoints.length !==
-            this._dynamicContentEndpoints.length) {
-          console.warn(
-              'Different number of dynamic file-list header and content.');
-        }
-        if (this._dynamicHeaderEndpoints.length !==
-            this._dynamicSummaryEndpoints.length) {
-          console.warn(
-              'Different number of dynamic file-list headers and summary.');
-        }
-      });
-    }
-
-    /** @override */
-    detached() {
-      super.detached();
-      this._cancelDiffs();
-    }
-
-    /**
-     * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
-     * events must be scoped to a component level (e.g. `enter`) in order to not
-     * override native browser functionality.
-     *
-     * Context: Issue 7277
-     */
-    _scopedKeydownHandler(e) {
-      if (e.keyCode === 13) {
-        // Enter.
-        this._handleOpenFile(e);
-      }
-    }
-
-    reload() {
-      if (!this.changeNum || !this.patchRange.patchNum) {
-        return Promise.resolve();
-      }
-
-      this._loading = true;
-
-      this.collapseAllDiffs();
-      const promises = [];
-
-      promises.push(this._getFiles().then(filesByPath => {
-        this._filesByPath = filesByPath;
-      }));
-      promises.push(this._getLoggedIn()
-          .then(loggedIn => this._loggedIn = loggedIn)
-          .then(loggedIn => {
-            if (!loggedIn) { return; }
-
-            return this._getReviewedFiles().then(reviewed => {
-              this._reviewed = reviewed;
-            });
-          }));
-
-      promises.push(this._getDiffPreferences().then(prefs => {
-        this.diffPrefs = prefs;
-      }));
-
-      promises.push(this._getPreferences().then(prefs => {
-        this._userPrefs = prefs;
-      }));
-
-      return Promise.all(promises).then(() => {
-        this._loading = false;
-        this._detectChromiteButler();
-        this.$.reporting.fileListDisplayed();
-      });
-    }
-
-    _detectChromiteButler() {
-      const hasButler = !!document.getElementById('butler-suggested-owners');
-      if (hasButler) {
-        this.$.reporting.reportExtension('butler');
-      }
-    }
-
-    get diffs() {
-      const diffs = Polymer.dom(this.root).querySelectorAll('gr-diff-host');
-      // It is possible that a bogus diff element is hanging around invisibly
-      // from earlier with a different patch set choice and associated with a
-      // different entry in the files array. So filter on visible items only.
-      return Array.from(diffs).filter(
-          el => !!el && !!el.style && el.style.display !== 'none');
-    }
-
-    openDiffPrefs() {
-      this.$.diffPreferencesDialog.open();
-    }
-
-    _calculatePatchChange(files) {
-      const magicFilesExcluded = files.filter(files =>
-        !this.isMagicPath(files.__path)
-      );
-
-      return magicFilesExcluded.reduce((acc, obj) => {
-        const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
-        const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
-        const total_size = (obj.size && obj.binary) ? obj.size : 0;
-        const size_delta_inserted =
-            obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
-        const size_delta_deleted =
-            obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
-
-        return {
-          inserted: acc.inserted + inserted,
-          deleted: acc.deleted + deleted,
-          size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
-          size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
-          total_size: acc.total_size + total_size,
-        };
-      }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
-        size_delta_deleted: 0, total_size: 0});
-    }
-
-    _getDiffPreferences() {
-      return this.$.restAPI.getDiffPreferences();
-    }
-
-    _getPreferences() {
-      return this.$.restAPI.getPreferences();
-    }
-
-    _togglePathExpanded(path) {
-      // Is the path in the list of expanded diffs? IF so remove it, otherwise
-      // add it to the list.
-      const pathIndex = this._expandedFilePaths.indexOf(path);
-      if (pathIndex === -1) {
-        this.push('_expandedFilePaths', path);
-      } else {
-        this.splice('_expandedFilePaths', pathIndex, 1);
-      }
-    }
-
-    _togglePathExpandedByIndex(index) {
-      this._togglePathExpanded(this._files[index].__path);
-    }
-
-    _updateDiffPreferences() {
-      if (!this.diffs.length) { return; }
-      // Re-render all expanded diffs sequentially.
-      this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);
-      this._renderInOrder(this._expandedFilePaths, this.diffs,
-          this._expandedFilePaths.length);
-    }
-
-    _forEachDiff(fn) {
-      const diffs = this.diffs;
-      for (let i = 0; i < diffs.length; i++) {
-        fn(diffs[i]);
-      }
-    }
-
-    expandAllDiffs() {
-      this._showInlineDiffs = true;
-
-      // Find the list of paths that are in the file list, but not in the
-      // expanded list.
-      const newPaths = [];
-      let path;
-      for (let i = 0; i < this._shownFiles.length; i++) {
-        path = this._shownFiles[i].__path;
-        if (!this._expandedFilePaths.includes(path)) {
-          newPaths.push(path);
-        }
-      }
-
-      this.splice(...['_expandedFilePaths', 0, 0].concat(newPaths));
-    }
-
-    collapseAllDiffs() {
-      this._showInlineDiffs = false;
-      this._expandedFilePaths = [];
-      this.filesExpanded = this._computeExpandedFiles(
-          this._expandedFilePaths.length, this._files.length);
-      this.$.diffCursor.handleDiffUpdate();
-    }
-
-    /**
-     * Computes a string with the number of comments and unresolved comments.
-     *
-     * @param {!Object} changeComments
-     * @param {!Object} patchRange
-     * @param {string} path
-     * @return {string}
-     */
-    _computeCommentsString(changeComments, patchRange, path) {
-      const unresolvedCount =
-          changeComments.computeUnresolvedNum(patchRange.basePatchNum, path) +
-          changeComments.computeUnresolvedNum(patchRange.patchNum, path);
-      const commentCount =
-          changeComments.computeCommentCount(patchRange.basePatchNum, path) +
-          changeComments.computeCommentCount(patchRange.patchNum, path);
-      const commentString = GrCountStringFormatter.computePluralString(
-          commentCount, 'comment');
-      const unresolvedString = GrCountStringFormatter.computeString(
-          unresolvedCount, 'unresolved');
-
-      return commentString +
-          // Add a space if both comments and unresolved
-          (commentString && unresolvedString ? ' ' : '') +
-          // Add parentheses around unresolved if it exists.
-          (unresolvedString ? `(${unresolvedString})` : '');
-    }
-
-    /**
-     * Computes a string with the number of drafts.
-     *
-     * @param {!Object} changeComments
-     * @param {!Object} patchRange
-     * @param {string} path
-     * @return {string}
-     */
-    _computeDraftsString(changeComments, patchRange, path) {
-      const draftCount =
-          changeComments.computeDraftCount(patchRange.basePatchNum, path) +
-          changeComments.computeDraftCount(patchRange.patchNum, path);
-      return GrCountStringFormatter.computePluralString(draftCount, 'draft');
-    }
-
-    /**
-     * Computes a shortened string with the number of drafts.
-     *
-     * @param {!Object} changeComments
-     * @param {!Object} patchRange
-     * @param {string} path
-     * @return {string}
-     */
-    _computeDraftsStringMobile(changeComments, patchRange, path) {
-      const draftCount =
-          changeComments.computeDraftCount(patchRange.basePatchNum, path) +
-          changeComments.computeDraftCount(patchRange.patchNum, path);
-      return GrCountStringFormatter.computeShortString(draftCount, 'd');
-    }
-
-    /**
-     * Computes a shortened string with the number of comments.
-     *
-     * @param {!Object} changeComments
-     * @param {!Object} patchRange
-     * @param {string} path
-     * @return {string}
-     */
-    _computeCommentsStringMobile(changeComments, patchRange, path) {
-      const commentCount =
-          changeComments.computeCommentCount(patchRange.basePatchNum, path) +
-          changeComments.computeCommentCount(patchRange.patchNum, path);
-      return GrCountStringFormatter.computeShortString(commentCount, 'c');
-    }
-
-    /**
-     * @param {string} path
-     * @param {boolean=} opt_reviewed
-     */
-    _reviewFile(path, opt_reviewed) {
-      if (this.editMode) { return; }
-      const index = this._files.findIndex(file => file.__path === path);
-      const reviewed = opt_reviewed || !this._files[index].isReviewed;
-
-      this.set(['_files', index, 'isReviewed'], reviewed);
-      if (index < this._shownFiles.length) {
-        this.notifyPath(`_shownFiles.${index}.isReviewed`);
-      }
-
-      this._saveReviewedState(path, reviewed);
-    }
-
-    _saveReviewedState(path, reviewed) {
-      return this.$.restAPI.saveFileReviewed(this.changeNum,
-          this.patchRange.patchNum, path, reviewed);
-    }
-
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    }
-
-    _getReviewedFiles() {
-      if (this.editMode) { return Promise.resolve([]); }
-      return this.$.restAPI.getReviewedFiles(this.changeNum,
-          this.patchRange.patchNum);
-    }
-
-    _getFiles() {
-      return this.$.restAPI.getChangeOrEditFiles(
-          this.changeNum, this.patchRange);
-    }
-
-    /**
-     * The closure compiler doesn't realize this.specialFilePathCompare is
-     * valid.
-     *
-     * @suppress {checkTypes}
-     */
-    _normalizeChangeFilesResponse(response) {
-      if (!response) { return []; }
-      const paths = Object.keys(response).sort(this.specialFilePathCompare);
-      const files = [];
-      for (let i = 0; i < paths.length; i++) {
-        const info = response[paths[i]];
-        info.__path = paths[i];
-        info.lines_inserted = info.lines_inserted || 0;
-        info.lines_deleted = info.lines_deleted || 0;
-        files.push(info);
-      }
-      return files;
-    }
-
-    /**
-     * Handle all events from the file list dom-repeat so event handleers don't
-     * have to get registered for potentially very long lists.
-     */
-    _handleFileListClick(e) {
-      // Traverse upwards to find the row element if the target is not the row.
-      let row = e.target;
-      while (!row.classList.contains('row') && row.parentElement) {
-        row = row.parentElement;
-      }
-
-      const path = row.dataset.path;
-      // Handle checkbox mark as reviewed.
-      if (e.target.classList.contains('markReviewed')) {
-        e.preventDefault();
-        return this._reviewFile(path);
-      }
-
-      // If a path cannot be interpreted from the click target (meaning it's not
-      // somewhere in the row, e.g. diff content) or if the user clicked the
-      // link, defer to the native behavior.
-      if (!path || this.descendedFromClass(e.target, 'pathLink')) { return; }
-
-      // Disregard the event if the click target is in the edit controls.
-      if (this.descendedFromClass(e.target, 'editFileControls')) { return; }
-
-      e.preventDefault();
-      this._togglePathExpanded(path);
-    }
-
-    _handleLeftPane(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
-        return;
-      }
-
-      e.preventDefault();
-      this.$.diffCursor.moveLeft();
-    }
-
-    _handleRightPane(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
-        return;
-      }
-
-      e.preventDefault();
-      this.$.diffCursor.moveRight();
-    }
-
-    _handleToggleInlineDiff(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e) ||
-          this.$.fileCursor.index === -1) { return; }
-
-      e.preventDefault();
-      this._togglePathExpandedByIndex(this.$.fileCursor.index);
-    }
-
-    _handleToggleAllInlineDiffs(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      e.preventDefault();
-      this._toggleInlineDiffs();
-    }
-
-    _handleCursorNext(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-        return;
-      }
-
-      if (this._showInlineDiffs) {
-        e.preventDefault();
-        this.$.diffCursor.moveDown();
-        this._displayLine = true;
-      } else {
-        // Down key
-        if (this.getKeyboardEvent(e).keyCode === 40) { return; }
-        e.preventDefault();
-        this.$.fileCursor.next();
-        this.selectedIndex = this.$.fileCursor.index;
-      }
-    }
-
-    _handleCursorPrev(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-        return;
-      }
-
-      if (this._showInlineDiffs) {
-        e.preventDefault();
-        this.$.diffCursor.moveUp();
-        this._displayLine = true;
-      } else {
-        // Up key
-        if (this.getKeyboardEvent(e).keyCode === 38) { return; }
-        e.preventDefault();
-        this.$.fileCursor.previous();
-        this.selectedIndex = this.$.fileCursor.index;
-      }
-    }
-
-    _handleNewComment(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-      e.preventDefault();
-      this.$.diffCursor.createCommentInPlace();
-    }
-
-    _handleOpenLastFile(e) {
-      // Check for meta key to avoid overriding native chrome shortcut.
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.getKeyboardEvent(e).metaKey) { return; }
-
-      e.preventDefault();
-      this._openSelectedFile(this._files.length - 1);
-    }
-
-    _handleOpenFirstFile(e) {
-      // Check for meta key to avoid overriding native chrome shortcut.
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.getKeyboardEvent(e).metaKey) { return; }
-
-      e.preventDefault();
-      this._openSelectedFile(0);
-    }
-
-    _handleOpenFile(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-      e.preventDefault();
-
-      if (this._showInlineDiffs) {
-        this._openCursorFile();
-        return;
-      }
-
-      this._openSelectedFile();
-    }
-
-    _handleNextChunk(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
-          this._noDiffsExpanded()) {
-        return;
-      }
-
-      e.preventDefault();
-      if (this.isModifierPressed(e, 'shiftKey')) {
-        this.$.diffCursor.moveToNextCommentThread();
-      } else {
-        this.$.diffCursor.moveToNextChunk();
-      }
-    }
-
-    _handlePrevChunk(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
-          this._noDiffsExpanded()) {
-        return;
-      }
-
-      e.preventDefault();
-      if (this.isModifierPressed(e, 'shiftKey')) {
-        this.$.diffCursor.moveToPreviousCommentThread();
-      } else {
-        this.$.diffCursor.moveToPreviousChunk();
-      }
-    }
-
-    _handleToggleFileReviewed(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-        return;
-      }
-
-      e.preventDefault();
-      if (!this._files[this.$.fileCursor.index]) { return; }
-      this._reviewFile(this._files[this.$.fileCursor.index].__path);
-    }
-
-    _handleToggleLeftPane(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      e.preventDefault();
-      this._forEachDiff(diff => {
-        diff.toggleLeftDiff();
-      });
-    }
-
-    _toggleInlineDiffs() {
-      if (this._showInlineDiffs) {
-        this.collapseAllDiffs();
-      } else {
-        this.expandAllDiffs();
-      }
-    }
-
-    _openCursorFile() {
-      const diff = this.$.diffCursor.getTargetDiffElement();
-      Gerrit.Nav.navigateToDiff(this.change, diff.path,
-          diff.patchRange.patchNum, this.patchRange.basePatchNum);
-    }
-
-    /**
-     * @param {number=} opt_index
-     */
-    _openSelectedFile(opt_index) {
-      if (opt_index != null) {
-        this.$.fileCursor.setCursorAtIndex(opt_index);
-      }
-      if (!this._files[this.$.fileCursor.index]) { return; }
-      Gerrit.Nav.navigateToDiff(this.change,
-          this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
-          this.patchRange.basePatchNum);
-    }
-
-    _addDraftAtTarget() {
-      const diff = this.$.diffCursor.getTargetDiffElement();
-      const target = this.$.diffCursor.getTargetLineElement();
-      if (diff && target) {
-        diff.addDraftAtLine(target);
-      }
-    }
-
-    _shouldHideChangeTotals(_patchChange) {
-      return _patchChange.inserted === 0 && _patchChange.deleted === 0;
-    }
-
-    _shouldHideBinaryChangeTotals(_patchChange) {
-      return _patchChange.size_delta_inserted === 0 &&
-          _patchChange.size_delta_deleted === 0;
-    }
-
-    _computeFileStatus(status) {
-      return status || 'M';
-    }
-
-    _computeDiffURL(change, patchRange, path, editMode) {
-      // Polymer 2: check for undefined
-      if ([change, patchRange, path, editMode]
-          .some(arg => arg === undefined)) {
-        return;
-      }
-      // TODO(kaspern): Fix editing for commit messages and merge lists.
-      if (editMode && path !== this.COMMIT_MESSAGE_PATH &&
-          path !== this.MERGE_LIST_PATH) {
-        return Gerrit.Nav.getEditUrlForDiff(change, path, patchRange.patchNum,
-            patchRange.basePatchNum);
-      }
-      return Gerrit.Nav.getUrlForDiff(change, path, patchRange.patchNum,
-          patchRange.basePatchNum);
-    }
-
-    _formatBytes(bytes) {
-      if (bytes == 0) return '+/-0 B';
-      const bits = 1024;
-      const decimals = 1;
-      const sizes =
-          ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
-      const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
-      const prepend = bytes > 0 ? '+' : '';
-      return prepend + parseFloat((bytes / Math.pow(bits, exponent))
-          .toFixed(decimals)) + ' ' + sizes[exponent];
-    }
-
-    _formatPercentage(size, delta) {
-      const oldSize = size - delta;
-
-      if (oldSize === 0) { return ''; }
-
-      const percentage = Math.round(Math.abs(delta * 100 / oldSize));
-      return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
-    }
-
-    _computeBinaryClass(delta) {
-      if (delta === 0) { return; }
-      return delta >= 0 ? 'added' : 'removed';
-    }
-
-    /**
-     * @param {string} baseClass
-     * @param {string} path
-     */
-    _computeClass(baseClass, path) {
-      const classes = [];
-      if (baseClass) {
-        classes.push(baseClass);
-      }
-      if (path === this.COMMIT_MESSAGE_PATH || path === this.MERGE_LIST_PATH) {
-        classes.push('invisible');
-      }
-      return classes.join(' ');
-    }
-
-    _computePathClass(path, expandedFilesRecord) {
-      return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
-    }
-
-    _computeShowHideIcon(path, expandedFilesRecord) {
-      return this._isFileExpanded(path, expandedFilesRecord) ?
-        'gr-icons:expand-less' : 'gr-icons:expand-more';
-    }
-
-    _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
-      // Polymer 2: check for undefined
-      if ([
-        filesByPath,
-        changeComments,
-        patchRange,
-        reviewed,
-        loading,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      // Await all promises resolving from reload. @See Issue 9057
-      if (loading || !changeComments) { return; }
-
-      const commentedPaths = changeComments.getPaths(patchRange);
-      const files = Object.assign({}, filesByPath);
-      Object.keys(commentedPaths).forEach(commentedPath => {
-        if (files.hasOwnProperty(commentedPath)) { return; }
-        files[commentedPath] = {status: 'U'};
-      });
-      const reviewedSet = new Set(reviewed || []);
-      for (const filePath in files) {
-        if (!files.hasOwnProperty(filePath)) { continue; }
-        files[filePath].isReviewed = reviewedSet.has(filePath);
-      }
-
-      this._files = this._normalizeChangeFilesResponse(files);
-    }
-
-    _computeFilesShown(numFilesShown, files) {
-      // Polymer 2: check for undefined
-      if ([numFilesShown, files].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      const previousNumFilesShown = this._shownFiles ?
-        this._shownFiles.length : 0;
-
-      const filesShown = files.slice(0, numFilesShown);
-      this.fire('files-shown-changed', {length: filesShown.length});
-
-      // Start the timer for the rendering work hwere because this is where the
-      // _shownFiles property is being set, and _shownFiles is used in the
-      // dom-repeat binding.
-      this.$.reporting.time(RENDER_TIMING_LABEL);
-
-      // How many more files are being shown (if it's an increase).
-      this._reportinShownFilesIncrement =
-          Math.max(0, filesShown.length - previousNumFilesShown);
-
-      return filesShown;
-    }
-
-    _updateDiffCursor() {
-      // Overwrite the cursor's list of diffs:
-      this.$.diffCursor.splice(
-          ...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
-    }
-
-    _filesChanged() {
-      if (this._files && this._files.length > 0) {
-        Polymer.dom.flush();
-        const files = Array.from(
-            Polymer.dom(this.root).querySelectorAll('.file-row'));
-        this.$.fileCursor.stops = files;
-        this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
-      }
-    }
-
-    _incrementNumFilesShown() {
-      this.numFilesShown += this.fileListIncrement;
-    }
-
-    _computeFileListControlClass(numFilesShown, files) {
-      return numFilesShown >= files.length ? 'invisible' : '';
-    }
-
-    _computeIncrementText(numFilesShown, files) {
-      if (!files) { return ''; }
-      const text =
-          Math.min(this.fileListIncrement, files.length - numFilesShown);
-      return 'Show ' + text + ' more';
-    }
-
-    _computeShowAllText(files) {
-      if (!files) { return ''; }
-      return 'Show all ' + files.length + ' files';
-    }
-
-    _computeWarnShowAll(files) {
-      return files.length > WARN_SHOW_ALL_THRESHOLD;
-    }
-
-    _computeShowAllWarning(files) {
-      if (!this._computeWarnShowAll(files)) { return ''; }
-      return 'Warning: showing all ' + files.length +
-          ' files may take several seconds.';
-    }
-
-    _showAllFiles() {
-      this.numFilesShown = this._files.length;
-    }
-
-    _computePatchSetDescription(revisions, patchNum) {
-      // Polymer 2: check for undefined
-      if ([revisions, patchNum].some(arg => arg === undefined)) {
-        return '';
-      }
-
-      const rev = this.getRevisionByPatchNum(revisions, patchNum);
-      return (rev && rev.description) ?
-        rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
-    }
-
-    /**
-     * Get a descriptive label for use in the status indicator's tooltip and
-     * ARIA label.
-     *
-     * @param {string} status
-     * @return {string}
-     */
-    _computeFileStatusLabel(status) {
-      const statusCode = this._computeFileStatus(status);
-      return FileStatus.hasOwnProperty(statusCode) ?
-        FileStatus[statusCode] : 'Status Unknown';
-    }
-
-    _isFileExpanded(path, expandedFilesRecord) {
-      return expandedFilesRecord.base.includes(path);
-    }
-
-    _onLineSelected(e, detail) {
-      this.$.diffCursor.moveToLineNumber(detail.number, detail.side,
-          detail.path);
-    }
-
-    _computeExpandedFiles(expandedCount, totalCount) {
-      if (expandedCount === 0) {
-        return GrFileListConstants.FilesExpandedState.NONE;
-      } else if (expandedCount === totalCount) {
-        return GrFileListConstants.FilesExpandedState.ALL;
-      }
-      return GrFileListConstants.FilesExpandedState.SOME;
-    }
-
-    /**
-     * Handle splices to the list of expanded file paths. If there are any new
-     * entries in the expanded list, then render each diff corresponding in
-     * order by waiting for the previous diff to finish before starting the next
-     * one.
-     *
-     * @param {!Array} record The splice record in the expanded paths list.
-     */
-    _expandedPathsChanged(record) {
-      // Clear content for any diffs that are not open so if they get re-opened
-      // the stale content does not flash before it is cleared and reloaded.
-      const collapsedDiffs = this.diffs.filter(diff =>
-        this._expandedFilePaths.indexOf(diff.path) === -1);
-      this._clearCollapsedDiffs(collapsedDiffs);
-
-      if (!record) { return; } // Happens after "Collapse all" clicked.
-
-      this.filesExpanded = this._computeExpandedFiles(
-          this._expandedFilePaths.length, this._files.length);
-
-      // Find the paths introduced by the new index splices:
-      const newPaths = record.indexSplices
-          .map(splice => splice.object.slice(
-              splice.index, splice.index + splice.addedCount))
-          .reduce((acc, paths) => acc.concat(paths), []);
-
-      // Required so that the newly created diff view is included in this.diffs.
-      Polymer.dom.flush();
-
-      this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);
-
-      if (newPaths.length) {
-        this._renderInOrder(newPaths, this.diffs, newPaths.length);
-      }
-
-      this._updateDiffCursor();
-      this.$.diffCursor.handleDiffUpdate();
-    }
-
-    _clearCollapsedDiffs(collapsedDiffs) {
-      for (const diff of collapsedDiffs) {
-        diff.cancel();
-        diff.clearDiffContent();
-      }
-    }
-
-    /**
-     * Given an array of paths and a NodeList of diff elements, render the diff
-     * for each path in order, awaiting the previous render to complete before
-     * continung.
-     *
-     * @param  {!Array<string>} paths
-     * @param  {!NodeList<!Object>} diffElements (GrDiffHostElement)
-     * @param  {number} initialCount The total number of paths in the pass. This
-     *   is used to generate log messages.
-     * @return {!Promise}
-     */
-    _renderInOrder(paths, diffElements, initialCount) {
-      let iter = 0;
-
-      return (new Promise(resolve => {
-        this.fire('reload-drafts', {resolve});
-      })).then(() => this.asyncForeach(paths, (path, cancel) => {
-        this._cancelForEachDiff = cancel;
-
-        iter++;
-        console.log('Expanding diff', iter, 'of', initialCount, ':',
-            path);
-        const diffElem = this._findDiffByPath(path, diffElements);
-        if (!diffElem) {
-          console.warn(`Did not find <gr-diff-host> element for ${path}`);
-          return Promise.resolve();
-        }
-        diffElem.comments = this.changeComments.getCommentsBySideForPath(
-            path, this.patchRange, this.projectConfig);
-        const promises = [diffElem.reload()];
-        if (this._loggedIn && !this.diffPrefs.manual_review) {
-          promises.push(this._reviewFile(path, true));
-        }
-        return Promise.all(promises);
-      }).then(() => {
-        this._cancelForEachDiff = null;
-        this._nextRenderParams = null;
-        console.log('Finished expanding', initialCount, 'diff(s)');
-        this.$.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
-            EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
-        this.$.diffCursor.handleDiffUpdate();
-      }));
-    }
-
-    /** Cancel the rendering work of every diff in the list */
-    _cancelDiffs() {
-      if (this._cancelForEachDiff) { this._cancelForEachDiff(); }
-      this._forEachDiff(d => d.cancel());
-    }
-
-    /**
-     * In the given NodeList of diff elements, find the diff for the given path.
-     *
-     * @param  {string} path
-     * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
-     * @return {!Object|undefined} (GrDiffElement)
-     */
-    _findDiffByPath(path, diffElements) {
-      for (let i = 0; i < diffElements.length; i++) {
-        if (diffElements[i].path === path) {
-          return diffElements[i];
-        }
-      }
-    }
-
-    /**
-     * Reset the comments of a modified thread
-     *
-     * @param  {string} rootId
-     * @param  {string} path
-     */
-    reloadCommentsForThreadWithRootId(rootId, path) {
-      // Don't bother continuing if we already know that the path that contains
-      // the updated comment thread is not expanded.
-      if (!this._expandedFilePaths.includes(path)) { return; }
-      const diff = this.diffs.find(d => d.path === path);
-
-      const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
-      if (!threadEl) { return; }
-
-      const newComments = this.changeComments.getCommentsForThread(rootId);
-
-      // If newComments is null, it means that a single draft was
-      // removed from a thread in the thread view, and the thread should
-      // no longer exist. Remove the existing thread element in the diff
-      // view.
-      if (!newComments) {
-        threadEl.fireRemoveSelf();
-        return;
-      }
-
-      // Comments are not returned with the commentSide attribute from
-      // the api, but it's necessary to be stored on the diff's
-      // comments due to use in the _handleCommentUpdate function.
-      // The comment thread already has a side associated with it, so
-      // set the comment's side to match.
-      threadEl.comments = newComments.map(c => Object.assign(
-          c, {__commentSide: threadEl.commentSide}
-      ));
-      Polymer.dom.flush();
-      return;
-    }
-
-    _handleEscKey(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-      e.preventDefault();
-      this._displayLine = false;
-    }
-
-    /**
-     * Update the loading class for the file list rows. The update is inside a
-     * debouncer so that the file list doesn't flash gray when the API requests
-     * are reasonably fast.
-     *
-     * @param {boolean} loading
-     */
-    _loadingChanged(loading) {
-      this.debounce('loading-change', () => {
-        // Only show set the loading if there have been files loaded to show. In
-        // this way, the gray loading style is not shown on initial loads.
-        this.classList.toggle('loading', loading && !!this._files.length);
-      }, LOADING_DEBOUNCE_INTERVAL);
-    }
-
-    _editModeChanged(editMode) {
-      this.classList.toggle('editMode', editMode);
-    }
-
-    _computeReviewedClass(isReviewed) {
-      return isReviewed ? 'isReviewed' : '';
-    }
-
-    _computeReviewedText(isReviewed) {
-      return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
-    }
-
-    /**
-     * Given a file path, return whether that path should have visible size bars
-     * and be included in the size bars calculation.
-     *
-     * @param {string} path
-     * @return {boolean}
-     */
-    _showBarsForPath(path) {
-      return path !== this.COMMIT_MESSAGE_PATH && path !== this.MERGE_LIST_PATH;
-    }
-
-    /**
-     * Compute size bar layout values from the file list.
-     *
-     * @return {Gerrit.LayoutStats|undefined}
-     *
-     */
-    _computeSizeBarLayout(shownFilesRecord) {
-      if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
-      const stats = {
-        maxInserted: 0,
-        maxDeleted: 0,
-        maxAdditionWidth: 0,
-        maxDeletionWidth: 0,
-        deletionOffset: 0,
-      };
-      shownFilesRecord.base
-          .filter(f => this._showBarsForPath(f.__path))
-          .forEach(f => {
-            if (f.lines_inserted) {
-              stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
-            }
-            if (f.lines_deleted) {
-              stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
-            }
-          });
-      const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
-      if (!isNaN(ratio)) {
-        stats.maxAdditionWidth =
-            (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
-        stats.maxDeletionWidth =
-            SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
-        stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
-      }
-      return stats;
-    }
-
-    /**
-     * Get the width of the addition bar for a file.
-     *
-     * @param {Object} file
-     * @param {Gerrit.LayoutStats} stats
-     * @return {number}
-     */
-    _computeBarAdditionWidth(file, stats) {
-      if (stats.maxInserted === 0 ||
-          !file.lines_inserted ||
-          !this._showBarsForPath(file.__path)) {
-        return 0;
-      }
-      const width =
-          stats.maxAdditionWidth * file.lines_inserted / stats.maxInserted;
-      return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
-    }
-
-    /**
-     * Get the x-offset of the addition bar for a file.
-     *
-     * @param {Object} file
-     * @param {Gerrit.LayoutStats} stats
-     * @return {number}
-     */
-    _computeBarAdditionX(file, stats) {
-      return stats.maxAdditionWidth -
-          this._computeBarAdditionWidth(file, stats);
-    }
-
-    /**
-     * Get the width of the deletion bar for a file.
-     *
-     * @param {Object} file
-     * @param {Gerrit.LayoutStats} stats
-     * @return {number}
-     */
-    _computeBarDeletionWidth(file, stats) {
-      if (stats.maxDeleted === 0 ||
-          !file.lines_deleted ||
-          !this._showBarsForPath(file.__path)) {
-        return 0;
-      }
-      const width =
-          stats.maxDeletionWidth * file.lines_deleted / stats.maxDeleted;
-      return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
-    }
-
-    /**
-     * Get the x-offset of the deletion bar for a file.
-     *
-     * @param {Gerrit.LayoutStats} stats
-     *
-     * @return {number}
-     */
-    _computeBarDeletionX(stats) {
-      return stats.deletionOffset;
-    }
-
-    _computeShowSizeBars(userPrefs) {
-      return !!userPrefs.size_bar_in_change_table;
-    }
-
-    _computeSizeBarsClass(showSizeBars, path) {
-      let hideClass = '';
-      if (!showSizeBars) {
-        hideClass = 'hide';
-      } else if (!this._showBarsForPath(path)) {
-        hideClass = 'invisible';
-      }
-      return `sizeBars desktop ${hideClass}`;
-    }
-
-    /**
-     * Shows registered dynamic columns iff the 'header', 'content' and
-     * 'summary' endpoints are regiestered the exact same number of times.
-     * Ideally, there should be a better way to enforce the expectation of the
-     * dependencies between dynamic endpoints.
-     */
-    _computeShowDynamicColumns(
-        headerEndpoints, contentEndpoints, summaryEndpoints) {
-      return headerEndpoints && contentEndpoints && summaryEndpoints &&
-             headerEndpoints.length === contentEndpoints.length &&
-             headerEndpoints.length === summaryEndpoints.length;
-    }
-
-    /**
-     * Returns true if none of the inline diffs have been expanded.
-     *
-     * @return {boolean}
-     */
-    _noDiffsExpanded() {
-      return this.filesExpanded === GrFileListConstants.FilesExpandedState.NONE;
-    }
-
-    /**
-     * Method to call via binding when each file list row is rendered. This
-     * allows approximate detection of when the dom-repeat has completed
-     * rendering.
-     *
-     * @param {number} index The index of the row being rendered.
-     * @return {string} an empty string.
-     */
-    _reportRenderedRow(index) {
-      if (index === this._shownFiles.length - 1) {
-        this.async(() => {
-          this.$.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
-              RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
-        }, 1);
-      }
-      return '';
-    }
-
-    _reviewedTitle(reviewed) {
-      if (reviewed) {
-        return 'Mark as not reviewed (shortcut: r)';
-      }
-
-      return 'Mark as reviewed (shortcut: r)';
-    }
-
-    _handleReloadingDiffPreference() {
-      this._getDiffPreferences().then(prefs => {
-        this.diffPrefs = prefs;
-      });
+  _scopedKeydownHandler(e) {
+    if (e.keyCode === 13) {
+      // Enter.
+      this._handleOpenFile(e);
     }
   }
 
-  customElements.define(GrFileList.is, GrFileList);
-})();
+  reload() {
+    if (!this.changeNum || !this.patchRange.patchNum) {
+      return Promise.resolve();
+    }
+
+    this._loading = true;
+
+    this.collapseAllDiffs();
+    const promises = [];
+
+    promises.push(this._getFiles().then(filesByPath => {
+      this._filesByPath = filesByPath;
+    }));
+    promises.push(this._getLoggedIn()
+        .then(loggedIn => this._loggedIn = loggedIn)
+        .then(loggedIn => {
+          if (!loggedIn) { return; }
+
+          return this._getReviewedFiles().then(reviewed => {
+            this._reviewed = reviewed;
+          });
+        }));
+
+    promises.push(this._getDiffPreferences().then(prefs => {
+      this.diffPrefs = prefs;
+    }));
+
+    promises.push(this._getPreferences().then(prefs => {
+      this._userPrefs = prefs;
+    }));
+
+    return Promise.all(promises).then(() => {
+      this._loading = false;
+      this._detectChromiteButler();
+      this.$.reporting.fileListDisplayed();
+    });
+  }
+
+  _detectChromiteButler() {
+    const hasButler = !!document.getElementById('butler-suggested-owners');
+    if (hasButler) {
+      this.$.reporting.reportExtension('butler');
+    }
+  }
+
+  get diffs() {
+    const diffs = dom(this.root).querySelectorAll('gr-diff-host');
+    // It is possible that a bogus diff element is hanging around invisibly
+    // from earlier with a different patch set choice and associated with a
+    // different entry in the files array. So filter on visible items only.
+    return Array.from(diffs).filter(
+        el => !!el && !!el.style && el.style.display !== 'none');
+  }
+
+  openDiffPrefs() {
+    this.$.diffPreferencesDialog.open();
+  }
+
+  _calculatePatchChange(files) {
+    const magicFilesExcluded = files.filter(files =>
+      !this.isMagicPath(files.__path)
+    );
+
+    return magicFilesExcluded.reduce((acc, obj) => {
+      const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
+      const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+      const total_size = (obj.size && obj.binary) ? obj.size : 0;
+      const size_delta_inserted =
+          obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
+      const size_delta_deleted =
+          obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
+
+      return {
+        inserted: acc.inserted + inserted,
+        deleted: acc.deleted + deleted,
+        size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
+        size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
+        total_size: acc.total_size + total_size,
+      };
+    }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
+      size_delta_deleted: 0, total_size: 0});
+  }
+
+  _getDiffPreferences() {
+    return this.$.restAPI.getDiffPreferences();
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  _togglePathExpanded(path) {
+    // Is the path in the list of expanded diffs? IF so remove it, otherwise
+    // add it to the list.
+    const pathIndex = this._expandedFilePaths.indexOf(path);
+    if (pathIndex === -1) {
+      this.push('_expandedFilePaths', path);
+    } else {
+      this.splice('_expandedFilePaths', pathIndex, 1);
+    }
+  }
+
+  _togglePathExpandedByIndex(index) {
+    this._togglePathExpanded(this._files[index].__path);
+  }
+
+  _updateDiffPreferences() {
+    if (!this.diffs.length) { return; }
+    // Re-render all expanded diffs sequentially.
+    this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);
+    this._renderInOrder(this._expandedFilePaths, this.diffs,
+        this._expandedFilePaths.length);
+  }
+
+  _forEachDiff(fn) {
+    const diffs = this.diffs;
+    for (let i = 0; i < diffs.length; i++) {
+      fn(diffs[i]);
+    }
+  }
+
+  expandAllDiffs() {
+    this._showInlineDiffs = true;
+
+    // Find the list of paths that are in the file list, but not in the
+    // expanded list.
+    const newPaths = [];
+    let path;
+    for (let i = 0; i < this._shownFiles.length; i++) {
+      path = this._shownFiles[i].__path;
+      if (!this._expandedFilePaths.includes(path)) {
+        newPaths.push(path);
+      }
+    }
+
+    this.splice(...['_expandedFilePaths', 0, 0].concat(newPaths));
+  }
+
+  collapseAllDiffs() {
+    this._showInlineDiffs = false;
+    this._expandedFilePaths = [];
+    this.filesExpanded = this._computeExpandedFiles(
+        this._expandedFilePaths.length, this._files.length);
+    this.$.diffCursor.handleDiffUpdate();
+  }
+
+  /**
+   * Computes a string with the number of comments and unresolved comments.
+   *
+   * @param {!Object} changeComments
+   * @param {!Object} patchRange
+   * @param {string} path
+   * @return {string}
+   */
+  _computeCommentsString(changeComments, patchRange, path) {
+    const unresolvedCount =
+        changeComments.computeUnresolvedNum(patchRange.basePatchNum, path) +
+        changeComments.computeUnresolvedNum(patchRange.patchNum, path);
+    const commentCount =
+        changeComments.computeCommentCount(patchRange.basePatchNum, path) +
+        changeComments.computeCommentCount(patchRange.patchNum, path);
+    const commentString = GrCountStringFormatter.computePluralString(
+        commentCount, 'comment');
+    const unresolvedString = GrCountStringFormatter.computeString(
+        unresolvedCount, 'unresolved');
+
+    return commentString +
+        // Add a space if both comments and unresolved
+        (commentString && unresolvedString ? ' ' : '') +
+        // Add parentheses around unresolved if it exists.
+        (unresolvedString ? `(${unresolvedString})` : '');
+  }
+
+  /**
+   * Computes a string with the number of drafts.
+   *
+   * @param {!Object} changeComments
+   * @param {!Object} patchRange
+   * @param {string} path
+   * @return {string}
+   */
+  _computeDraftsString(changeComments, patchRange, path) {
+    const draftCount =
+        changeComments.computeDraftCount(patchRange.basePatchNum, path) +
+        changeComments.computeDraftCount(patchRange.patchNum, path);
+    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+  }
+
+  /**
+   * Computes a shortened string with the number of drafts.
+   *
+   * @param {!Object} changeComments
+   * @param {!Object} patchRange
+   * @param {string} path
+   * @return {string}
+   */
+  _computeDraftsStringMobile(changeComments, patchRange, path) {
+    const draftCount =
+        changeComments.computeDraftCount(patchRange.basePatchNum, path) +
+        changeComments.computeDraftCount(patchRange.patchNum, path);
+    return GrCountStringFormatter.computeShortString(draftCount, 'd');
+  }
+
+  /**
+   * Computes a shortened string with the number of comments.
+   *
+   * @param {!Object} changeComments
+   * @param {!Object} patchRange
+   * @param {string} path
+   * @return {string}
+   */
+  _computeCommentsStringMobile(changeComments, patchRange, path) {
+    const commentCount =
+        changeComments.computeCommentCount(patchRange.basePatchNum, path) +
+        changeComments.computeCommentCount(patchRange.patchNum, path);
+    return GrCountStringFormatter.computeShortString(commentCount, 'c');
+  }
+
+  /**
+   * @param {string} path
+   * @param {boolean=} opt_reviewed
+   */
+  _reviewFile(path, opt_reviewed) {
+    if (this.editMode) { return; }
+    const index = this._files.findIndex(file => file.__path === path);
+    const reviewed = opt_reviewed || !this._files[index].isReviewed;
+
+    this.set(['_files', index, 'isReviewed'], reviewed);
+    if (index < this._shownFiles.length) {
+      this.notifyPath(`_shownFiles.${index}.isReviewed`);
+    }
+
+    this._saveReviewedState(path, reviewed);
+  }
+
+  _saveReviewedState(path, reviewed) {
+    return this.$.restAPI.saveFileReviewed(this.changeNum,
+        this.patchRange.patchNum, path, reviewed);
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getReviewedFiles() {
+    if (this.editMode) { return Promise.resolve([]); }
+    return this.$.restAPI.getReviewedFiles(this.changeNum,
+        this.patchRange.patchNum);
+  }
+
+  _getFiles() {
+    return this.$.restAPI.getChangeOrEditFiles(
+        this.changeNum, this.patchRange);
+  }
+
+  /**
+   * The closure compiler doesn't realize this.specialFilePathCompare is
+   * valid.
+   *
+   * @suppress {checkTypes}
+   */
+  _normalizeChangeFilesResponse(response) {
+    if (!response) { return []; }
+    const paths = Object.keys(response).sort(this.specialFilePathCompare);
+    const files = [];
+    for (let i = 0; i < paths.length; i++) {
+      const info = response[paths[i]];
+      info.__path = paths[i];
+      info.lines_inserted = info.lines_inserted || 0;
+      info.lines_deleted = info.lines_deleted || 0;
+      files.push(info);
+    }
+    return files;
+  }
+
+  /**
+   * Handle all events from the file list dom-repeat so event handleers don't
+   * have to get registered for potentially very long lists.
+   */
+  _handleFileListClick(e) {
+    // Traverse upwards to find the row element if the target is not the row.
+    let row = e.target;
+    while (!row.classList.contains('row') && row.parentElement) {
+      row = row.parentElement;
+    }
+
+    const path = row.dataset.path;
+    // Handle checkbox mark as reviewed.
+    if (e.target.classList.contains('markReviewed')) {
+      e.preventDefault();
+      return this._reviewFile(path);
+    }
+
+    // If a path cannot be interpreted from the click target (meaning it's not
+    // somewhere in the row, e.g. diff content) or if the user clicked the
+    // link, defer to the native behavior.
+    if (!path || this.descendedFromClass(e.target, 'pathLink')) { return; }
+
+    // Disregard the event if the click target is in the edit controls.
+    if (this.descendedFromClass(e.target, 'editFileControls')) { return; }
+
+    e.preventDefault();
+    this._togglePathExpanded(path);
+  }
+
+  _handleLeftPane(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffCursor.moveLeft();
+  }
+
+  _handleRightPane(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffCursor.moveRight();
+  }
+
+  _handleToggleInlineDiff(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e) ||
+        this.$.fileCursor.index === -1) { return; }
+
+    e.preventDefault();
+    this._togglePathExpandedByIndex(this.$.fileCursor.index);
+  }
+
+  _handleToggleAllInlineDiffs(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    e.preventDefault();
+    this._toggleInlineDiffs();
+  }
+
+  _handleCursorNext(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    if (this._showInlineDiffs) {
+      e.preventDefault();
+      this.$.diffCursor.moveDown();
+      this._displayLine = true;
+    } else {
+      // Down key
+      if (this.getKeyboardEvent(e).keyCode === 40) { return; }
+      e.preventDefault();
+      this.$.fileCursor.next();
+      this.selectedIndex = this.$.fileCursor.index;
+    }
+  }
+
+  _handleCursorPrev(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    if (this._showInlineDiffs) {
+      e.preventDefault();
+      this.$.diffCursor.moveUp();
+      this._displayLine = true;
+    } else {
+      // Up key
+      if (this.getKeyboardEvent(e).keyCode === 38) { return; }
+      e.preventDefault();
+      this.$.fileCursor.previous();
+      this.selectedIndex = this.$.fileCursor.index;
+    }
+  }
+
+  _handleNewComment(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+    e.preventDefault();
+    this.$.diffCursor.createCommentInPlace();
+  }
+
+  _handleOpenLastFile(e) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.getKeyboardEvent(e).metaKey) { return; }
+
+    e.preventDefault();
+    this._openSelectedFile(this._files.length - 1);
+  }
+
+  _handleOpenFirstFile(e) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.getKeyboardEvent(e).metaKey) { return; }
+
+    e.preventDefault();
+    this._openSelectedFile(0);
+  }
+
+  _handleOpenFile(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+    e.preventDefault();
+
+    if (this._showInlineDiffs) {
+      this._openCursorFile();
+      return;
+    }
+
+    this._openSelectedFile();
+  }
+
+  _handleNextChunk(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
+        this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.isModifierPressed(e, 'shiftKey')) {
+      this.$.diffCursor.moveToNextCommentThread();
+    } else {
+      this.$.diffCursor.moveToNextChunk();
+    }
+  }
+
+  _handlePrevChunk(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
+        this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.isModifierPressed(e, 'shiftKey')) {
+      this.$.diffCursor.moveToPreviousCommentThread();
+    } else {
+      this.$.diffCursor.moveToPreviousChunk();
+    }
+  }
+
+  _handleToggleFileReviewed(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (!this._files[this.$.fileCursor.index]) { return; }
+    this._reviewFile(this._files[this.$.fileCursor.index].__path);
+  }
+
+  _handleToggleLeftPane(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    e.preventDefault();
+    this._forEachDiff(diff => {
+      diff.toggleLeftDiff();
+    });
+  }
+
+  _toggleInlineDiffs() {
+    if (this._showInlineDiffs) {
+      this.collapseAllDiffs();
+    } else {
+      this.expandAllDiffs();
+    }
+  }
+
+  _openCursorFile() {
+    const diff = this.$.diffCursor.getTargetDiffElement();
+    Gerrit.Nav.navigateToDiff(this.change, diff.path,
+        diff.patchRange.patchNum, this.patchRange.basePatchNum);
+  }
+
+  /**
+   * @param {number=} opt_index
+   */
+  _openSelectedFile(opt_index) {
+    if (opt_index != null) {
+      this.$.fileCursor.setCursorAtIndex(opt_index);
+    }
+    if (!this._files[this.$.fileCursor.index]) { return; }
+    Gerrit.Nav.navigateToDiff(this.change,
+        this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
+        this.patchRange.basePatchNum);
+  }
+
+  _addDraftAtTarget() {
+    const diff = this.$.diffCursor.getTargetDiffElement();
+    const target = this.$.diffCursor.getTargetLineElement();
+    if (diff && target) {
+      diff.addDraftAtLine(target);
+    }
+  }
+
+  _shouldHideChangeTotals(_patchChange) {
+    return _patchChange.inserted === 0 && _patchChange.deleted === 0;
+  }
+
+  _shouldHideBinaryChangeTotals(_patchChange) {
+    return _patchChange.size_delta_inserted === 0 &&
+        _patchChange.size_delta_deleted === 0;
+  }
+
+  _computeFileStatus(status) {
+    return status || 'M';
+  }
+
+  _computeDiffURL(change, patchRange, path, editMode) {
+    // Polymer 2: check for undefined
+    if ([change, patchRange, path, editMode]
+        .some(arg => arg === undefined)) {
+      return;
+    }
+    // TODO(kaspern): Fix editing for commit messages and merge lists.
+    if (editMode && path !== this.COMMIT_MESSAGE_PATH &&
+        path !== this.MERGE_LIST_PATH) {
+      return Gerrit.Nav.getEditUrlForDiff(change, path, patchRange.patchNum,
+          patchRange.basePatchNum);
+    }
+    return Gerrit.Nav.getUrlForDiff(change, path, patchRange.patchNum,
+        patchRange.basePatchNum);
+  }
+
+  _formatBytes(bytes) {
+    if (bytes == 0) return '+/-0 B';
+    const bits = 1024;
+    const decimals = 1;
+    const sizes =
+        ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+    const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
+    const prepend = bytes > 0 ? '+' : '';
+    return prepend + parseFloat((bytes / Math.pow(bits, exponent))
+        .toFixed(decimals)) + ' ' + sizes[exponent];
+  }
+
+  _formatPercentage(size, delta) {
+    const oldSize = size - delta;
+
+    if (oldSize === 0) { return ''; }
+
+    const percentage = Math.round(Math.abs(delta * 100 / oldSize));
+    return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
+  }
+
+  _computeBinaryClass(delta) {
+    if (delta === 0) { return; }
+    return delta >= 0 ? 'added' : 'removed';
+  }
+
+  /**
+   * @param {string} baseClass
+   * @param {string} path
+   */
+  _computeClass(baseClass, path) {
+    const classes = [];
+    if (baseClass) {
+      classes.push(baseClass);
+    }
+    if (path === this.COMMIT_MESSAGE_PATH || path === this.MERGE_LIST_PATH) {
+      classes.push('invisible');
+    }
+    return classes.join(' ');
+  }
+
+  _computePathClass(path, expandedFilesRecord) {
+    return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
+  }
+
+  _computeShowHideIcon(path, expandedFilesRecord) {
+    return this._isFileExpanded(path, expandedFilesRecord) ?
+      'gr-icons:expand-less' : 'gr-icons:expand-more';
+  }
+
+  _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
+    // Polymer 2: check for undefined
+    if ([
+      filesByPath,
+      changeComments,
+      patchRange,
+      reviewed,
+      loading,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    // Await all promises resolving from reload. @See Issue 9057
+    if (loading || !changeComments) { return; }
+
+    const commentedPaths = changeComments.getPaths(patchRange);
+    const files = Object.assign({}, filesByPath);
+    Object.keys(commentedPaths).forEach(commentedPath => {
+      if (files.hasOwnProperty(commentedPath)) { return; }
+      files[commentedPath] = {status: 'U'};
+    });
+    const reviewedSet = new Set(reviewed || []);
+    for (const filePath in files) {
+      if (!files.hasOwnProperty(filePath)) { continue; }
+      files[filePath].isReviewed = reviewedSet.has(filePath);
+    }
+
+    this._files = this._normalizeChangeFilesResponse(files);
+  }
+
+  _computeFilesShown(numFilesShown, files) {
+    // Polymer 2: check for undefined
+    if ([numFilesShown, files].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const previousNumFilesShown = this._shownFiles ?
+      this._shownFiles.length : 0;
+
+    const filesShown = files.slice(0, numFilesShown);
+    this.fire('files-shown-changed', {length: filesShown.length});
+
+    // Start the timer for the rendering work hwere because this is where the
+    // _shownFiles property is being set, and _shownFiles is used in the
+    // dom-repeat binding.
+    this.$.reporting.time(RENDER_TIMING_LABEL);
+
+    // How many more files are being shown (if it's an increase).
+    this._reportinShownFilesIncrement =
+        Math.max(0, filesShown.length - previousNumFilesShown);
+
+    return filesShown;
+  }
+
+  _updateDiffCursor() {
+    // Overwrite the cursor's list of diffs:
+    this.$.diffCursor.splice(
+        ...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
+  }
+
+  _filesChanged() {
+    if (this._files && this._files.length > 0) {
+      flush();
+      const files = Array.from(
+          dom(this.root).querySelectorAll('.file-row'));
+      this.$.fileCursor.stops = files;
+      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
+    }
+  }
+
+  _incrementNumFilesShown() {
+    this.numFilesShown += this.fileListIncrement;
+  }
+
+  _computeFileListControlClass(numFilesShown, files) {
+    return numFilesShown >= files.length ? 'invisible' : '';
+  }
+
+  _computeIncrementText(numFilesShown, files) {
+    if (!files) { return ''; }
+    const text =
+        Math.min(this.fileListIncrement, files.length - numFilesShown);
+    return 'Show ' + text + ' more';
+  }
+
+  _computeShowAllText(files) {
+    if (!files) { return ''; }
+    return 'Show all ' + files.length + ' files';
+  }
+
+  _computeWarnShowAll(files) {
+    return files.length > WARN_SHOW_ALL_THRESHOLD;
+  }
+
+  _computeShowAllWarning(files) {
+    if (!this._computeWarnShowAll(files)) { return ''; }
+    return 'Warning: showing all ' + files.length +
+        ' files may take several seconds.';
+  }
+
+  _showAllFiles() {
+    this.numFilesShown = this._files.length;
+  }
+
+  _computePatchSetDescription(revisions, patchNum) {
+    // Polymer 2: check for undefined
+    if ([revisions, patchNum].some(arg => arg === undefined)) {
+      return '';
+    }
+
+    const rev = this.getRevisionByPatchNum(revisions, patchNum);
+    return (rev && rev.description) ?
+      rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
+  }
+
+  /**
+   * Get a descriptive label for use in the status indicator's tooltip and
+   * ARIA label.
+   *
+   * @param {string} status
+   * @return {string}
+   */
+  _computeFileStatusLabel(status) {
+    const statusCode = this._computeFileStatus(status);
+    return FileStatus.hasOwnProperty(statusCode) ?
+      FileStatus[statusCode] : 'Status Unknown';
+  }
+
+  _isFileExpanded(path, expandedFilesRecord) {
+    return expandedFilesRecord.base.includes(path);
+  }
+
+  _onLineSelected(e, detail) {
+    this.$.diffCursor.moveToLineNumber(detail.number, detail.side,
+        detail.path);
+  }
+
+  _computeExpandedFiles(expandedCount, totalCount) {
+    if (expandedCount === 0) {
+      return GrFileListConstants.FilesExpandedState.NONE;
+    } else if (expandedCount === totalCount) {
+      return GrFileListConstants.FilesExpandedState.ALL;
+    }
+    return GrFileListConstants.FilesExpandedState.SOME;
+  }
+
+  /**
+   * Handle splices to the list of expanded file paths. If there are any new
+   * entries in the expanded list, then render each diff corresponding in
+   * order by waiting for the previous diff to finish before starting the next
+   * one.
+   *
+   * @param {!Array} record The splice record in the expanded paths list.
+   */
+  _expandedPathsChanged(record) {
+    // Clear content for any diffs that are not open so if they get re-opened
+    // the stale content does not flash before it is cleared and reloaded.
+    const collapsedDiffs = this.diffs.filter(diff =>
+      this._expandedFilePaths.indexOf(diff.path) === -1);
+    this._clearCollapsedDiffs(collapsedDiffs);
+
+    if (!record) { return; } // Happens after "Collapse all" clicked.
+
+    this.filesExpanded = this._computeExpandedFiles(
+        this._expandedFilePaths.length, this._files.length);
+
+    // Find the paths introduced by the new index splices:
+    const newPaths = record.indexSplices
+        .map(splice => splice.object.slice(
+            splice.index, splice.index + splice.addedCount))
+        .reduce((acc, paths) => acc.concat(paths), []);
+
+    // Required so that the newly created diff view is included in this.diffs.
+    flush();
+
+    this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);
+
+    if (newPaths.length) {
+      this._renderInOrder(newPaths, this.diffs, newPaths.length);
+    }
+
+    this._updateDiffCursor();
+    this.$.diffCursor.handleDiffUpdate();
+  }
+
+  _clearCollapsedDiffs(collapsedDiffs) {
+    for (const diff of collapsedDiffs) {
+      diff.cancel();
+      diff.clearDiffContent();
+    }
+  }
+
+  /**
+   * Given an array of paths and a NodeList of diff elements, render the diff
+   * for each path in order, awaiting the previous render to complete before
+   * continung.
+   *
+   * @param  {!Array<string>} paths
+   * @param  {!NodeList<!Object>} diffElements (GrDiffHostElement)
+   * @param  {number} initialCount The total number of paths in the pass. This
+   *   is used to generate log messages.
+   * @return {!Promise}
+   */
+  _renderInOrder(paths, diffElements, initialCount) {
+    let iter = 0;
+
+    return (new Promise(resolve => {
+      this.fire('reload-drafts', {resolve});
+    })).then(() => this.asyncForeach(paths, (path, cancel) => {
+      this._cancelForEachDiff = cancel;
+
+      iter++;
+      console.log('Expanding diff', iter, 'of', initialCount, ':',
+          path);
+      const diffElem = this._findDiffByPath(path, diffElements);
+      if (!diffElem) {
+        console.warn(`Did not find <gr-diff-host> element for ${path}`);
+        return Promise.resolve();
+      }
+      diffElem.comments = this.changeComments.getCommentsBySideForPath(
+          path, this.patchRange, this.projectConfig);
+      const promises = [diffElem.reload()];
+      if (this._loggedIn && !this.diffPrefs.manual_review) {
+        promises.push(this._reviewFile(path, true));
+      }
+      return Promise.all(promises);
+    }).then(() => {
+      this._cancelForEachDiff = null;
+      this._nextRenderParams = null;
+      console.log('Finished expanding', initialCount, 'diff(s)');
+      this.$.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
+          EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
+      this.$.diffCursor.handleDiffUpdate();
+    }));
+  }
+
+  /** Cancel the rendering work of every diff in the list */
+  _cancelDiffs() {
+    if (this._cancelForEachDiff) { this._cancelForEachDiff(); }
+    this._forEachDiff(d => d.cancel());
+  }
+
+  /**
+   * In the given NodeList of diff elements, find the diff for the given path.
+   *
+   * @param  {string} path
+   * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
+   * @return {!Object|undefined} (GrDiffElement)
+   */
+  _findDiffByPath(path, diffElements) {
+    for (let i = 0; i < diffElements.length; i++) {
+      if (diffElements[i].path === path) {
+        return diffElements[i];
+      }
+    }
+  }
+
+  /**
+   * Reset the comments of a modified thread
+   *
+   * @param  {string} rootId
+   * @param  {string} path
+   */
+  reloadCommentsForThreadWithRootId(rootId, path) {
+    // Don't bother continuing if we already know that the path that contains
+    // the updated comment thread is not expanded.
+    if (!this._expandedFilePaths.includes(path)) { return; }
+    const diff = this.diffs.find(d => d.path === path);
+
+    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
+    if (!threadEl) { return; }
+
+    const newComments = this.changeComments.getCommentsForThread(rootId);
+
+    // If newComments is null, it means that a single draft was
+    // removed from a thread in the thread view, and the thread should
+    // no longer exist. Remove the existing thread element in the diff
+    // view.
+    if (!newComments) {
+      threadEl.fireRemoveSelf();
+      return;
+    }
+
+    // Comments are not returned with the commentSide attribute from
+    // the api, but it's necessary to be stored on the diff's
+    // comments due to use in the _handleCommentUpdate function.
+    // The comment thread already has a side associated with it, so
+    // set the comment's side to match.
+    threadEl.comments = newComments.map(c => Object.assign(
+        c, {__commentSide: threadEl.commentSide}
+    ));
+    flush();
+    return;
+  }
+
+  _handleEscKey(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+    e.preventDefault();
+    this._displayLine = false;
+  }
+
+  /**
+   * Update the loading class for the file list rows. The update is inside a
+   * debouncer so that the file list doesn't flash gray when the API requests
+   * are reasonably fast.
+   *
+   * @param {boolean} loading
+   */
+  _loadingChanged(loading) {
+    this.debounce('loading-change', () => {
+      // Only show set the loading if there have been files loaded to show. In
+      // this way, the gray loading style is not shown on initial loads.
+      this.classList.toggle('loading', loading && !!this._files.length);
+    }, LOADING_DEBOUNCE_INTERVAL);
+  }
+
+  _editModeChanged(editMode) {
+    this.classList.toggle('editMode', editMode);
+  }
+
+  _computeReviewedClass(isReviewed) {
+    return isReviewed ? 'isReviewed' : '';
+  }
+
+  _computeReviewedText(isReviewed) {
+    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
+  }
+
+  /**
+   * Given a file path, return whether that path should have visible size bars
+   * and be included in the size bars calculation.
+   *
+   * @param {string} path
+   * @return {boolean}
+   */
+  _showBarsForPath(path) {
+    return path !== this.COMMIT_MESSAGE_PATH && path !== this.MERGE_LIST_PATH;
+  }
+
+  /**
+   * Compute size bar layout values from the file list.
+   *
+   * @return {Gerrit.LayoutStats|undefined}
+   *
+   */
+  _computeSizeBarLayout(shownFilesRecord) {
+    if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
+    const stats = {
+      maxInserted: 0,
+      maxDeleted: 0,
+      maxAdditionWidth: 0,
+      maxDeletionWidth: 0,
+      deletionOffset: 0,
+    };
+    shownFilesRecord.base
+        .filter(f => this._showBarsForPath(f.__path))
+        .forEach(f => {
+          if (f.lines_inserted) {
+            stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
+          }
+          if (f.lines_deleted) {
+            stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
+          }
+        });
+    const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
+    if (!isNaN(ratio)) {
+      stats.maxAdditionWidth =
+          (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
+      stats.maxDeletionWidth =
+          SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
+      stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
+    }
+    return stats;
+  }
+
+  /**
+   * Get the width of the addition bar for a file.
+   *
+   * @param {Object} file
+   * @param {Gerrit.LayoutStats} stats
+   * @return {number}
+   */
+  _computeBarAdditionWidth(file, stats) {
+    if (stats.maxInserted === 0 ||
+        !file.lines_inserted ||
+        !this._showBarsForPath(file.__path)) {
+      return 0;
+    }
+    const width =
+        stats.maxAdditionWidth * file.lines_inserted / stats.maxInserted;
+    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+  }
+
+  /**
+   * Get the x-offset of the addition bar for a file.
+   *
+   * @param {Object} file
+   * @param {Gerrit.LayoutStats} stats
+   * @return {number}
+   */
+  _computeBarAdditionX(file, stats) {
+    return stats.maxAdditionWidth -
+        this._computeBarAdditionWidth(file, stats);
+  }
+
+  /**
+   * Get the width of the deletion bar for a file.
+   *
+   * @param {Object} file
+   * @param {Gerrit.LayoutStats} stats
+   * @return {number}
+   */
+  _computeBarDeletionWidth(file, stats) {
+    if (stats.maxDeleted === 0 ||
+        !file.lines_deleted ||
+        !this._showBarsForPath(file.__path)) {
+      return 0;
+    }
+    const width =
+        stats.maxDeletionWidth * file.lines_deleted / stats.maxDeleted;
+    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+  }
+
+  /**
+   * Get the x-offset of the deletion bar for a file.
+   *
+   * @param {Gerrit.LayoutStats} stats
+   *
+   * @return {number}
+   */
+  _computeBarDeletionX(stats) {
+    return stats.deletionOffset;
+  }
+
+  _computeShowSizeBars(userPrefs) {
+    return !!userPrefs.size_bar_in_change_table;
+  }
+
+  _computeSizeBarsClass(showSizeBars, path) {
+    let hideClass = '';
+    if (!showSizeBars) {
+      hideClass = 'hide';
+    } else if (!this._showBarsForPath(path)) {
+      hideClass = 'invisible';
+    }
+    return `sizeBars desktop ${hideClass}`;
+  }
+
+  /**
+   * Shows registered dynamic columns iff the 'header', 'content' and
+   * 'summary' endpoints are regiestered the exact same number of times.
+   * Ideally, there should be a better way to enforce the expectation of the
+   * dependencies between dynamic endpoints.
+   */
+  _computeShowDynamicColumns(
+      headerEndpoints, contentEndpoints, summaryEndpoints) {
+    return headerEndpoints && contentEndpoints && summaryEndpoints &&
+           headerEndpoints.length === contentEndpoints.length &&
+           headerEndpoints.length === summaryEndpoints.length;
+  }
+
+  /**
+   * Returns true if none of the inline diffs have been expanded.
+   *
+   * @return {boolean}
+   */
+  _noDiffsExpanded() {
+    return this.filesExpanded === GrFileListConstants.FilesExpandedState.NONE;
+  }
+
+  /**
+   * Method to call via binding when each file list row is rendered. This
+   * allows approximate detection of when the dom-repeat has completed
+   * rendering.
+   *
+   * @param {number} index The index of the row being rendered.
+   * @return {string} an empty string.
+   */
+  _reportRenderedRow(index) {
+    if (index === this._shownFiles.length - 1) {
+      this.async(() => {
+        this.$.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
+            RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
+      }, 1);
+    }
+    return '';
+  }
+
+  _reviewedTitle(reviewed) {
+    if (reviewed) {
+      return 'Mark as not reviewed (shortcut: r)';
+    }
+
+    return 'Mark as reviewed (shortcut: r)';
+  }
+
+  _handleReloadingDiffPreference() {
+    this._getDiffPreferences().then(prefs => {
+      this.diffPrefs = prefs;
+    });
+  }
+}
+
+customElements.define(GrFileList.is, GrFileList);
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
index 289e3f4..9652156 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
@@ -1,46 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/async-foreach-behavior/async-foreach-behavior.html">
-<link rel="import" href="../../../behaviors/dom-util-behavior/dom-util-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html">
-<link rel="import" href="../../diff/gr-diff-host/gr-diff-host.html">
-<link rel="import" href="../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.html">
-<link rel="import" href="../../edit/gr-edit-file-controls/gr-edit-file-controls.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-<link rel="import" href="../gr-file-list-constants.html">
-
-<dom-module id="gr-file-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -298,9 +274,7 @@
         }
       }
     </style>
-    <div
-        id="container"
-        on-click="_handleFileListClick">
+    <div id="container" on-click="_handleFileListClick">
       <div class="header-row row">
         <div class="status"></div>
         <div class="path">File</div>
@@ -309,55 +283,38 @@
         <div class="header-stats">Delta</div>
         <template is="dom-if" if="[[_showDynamicColumns]]">
           <template is="dom-repeat" items="[[_dynamicHeaderEndpoints]]" as="headerEndpoint">
-            <gr-endpoint-decorator name$="[[headerEndpoint]]">
+            <gr-endpoint-decorator name\$="[[headerEndpoint]]">
             </gr-endpoint-decorator>
           </template>
         </template>
         <!-- Empty div here exists to keep spacing in sync with file rows. -->
-        <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
+        <div class="reviewed hideOnEdit" hidden\$="[[!_loggedIn]]"></div>
         <div class="editFileControls showOnEdit"></div>
         <div class="show-hide"></div>
       </div>
 
-      <template is="dom-repeat"
-          items="[[_shownFiles]]"
-          id="files"
-          as="file"
-          initial-count="[[fileListIncrement]]"
-          target-framerate="1">
+      <template is="dom-repeat" items="[[_shownFiles]]" id="files" as="file" initial-count="[[fileListIncrement]]" target-framerate="1">
         [[_reportRenderedRow(index)]]
         <div class="stickyArea">
-          <div class$="file-row row [[_computePathClass(file.__path, _expandedFilePaths.*)]]"
-              data-path$="[[file.__path]]" tabindex="-1">
-              <div class$="[[_computeClass('status', file.__path)]]"
-                  tabindex="0"
-                  title$="[[_computeFileStatusLabel(file.status)]]"
-                  aria-label$="[[_computeFileStatusLabel(file.status)]]">
+          <div class\$="file-row row [[_computePathClass(file.__path, _expandedFilePaths.*)]]" data-path\$="[[file.__path]]" tabindex="-1">
+              <div class\$="[[_computeClass('status', file.__path)]]" tabindex="0" title\$="[[_computeFileStatusLabel(file.status)]]" aria-label\$="[[_computeFileStatusLabel(file.status)]]">
               [[_computeFileStatus(file.status)]]
             </div>
             <!-- TODO: Remove data-url as it appears its not used -->
-            <span
-                data-url="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
-                class="path">
-              <a class="pathLink" href$="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]">
-                <span title$="[[computeDisplayPath(file.__path)]]"
-                    class="fullFileName">
+            <span data-url="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]" class="path">
+              <a class="pathLink" href\$="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]">
+                <span title\$="[[computeDisplayPath(file.__path)]]" class="fullFileName">
                   [[computeDisplayPath(file.__path)]]
                 </span>
-                <span title$="[[computeDisplayPath(file.__path)]]"
-                    class="truncatedFileName">
+                <span title\$="[[computeDisplayPath(file.__path)]]" class="truncatedFileName">
                   [[computeTruncatedPath(file.__path)]]
                 </span>
-                <gr-copy-clipboard
-                  hide-input
-                  text="[[file.__path]]"></gr-copy-clipboard>
+                <gr-copy-clipboard hide-input="" text="[[file.__path]]"></gr-copy-clipboard>
               </a>
               <template is="dom-if" if="[[file.old_path]]">
-                <div class="oldPath" title$="[[file.old_path]]">
+                <div class="oldPath" title\$="[[file.old_path]]">
                   [[file.old_path]]
-                  <gr-copy-clipboard
-                    hide-input
-                    text="[[file.old_path]]"></gr-copy-clipboard>
+                  <gr-copy-clipboard hide-input="" text="[[file.old_path]]"></gr-copy-clipboard>
                 </div>
               </template>
             </span>
@@ -375,46 +332,27 @@
               [[_computeCommentsStringMobile(changeComments, patchRange,
                   file.__path)]]
             </div>
-            <div class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]">
+            <div class\$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]">
               <svg width="61" height="8">
-                <rect
-                    x$="[[_computeBarAdditionX(file, _sizeBarLayout)]]"
-                    y="0"
-                    height="8"
-                    fill="#388E3C"
-                    width$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]" />
-                <rect
-                    x$="[[_computeBarDeletionX(_sizeBarLayout)]]"
-                    y="0"
-                    height="8"
-                    fill="#D32F2F"
-                    width$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]" />
+                <rect x\$="[[_computeBarAdditionX(file, _sizeBarLayout)]]" y="0" height="8" fill="#388E3C" width\$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]"></rect>
+                <rect x\$="[[_computeBarDeletionX(_sizeBarLayout)]]" y="0" height="8" fill="#D32F2F" width\$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]"></rect>
               </svg>
             </div>
-            <div class$="[[_computeClass('stats', file.__path)]]">
-              <span
-                  class="added"
-                  tabindex="0"
-                  aria-label$="[[file.lines_inserted]] lines added"
-                  hidden$=[[file.binary]]>
+            <div class\$="[[_computeClass('stats', file.__path)]]">
+              <span class="added" tabindex="0" aria-label\$="[[file.lines_inserted]] lines added" hidden\$="[[file.binary]]">
                 +[[file.lines_inserted]]
               </span>
-              <span
-                  class="removed"
-                  tabindex="0"
-                  aria-label$="[[file.lines_deleted]] lines removed"
-                  hidden$=[[file.binary]]>
+              <span class="removed" tabindex="0" aria-label\$="[[file.lines_deleted]] lines removed" hidden\$="[[file.binary]]">
                 -[[file.lines_deleted]]
               </span>
-              <span class$="[[_computeBinaryClass(file.size_delta)]]"
-                  hidden$=[[!file.binary]]>
+              <span class\$="[[_computeBinaryClass(file.size_delta)]]" hidden\$="[[!file.binary]]">
                 [[_formatBytes(file.size_delta)]]
                 [[_formatPercentage(file.size, file.size_delta)]]
               </span>
             </div>
             <template is="dom-if" if="[[_showDynamicColumns]]">
               <template is="dom-repeat" items="[[_dynamicContentEndpoints]]" as="contentEndpoint">
-                <div class$="[[_computeClass('', file.__path)]]">
+                <div class\$="[[_computeClass('', file.__path)]]">
                   <gr-endpoint-decorator name="[[contentEndpoint]]">
                     <gr-endpoint-param name="changeNum" value="[[changeNum]]">
                     </gr-endpoint-param>
@@ -426,66 +364,38 @@
                 </div>
               </template>
             </template>
-            <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]" hidden>
-              <span class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]">Reviewed</span>
+            <div class="reviewed hideOnEdit" hidden\$="[[!_loggedIn]]" hidden="">
+              <span class\$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]">Reviewed</span>
               <label>
                 <input class="reviewed" type="checkbox" checked="[[file.isReviewed]]">
-                <span class="markReviewed" title$="[[_reviewedTitle(file.isReviewed)]]">[[_computeReviewedText(file.isReviewed)]]</span>
+                <span class="markReviewed" title\$="[[_reviewedTitle(file.isReviewed)]]">[[_computeReviewedText(file.isReviewed)]]</span>
               </label>
             </div>
             <div class="editFileControls showOnEdit">
               <template is="dom-if" if="[[editMode]]">
-                <gr-edit-file-controls
-                    class$="[[_computeClass('', file.__path)]]"
-                    file-path="[[file.__path]]"></gr-edit-file-controls>
+                <gr-edit-file-controls class\$="[[_computeClass('', file.__path)]]" file-path="[[file.__path]]"></gr-edit-file-controls>
               </template>
             </div>
             <div class="show-hide">
-              <label class="show-hide" data-path$="[[file.__path]]"
-                  data-expand=true>
-                <input type="checkbox" class="show-hide"
-                    checked$="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
-                    data-path$="[[file.__path]]" data-expand=true>
-                  <iron-icon
-                      id="icon"
-                      icon="[[_computeShowHideIcon(file.__path, _expandedFilePaths.*)]]">
+              <label class="show-hide" data-path\$="[[file.__path]]" data-expand="true">
+                <input type="checkbox" class="show-hide" checked\$="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]" data-path\$="[[file.__path]]" data-expand="true">
+                  <iron-icon id="icon" icon="[[_computeShowHideIcon(file.__path, _expandedFilePaths.*)]]">
                   </iron-icon>
               </label>
             </div>
           </div>
-          <template is="dom-if"
-              if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
-            <gr-diff-host
-                no-auto-render
-                show-load-failure
-                display-line="[[_displayLine]]"
-                hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
-                change-num="[[changeNum]]"
-                patch-range="[[patchRange]]"
-                path="[[file.__path]]"
-                prefs="[[diffPrefs]]"
-                project-name="[[change.project]]"
-                on-line-selected="_onLineSelected"
-                no-render-on-prefs-change
-                view-mode="[[diffViewMode]]"></gr-diff-host>
+          <template is="dom-if" if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
+            <gr-diff-host no-auto-render="" show-load-failure="" display-line="[[_displayLine]]" hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]" change-num="[[changeNum]]" patch-range="[[patchRange]]" path="[[file.__path]]" prefs="[[diffPrefs]]" project-name="[[change.project]]" on-line-selected="_onLineSelected" no-render-on-prefs-change="" view-mode="[[diffViewMode]]"></gr-diff-host>
           </template>
         </div>
       </template>
     </div>
-    <div
-        class="row totalChanges"
-        hidden$="[[_hideChangeTotals]]">
+    <div class="row totalChanges" hidden\$="[[_hideChangeTotals]]">
       <div class="total-stats">
-        <span
-            class="added"
-            tabindex="0"
-            aria-label$="[[_patchChange.inserted]] lines added">
+        <span class="added" tabindex="0" aria-label\$="[[_patchChange.inserted]] lines added">
           +[[_patchChange.inserted]]
         </span>
-        <span
-            class="removed"
-            tabindex="0"
-            aria-label$="[[_patchChange.deleted]] lines removed">
+        <span class="removed" tabindex="0" aria-label\$="[[_patchChange.deleted]] lines removed">
           -[[_patchChange.deleted]]
         </span>
       </div>
@@ -496,13 +406,11 @@
         </template>
       </template>
       <!-- Empty div here exists to keep spacing in sync with file rows. -->
-      <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
+      <div class="reviewed hideOnEdit" hidden\$="[[!_loggedIn]]"></div>
       <div class="editFileControls showOnEdit"></div>
       <div class="show-hide"></div>
     </div>
-    <div
-        class="row totalChanges"
-        hidden$="[[_hideBinaryChangeTotals]]">
+    <div class="row totalChanges" hidden\$="[[_hideBinaryChangeTotals]]">
       <div class="total-stats">
         <span class="added" aria-label="Total lines added">
           [[_formatBytes(_patchChange.size_delta_inserted)]]
@@ -516,39 +424,21 @@
         </span>
       </div>
     </div>
-    <div class$="row controlRow [[_computeFileListControlClass(numFilesShown, _files)]]">
-      <gr-button
-          class="fileListButton"
-          id="incrementButton"
-          link on-click="_incrementNumFilesShown">
+    <div class\$="row controlRow [[_computeFileListControlClass(numFilesShown, _files)]]">
+      <gr-button class="fileListButton" id="incrementButton" link="" on-click="_incrementNumFilesShown">
         [[_computeIncrementText(numFilesShown, _files)]]
       </gr-button>
-      <gr-tooltip-content
-          has-tooltip="[[_computeWarnShowAll(_files)]]"
-          show-icon="[[_computeWarnShowAll(_files)]]"
-          title$="[[_computeShowAllWarning(_files)]]">
-        <gr-button
-            class="fileListButton"
-            id="showAllButton"
-            link on-click="_showAllFiles">
+      <gr-tooltip-content has-tooltip="[[_computeWarnShowAll(_files)]]" show-icon="[[_computeWarnShowAll(_files)]]" title\$="[[_computeShowAllWarning(_files)]]">
+        <gr-button class="fileListButton" id="showAllButton" link="" on-click="_showAllFiles">
           [[_computeShowAllText(_files)]]
         </gr-button><!--
   --></gr-tooltip-content>
     </div>
-    <gr-diff-preferences-dialog
-        id="diffPreferencesDialog"
-        diff-prefs="{{diffPrefs}}"
-        on-reload-diff-preference="_handleReloadingDiffPreference">
+    <gr-diff-preferences-dialog id="diffPreferencesDialog" diff-prefs="{{diffPrefs}}" on-reload-diff-preference="_handleReloadingDiffPreference">
     </gr-diff-preferences-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
     <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
-    <gr-cursor-manager
-        id="fileCursor"
-        scroll-behavior="keep-visible"
-        focus-on-move
-        cursor-target-class="selected"></gr-cursor-manager>
+    <gr-cursor-manager id="fileCursor" scroll-behavior="keep-visible" focus-on-move="" cursor-target-class="selected"></gr-cursor-manager>
     <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-file-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index af80ca8..84dfc9c 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -19,21 +19,30 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-file-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
 <script src="/components/web-component-tester/data/a11ySuite.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
-<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
-<script src="../../../scripts/util.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script src="/node_modules/page/page.js"></script>
+<script type="module" src="../../diff/gr-comment-api/gr-comment-api.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
-<link rel="import" href="gr-file-list.html">
+<script type="module" src="../../shared/gr-rest-api-interface/mock-diff-response_test.js"></script>
+<script type="module" src="./gr-file-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import '../../../scripts/util.js';
+import '../../shared/gr-rest-api-interface/mock-diff-response_test.js';
+import './gr-file-list.js';
+import '../../diff/gr-comment-api/gr-comment-api-mock_test.js';
+void(0);
+</script>
 
 <dom-module id="comment-api-mock">
   <template>
@@ -42,7 +51,7 @@
         on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
     <gr-comment-api id="commentAPI"></gr-comment-api>
   </template>
-  <script src="../../diff/gr-comment-api/gr-comment-api-mock_test.js"></script>
+  <script type="module" src="../../diff/gr-comment-api/gr-comment-api-mock_test.js"></script>
 </dom-module>
 
 <test-fixture id="basic">
@@ -51,1389 +60,1790 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-file-list tests', async () => {
-    await readyToTest();
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import '../../../scripts/util.js';
+import '../../shared/gr-rest-api-interface/mock-diff-response_test.js';
+import './gr-file-list.js';
+import '../../diff/gr-comment-api/gr-comment-api-mock_test.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-file-list tests', () => {
+  const kb = window.Gerrit.KeyboardShortcutBinder;
+  kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
+  kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+  kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+  kb.bindShortcut(kb.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+  kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
+  kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
+  kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
+  kb.bindShortcut(kb.Shortcut.OPEN_LAST_FILE, '[');
+  kb.bindShortcut(kb.Shortcut.OPEN_FIRST_FILE, ']');
+  kb.bindShortcut(kb.Shortcut.OPEN_FILE, 'o');
+  kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
+  kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
 
-    const kb = window.Gerrit.KeyboardShortcutBinder;
-    kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
-    kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
-    kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-    kb.bindShortcut(kb.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-    kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
-    kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
-    kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
-    kb.bindShortcut(kb.Shortcut.OPEN_LAST_FILE, '[');
-    kb.bindShortcut(kb.Shortcut.OPEN_FIRST_FILE, ']');
-    kb.bindShortcut(kb.Shortcut.OPEN_FILE, 'o');
-    kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
-    kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+  let element;
+  let commentApiWrapper;
+  let sandbox;
+  let saveStub;
+  let loadCommentSpy;
 
-    let element;
-    let commentApiWrapper;
-    let sandbox;
-    let saveStub;
-    let loadCommentSpy;
-
-    suite('basic tests', () => {
-      setup(done => {
-        sandbox = sinon.sandbox.create();
-        stub('gr-rest-api-interface', {
-          getLoggedIn() { return Promise.resolve(true); },
-          getPreferences() { return Promise.resolve({}); },
-          getDiffPreferences() { return Promise.resolve({}); },
-          getDiffComments() { return Promise.resolve({}); },
-          getDiffRobotComments() { return Promise.resolve({}); },
-          getDiffDrafts() { return Promise.resolve({}); },
-          getAccountCapabilities() { return Promise.resolve({}); },
-        });
-        stub('gr-date-formatter', {
-          _loadTimeFormat() { return Promise.resolve(''); },
-        });
-        stub('gr-diff-host', {
-          reload() { return Promise.resolve(); },
-        });
-
-        // Element must be wrapped in an element with direct access to the
-        // comment API.
-        commentApiWrapper = fixture('basic');
-        element = commentApiWrapper.$.fileList;
-        loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
-
-        // Stub methods on the changeComments object after changeComments has
-        // been initialized.
-        commentApiWrapper.loadComments().then(() => {
-          sandbox.stub(element.changeComments, 'getPaths').returns({});
-          sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
-              .returns({meta: {}, left: [], right: []});
-          done();
-        });
-        element._loading = false;
-        element.diffPrefs = {};
-        element.numFilesShown = 200;
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: '2',
-        };
-        saveStub = sandbox.stub(element, '_saveReviewedState',
-            () => Promise.resolve());
+  suite('basic tests', () => {
+    setup(done => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+        getDiffPreferences() { return Promise.resolve({}); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+        getAccountCapabilities() { return Promise.resolve({}); },
+      });
+      stub('gr-date-formatter', {
+        _loadTimeFormat() { return Promise.resolve(''); },
+      });
+      stub('gr-diff-host', {
+        reload() { return Promise.resolve(); },
       });
 
-      teardown(() => {
-        sandbox.restore();
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = fixture('basic');
+      element = commentApiWrapper.$.fileList;
+      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      commentApiWrapper.loadComments().then(() => {
+        sandbox.stub(element.changeComments, 'getPaths').returns({});
+        sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
+            .returns({meta: {}, left: [], right: []});
+        done();
       });
+      element._loading = false;
+      element.diffPrefs = {};
+      element.numFilesShown = 200;
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      saveStub = sandbox.stub(element, '_saveReviewedState',
+          () => Promise.resolve());
+    });
 
-      test('correct number of files are shown', () => {
-        element.fileListIncrement = 300;
-        element._filesByPath = _.range(500)
-            .reduce((_filesByPath, i) => {
-              _filesByPath['/file' + i] = {lines_inserted: 9};
-              return _filesByPath;
-            }, {});
+    teardown(() => {
+      sandbox.restore();
+    });
 
-        flushAsynchronousOperations();
-        assert.equal(
-            Polymer.dom(element.root).querySelectorAll('.file-row').length,
-            element.numFilesShown);
-        const controlRow = element.shadowRoot
-            .querySelector('.controlRow');
-        assert.isFalse(controlRow.classList.contains('invisible'));
-        assert.equal(element.$.incrementButton.textContent.trim(),
-            'Show 300 more');
-        assert.equal(element.$.showAllButton.textContent.trim(),
-            'Show all 500 files');
+    test('correct number of files are shown', () => {
+      element.fileListIncrement = 300;
+      element._filesByPath = _.range(500)
+          .reduce((_filesByPath, i) => {
+            _filesByPath['/file' + i] = {lines_inserted: 9};
+            return _filesByPath;
+          }, {});
 
-        MockInteractions.tap(element.$.showAllButton);
-        flushAsynchronousOperations();
+      flushAsynchronousOperations();
+      assert.equal(
+          dom(element.root).querySelectorAll('.file-row').length,
+          element.numFilesShown);
+      const controlRow = element.shadowRoot
+          .querySelector('.controlRow');
+      assert.isFalse(controlRow.classList.contains('invisible'));
+      assert.equal(element.$.incrementButton.textContent.trim(),
+          'Show 300 more');
+      assert.equal(element.$.showAllButton.textContent.trim(),
+          'Show all 500 files');
 
-        assert.equal(element.numFilesShown, 500);
-        assert.equal(element._shownFiles.length, 500);
-        assert.isTrue(controlRow.classList.contains('invisible'));
+      MockInteractions.tap(element.$.showAllButton);
+      flushAsynchronousOperations();
+
+      assert.equal(element.numFilesShown, 500);
+      assert.equal(element._shownFiles.length, 500);
+      assert.isTrue(controlRow.classList.contains('invisible'));
+    });
+
+    test('rendering each row calls the _reportRenderedRow method', () => {
+      const renderedStub = sandbox.stub(element, '_reportRenderedRow');
+      element._filesByPath = _.range(10)
+          .reduce((_filesByPath, i) => {
+            _filesByPath['/file' + i] = {lines_inserted: 9};
+            return _filesByPath;
+          }, {});
+      flushAsynchronousOperations();
+      assert.equal(
+          dom(element.root).querySelectorAll('.file-row').length, 10);
+      assert.equal(renderedStub.callCount, 10);
+    });
+
+    test('calculate totals for patch number', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {
+          lines_inserted: 9,
+        },
+        '/MERGE_LIST': {
+          lines_inserted: 9,
+        },
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      };
+
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
       });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
 
-      test('rendering each row calls the _reportRenderedRow method', () => {
-        const renderedStub = sandbox.stub(element, '_reportRenderedRow');
-        element._filesByPath = _.range(10)
-            .reduce((_filesByPath, i) => {
-              _filesByPath['/file' + i] = {lines_inserted: 9};
-              return _filesByPath;
-            }, {});
-        flushAsynchronousOperations();
-        assert.equal(
-            Polymer.dom(element.root).querySelectorAll('.file-row').length, 10);
-        assert.equal(renderedStub.callCount, 10);
+      // Test with a commit message that isn't the first file.
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+        '/COMMIT_MSG': {
+          lines_inserted: 9,
+        },
+        '/MERGE_LIST': {
+          lines_inserted: 9,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+      };
+
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
       });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
 
-      test('calculate totals for patch number', () => {
-        element._filesByPath = {
-          '/COMMIT_MSG': {
-            lines_inserted: 9,
-          },
-          '/MERGE_LIST': {
-            lines_inserted: 9,
-          },
-          'file_added_in_rev2.txt': {
-            lines_inserted: 1,
-            lines_deleted: 1,
-            size_delta: 10,
-            size: 100,
-          },
-          'myfile.txt': {
-            lines_inserted: 1,
-            lines_deleted: 1,
-            size_delta: 10,
-            size: 100,
-          },
-        };
+      // Test with no commit message.
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+      };
 
-        assert.deepEqual(element._patchChange, {
-          inserted: 2,
-          deleted: 2,
-          size_delta_inserted: 0,
-          size_delta_deleted: 0,
-          total_size: 0,
-        });
-        assert.isTrue(element._hideBinaryChangeTotals);
-        assert.isFalse(element._hideChangeTotals);
-
-        // Test with a commit message that isn't the first file.
-        element._filesByPath = {
-          'file_added_in_rev2.txt': {
-            lines_inserted: 1,
-            lines_deleted: 1,
-          },
-          '/COMMIT_MSG': {
-            lines_inserted: 9,
-          },
-          '/MERGE_LIST': {
-            lines_inserted: 9,
-          },
-          'myfile.txt': {
-            lines_inserted: 1,
-            lines_deleted: 1,
-          },
-        };
-
-        assert.deepEqual(element._patchChange, {
-          inserted: 2,
-          deleted: 2,
-          size_delta_inserted: 0,
-          size_delta_deleted: 0,
-          total_size: 0,
-        });
-        assert.isTrue(element._hideBinaryChangeTotals);
-        assert.isFalse(element._hideChangeTotals);
-
-        // Test with no commit message.
-        element._filesByPath = {
-          'file_added_in_rev2.txt': {
-            lines_inserted: 1,
-            lines_deleted: 1,
-          },
-          'myfile.txt': {
-            lines_inserted: 1,
-            lines_deleted: 1,
-          },
-        };
-
-        assert.deepEqual(element._patchChange, {
-          inserted: 2,
-          deleted: 2,
-          size_delta_inserted: 0,
-          size_delta_deleted: 0,
-          total_size: 0,
-        });
-        assert.isTrue(element._hideBinaryChangeTotals);
-        assert.isFalse(element._hideChangeTotals);
-
-        // Test with files missing either lines_inserted or lines_deleted.
-        element._filesByPath = {
-          'file_added_in_rev2.txt': {lines_inserted: 1},
-          'myfile.txt': {lines_deleted: 1},
-        };
-        assert.deepEqual(element._patchChange, {
-          inserted: 1,
-          deleted: 1,
-          size_delta_inserted: 0,
-          size_delta_deleted: 0,
-          total_size: 0,
-        });
-        assert.isTrue(element._hideBinaryChangeTotals);
-        assert.isFalse(element._hideChangeTotals);
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
       });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
 
-      test('binary only files', () => {
-        element._filesByPath = {
-          '/COMMIT_MSG': {lines_inserted: 9},
-          'file_binary_1': {binary: true, size_delta: 10, size: 100},
-          'file_binary_2': {binary: true, size_delta: -5, size: 120},
-        };
-        assert.deepEqual(element._patchChange, {
-          inserted: 0,
-          deleted: 0,
-          size_delta_inserted: 10,
-          size_delta_deleted: -5,
-          total_size: 220,
-        });
-        assert.isFalse(element._hideBinaryChangeTotals);
-        assert.isTrue(element._hideChangeTotals);
+      // Test with files missing either lines_inserted or lines_deleted.
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {lines_inserted: 1},
+        'myfile.txt': {lines_deleted: 1},
+      };
+      assert.deepEqual(element._patchChange, {
+        inserted: 1,
+        deleted: 1,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
       });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
 
-      test('binary and regular files', () => {
-        element._filesByPath = {
-          '/COMMIT_MSG': {lines_inserted: 9},
-          'file_binary_1': {binary: true, size_delta: 10, size: 100},
-          'file_binary_2': {binary: true, size_delta: -5, size: 120},
-          'myfile.txt': {lines_deleted: 5, size_delta: -10, size: 100},
-          'myfile2.txt': {lines_inserted: 10},
-        };
-        assert.deepEqual(element._patchChange, {
-          inserted: 10,
-          deleted: 5,
-          size_delta_inserted: 10,
-          size_delta_deleted: -5,
-          total_size: 220,
-        });
-        assert.isFalse(element._hideBinaryChangeTotals);
-        assert.isFalse(element._hideChangeTotals);
+    test('binary only files', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9},
+        'file_binary_1': {binary: true, size_delta: 10, size: 100},
+        'file_binary_2': {binary: true, size_delta: -5, size: 120},
+      };
+      assert.deepEqual(element._patchChange, {
+        inserted: 0,
+        deleted: 0,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
       });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isTrue(element._hideChangeTotals);
+    });
 
-      test('_formatBytes function', () => {
-        const table = {
-          '64': '+64 B',
-          '1023': '+1023 B',
-          '1024': '+1 KiB',
-          '4096': '+4 KiB',
-          '1073741824': '+1 GiB',
-          '-64': '-64 B',
-          '-1023': '-1023 B',
-          '-1024': '-1 KiB',
-          '-4096': '-4 KiB',
-          '-1073741824': '-1 GiB',
-          '0': '+/-0 B',
-        };
+    test('binary and regular files', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9},
+        'file_binary_1': {binary: true, size_delta: 10, size: 100},
+        'file_binary_2': {binary: true, size_delta: -5, size: 120},
+        'myfile.txt': {lines_deleted: 5, size_delta: -10, size: 100},
+        'myfile2.txt': {lines_inserted: 10},
+      };
+      assert.deepEqual(element._patchChange, {
+        inserted: 10,
+        deleted: 5,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
 
-        for (const bytes in table) {
-          if (table.hasOwnProperty(bytes)) {
-            assert.equal(element._formatBytes(bytes), table[bytes]);
-          }
+    test('_formatBytes function', () => {
+      const table = {
+        '64': '+64 B',
+        '1023': '+1023 B',
+        '1024': '+1 KiB',
+        '4096': '+4 KiB',
+        '1073741824': '+1 GiB',
+        '-64': '-64 B',
+        '-1023': '-1023 B',
+        '-1024': '-1 KiB',
+        '-4096': '-4 KiB',
+        '-1073741824': '-1 GiB',
+        '0': '+/-0 B',
+      };
+
+      for (const bytes in table) {
+        if (table.hasOwnProperty(bytes)) {
+          assert.equal(element._formatBytes(bytes), table[bytes]);
         }
-      });
+      }
+    });
 
-      test('_formatPercentage function', () => {
-        const table = [
-          {size: 100,
-            delta: 100,
-            display: '',
+    test('_formatPercentage function', () => {
+      const table = [
+        {size: 100,
+          delta: 100,
+          display: '',
+        },
+        {size: 195060,
+          delta: 64,
+          display: '(+0%)',
+        },
+        {size: 195060,
+          delta: -64,
+          display: '(-0%)',
+        },
+        {size: 394892,
+          delta: -7128,
+          display: '(-2%)',
+        },
+        {size: 90,
+          delta: -10,
+          display: '(-10%)',
+        },
+        {size: 110,
+          delta: 10,
+          display: '(+10%)',
+        },
+      ];
+
+      for (const item of table) {
+        assert.equal(element._formatPercentage(
+            item.size, item.delta), item.display);
+      }
+    });
+
+    test('comment filtering', () => {
+      element.changeComments._comments = {
+        '/COMMIT_MSG': [
+          {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
+          {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
+          {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
+        ],
+        'myfile.txt': [
+          {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
+          {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
+          {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
+        ],
+        'unresolved.file': [
+          {
+            patch_set: 2,
+            message: 'wat!?',
+            updated: '2017-02-09 16:40:49',
+            id: '1',
+            unresolved: true,
           },
-          {size: 195060,
-            delta: 64,
-            display: '(+0%)',
+          {
+            patch_set: 2,
+            message: 'hi',
+            updated: '2017-02-10 16:40:49',
+            id: '2',
+            in_reply_to: '1',
+            unresolved: false,
           },
-          {size: 195060,
-            delta: -64,
-            display: '(-0%)',
+          {
+            patch_set: 2,
+            message: 'good news!',
+            updated: '2017-02-08 16:40:49',
+            id: '3',
+            unresolved: true,
           },
-          {size: 394892,
-            delta: -7128,
-            display: '(-2%)',
+        ],
+      };
+      element.changeComments._drafts = {
+        '/COMMIT_MSG': [
+          {
+            patch_set: 1,
+            message: 'hi',
+            updated: '2017-02-15 16:40:49',
+            id: '5',
+            unresolved: true,
           },
-          {size: 90,
-            delta: -10,
-            display: '(-10%)',
+          {
+            patch_set: 1,
+            message: 'fyi',
+            updated: '2017-02-15 16:40:49',
+            id: '6',
+            unresolved: false,
           },
-          {size: 110,
-            delta: 10,
-            display: '(+10%)',
+        ],
+        'unresolved.file': [
+          {
+            patch_set: 1,
+            message: 'hi',
+            updated: '2017-02-11 16:40:49',
+            id: '4',
+            unresolved: false,
           },
-        ];
+        ],
+      };
 
-        for (const item of table) {
-          assert.equal(element._formatPercentage(
-              item.size, item.delta), item.display);
-        }
-      });
+      const parentTo1 = {
+        basePatchNum: 'PARENT',
+        patchNum: '1',
+      };
 
-      test('comment filtering', () => {
-        element.changeComments._comments = {
-          '/COMMIT_MSG': [
-            {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
-            {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
-            {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
-          ],
-          'myfile.txt': [
-            {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
-            {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
-            {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
-          ],
-          'unresolved.file': [
-            {
-              patch_set: 2,
-              message: 'wat!?',
-              updated: '2017-02-09 16:40:49',
-              id: '1',
-              unresolved: true,
-            },
-            {
-              patch_set: 2,
-              message: 'hi',
-              updated: '2017-02-10 16:40:49',
-              id: '2',
-              in_reply_to: '1',
-              unresolved: false,
-            },
-            {
-              patch_set: 2,
-              message: 'good news!',
-              updated: '2017-02-08 16:40:49',
-              id: '3',
-              unresolved: true,
-            },
-          ],
-        };
-        element.changeComments._drafts = {
-          '/COMMIT_MSG': [
-            {
-              patch_set: 1,
-              message: 'hi',
-              updated: '2017-02-15 16:40:49',
-              id: '5',
-              unresolved: true,
-            },
-            {
-              patch_set: 1,
-              message: 'fyi',
-              updated: '2017-02-15 16:40:49',
-              id: '6',
-              unresolved: false,
-            },
-          ],
-          'unresolved.file': [
-            {
-              patch_set: 1,
-              message: 'hi',
-              updated: '2017-02-11 16:40:49',
-              id: '4',
-              unresolved: false,
-            },
-          ],
-        };
+      const parentTo2 = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
 
-        const parentTo1 = {
-          basePatchNum: 'PARENT',
-          patchNum: '1',
-        };
+      const _1To2 = {
+        basePatchNum: '1',
+        patchNum: '2',
+      };
 
-        const parentTo2 = {
-          basePatchNum: 'PARENT',
-          patchNum: '2',
-        };
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo1,
+              '/COMMIT_MSG', 'comment'), '2 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, parentTo1
+              , '/COMMIT_MSG'), '2c');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2
+              , '/COMMIT_MSG'), '3c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
+              'unresolved.file'), '1 draft');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, _1To2,
+              'unresolved.file'), '1 draft');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
+              'unresolved.file'), '1d');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'unresolved.file'), '1d');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo1,
+              'myfile.txt', 'comment'), '1 comment');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'myfile.txt', 'comment'), '3 comments');
+      assert.equal(
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo1,
+              'myfile.txt'
+          ), '1c');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '3c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, _1To2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo1,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo1,
+              'file_added_in_rev2.txt'
+          ), '');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, _1To2,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              '/COMMIT_MSG', 'comment'), '1 comment');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo2,
+              '/COMMIT_MSG'
+          ), '1c');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              '/COMMIT_MSG'), '3c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
+              '/COMMIT_MSG'), '2 drafts');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, _1To2,
+              '/COMMIT_MSG'), '2 drafts');
+      assert.equal(
+          element._computeDraftsStringMobile(
+              element.changeComments,
+              parentTo1,
+              '/COMMIT_MSG'
+          ), '2d');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              '/COMMIT_MSG'), '2d');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              'myfile.txt', 'comment'), '2 comments');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'myfile.txt', 'comment'), '3 comments');
+      assert.equal(
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo2,
+              'myfile.txt'
+          ), '2c');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '3c');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
+    });
 
-        const _1To2 = {
-          basePatchNum: '1',
-          patchNum: '2',
-        };
+    test('_reviewedTitle', () => {
+      assert.equal(
+          element._reviewedTitle(true), 'Mark as not reviewed (shortcut: r)');
 
-        assert.equal(
-            element._computeCommentsString(element.changeComments, parentTo1,
-                '/COMMIT_MSG', 'comment'), '2 comments (1 unresolved)');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, _1To2,
-                '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
-        assert.equal(
-            element._computeCommentsStringMobile(element.changeComments, parentTo1
-                , '/COMMIT_MSG'), '2c');
-        assert.equal(
-            element._computeCommentsStringMobile(element.changeComments, _1To2
-                , '/COMMIT_MSG'), '3c');
-        assert.equal(
-            element._computeDraftsString(element.changeComments, parentTo1,
-                'unresolved.file'), '1 draft');
-        assert.equal(
-            element._computeDraftsString(element.changeComments, _1To2,
-                'unresolved.file'), '1 draft');
-        assert.equal(
-            element._computeDraftsStringMobile(element.changeComments, parentTo1,
-                'unresolved.file'), '1d');
-        assert.equal(
-            element._computeDraftsStringMobile(element.changeComments, _1To2,
-                'unresolved.file'), '1d');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, parentTo1,
-                'myfile.txt', 'comment'), '1 comment');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, _1To2,
-                'myfile.txt', 'comment'), '3 comments');
-        assert.equal(
-            element._computeCommentsStringMobile(
-                element.changeComments,
-                parentTo1,
-                'myfile.txt'
-            ), '1c');
-        assert.equal(
-            element._computeCommentsStringMobile(element.changeComments, _1To2,
-                'myfile.txt'), '3c');
-        assert.equal(
-            element._computeDraftsString(element.changeComments, parentTo1,
-                'myfile.txt'), '');
-        assert.equal(
-            element._computeDraftsString(element.changeComments, _1To2,
-                'myfile.txt'), '');
-        assert.equal(
-            element._computeDraftsStringMobile(element.changeComments, parentTo1,
-                'myfile.txt'), '');
-        assert.equal(
-            element._computeDraftsStringMobile(element.changeComments, _1To2,
-                'myfile.txt'), '');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, parentTo1,
-                'file_added_in_rev2.txt', 'comment'), '');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, _1To2,
-                'file_added_in_rev2.txt', 'comment'), '');
-        assert.equal(
-            element._computeCommentsStringMobile(
-                element.changeComments,
-                parentTo1,
-                'file_added_in_rev2.txt'
-            ), '');
-        assert.equal(
-            element._computeCommentsStringMobile(element.changeComments, _1To2,
-                'file_added_in_rev2.txt'), '');
-        assert.equal(
-            element._computeDraftsString(element.changeComments, parentTo1,
-                'file_added_in_rev2.txt'), '');
-        assert.equal(
-            element._computeDraftsString(element.changeComments, _1To2,
-                'file_added_in_rev2.txt'), '');
-        assert.equal(
-            element._computeDraftsStringMobile(element.changeComments, parentTo1,
-                'file_added_in_rev2.txt'), '');
-        assert.equal(
-            element._computeDraftsStringMobile(element.changeComments, _1To2,
-                'file_added_in_rev2.txt'), '');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, parentTo2,
-                '/COMMIT_MSG', 'comment'), '1 comment');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, _1To2,
-                '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
-        assert.equal(
-            element._computeCommentsStringMobile(
-                element.changeComments,
-                parentTo2,
-                '/COMMIT_MSG'
-            ), '1c');
-        assert.equal(
-            element._computeCommentsStringMobile(element.changeComments, _1To2,
-                '/COMMIT_MSG'), '3c');
-        assert.equal(
-            element._computeDraftsString(element.changeComments, parentTo1,
-                '/COMMIT_MSG'), '2 drafts');
-        assert.equal(
-            element._computeDraftsString(element.changeComments, _1To2,
-                '/COMMIT_MSG'), '2 drafts');
-        assert.equal(
-            element._computeDraftsStringMobile(
-                element.changeComments,
-                parentTo1,
-                '/COMMIT_MSG'
-            ), '2d');
-        assert.equal(
-            element._computeDraftsStringMobile(element.changeComments, _1To2,
-                '/COMMIT_MSG'), '2d');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, parentTo2,
-                'myfile.txt', 'comment'), '2 comments');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, _1To2,
-                'myfile.txt', 'comment'), '3 comments');
-        assert.equal(
-            element._computeCommentsStringMobile(
-                element.changeComments,
-                parentTo2,
-                'myfile.txt'
-            ), '2c');
-        assert.equal(
-            element._computeCommentsStringMobile(element.changeComments, _1To2,
-                'myfile.txt'), '3c');
-        assert.equal(
-            element._computeDraftsStringMobile(element.changeComments, parentTo2,
-                'myfile.txt'), '');
-        assert.equal(
-            element._computeDraftsStringMobile(element.changeComments, _1To2,
-                'myfile.txt'), '');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, parentTo2,
-                'file_added_in_rev2.txt', 'comment'), '');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, _1To2,
-                'file_added_in_rev2.txt', 'comment'), '');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, parentTo2,
-                'unresolved.file', 'comment'), '3 comments (1 unresolved)');
-        assert.equal(
-            element._computeCommentsString(element.changeComments, _1To2,
-                'unresolved.file', 'comment'), '3 comments (1 unresolved)');
-      });
+      assert.equal(
+          element._reviewedTitle(false), 'Mark as reviewed (shortcut: r)');
+    });
 
-      test('_reviewedTitle', () => {
-        assert.equal(
-            element._reviewedTitle(true), 'Mark as not reviewed (shortcut: r)');
-
-        assert.equal(
-            element._reviewedTitle(false), 'Mark as reviewed (shortcut: r)');
-      });
-
-      suite('keyboard shortcuts', () => {
-        setup(() => {
-          element._filesByPath = {
-            '/COMMIT_MSG': {},
-            'file_added_in_rev2.txt': {},
-            'myfile.txt': {},
-          };
-          element.changeNum = '42';
-          element.patchRange = {
-            basePatchNum: 'PARENT',
-            patchNum: '2',
-          };
-          element.change = {_number: 42};
-          element.$.fileCursor.setCursorAtIndex(0);
-        });
-
-        test('toggle left diff via shortcut', () => {
-          const toggleLeftDiffStub = sandbox.stub();
-          // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
-          // https://github.com/sinonjs/sinon/issues/781
-          const diffsStub = sinon.stub(element, 'diffs', {
-            get() {
-              return [{toggleLeftDiff: toggleLeftDiffStub}];
-            },
-          });
-          MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-          assert.isTrue(toggleLeftDiffStub.calledOnce);
-          diffsStub.restore();
-        });
-
-        test('keyboard shortcuts', () => {
-          flushAsynchronousOperations();
-
-          const items = Polymer.dom(element.root).querySelectorAll('.file-row');
-          element.$.fileCursor.stops = items;
-          element.$.fileCursor.setCursorAtIndex(0);
-          assert.equal(items.length, 3);
-          assert.isTrue(items[0].classList.contains('selected'));
-          assert.isFalse(items[1].classList.contains('selected'));
-          assert.isFalse(items[2].classList.contains('selected'));
-          // j with a modifier should not move the cursor.
-          MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
-          assert.equal(element.$.fileCursor.index, 0);
-          // down should not move the cursor.
-          MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
-          assert.equal(element.$.fileCursor.index, 0);
-
-          MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-          assert.equal(element.$.fileCursor.index, 1);
-          assert.equal(element.selectedIndex, 1);
-          MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-
-          const navStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-          assert.equal(element.$.fileCursor.index, 2);
-          assert.equal(element.selectedIndex, 2);
-
-          // k with a modifier should not move the cursor.
-          MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
-          assert.equal(element.$.fileCursor.index, 2);
-
-          // up should not move the cursor.
-          MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
-          assert.equal(element.$.fileCursor.index, 2);
-
-          MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-          assert.equal(element.$.fileCursor.index, 1);
-          assert.equal(element.selectedIndex, 1);
-          MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
-
-          assert(navStub.lastCall.calledWith(element.change,
-              'file_added_in_rev2.txt', '2'),
-          'Should navigate to /c/42/2/file_added_in_rev2.txt');
-
-          MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-          MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-          MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-          assert.equal(element.$.fileCursor.index, 0);
-          assert.equal(element.selectedIndex, 0);
-
-          const createCommentInPlaceStub = sandbox.stub(element.$.diffCursor,
-              'createCommentInPlace');
-          MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
-          assert.isTrue(createCommentInPlaceStub.called);
-        });
-
-        test('i key shows/hides selected inline diff', () => {
-          const paths = Object.keys(element._filesByPath);
-          sandbox.stub(element, '_expandedPathsChanged');
-          flushAsynchronousOperations();
-          const files = Polymer.dom(element.root).querySelectorAll('.file-row');
-          element.$.fileCursor.stops = files;
-          element.$.fileCursor.setCursorAtIndex(0);
-          assert.equal(element.diffs.length, 0);
-          assert.equal(element._expandedFilePaths.length, 0);
-
-          MockInteractions.keyUpOn(element, 73, null, 'i');
-          flushAsynchronousOperations();
-          assert.equal(element.diffs.length, 1);
-          assert.equal(element.diffs[0].path, paths[0]);
-          assert.equal(element._expandedFilePaths.length, 1);
-          assert.equal(element._expandedFilePaths[0], paths[0]);
-
-          MockInteractions.keyUpOn(element, 73, null, 'i');
-          flushAsynchronousOperations();
-          assert.equal(element.diffs.length, 0);
-          assert.equal(element._expandedFilePaths.length, 0);
-
-          element.$.fileCursor.setCursorAtIndex(1);
-          MockInteractions.keyUpOn(element, 73, null, 'i');
-          flushAsynchronousOperations();
-          assert.equal(element.diffs.length, 1);
-          assert.equal(element.diffs[0].path, paths[1]);
-          assert.equal(element._expandedFilePaths.length, 1);
-          assert.equal(element._expandedFilePaths[0], paths[1]);
-
-          MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-          flushAsynchronousOperations();
-          assert.equal(element.diffs.length, paths.length);
-          assert.equal(element._expandedFilePaths.length, paths.length);
-          for (const index in element.diffs) {
-            if (!element.diffs.hasOwnProperty(index)) { continue; }
-            assert.include(element._expandedFilePaths, element.diffs[index].path);
-          }
-
-          MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-          flushAsynchronousOperations();
-          assert.equal(element.diffs.length, 0);
-          assert.equal(element._expandedFilePaths.length, 0);
-        });
-
-        test('r key toggles reviewed flag', () => {
-          const reducer = (accum, file) => (file.isReviewed ? ++accum : accum);
-          const getNumReviewed = () => element._files.reduce(reducer, 0);
-          flushAsynchronousOperations();
-
-          // Default state should be unreviewed.
-          assert.equal(getNumReviewed(), 0);
-
-          // Press the review key to toggle it (set the flag).
-          MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-          flushAsynchronousOperations();
-          assert.equal(getNumReviewed(), 1);
-
-          // Press the review key to toggle it (clear the flag).
-          MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-          assert.equal(getNumReviewed(), 0);
-        });
-
-        suite('_handleOpenFile', () => {
-          let interact;
-
-          setup(() => {
-            sandbox.stub(element, 'shouldSuppressKeyboardShortcut')
-                .returns(false);
-            sandbox.stub(element, 'modifierPressed').returns(false);
-            const openCursorStub = sandbox.stub(element, '_openCursorFile');
-            const openSelectedStub = sandbox.stub(element, '_openSelectedFile');
-            const expandStub = sandbox.stub(element, '_togglePathExpanded');
-
-            interact = function(opt_payload) {
-              openCursorStub.reset();
-              openSelectedStub.reset();
-              expandStub.reset();
-
-              const e = new CustomEvent('fake-keyboard-event', opt_payload);
-              sinon.stub(e, 'preventDefault');
-              element._handleOpenFile(e);
-              assert.isTrue(e.preventDefault.called);
-              const result = {};
-              if (openCursorStub.called) {
-                result.opened_cursor = true;
-              }
-              if (openSelectedStub.called) {
-                result.opened_selected = true;
-              }
-              if (expandStub.called) {
-                result.expanded = true;
-              }
-              return result;
-            };
-          });
-
-          test('open from selected file', () => {
-            element._showInlineDiffs = false;
-            assert.deepEqual(interact(), {opened_selected: true});
-          });
-
-          test('open from diff cursor', () => {
-            element._showInlineDiffs = true;
-            assert.deepEqual(interact(), {opened_cursor: true});
-          });
-
-          test('expand when user prefers', () => {
-            element._showInlineDiffs = false;
-            assert.deepEqual(interact(), {opened_selected: true});
-            element._userPrefs = {};
-            assert.deepEqual(interact(), {opened_selected: true});
-          });
-        });
-
-        test('shift+left/shift+right', () => {
-          const moveLeftStub = sandbox.stub(element.$.diffCursor, 'moveLeft');
-          const moveRightStub = sandbox.stub(element.$.diffCursor, 'moveRight');
-
-          let noDiffsExpanded = true;
-          sandbox.stub(element, '_noDiffsExpanded', () => noDiffsExpanded);
-
-          MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
-          assert.isFalse(moveLeftStub.called);
-          MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
-          assert.isFalse(moveRightStub.called);
-
-          noDiffsExpanded = false;
-
-          MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
-          assert.isTrue(moveLeftStub.called);
-          MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
-          assert.isTrue(moveRightStub.called);
-        });
-      });
-
-      test('computed properties', () => {
-        assert.equal(element._computeFileStatus('A'), 'A');
-        assert.equal(element._computeFileStatus(undefined), 'M');
-        assert.equal(element._computeFileStatus(null), 'M');
-
-        assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
-        assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
-            'clazz invisible');
-      });
-
-      test('file review status', () => {
-        element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+    suite('keyboard shortcuts', () => {
+      setup(() => {
         element._filesByPath = {
           '/COMMIT_MSG': {},
           'file_added_in_rev2.txt': {},
           'myfile.txt': {},
         };
-        element._loggedIn = true;
         element.changeNum = '42';
         element.patchRange = {
           basePatchNum: 'PARENT',
           patchNum: '2',
         };
+        element.change = {_number: 42};
         element.$.fileCursor.setCursorAtIndex(0);
+      });
 
+      test('toggle left diff via shortcut', () => {
+        const toggleLeftDiffStub = sandbox.stub();
+        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
+        // https://github.com/sinonjs/sinon/issues/781
+        const diffsStub = sinon.stub(element, 'diffs', {
+          get() {
+            return [{toggleLeftDiff: toggleLeftDiffStub}];
+          },
+        });
+        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+        assert.isTrue(toggleLeftDiffStub.calledOnce);
+        diffsStub.restore();
+      });
+
+      test('keyboard shortcuts', () => {
         flushAsynchronousOperations();
-        const fileRows =
-            Polymer.dom(element.root).querySelectorAll('.row:not(.header-row)');
-        const checkSelector = 'input.reviewed[type="checkbox"]';
-        const commitMsg = fileRows[0].querySelector(checkSelector);
-        const fileAdded = fileRows[1].querySelector(checkSelector);
-        const myFile = fileRows[2].querySelector(checkSelector);
 
-        assert.isTrue(commitMsg.checked);
-        assert.isFalse(fileAdded.checked);
-        assert.isTrue(myFile.checked);
+        const items = dom(element.root).querySelectorAll('.file-row');
+        element.$.fileCursor.stops = items;
+        element.$.fileCursor.setCursorAtIndex(0);
+        assert.equal(items.length, 3);
+        assert.isTrue(items[0].classList.contains('selected'));
+        assert.isFalse(items[1].classList.contains('selected'));
+        assert.isFalse(items[2].classList.contains('selected'));
+        // j with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
+        assert.equal(element.$.fileCursor.index, 0);
+        // down should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
+        assert.equal(element.$.fileCursor.index, 0);
 
-        const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
-        const markReviewLabel = commitMsg.nextElementSibling;
-        assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
-        assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+        assert.equal(element.$.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
 
-        const clickSpy = sandbox.spy(element, '_handleFileListClick');
-        MockInteractions.tap(markReviewLabel);
-        assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
-        assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
-        assert.equal(markReviewLabel.textContent, 'MARK REVIEWED');
-        assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+        const navStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+        assert.equal(element.$.fileCursor.index, 2);
+        assert.equal(element.selectedIndex, 2);
 
-        MockInteractions.tap(markReviewLabel);
-        assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
-        assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
-        assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
-        assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+        // k with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
+        assert.equal(element.$.fileCursor.index, 2);
+
+        // up should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
+        assert.equal(element.$.fileCursor.index, 2);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        assert.equal(element.$.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
+
+        assert(navStub.lastCall.calledWith(element.change,
+            'file_added_in_rev2.txt', '2'),
+        'Should navigate to /c/42/2/file_added_in_rev2.txt');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        assert.equal(element.$.fileCursor.index, 0);
+        assert.equal(element.selectedIndex, 0);
+
+        const createCommentInPlaceStub = sandbox.stub(element.$.diffCursor,
+            'createCommentInPlace');
+        MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
+        assert.isTrue(createCommentInPlaceStub.called);
       });
 
-      test('_computeFileStatusLabel', () => {
-        assert.equal(element._computeFileStatusLabel('A'), 'Added');
-        assert.equal(element._computeFileStatusLabel('M'), 'Modified');
+      test('i key shows/hides selected inline diff', () => {
+        const paths = Object.keys(element._filesByPath);
+        sandbox.stub(element, '_expandedPathsChanged');
+        flushAsynchronousOperations();
+        const files = dom(element.root).querySelectorAll('.file-row');
+        element.$.fileCursor.stops = files;
+        element.$.fileCursor.setCursorAtIndex(0);
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFilePaths.length, 0);
+
+        MockInteractions.keyUpOn(element, 73, null, 'i');
+        flushAsynchronousOperations();
+        assert.equal(element.diffs.length, 1);
+        assert.equal(element.diffs[0].path, paths[0]);
+        assert.equal(element._expandedFilePaths.length, 1);
+        assert.equal(element._expandedFilePaths[0], paths[0]);
+
+        MockInteractions.keyUpOn(element, 73, null, 'i');
+        flushAsynchronousOperations();
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFilePaths.length, 0);
+
+        element.$.fileCursor.setCursorAtIndex(1);
+        MockInteractions.keyUpOn(element, 73, null, 'i');
+        flushAsynchronousOperations();
+        assert.equal(element.diffs.length, 1);
+        assert.equal(element.diffs[0].path, paths[1]);
+        assert.equal(element._expandedFilePaths.length, 1);
+        assert.equal(element._expandedFilePaths[0], paths[1]);
+
+        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+        flushAsynchronousOperations();
+        assert.equal(element.diffs.length, paths.length);
+        assert.equal(element._expandedFilePaths.length, paths.length);
+        for (const index in element.diffs) {
+          if (!element.diffs.hasOwnProperty(index)) { continue; }
+          assert.include(element._expandedFilePaths, element.diffs[index].path);
+        }
+
+        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+        flushAsynchronousOperations();
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFilePaths.length, 0);
       });
 
-      test('_handleFileListClick', () => {
-        element._filesByPath = {
-          '/COMMIT_MSG': {},
-          'f1.txt': {},
-          'f2.txt': {},
-        };
-        element.changeNum = '42';
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: '2',
-        };
+      test('r key toggles reviewed flag', () => {
+        const reducer = (accum, file) => (file.isReviewed ? ++accum : accum);
+        const getNumReviewed = () => element._files.reduce(reducer, 0);
+        flushAsynchronousOperations();
 
-        const clickSpy = sandbox.spy(element, '_handleFileListClick');
-        const reviewStub = sandbox.stub(element, '_reviewFile');
-        const toggleExpandSpy = sandbox.spy(element, '_togglePathExpanded');
+        // Default state should be unreviewed.
+        assert.equal(getNumReviewed(), 0);
 
-        const row = Polymer.dom(element.root)
-            .querySelector('.row[data-path="f1.txt"]');
+        // Press the review key to toggle it (set the flag).
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        flushAsynchronousOperations();
+        assert.equal(getNumReviewed(), 1);
 
-        // Click on the expand button, resulting in _togglePathExpanded being
-        // called and not resulting in a call to _reviewFile.
-        row.querySelector('div.show-hide').click();
-        assert.isTrue(clickSpy.calledOnce);
-        assert.isTrue(toggleExpandSpy.calledOnce);
+        // Press the review key to toggle it (clear the flag).
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.equal(getNumReviewed(), 0);
+      });
+
+      suite('_handleOpenFile', () => {
+        let interact;
+
+        setup(() => {
+          sandbox.stub(element, 'shouldSuppressKeyboardShortcut')
+              .returns(false);
+          sandbox.stub(element, 'modifierPressed').returns(false);
+          const openCursorStub = sandbox.stub(element, '_openCursorFile');
+          const openSelectedStub = sandbox.stub(element, '_openSelectedFile');
+          const expandStub = sandbox.stub(element, '_togglePathExpanded');
+
+          interact = function(opt_payload) {
+            openCursorStub.reset();
+            openSelectedStub.reset();
+            expandStub.reset();
+
+            const e = new CustomEvent('fake-keyboard-event', opt_payload);
+            sinon.stub(e, 'preventDefault');
+            element._handleOpenFile(e);
+            assert.isTrue(e.preventDefault.called);
+            const result = {};
+            if (openCursorStub.called) {
+              result.opened_cursor = true;
+            }
+            if (openSelectedStub.called) {
+              result.opened_selected = true;
+            }
+            if (expandStub.called) {
+              result.expanded = true;
+            }
+            return result;
+          };
+        });
+
+        test('open from selected file', () => {
+          element._showInlineDiffs = false;
+          assert.deepEqual(interact(), {opened_selected: true});
+        });
+
+        test('open from diff cursor', () => {
+          element._showInlineDiffs = true;
+          assert.deepEqual(interact(), {opened_cursor: true});
+        });
+
+        test('expand when user prefers', () => {
+          element._showInlineDiffs = false;
+          assert.deepEqual(interact(), {opened_selected: true});
+          element._userPrefs = {};
+          assert.deepEqual(interact(), {opened_selected: true});
+        });
+      });
+
+      test('shift+left/shift+right', () => {
+        const moveLeftStub = sandbox.stub(element.$.diffCursor, 'moveLeft');
+        const moveRightStub = sandbox.stub(element.$.diffCursor, 'moveRight');
+
+        let noDiffsExpanded = true;
+        sandbox.stub(element, '_noDiffsExpanded', () => noDiffsExpanded);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
+        assert.isFalse(moveLeftStub.called);
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
+        assert.isFalse(moveRightStub.called);
+
+        noDiffsExpanded = false;
+
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
+        assert.isTrue(moveLeftStub.called);
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
+        assert.isTrue(moveRightStub.called);
+      });
+    });
+
+    test('computed properties', () => {
+      assert.equal(element._computeFileStatus('A'), 'A');
+      assert.equal(element._computeFileStatus(undefined), 'M');
+      assert.equal(element._computeFileStatus(null), 'M');
+
+      assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
+      assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
+          'clazz invisible');
+    });
+
+    test('file review status', () => {
+      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'file_added_in_rev2.txt': {},
+        'myfile.txt': {},
+      };
+      element._loggedIn = true;
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.$.fileCursor.setCursorAtIndex(0);
+
+      flushAsynchronousOperations();
+      const fileRows =
+          dom(element.root).querySelectorAll('.row:not(.header-row)');
+      const checkSelector = 'input.reviewed[type="checkbox"]';
+      const commitMsg = fileRows[0].querySelector(checkSelector);
+      const fileAdded = fileRows[1].querySelector(checkSelector);
+      const myFile = fileRows[2].querySelector(checkSelector);
+
+      assert.isTrue(commitMsg.checked);
+      assert.isFalse(fileAdded.checked);
+      assert.isTrue(myFile.checked);
+
+      const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
+      const markReviewLabel = commitMsg.nextElementSibling;
+      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
+
+      const clickSpy = sandbox.spy(element, '_handleFileListClick');
+      MockInteractions.tap(markReviewLabel);
+      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
+      assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel.textContent, 'MARK REVIEWED');
+      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+
+      MockInteractions.tap(markReviewLabel);
+      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
+      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
+      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+    });
+
+    test('_computeFileStatusLabel', () => {
+      assert.equal(element._computeFileStatusLabel('A'), 'Added');
+      assert.equal(element._computeFileStatusLabel('M'), 'Modified');
+    });
+
+    test('_handleFileListClick', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'f1.txt': {},
+        'f2.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+
+      const clickSpy = sandbox.spy(element, '_handleFileListClick');
+      const reviewStub = sandbox.stub(element, '_reviewFile');
+      const toggleExpandSpy = sandbox.spy(element, '_togglePathExpanded');
+
+      const row = dom(element.root)
+          .querySelector('.row[data-path="f1.txt"]');
+
+      // Click on the expand button, resulting in _togglePathExpanded being
+      // called and not resulting in a call to _reviewFile.
+      row.querySelector('div.show-hide').click();
+      assert.isTrue(clickSpy.calledOnce);
+      assert.isTrue(toggleExpandSpy.calledOnce);
+      assert.isFalse(reviewStub.called);
+
+      // Click inside the diff. This should result in no additional calls to
+      // _togglePathExpanded or _reviewFile.
+      dom(element.root).querySelector('gr-diff-host')
+          .click();
+      assert.isTrue(clickSpy.calledTwice);
+      assert.isTrue(toggleExpandSpy.calledOnce);
+      assert.isFalse(reviewStub.called);
+
+      // Click the reviewed checkbox, resulting in a call to _reviewFile, but
+      // no additional call to _togglePathExpanded.
+      row.querySelector('.markReviewed').click();
+      assert.isTrue(clickSpy.calledThrice);
+      assert.isTrue(toggleExpandSpy.calledOnce);
+      assert.isTrue(reviewStub.calledOnce);
+    });
+
+    test('_handleFileListClick editMode', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'f1.txt': {},
+        'f2.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.editMode = true;
+      flushAsynchronousOperations();
+      const clickSpy = sandbox.spy(element, '_handleFileListClick');
+      const toggleExpandSpy = sandbox.spy(element, '_togglePathExpanded');
+
+      // Tap the edit controls. Should be ignored by _handleFileListClick.
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.editFileControls'));
+      assert.isTrue(clickSpy.calledOnce);
+      assert.isFalse(toggleExpandSpy.called);
+    });
+
+    test('patch set from revisions', () => {
+      const expected = [
+        {num: 4, desc: 'test', sha: 'rev4'},
+        {num: 3, desc: 'test', sha: 'rev3'},
+        {num: 2, desc: 'test', sha: 'rev2'},
+        {num: 1, desc: 'test', sha: 'rev1'},
+      ];
+      const patchNums = element.computeAllPatchSets({
+        revisions: {
+          rev3: {_number: 3, description: 'test', date: 3},
+          rev1: {_number: 1, description: 'test', date: 1},
+          rev4: {_number: 4, description: 'test', date: 4},
+          rev2: {_number: 2, description: 'test', date: 2},
+        },
+      });
+      assert.equal(patchNums.length, expected.length);
+      for (let i = 0; i < expected.length; i++) {
+        assert.deepEqual(patchNums[i], expected[i]);
+      }
+    });
+
+    test('checkbox shows/hides diff inline', () => {
+      element._filesByPath = {
+        'myfile.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.$.fileCursor.setCursorAtIndex(0);
+      sandbox.stub(element, '_expandedPathsChanged');
+      flushAsynchronousOperations();
+      const fileRows =
+          dom(element.root).querySelectorAll('.row:not(.header-row)');
+      // Because the label surrounds the input, the tap event is triggered
+      // there first.
+      const showHideLabel = fileRows[0].querySelector('label.show-hide');
+      const showHideCheck = fileRows[0].querySelector(
+          'input.show-hide[type="checkbox"]');
+      assert.isNotOk(showHideCheck.checked);
+      MockInteractions.tap(showHideLabel);
+      assert.isOk(showHideCheck.checked);
+      assert.notEqual(element._expandedFilePaths.indexOf('myfile.txt'), -1);
+    });
+
+    test('diff mode correctly toggles the diffs', () => {
+      element._filesByPath = {
+        'myfile.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      sandbox.spy(element, '_updateDiffPreferences');
+      element.$.fileCursor.setCursorAtIndex(0);
+      flushAsynchronousOperations();
+
+      // Tap on a file to generate the diff.
+      const row = dom(element.root)
+          .querySelectorAll('.row:not(.header-row) label.show-hide')[0];
+
+      MockInteractions.tap(row);
+      flushAsynchronousOperations();
+      const diffDisplay = element.diffs[0];
+      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
+      element.set('diffViewMode', 'UNIFIED_DIFF');
+      assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
+      assert.isTrue(element._updateDiffPreferences.called);
+    });
+
+    test('expanded attribute not set on path when not expanded', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+      };
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.expanded'));
+    });
+
+    test('tapping row ignores links', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      sandbox.stub(element, '_expandedPathsChanged');
+      flushAsynchronousOperations();
+      const commitMsgFile = dom(element.root)
+          .querySelectorAll('.row:not(.header-row) a.pathLink')[0];
+
+      // Remove href attribute so the app doesn't route to a diff view
+      commitMsgFile.removeAttribute('href');
+      const togglePathSpy = sandbox.spy(element, '_togglePathExpanded');
+
+      MockInteractions.tap(commitMsgFile);
+      flushAsynchronousOperations();
+      assert(togglePathSpy.notCalled, 'file is opened as diff view');
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.expanded'));
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('.show-hide')).display,
+      'none');
+    });
+
+    test('_togglePathExpanded', () => {
+      const path = 'path/to/my/file.txt';
+      element._filesByPath = {[path]: {}};
+      const renderSpy = sandbox.spy(element, '_renderInOrder');
+      const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
+
+      assert.equal(element.shadowRoot
+          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
+      assert.equal(element._expandedFilePaths.length, 0);
+      element._togglePathExpanded(path);
+      flushAsynchronousOperations();
+      assert.equal(collapseStub.lastCall.args[0].length, 0);
+      assert.equal(element.shadowRoot
+          .querySelector('iron-icon').icon, 'gr-icons:expand-less');
+
+      assert.equal(renderSpy.callCount, 1);
+      assert.include(element._expandedFilePaths, path);
+      element._togglePathExpanded(path);
+      flushAsynchronousOperations();
+
+      assert.equal(element.shadowRoot
+          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
+      assert.equal(renderSpy.callCount, 1);
+      assert.notInclude(element._expandedFilePaths, path);
+      assert.equal(collapseStub.lastCall.args[0].length, 1);
+    });
+
+    test('expandAllDiffs and collapseAllDiffs', () => {
+      const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
+      const cursorUpdateStub = sandbox.stub(element.$.diffCursor,
+          'handleDiffUpdate');
+
+      const path = 'path/to/my/file.txt';
+      element._filesByPath = {[path]: {}};
+      element.expandAllDiffs();
+      flushAsynchronousOperations();
+      assert.isTrue(element._showInlineDiffs);
+      assert.isTrue(cursorUpdateStub.calledOnce);
+      assert.equal(collapseStub.lastCall.args[0].length, 0);
+
+      element.collapseAllDiffs();
+      flushAsynchronousOperations();
+      assert.equal(element._expandedFilePaths.length, 0);
+      assert.isFalse(element._showInlineDiffs);
+      assert.isTrue(cursorUpdateStub.calledTwice);
+      assert.equal(collapseStub.lastCall.args[0].length, 1);
+    });
+
+    test('_expandedPathsChanged', done => {
+      sandbox.stub(element, '_reviewFile');
+      const path = 'path/to/my/file.txt';
+      const diffs = [{
+        path,
+        style: {},
+        reload() {
+          done();
+        },
+        cancel() {},
+        getCursorStops() { return []; },
+        addEventListener(eventName, callback) {
+          callback(new Event(eventName));
+        },
+      }];
+      sinon.stub(element, 'diffs', {
+        get() { return diffs; },
+      });
+      element.push('_expandedFilePaths', path);
+    });
+
+    test('_clearCollapsedDiffs', () => {
+      const diff = {
+        cancel: sinon.stub(),
+        clearDiffContent: sinon.stub(),
+      };
+      element._clearCollapsedDiffs([diff]);
+      assert.isTrue(diff.cancel.calledOnce);
+      assert.isTrue(diff.clearDiffContent.calledOnce);
+    });
+
+    test('filesExpanded value updates to correct enum', () => {
+      element._filesByPath = {
+        'foo.bar': {},
+        'baz.bar': {},
+      };
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.NONE);
+      element.push('_expandedFilePaths', 'baz.bar');
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.SOME);
+      element.push('_expandedFilePaths', 'foo.bar');
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.ALL);
+      element.collapseAllDiffs();
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.NONE);
+      element.expandAllDiffs();
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.ALL);
+    });
+
+    test('_renderInOrder', done => {
+      const reviewStub = sandbox.stub(element, '_reviewFile');
+      let callCount = 0;
+      const diffs = [{
+        path: 'p0',
+        style: {},
+        reload() {
+          assert.equal(callCount++, 2);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p1',
+        style: {},
+        reload() {
+          assert.equal(callCount++, 1);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p2',
+        style: {},
+        reload() {
+          assert.equal(callCount++, 0);
+          return Promise.resolve();
+        },
+      }];
+      element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
+          .then(() => {
+            assert.isFalse(reviewStub.called);
+            assert.isTrue(loadCommentSpy.called);
+            done();
+          });
+    });
+
+    test('_renderInOrder logged in', done => {
+      element._loggedIn = true;
+      const reviewStub = sandbox.stub(element, '_reviewFile');
+      let callCount = 0;
+      const diffs = [{
+        path: 'p0',
+        style: {},
+        reload() {
+          assert.equal(reviewStub.callCount, 2);
+          assert.equal(callCount++, 2);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p1',
+        style: {},
+        reload() {
+          assert.equal(reviewStub.callCount, 1);
+          assert.equal(callCount++, 1);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p2',
+        style: {},
+        reload() {
+          assert.equal(reviewStub.callCount, 0);
+          assert.equal(callCount++, 0);
+          return Promise.resolve();
+        },
+      }];
+      element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
+          .then(() => {
+            assert.equal(reviewStub.callCount, 3);
+            done();
+          });
+    });
+
+    test('_renderInOrder respects diffPrefs.manual_review', () => {
+      element._loggedIn = true;
+      element.diffPrefs = {manual_review: true};
+      const reviewStub = sandbox.stub(element, '_reviewFile');
+      const diffs = [{
+        path: 'p',
+        style: {},
+        reload() { return Promise.resolve(); },
+      }];
+
+      return element._renderInOrder(['p'], diffs, 1).then(() => {
         assert.isFalse(reviewStub.called);
+        delete element.diffPrefs.manual_review;
+        return element._renderInOrder(['p'], diffs, 1).then(() => {
+          assert.isTrue(reviewStub.called);
+          assert.isTrue(reviewStub.calledWithExactly('p', true));
+        });
+      });
+    });
 
-        // Click inside the diff. This should result in no additional calls to
-        // _togglePathExpanded or _reviewFile.
-        Polymer.dom(element.root).querySelector('gr-diff-host')
-            .click();
-        assert.isTrue(clickSpy.calledTwice);
-        assert.isTrue(toggleExpandSpy.calledOnce);
-        assert.isFalse(reviewStub.called);
+    test('_loadingChanged fired from reload in debouncer', done => {
+      sandbox.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
+      element.changeNum = 123;
+      element.patchRange = {patchNum: 12};
+      element._filesByPath = {'foo.bar': {}};
 
-        // Click the reviewed checkbox, resulting in a call to _reviewFile, but
-        // no additional call to _togglePathExpanded.
-        row.querySelector('.markReviewed').click();
-        assert.isTrue(clickSpy.calledThrice);
-        assert.isTrue(toggleExpandSpy.calledOnce);
-        assert.isTrue(reviewStub.calledOnce);
+      element.reload().then(() => {
+        assert.isFalse(element._loading);
+        element.flushDebouncer('loading-change');
+        assert.isFalse(element.classList.contains('loading'));
+        done();
+      });
+      assert.isTrue(element._loading);
+      assert.isFalse(element.classList.contains('loading'));
+      element.flushDebouncer('loading-change');
+      assert.isTrue(element.classList.contains('loading'));
+    });
+
+    test('_loadingChanged does not set class when there are no files', () => {
+      sandbox.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
+      element.changeNum = 123;
+      element.patchRange = {patchNum: 12};
+      element.reload();
+      assert.isTrue(element._loading);
+      element.flushDebouncer('loading-change');
+      assert.isFalse(element.classList.contains('loading'));
+    });
+  });
+
+  suite('diff url file list', () => {
+    test('diff url', () => {
+      const diffStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiff')
+          .returns('/c/gerrit/+/1/1/index.php');
+      const change = {
+        _number: 1,
+        project: 'gerrit',
+      };
+      const path = 'index.php';
+      const patchRange = {
+        patchNum: 1,
+      };
+      assert.equal(
+          element._computeDiffURL(change, patchRange, path, false),
+          '/c/gerrit/+/1/1/index.php');
+      diffStub.restore();
+    });
+
+    test('diff url commit msg', () => {
+      const diffStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiff')
+          .returns('/c/gerrit/+/1/1//COMMIT_MSG');
+      const change = {
+        _number: 1,
+        project: 'gerrit',
+      };
+      const path = '/COMMIT_MSG';
+      const patchRange = {
+        patchNum: 1,
+      };
+      assert.equal(
+          element._computeDiffURL(change, patchRange, path, false),
+          '/c/gerrit/+/1/1//COMMIT_MSG');
+      diffStub.restore();
+    });
+  });
+
+  suite('size bars', () => {
+    test('_computeSizeBarLayout', () => {
+      assert.isUndefined(element._computeSizeBarLayout(null));
+      assert.isUndefined(element._computeSizeBarLayout({}));
+      assert.deepEqual(element._computeSizeBarLayout({base: []}), {
+        maxInserted: 0,
+        maxDeleted: 0,
+        maxAdditionWidth: 0,
+        maxDeletionWidth: 0,
+        deletionOffset: 0,
       });
 
-      test('_handleFileListClick editMode', () => {
-        element._filesByPath = {
-          '/COMMIT_MSG': {},
-          'f1.txt': {},
-          'f2.txt': {},
-        };
-        element.changeNum = '42';
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: '2',
-        };
+      const files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 10000},
+        {__path: 'foo', lines_inserted: 4, lines_deleted: 10},
+        {__path: 'bar', lines_inserted: 5, lines_deleted: 8},
+      ];
+      const layout = element._computeSizeBarLayout({base: files});
+      assert.equal(layout.maxInserted, 5);
+      assert.equal(layout.maxDeleted, 10);
+    });
+
+    test('_computeBarAdditionWidth', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 5,
+        lines_deleted: 0,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 0,
+        maxAdditionWidth: 60,
+        maxDeletionWidth: 0,
+        deletionOffset: 60,
+      };
+
+      // Uses half the space when file is half the largest addition and there
+      // are no deletions.
+      assert.equal(element._computeBarAdditionWidth(file, stats), 30);
+
+      // If there are no insetions, there is no width.
+      stats.maxInserted = 0;
+      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+
+      // If the insertions is not present on the file, there is no width.
+      stats.maxInserted = 10;
+      file.lines_inserted = undefined;
+      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+
+      // If the file is a commit message, returns zero.
+      file.lines_inserted = 5;
+      file.__path = '/COMMIT_MSG';
+      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+
+      // Width bottoms-out at the minimum width.
+      file.__path = 'stuff.txt';
+      file.lines_inserted = 1;
+      stats.maxInserted = 1000000;
+      assert.equal(element._computeBarAdditionWidth(file, stats), 1.5);
+    });
+
+    test('_computeBarAdditionX', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 5,
+        lines_deleted: 0,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 0,
+        maxAdditionWidth: 60,
+        maxDeletionWidth: 0,
+        deletionOffset: 60,
+      };
+      assert.equal(element._computeBarAdditionX(file, stats), 30);
+    });
+
+    test('_computeBarDeletionWidth', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 0,
+        lines_deleted: 5,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 10,
+        maxAdditionWidth: 30,
+        maxDeletionWidth: 30,
+        deletionOffset: 31,
+      };
+
+      // Uses a quarter the space when file is half the largest deletions and
+      // there are equal additions.
+      assert.equal(element._computeBarDeletionWidth(file, stats), 15);
+
+      // If there are no deletions, there is no width.
+      stats.maxDeleted = 0;
+      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+
+      // If the deletions is not present on the file, there is no width.
+      stats.maxDeleted = 10;
+      file.lines_deleted = undefined;
+      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+
+      // If the file is a commit message, returns zero.
+      file.lines_deleted = 5;
+      file.__path = '/COMMIT_MSG';
+      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+
+      // Width bottoms-out at the minimum width.
+      file.__path = 'stuff.txt';
+      file.lines_deleted = 1;
+      stats.maxDeleted = 1000000;
+      assert.equal(element._computeBarDeletionWidth(file, stats), 1.5);
+    });
+
+    test('_computeSizeBarsClass', () => {
+      assert.equal(element._computeSizeBarsClass(false, 'foo/bar.baz'),
+          'sizeBars desktop hide');
+      assert.equal(element._computeSizeBarsClass(true, '/COMMIT_MSG'),
+          'sizeBars desktop invisible');
+      assert.equal(element._computeSizeBarsClass(true, 'foo/bar.baz'),
+          'sizeBars desktop ');
+    });
+  });
+
+  suite('gr-file-list inline diff tests', () => {
+    let element;
+    let sandbox;
+
+    const commitMsgComments = [
+      {
+        patch_set: 2,
+        id: 'ecf0b9fa_fe1a5f62',
+        line: 20,
+        updated: '2018-02-08 18:49:18.000000000',
+        message: 'another comment',
+        unresolved: true,
+      },
+      {
+        patch_set: 2,
+        id: '503008e2_0ab203ee',
+        line: 10,
+        updated: '2018-02-14 22:07:43.000000000',
+        message: 'a comment',
+        unresolved: true,
+      },
+      {
+        patch_set: 2,
+        id: 'cc788d2c_cb1d728c',
+        line: 20,
+        in_reply_to: 'ecf0b9fa_fe1a5f62',
+        updated: '2018-02-13 22:07:43.000000000',
+        message: 'response',
+        unresolved: true,
+      },
+    ];
+
+    const setupDiff = function(diff) {
+      const mock = document.createElement('mock-diff-response');
+      diff.comments = {
+        left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
+        right: [],
+        meta: {
+          changeNum: 1,
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 2,
+          },
+        },
+      };
+      diff.prefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        intraline_difference: true,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        auto_hide_diff_table_header: true,
+        theme: 'DEFAULT',
+        ignore_whitespace: 'IGNORE_NONE',
+      };
+      diff.diff = mock.diffResponse;
+      diff.$.diff.flushDebouncer('renderDiffTable');
+    };
+
+    const renderAndGetNewDiffs = function(index) {
+      const diffs =
+          dom(element.root).querySelectorAll('gr-diff-host');
+
+      for (let i = index; i < diffs.length; i++) {
+        setupDiff(diffs[i]);
+      }
+
+      element._updateDiffCursor();
+      element.$.diffCursor.handleDiffUpdate();
+      return diffs;
+    };
+
+    setup(done => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+      stub('gr-date-formatter', {
+        _loadTimeFormat() { return Promise.resolve(''); },
+      });
+      stub('gr-diff-host', {
+        reload() { return Promise.resolve(); },
+      });
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = fixture('basic');
+      element = commentApiWrapper.$.fileList;
+      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+      element.diffPrefs = {};
+      sandbox.stub(element, '_reviewFile');
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      commentApiWrapper.loadComments().then(() => {
+        sandbox.stub(element.changeComments, 'getPaths').returns({});
+        sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
+            .returns({meta: {}, left: [], right: []});
+        done();
+      });
+      element._loading = false;
+      element.numFilesShown = 75;
+      element.selectedIndex = 0;
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9},
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      };
+      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element._loggedIn = true;
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      sandbox.stub(window, 'fetch', () => Promise.resolve());
+      flushAsynchronousOperations();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('cursor with individually opened files', () => {
+      MockInteractions.keyUpOn(element, 73, null, 'i');
+      flushAsynchronousOperations();
+      let diffs = renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 1);
+
+      // No line number is selected.
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(dom(
+          diffStops[10]).querySelectorAll('.contentText')[0]);
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+      assert.isFalse(diffStops[11].classList.contains('target-row'));
+
+      // The file cusor is now at 1.
+      assert.equal(element.$.fileCursor.index, 1);
+      MockInteractions.keyUpOn(element, 73, null, 'i');
+      flushAsynchronousOperations();
+
+      diffs = renderAndGetNewDiffs(1);
+      // Two diffs should be rendered.
+      assert.equal(diffs.length, 2);
+      const diffStopsFirst = diffs[0].getCursorStops();
+      const diffStopsSecond = diffs[1].getCursorStops();
+
+      // The line on the first diff is stil selected
+      assert.isTrue(diffStopsFirst[10].classList.contains('target-row'));
+      assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
+    });
+
+    test('cursor with toggle all files', () => {
+      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+      flushAsynchronousOperations();
+
+      const diffs = renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 3);
+
+      // No line number is selected.
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(dom(
+          diffStops[10]).querySelectorAll('.contentText')[0]);
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      flushAsynchronousOperations();
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+      assert.isTrue(diffStops[11].classList.contains('target-row'));
+
+      // The file cusor is still at 0.
+      assert.equal(element.$.fileCursor.index, 0);
+    });
+
+    suite('n key presses', () => {
+      let nKeySpy;
+      let nextCommentStub;
+      let nextChunkStub;
+      let fileRows;
+
+      setup(() => {
+        sandbox.stub(element, '_renderInOrder').returns(Promise.resolve());
+        nKeySpy = sandbox.spy(element, '_handleNextChunk');
+        nextCommentStub = sandbox.stub(element.$.diffCursor,
+            'moveToNextCommentThread');
+        nextChunkStub = sandbox.stub(element.$.diffCursor,
+            'moveToNextChunk');
+        fileRows =
+            dom(element.root).querySelectorAll('.row:not(.header-row)');
+      });
+
+      test('n key with some files expanded and no shift key', () => {
+        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
+        flushAsynchronousOperations();
+        assert.equal(nextChunkStub.callCount, 1);
+
+        // Handle N key should return before calling diff cursor functions.
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 2);
+        assert.equal(element.filesExpanded, 'some');
+      });
+
+      test('n key with some files expanded and shift key', () => {
+        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
+        flushAsynchronousOperations();
+        assert.equal(nextChunkStub.callCount, 1);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isTrue(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.equal(element.filesExpanded, 'some');
+      });
+
+      test('n key without all files expanded and shift key', () => {
+        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 2);
+        assert.isTrue(element._showInlineDiffs);
+      });
+
+      test('n key without all files expanded and no shift key', () => {
+        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isTrue(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.isTrue(element._showInlineDiffs);
+      });
+    });
+
+    test('_openSelectedFile behavior', () => {
+      const _filesByPath = element._filesByPath;
+      element.set('_filesByPath', {});
+      const navStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      // Noop when there are no files.
+      element._openSelectedFile();
+      assert.isFalse(navStub.called);
+
+      element.set('_filesByPath', _filesByPath);
+      flushAsynchronousOperations();
+      // Navigates when a file is selected.
+      element._openSelectedFile();
+      assert.isTrue(navStub.called);
+    });
+
+    test('_displayLine', () => {
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut', () => false);
+      sandbox.stub(element, 'modifierPressed', () => false);
+      element._showInlineDiffs = true;
+      const mockEvent = {preventDefault() {}};
+
+      element._displayLine = false;
+      element._handleCursorNext(mockEvent);
+      assert.isTrue(element._displayLine);
+
+      element._displayLine = false;
+      element._handleCursorPrev(mockEvent);
+      assert.isTrue(element._displayLine);
+
+      element._displayLine = true;
+      element._handleEscKey(mockEvent);
+      assert.isFalse(element._displayLine);
+    });
+
+    suite('editMode behavior', () => {
+      test('reviewed checkbox', () => {
+        element._reviewFile.restore();
+        const saveReviewStub = sandbox.stub(element, '_saveReviewedState');
+
+        element.editMode = false;
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.isTrue(saveReviewStub.calledOnce);
+
         element.editMode = true;
         flushAsynchronousOperations();
-        const clickSpy = sandbox.spy(element, '_handleFileListClick');
-        const toggleExpandSpy = sandbox.spy(element, '_togglePathExpanded');
 
-        // Tap the edit controls. Should be ignored by _handleFileListClick.
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.editFileControls'));
-        assert.isTrue(clickSpy.calledOnce);
-        assert.isFalse(toggleExpandSpy.called);
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.isTrue(saveReviewStub.calledOnce);
       });
 
-      test('patch set from revisions', () => {
-        const expected = [
-          {num: 4, desc: 'test', sha: 'rev4'},
-          {num: 3, desc: 'test', sha: 'rev3'},
-          {num: 2, desc: 'test', sha: 'rev2'},
-          {num: 1, desc: 'test', sha: 'rev1'},
-        ];
-        const patchNums = element.computeAllPatchSets({
-          revisions: {
-            rev3: {_number: 3, description: 'test', date: 3},
-            rev1: {_number: 1, description: 'test', date: 1},
-            rev4: {_number: 4, description: 'test', date: 4},
-            rev2: {_number: 2, description: 'test', date: 2},
-          },
+      test('_getReviewedFiles does not call API', () => {
+        const apiSpy = sandbox.spy(element.$.restAPI, 'getReviewedFiles');
+        element.editMode = true;
+        return element._getReviewedFiles().then(files => {
+          assert.equal(files.length, 0);
+          assert.isFalse(apiSpy.called);
         });
-        assert.equal(patchNums.length, expected.length);
-        for (let i = 0; i < expected.length; i++) {
-          assert.deepEqual(patchNums[i], expected[i]);
-        }
-      });
-
-      test('checkbox shows/hides diff inline', () => {
-        element._filesByPath = {
-          'myfile.txt': {},
-        };
-        element.changeNum = '42';
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: '2',
-        };
-        element.$.fileCursor.setCursorAtIndex(0);
-        sandbox.stub(element, '_expandedPathsChanged');
-        flushAsynchronousOperations();
-        const fileRows =
-            Polymer.dom(element.root).querySelectorAll('.row:not(.header-row)');
-        // Because the label surrounds the input, the tap event is triggered
-        // there first.
-        const showHideLabel = fileRows[0].querySelector('label.show-hide');
-        const showHideCheck = fileRows[0].querySelector(
-            'input.show-hide[type="checkbox"]');
-        assert.isNotOk(showHideCheck.checked);
-        MockInteractions.tap(showHideLabel);
-        assert.isOk(showHideCheck.checked);
-        assert.notEqual(element._expandedFilePaths.indexOf('myfile.txt'), -1);
-      });
-
-      test('diff mode correctly toggles the diffs', () => {
-        element._filesByPath = {
-          'myfile.txt': {},
-        };
-        element.changeNum = '42';
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: '2',
-        };
-        sandbox.spy(element, '_updateDiffPreferences');
-        element.$.fileCursor.setCursorAtIndex(0);
-        flushAsynchronousOperations();
-
-        // Tap on a file to generate the diff.
-        const row = Polymer.dom(element.root)
-            .querySelectorAll('.row:not(.header-row) label.show-hide')[0];
-
-        MockInteractions.tap(row);
-        flushAsynchronousOperations();
-        const diffDisplay = element.diffs[0];
-        element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
-        element.set('diffViewMode', 'UNIFIED_DIFF');
-        assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
-        assert.isTrue(element._updateDiffPreferences.called);
-      });
-
-      test('expanded attribute not set on path when not expanded', () => {
-        element._filesByPath = {
-          '/COMMIT_MSG': {},
-        };
-        assert.isNotOk(element.shadowRoot
-            .querySelector('.expanded'));
-      });
-
-      test('tapping row ignores links', () => {
-        element._filesByPath = {
-          '/COMMIT_MSG': {},
-        };
-        element.changeNum = '42';
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: '2',
-        };
-        sandbox.stub(element, '_expandedPathsChanged');
-        flushAsynchronousOperations();
-        const commitMsgFile = Polymer.dom(element.root)
-            .querySelectorAll('.row:not(.header-row) a.pathLink')[0];
-
-        // Remove href attribute so the app doesn't route to a diff view
-        commitMsgFile.removeAttribute('href');
-        const togglePathSpy = sandbox.spy(element, '_togglePathExpanded');
-
-        MockInteractions.tap(commitMsgFile);
-        flushAsynchronousOperations();
-        assert(togglePathSpy.notCalled, 'file is opened as diff view');
-        assert.isNotOk(element.shadowRoot
-            .querySelector('.expanded'));
-        assert.notEqual(getComputedStyle(element.shadowRoot
-            .querySelector('.show-hide')).display,
-        'none');
-      });
-
-      test('_togglePathExpanded', () => {
-        const path = 'path/to/my/file.txt';
-        element._filesByPath = {[path]: {}};
-        const renderSpy = sandbox.spy(element, '_renderInOrder');
-        const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
-
-        assert.equal(element.shadowRoot
-            .querySelector('iron-icon').icon, 'gr-icons:expand-more');
-        assert.equal(element._expandedFilePaths.length, 0);
-        element._togglePathExpanded(path);
-        flushAsynchronousOperations();
-        assert.equal(collapseStub.lastCall.args[0].length, 0);
-        assert.equal(element.shadowRoot
-            .querySelector('iron-icon').icon, 'gr-icons:expand-less');
-
-        assert.equal(renderSpy.callCount, 1);
-        assert.include(element._expandedFilePaths, path);
-        element._togglePathExpanded(path);
-        flushAsynchronousOperations();
-
-        assert.equal(element.shadowRoot
-            .querySelector('iron-icon').icon, 'gr-icons:expand-more');
-        assert.equal(renderSpy.callCount, 1);
-        assert.notInclude(element._expandedFilePaths, path);
-        assert.equal(collapseStub.lastCall.args[0].length, 1);
-      });
-
-      test('expandAllDiffs and collapseAllDiffs', () => {
-        const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
-        const cursorUpdateStub = sandbox.stub(element.$.diffCursor,
-            'handleDiffUpdate');
-
-        const path = 'path/to/my/file.txt';
-        element._filesByPath = {[path]: {}};
-        element.expandAllDiffs();
-        flushAsynchronousOperations();
-        assert.isTrue(element._showInlineDiffs);
-        assert.isTrue(cursorUpdateStub.calledOnce);
-        assert.equal(collapseStub.lastCall.args[0].length, 0);
-
-        element.collapseAllDiffs();
-        flushAsynchronousOperations();
-        assert.equal(element._expandedFilePaths.length, 0);
-        assert.isFalse(element._showInlineDiffs);
-        assert.isTrue(cursorUpdateStub.calledTwice);
-        assert.equal(collapseStub.lastCall.args[0].length, 1);
-      });
-
-      test('_expandedPathsChanged', done => {
-        sandbox.stub(element, '_reviewFile');
-        const path = 'path/to/my/file.txt';
-        const diffs = [{
-          path,
-          style: {},
-          reload() {
-            done();
-          },
-          cancel() {},
-          getCursorStops() { return []; },
-          addEventListener(eventName, callback) {
-            callback(new Event(eventName));
-          },
-        }];
-        sinon.stub(element, 'diffs', {
-          get() { return diffs; },
-        });
-        element.push('_expandedFilePaths', path);
-      });
-
-      test('_clearCollapsedDiffs', () => {
-        const diff = {
-          cancel: sinon.stub(),
-          clearDiffContent: sinon.stub(),
-        };
-        element._clearCollapsedDiffs([diff]);
-        assert.isTrue(diff.cancel.calledOnce);
-        assert.isTrue(diff.clearDiffContent.calledOnce);
-      });
-
-      test('filesExpanded value updates to correct enum', () => {
-        element._filesByPath = {
-          'foo.bar': {},
-          'baz.bar': {},
-        };
-        flushAsynchronousOperations();
-        assert.equal(element.filesExpanded,
-            GrFileListConstants.FilesExpandedState.NONE);
-        element.push('_expandedFilePaths', 'baz.bar');
-        flushAsynchronousOperations();
-        assert.equal(element.filesExpanded,
-            GrFileListConstants.FilesExpandedState.SOME);
-        element.push('_expandedFilePaths', 'foo.bar');
-        flushAsynchronousOperations();
-        assert.equal(element.filesExpanded,
-            GrFileListConstants.FilesExpandedState.ALL);
-        element.collapseAllDiffs();
-        flushAsynchronousOperations();
-        assert.equal(element.filesExpanded,
-            GrFileListConstants.FilesExpandedState.NONE);
-        element.expandAllDiffs();
-        flushAsynchronousOperations();
-        assert.equal(element.filesExpanded,
-            GrFileListConstants.FilesExpandedState.ALL);
-      });
-
-      test('_renderInOrder', done => {
-        const reviewStub = sandbox.stub(element, '_reviewFile');
-        let callCount = 0;
-        const diffs = [{
-          path: 'p0',
-          style: {},
-          reload() {
-            assert.equal(callCount++, 2);
-            return Promise.resolve();
-          },
-        }, {
-          path: 'p1',
-          style: {},
-          reload() {
-            assert.equal(callCount++, 1);
-            return Promise.resolve();
-          },
-        }, {
-          path: 'p2',
-          style: {},
-          reload() {
-            assert.equal(callCount++, 0);
-            return Promise.resolve();
-          },
-        }];
-        element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
-            .then(() => {
-              assert.isFalse(reviewStub.called);
-              assert.isTrue(loadCommentSpy.called);
-              done();
-            });
-      });
-
-      test('_renderInOrder logged in', done => {
-        element._loggedIn = true;
-        const reviewStub = sandbox.stub(element, '_reviewFile');
-        let callCount = 0;
-        const diffs = [{
-          path: 'p0',
-          style: {},
-          reload() {
-            assert.equal(reviewStub.callCount, 2);
-            assert.equal(callCount++, 2);
-            return Promise.resolve();
-          },
-        }, {
-          path: 'p1',
-          style: {},
-          reload() {
-            assert.equal(reviewStub.callCount, 1);
-            assert.equal(callCount++, 1);
-            return Promise.resolve();
-          },
-        }, {
-          path: 'p2',
-          style: {},
-          reload() {
-            assert.equal(reviewStub.callCount, 0);
-            assert.equal(callCount++, 0);
-            return Promise.resolve();
-          },
-        }];
-        element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
-            .then(() => {
-              assert.equal(reviewStub.callCount, 3);
-              done();
-            });
-      });
-
-      test('_renderInOrder respects diffPrefs.manual_review', () => {
-        element._loggedIn = true;
-        element.diffPrefs = {manual_review: true};
-        const reviewStub = sandbox.stub(element, '_reviewFile');
-        const diffs = [{
-          path: 'p',
-          style: {},
-          reload() { return Promise.resolve(); },
-        }];
-
-        return element._renderInOrder(['p'], diffs, 1).then(() => {
-          assert.isFalse(reviewStub.called);
-          delete element.diffPrefs.manual_review;
-          return element._renderInOrder(['p'], diffs, 1).then(() => {
-            assert.isTrue(reviewStub.called);
-            assert.isTrue(reviewStub.calledWithExactly('p', true));
-          });
-        });
-      });
-
-      test('_loadingChanged fired from reload in debouncer', done => {
-        sandbox.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
-        element.changeNum = 123;
-        element.patchRange = {patchNum: 12};
-        element._filesByPath = {'foo.bar': {}};
-
-        element.reload().then(() => {
-          assert.isFalse(element._loading);
-          element.flushDebouncer('loading-change');
-          assert.isFalse(element.classList.contains('loading'));
-          done();
-        });
-        assert.isTrue(element._loading);
-        assert.isFalse(element.classList.contains('loading'));
-        element.flushDebouncer('loading-change');
-        assert.isTrue(element.classList.contains('loading'));
-      });
-
-      test('_loadingChanged does not set class when there are no files', () => {
-        sandbox.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
-        element.changeNum = 123;
-        element.patchRange = {patchNum: 12};
-        element.reload();
-        assert.isTrue(element._loading);
-        element.flushDebouncer('loading-change');
-        assert.isFalse(element.classList.contains('loading'));
       });
     });
 
-    suite('diff url file list', () => {
-      test('diff url', () => {
-        const diffStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiff')
-            .returns('/c/gerrit/+/1/1/index.php');
-        const change = {
-          _number: 1,
-          project: 'gerrit',
-        };
-        const path = 'index.php';
-        const patchRange = {
-          patchNum: 1,
-        };
-        assert.equal(
-            element._computeDiffURL(change, patchRange, path, false),
-            '/c/gerrit/+/1/1/index.php');
-        diffStub.restore();
-      });
+    test('editing actions', () => {
+      // Edit controls are guarded behind a dom-if initially and not rendered.
+      assert.isNotOk(dom(element.root)
+          .querySelector('gr-edit-file-controls'));
 
-      test('diff url commit msg', () => {
-        const diffStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiff')
-            .returns('/c/gerrit/+/1/1//COMMIT_MSG');
-        const change = {
-          _number: 1,
-          project: 'gerrit',
-        };
-        const path = '/COMMIT_MSG';
-        const patchRange = {
-          patchNum: 1,
-        };
-        assert.equal(
-            element._computeDiffURL(change, patchRange, path, false),
-            '/c/gerrit/+/1/1//COMMIT_MSG');
-        diffStub.restore();
-      });
+      element.editMode = true;
+      flushAsynchronousOperations();
+
+      // Commit message should not have edit controls.
+      const editControls =
+          Array.from(
+              dom(element.root)
+                  .querySelectorAll('.row:not(.header-row)'))
+              .map(row => row.querySelector('gr-edit-file-controls'));
+      assert.isTrue(editControls[0].classList.contains('invisible'));
     });
 
-    suite('size bars', () => {
-      test('_computeSizeBarLayout', () => {
-        assert.isUndefined(element._computeSizeBarLayout(null));
-        assert.isUndefined(element._computeSizeBarLayout({}));
-        assert.deepEqual(element._computeSizeBarLayout({base: []}), {
-          maxInserted: 0,
-          maxDeleted: 0,
-          maxAdditionWidth: 0,
-          maxDeletionWidth: 0,
-          deletionOffset: 0,
-        });
+    test('reloadCommentsForThreadWithRootId', () => {
+      // Expand the commit message diff
+      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+      const diffs = renderAndGetNewDiffs(0);
+      flushAsynchronousOperations();
 
-        const files = [
-          {__path: '/COMMIT_MSG', lines_inserted: 10000},
-          {__path: 'foo', lines_inserted: 4, lines_deleted: 10},
-          {__path: 'bar', lines_inserted: 5, lines_deleted: 8},
-        ];
-        const layout = element._computeSizeBarLayout({base: files});
-        assert.equal(layout.maxInserted, 5);
-        assert.equal(layout.maxDeleted, 10);
-      });
+      // Two comment threads should be generated by renderAndGetNewDiffs
+      const threadEls = diffs[0].getThreadEls();
+      assert.equal(threadEls.length, 2);
+      const threadElsByRootId = new Map(
+          threadEls.map(threadEl => [threadEl.rootId, threadEl]));
 
-      test('_computeBarAdditionWidth', () => {
-        const file = {
-          __path: 'foo/bar.baz',
-          lines_inserted: 5,
-          lines_deleted: 0,
-        };
-        const stats = {
-          maxInserted: 10,
-          maxDeleted: 0,
-          maxAdditionWidth: 60,
-          maxDeletionWidth: 0,
-          deletionOffset: 60,
-        };
+      const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
+      assert.equal(thread1.comments.length, 1);
+      assert.equal(thread1.comments[0].message, 'a comment');
+      assert.equal(thread1.comments[0].line, 10);
 
-        // Uses half the space when file is half the largest addition and there
-        // are no deletions.
-        assert.equal(element._computeBarAdditionWidth(file, stats), 30);
+      const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
+      assert.equal(thread2.comments.length, 2);
+      assert.isTrue(thread2.comments[0].unresolved);
+      assert.equal(thread2.comments[0].message, 'another comment');
+      assert.equal(thread2.comments[0].line, 20);
 
-        // If there are no insetions, there is no width.
-        stats.maxInserted = 0;
-        assert.equal(element._computeBarAdditionWidth(file, stats), 0);
-
-        // If the insertions is not present on the file, there is no width.
-        stats.maxInserted = 10;
-        file.lines_inserted = undefined;
-        assert.equal(element._computeBarAdditionWidth(file, stats), 0);
-
-        // If the file is a commit message, returns zero.
-        file.lines_inserted = 5;
-        file.__path = '/COMMIT_MSG';
-        assert.equal(element._computeBarAdditionWidth(file, stats), 0);
-
-        // Width bottoms-out at the minimum width.
-        file.__path = 'stuff.txt';
-        file.lines_inserted = 1;
-        stats.maxInserted = 1000000;
-        assert.equal(element._computeBarAdditionWidth(file, stats), 1.5);
-      });
-
-      test('_computeBarAdditionX', () => {
-        const file = {
-          __path: 'foo/bar.baz',
-          lines_inserted: 5,
-          lines_deleted: 0,
-        };
-        const stats = {
-          maxInserted: 10,
-          maxDeleted: 0,
-          maxAdditionWidth: 60,
-          maxDeletionWidth: 0,
-          deletionOffset: 60,
-        };
-        assert.equal(element._computeBarAdditionX(file, stats), 30);
-      });
-
-      test('_computeBarDeletionWidth', () => {
-        const file = {
-          __path: 'foo/bar.baz',
-          lines_inserted: 0,
-          lines_deleted: 5,
-        };
-        const stats = {
-          maxInserted: 10,
-          maxDeleted: 10,
-          maxAdditionWidth: 30,
-          maxDeletionWidth: 30,
-          deletionOffset: 31,
-        };
-
-        // Uses a quarter the space when file is half the largest deletions and
-        // there are equal additions.
-        assert.equal(element._computeBarDeletionWidth(file, stats), 15);
-
-        // If there are no deletions, there is no width.
-        stats.maxDeleted = 0;
-        assert.equal(element._computeBarDeletionWidth(file, stats), 0);
-
-        // If the deletions is not present on the file, there is no width.
-        stats.maxDeleted = 10;
-        file.lines_deleted = undefined;
-        assert.equal(element._computeBarDeletionWidth(file, stats), 0);
-
-        // If the file is a commit message, returns zero.
-        file.lines_deleted = 5;
-        file.__path = '/COMMIT_MSG';
-        assert.equal(element._computeBarDeletionWidth(file, stats), 0);
-
-        // Width bottoms-out at the minimum width.
-        file.__path = 'stuff.txt';
-        file.lines_deleted = 1;
-        stats.maxDeleted = 1000000;
-        assert.equal(element._computeBarDeletionWidth(file, stats), 1.5);
-      });
-
-      test('_computeSizeBarsClass', () => {
-        assert.equal(element._computeSizeBarsClass(false, 'foo/bar.baz'),
-            'sizeBars desktop hide');
-        assert.equal(element._computeSizeBarsClass(true, '/COMMIT_MSG'),
-            'sizeBars desktop invisible');
-        assert.equal(element._computeSizeBarsClass(true, 'foo/bar.baz'),
-            'sizeBars desktop ');
-      });
-    });
-
-    suite('gr-file-list inline diff tests', () => {
-      let element;
-      let sandbox;
-
-      const commitMsgComments = [
+      const commentStub =
+          sandbox.stub(element.changeComments, 'getCommentsForThread');
+      const commentStubRes1 = [
+        {
+          patch_set: 2,
+          id: '503008e2_0ab203ee',
+          line: 20,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'edited text',
+          unresolved: false,
+        },
+      ];
+      const commentStubRes2 = [
         {
           patch_set: 2,
           id: 'ecf0b9fa_fe1a5f62',
@@ -1446,453 +1856,58 @@
           patch_set: 2,
           id: '503008e2_0ab203ee',
           line: 10,
+          in_reply_to: 'ecf0b9fa_fe1a5f62',
           updated: '2018-02-14 22:07:43.000000000',
-          message: 'a comment',
+          message: 'response',
           unresolved: true,
         },
         {
           patch_set: 2,
-          id: 'cc788d2c_cb1d728c',
+          id: '503008e2_0ab203ef',
           line: 20,
-          in_reply_to: 'ecf0b9fa_fe1a5f62',
-          updated: '2018-02-13 22:07:43.000000000',
-          message: 'response',
+          in_reply_to: '503008e2_0ab203ee',
+          updated: '2018-02-15 22:07:43.000000000',
+          message: 'a third comment in the thread',
           unresolved: true,
         },
       ];
+      commentStub.withArgs('503008e2_0ab203ee').returns(
+          commentStubRes1);
+      commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
+          commentStubRes2);
 
-      const setupDiff = function(diff) {
-        const mock = document.createElement('mock-diff-response');
-        diff.comments = {
-          left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
-          right: [],
-          meta: {
-            changeNum: 1,
-            patchRange: {
-              basePatchNum: 'PARENT',
-              patchNum: 2,
-            },
-          },
-        };
-        diff.prefs = {
-          context: 10,
-          tab_size: 8,
-          font_size: 12,
-          line_length: 100,
-          cursor_blink_rate: 0,
-          line_wrapping: false,
-          intraline_difference: true,
-          show_line_endings: true,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          auto_hide_diff_table_header: true,
-          theme: 'DEFAULT',
-          ignore_whitespace: 'IGNORE_NONE',
-        };
-        diff.diff = mock.diffResponse;
-        diff.$.diff.flushDebouncer('renderDiffTable');
-      };
+      // Reload comments from the first comment thread, which should have a
+      // an updated message and a toggled resolve state.
+      element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
+          '/COMMIT_MSG');
+      assert.equal(thread1.comments.length, 1);
+      assert.isFalse(thread1.comments[0].unresolved);
+      assert.equal(thread1.comments[0].message, 'edited text');
 
-      const renderAndGetNewDiffs = function(index) {
-        const diffs =
-            Polymer.dom(element.root).querySelectorAll('gr-diff-host');
+      // Reload comments from the second comment thread, which should have a new
+      // reply.
+      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
+          '/COMMIT_MSG');
+      assert.equal(thread2.comments.length, 3);
 
-        for (let i = index; i < diffs.length; i++) {
-          setupDiff(diffs[i]);
-        }
+      const commentStubCount = commentStub.callCount;
+      const getThreadsSpy = sandbox.spy(diffs[0], 'getThreadEls');
 
-        element._updateDiffCursor();
-        element.$.diffCursor.handleDiffUpdate();
-        return diffs;
-      };
+      // Should not be getting threads when the file is not expanded.
+      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
+          'other/file');
+      assert.isFalse(getThreadsSpy.called);
+      assert.equal(commentStubCount, commentStub.callCount);
 
-      setup(done => {
-        sandbox = sinon.sandbox.create();
-        stub('gr-rest-api-interface', {
-          getLoggedIn() { return Promise.resolve(true); },
-          getPreferences() { return Promise.resolve({}); },
-          getDiffComments() { return Promise.resolve({}); },
-          getDiffRobotComments() { return Promise.resolve({}); },
-          getDiffDrafts() { return Promise.resolve({}); },
-        });
-        stub('gr-date-formatter', {
-          _loadTimeFormat() { return Promise.resolve(''); },
-        });
-        stub('gr-diff-host', {
-          reload() { return Promise.resolve(); },
-        });
-
-        // Element must be wrapped in an element with direct access to the
-        // comment API.
-        commentApiWrapper = fixture('basic');
-        element = commentApiWrapper.$.fileList;
-        loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
-        element.diffPrefs = {};
-        sandbox.stub(element, '_reviewFile');
-
-        // Stub methods on the changeComments object after changeComments has
-        // been initialized.
-        commentApiWrapper.loadComments().then(() => {
-          sandbox.stub(element.changeComments, 'getPaths').returns({});
-          sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
-              .returns({meta: {}, left: [], right: []});
-          done();
-        });
-        element._loading = false;
-        element.numFilesShown = 75;
-        element.selectedIndex = 0;
-        element._filesByPath = {
-          '/COMMIT_MSG': {lines_inserted: 9},
-          'file_added_in_rev2.txt': {
-            lines_inserted: 1,
-            lines_deleted: 1,
-            size_delta: 10,
-            size: 100,
-          },
-          'myfile.txt': {
-            lines_inserted: 1,
-            lines_deleted: 1,
-            size_delta: 10,
-            size: 100,
-          },
-        };
-        element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
-        element._loggedIn = true;
-        element.changeNum = '42';
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: '2',
-        };
-        sandbox.stub(window, 'fetch', () => Promise.resolve());
-        flushAsynchronousOperations();
-      });
-
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      test('cursor with individually opened files', () => {
-        MockInteractions.keyUpOn(element, 73, null, 'i');
-        flushAsynchronousOperations();
-        let diffs = renderAndGetNewDiffs(0);
-        const diffStops = diffs[0].getCursorStops();
-
-        // 1 diff should be rendered.
-        assert.equal(diffs.length, 1);
-
-        // No line number is selected.
-        assert.isFalse(diffStops[10].classList.contains('target-row'));
-
-        // Tapping content on a line selects the line number.
-        MockInteractions.tap(Polymer.dom(
-            diffStops[10]).querySelectorAll('.contentText')[0]);
-        flushAsynchronousOperations();
-        assert.isTrue(diffStops[10].classList.contains('target-row'));
-
-        // Keyboard shortcuts are still moving the file cursor, not the diff
-        // cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-        flushAsynchronousOperations();
-        assert.isTrue(diffStops[10].classList.contains('target-row'));
-        assert.isFalse(diffStops[11].classList.contains('target-row'));
-
-        // The file cusor is now at 1.
-        assert.equal(element.$.fileCursor.index, 1);
-        MockInteractions.keyUpOn(element, 73, null, 'i');
-        flushAsynchronousOperations();
-
-        diffs = renderAndGetNewDiffs(1);
-        // Two diffs should be rendered.
-        assert.equal(diffs.length, 2);
-        const diffStopsFirst = diffs[0].getCursorStops();
-        const diffStopsSecond = diffs[1].getCursorStops();
-
-        // The line on the first diff is stil selected
-        assert.isTrue(diffStopsFirst[10].classList.contains('target-row'));
-        assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
-      });
-
-      test('cursor with toggle all files', () => {
-        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-        flushAsynchronousOperations();
-
-        const diffs = renderAndGetNewDiffs(0);
-        const diffStops = diffs[0].getCursorStops();
-
-        // 1 diff should be rendered.
-        assert.equal(diffs.length, 3);
-
-        // No line number is selected.
-        assert.isFalse(diffStops[10].classList.contains('target-row'));
-
-        // Tapping content on a line selects the line number.
-        MockInteractions.tap(Polymer.dom(
-            diffStops[10]).querySelectorAll('.contentText')[0]);
-        flushAsynchronousOperations();
-        assert.isTrue(diffStops[10].classList.contains('target-row'));
-
-        // Keyboard shortcuts are still moving the file cursor, not the diff
-        // cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-        flushAsynchronousOperations();
-        assert.isFalse(diffStops[10].classList.contains('target-row'));
-        assert.isTrue(diffStops[11].classList.contains('target-row'));
-
-        // The file cusor is still at 0.
-        assert.equal(element.$.fileCursor.index, 0);
-      });
-
-      suite('n key presses', () => {
-        let nKeySpy;
-        let nextCommentStub;
-        let nextChunkStub;
-        let fileRows;
-
-        setup(() => {
-          sandbox.stub(element, '_renderInOrder').returns(Promise.resolve());
-          nKeySpy = sandbox.spy(element, '_handleNextChunk');
-          nextCommentStub = sandbox.stub(element.$.diffCursor,
-              'moveToNextCommentThread');
-          nextChunkStub = sandbox.stub(element.$.diffCursor,
-              'moveToNextChunk');
-          fileRows =
-              Polymer.dom(element.root).querySelectorAll('.row:not(.header-row)');
-        });
-
-        test('n key with some files expanded and no shift key', () => {
-          MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
-          flushAsynchronousOperations();
-          assert.equal(nextChunkStub.callCount, 1);
-
-          // Handle N key should return before calling diff cursor functions.
-          MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-          assert.isTrue(nKeySpy.called);
-          assert.isFalse(nextCommentStub.called);
-
-          // This is also called in diffCursor.moveToFirstChunk.
-          assert.equal(nextChunkStub.callCount, 2);
-          assert.equal(element.filesExpanded, 'some');
-        });
-
-        test('n key with some files expanded and shift key', () => {
-          MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
-          flushAsynchronousOperations();
-          assert.equal(nextChunkStub.callCount, 1);
-
-          MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-          assert.isTrue(nKeySpy.called);
-          assert.isTrue(nextCommentStub.called);
-
-          // This is also called in diffCursor.moveToFirstChunk.
-          assert.equal(nextChunkStub.callCount, 1);
-          assert.equal(element.filesExpanded, 'some');
-        });
-
-        test('n key without all files expanded and shift key', () => {
-          MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
-          flushAsynchronousOperations();
-
-          MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-          assert.isTrue(nKeySpy.called);
-          assert.isFalse(nextCommentStub.called);
-
-          // This is also called in diffCursor.moveToFirstChunk.
-          assert.equal(nextChunkStub.callCount, 2);
-          assert.isTrue(element._showInlineDiffs);
-        });
-
-        test('n key without all files expanded and no shift key', () => {
-          MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
-          flushAsynchronousOperations();
-
-          MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-          assert.isTrue(nKeySpy.called);
-          assert.isTrue(nextCommentStub.called);
-
-          // This is also called in diffCursor.moveToFirstChunk.
-          assert.equal(nextChunkStub.callCount, 1);
-          assert.isTrue(element._showInlineDiffs);
-        });
-      });
-
-      test('_openSelectedFile behavior', () => {
-        const _filesByPath = element._filesByPath;
-        element.set('_filesByPath', {});
-        const navStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-        // Noop when there are no files.
-        element._openSelectedFile();
-        assert.isFalse(navStub.called);
-
-        element.set('_filesByPath', _filesByPath);
-        flushAsynchronousOperations();
-        // Navigates when a file is selected.
-        element._openSelectedFile();
-        assert.isTrue(navStub.called);
-      });
-
-      test('_displayLine', () => {
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut', () => false);
-        sandbox.stub(element, 'modifierPressed', () => false);
-        element._showInlineDiffs = true;
-        const mockEvent = {preventDefault() {}};
-
-        element._displayLine = false;
-        element._handleCursorNext(mockEvent);
-        assert.isTrue(element._displayLine);
-
-        element._displayLine = false;
-        element._handleCursorPrev(mockEvent);
-        assert.isTrue(element._displayLine);
-
-        element._displayLine = true;
-        element._handleEscKey(mockEvent);
-        assert.isFalse(element._displayLine);
-      });
-
-      suite('editMode behavior', () => {
-        test('reviewed checkbox', () => {
-          element._reviewFile.restore();
-          const saveReviewStub = sandbox.stub(element, '_saveReviewedState');
-
-          element.editMode = false;
-          MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-          assert.isTrue(saveReviewStub.calledOnce);
-
-          element.editMode = true;
-          flushAsynchronousOperations();
-
-          MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-          assert.isTrue(saveReviewStub.calledOnce);
-        });
-
-        test('_getReviewedFiles does not call API', () => {
-          const apiSpy = sandbox.spy(element.$.restAPI, 'getReviewedFiles');
-          element.editMode = true;
-          return element._getReviewedFiles().then(files => {
-            assert.equal(files.length, 0);
-            assert.isFalse(apiSpy.called);
-          });
-        });
-      });
-
-      test('editing actions', () => {
-        // Edit controls are guarded behind a dom-if initially and not rendered.
-        assert.isNotOk(Polymer.dom(element.root)
-            .querySelector('gr-edit-file-controls'));
-
-        element.editMode = true;
-        flushAsynchronousOperations();
-
-        // Commit message should not have edit controls.
-        const editControls =
-            Array.from(
-                Polymer.dom(element.root)
-                    .querySelectorAll('.row:not(.header-row)'))
-                .map(row => row.querySelector('gr-edit-file-controls'));
-        assert.isTrue(editControls[0].classList.contains('invisible'));
-      });
-
-      test('reloadCommentsForThreadWithRootId', () => {
-        // Expand the commit message diff
-        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-        const diffs = renderAndGetNewDiffs(0);
-        flushAsynchronousOperations();
-
-        // Two comment threads should be generated by renderAndGetNewDiffs
-        const threadEls = diffs[0].getThreadEls();
-        assert.equal(threadEls.length, 2);
-        const threadElsByRootId = new Map(
-            threadEls.map(threadEl => [threadEl.rootId, threadEl]));
-
-        const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
-        assert.equal(thread1.comments.length, 1);
-        assert.equal(thread1.comments[0].message, 'a comment');
-        assert.equal(thread1.comments[0].line, 10);
-
-        const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
-        assert.equal(thread2.comments.length, 2);
-        assert.isTrue(thread2.comments[0].unresolved);
-        assert.equal(thread2.comments[0].message, 'another comment');
-        assert.equal(thread2.comments[0].line, 20);
-
-        const commentStub =
-            sandbox.stub(element.changeComments, 'getCommentsForThread');
-        const commentStubRes1 = [
-          {
-            patch_set: 2,
-            id: '503008e2_0ab203ee',
-            line: 20,
-            updated: '2018-02-08 18:49:18.000000000',
-            message: 'edited text',
-            unresolved: false,
-          },
-        ];
-        const commentStubRes2 = [
-          {
-            patch_set: 2,
-            id: 'ecf0b9fa_fe1a5f62',
-            line: 20,
-            updated: '2018-02-08 18:49:18.000000000',
-            message: 'another comment',
-            unresolved: true,
-          },
-          {
-            patch_set: 2,
-            id: '503008e2_0ab203ee',
-            line: 10,
-            in_reply_to: 'ecf0b9fa_fe1a5f62',
-            updated: '2018-02-14 22:07:43.000000000',
-            message: 'response',
-            unresolved: true,
-          },
-          {
-            patch_set: 2,
-            id: '503008e2_0ab203ef',
-            line: 20,
-            in_reply_to: '503008e2_0ab203ee',
-            updated: '2018-02-15 22:07:43.000000000',
-            message: 'a third comment in the thread',
-            unresolved: true,
-          },
-        ];
-        commentStub.withArgs('503008e2_0ab203ee').returns(
-            commentStubRes1);
-        commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
-            commentStubRes2);
-
-        // Reload comments from the first comment thread, which should have a
-        // an updated message and a toggled resolve state.
-        element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
-            '/COMMIT_MSG');
-        assert.equal(thread1.comments.length, 1);
-        assert.isFalse(thread1.comments[0].unresolved);
-        assert.equal(thread1.comments[0].message, 'edited text');
-
-        // Reload comments from the second comment thread, which should have a new
-        // reply.
-        element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-            '/COMMIT_MSG');
-        assert.equal(thread2.comments.length, 3);
-
-        const commentStubCount = commentStub.callCount;
-        const getThreadsSpy = sandbox.spy(diffs[0], 'getThreadEls');
-
-        // Should not be getting threads when the file is not expanded.
-        element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-            'other/file');
-        assert.isFalse(getThreadsSpy.called);
-        assert.equal(commentStubCount, commentStub.callCount);
-
-        // Should be query selecting diffs when the file is expanded.
-        // Should not be fetching change comments when the rootId is not found
-        // to match.
-        element.reloadCommentsForThreadWithRootId('acf0b9fa_fe1a5f62',
-            '/COMMIT_MSG');
-        assert.isTrue(getThreadsSpy.called);
-        assert.equal(commentStubCount, commentStub.callCount);
-      });
+      // Should be query selecting diffs when the file is expanded.
+      // Should not be fetching change comments when the rootId is not found
+      // to match.
+      element.reloadCommentsForThreadWithRootId('acf0b9fa_fe1a5f62',
+          '/COMMIT_MSG');
+      assert.isTrue(getThreadsSpy.called);
+      assert.equal(commentStubCount, commentStub.callCount);
     });
-    a11ySuite('basic');
   });
+  a11ySuite('basic');
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
index 01c9b6e..bdf8c1d 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
@@ -14,98 +14,109 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
+import '@polymer/iron-input/iron-input.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-included-in-dialog_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrIncludedInDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-included-in-dialog'; }
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the user presses the close button.
+   *
+   * @event close
    */
-  class GrIncludedInDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-included-in-dialog'; }
-    /**
-     * Fired when the user presses the close button.
-     *
-     * @event close
-     */
 
-    static get properties() {
-      return {
+  static get properties() {
+    return {
+    /** @type {?} */
+      changeNum: {
+        type: Object,
+        observer: '_resetData',
+      },
       /** @type {?} */
-        changeNum: {
-          type: Object,
-          observer: '_resetData',
-        },
-        /** @type {?} */
-        _includedIn: Object,
-        _loaded: {
-          type: Boolean,
-          value: false,
-        },
-        _filterText: {
-          type: String,
-          value: '',
-        },
-      };
-    }
-
-    loadData() {
-      if (!this.changeNum) { return; }
-      this._filterText = '';
-      return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(
-          configs => {
-            if (!configs) { return; }
-            this._includedIn = configs;
-            this._loaded = true;
-          });
-    }
-
-    _resetData() {
-      this._includedIn = null;
-      this._loaded = false;
-    }
-
-    _computeGroups(includedIn, filterText) {
-      if (!includedIn) { return []; }
-
-      const filter = item => !filterText.length ||
-          item.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
-
-      const groups = [
-        {title: 'Branches', items: includedIn.branches.filter(filter)},
-        {title: 'Tags', items: includedIn.tags.filter(filter)},
-      ];
-      if (includedIn.external) {
-        for (const externalKey of Object.keys(includedIn.external)) {
-          groups.push({
-            title: externalKey,
-            items: includedIn.external[externalKey].filter(filter),
-          });
-        }
-      }
-      return groups.filter(g => g.items.length);
-    }
-
-    _handleCloseTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('close', null, {bubbles: false});
-    }
-
-    _computeLoadingClass(loaded) {
-      return loaded ? 'loading loaded' : 'loading';
-    }
-
-    _onFilterChanged() {
-      this.debounce('filter-change', () => {
-        this._filterText = this.$.filterInput.bindValue;
-      }, 100);
-    }
+      _includedIn: Object,
+      _loaded: {
+        type: Boolean,
+        value: false,
+      },
+      _filterText: {
+        type: String,
+        value: '',
+      },
+    };
   }
 
-  customElements.define(GrIncludedInDialog.is, GrIncludedInDialog);
-})();
+  loadData() {
+    if (!this.changeNum) { return; }
+    this._filterText = '';
+    return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(
+        configs => {
+          if (!configs) { return; }
+          this._includedIn = configs;
+          this._loaded = true;
+        });
+  }
+
+  _resetData() {
+    this._includedIn = null;
+    this._loaded = false;
+  }
+
+  _computeGroups(includedIn, filterText) {
+    if (!includedIn) { return []; }
+
+    const filter = item => !filterText.length ||
+        item.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
+
+    const groups = [
+      {title: 'Branches', items: includedIn.branches.filter(filter)},
+      {title: 'Tags', items: includedIn.tags.filter(filter)},
+    ];
+    if (includedIn.external) {
+      for (const externalKey of Object.keys(includedIn.external)) {
+        groups.push({
+          title: externalKey,
+          items: includedIn.external[externalKey].filter(filter),
+        });
+      }
+    }
+    return groups.filter(g => g.items.length);
+  }
+
+  _handleCloseTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('close', null, {bubbles: false});
+  }
+
+  _computeLoadingClass(loaded) {
+    return loaded ? 'loading loaded' : 'loading';
+  }
+
+  _onFilterChanged() {
+    this.debounce('filter-change', () => {
+      this._filterText = this.$.filterInput.bindValue;
+    }, 100);
+  }
+}
+
+customElements.define(GrIncludedInDialog.is, GrIncludedInDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
index 075b41e..b7d455c 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-included-in-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         background-color: var(--dialog-background-color);
@@ -76,25 +69,14 @@
     <header>
       <h1 id="title">Included In:</h1>
       <span class="closeButtonContainer">
-        <gr-button id="closeButton"
-            link
-            on-click="_handleCloseTap">Close</gr-button>
+        <gr-button id="closeButton" link="" on-click="_handleCloseTap">Close</gr-button>
       </span>
-      <iron-input
-          placeholder="Filter"
-          on-bind-value-changed="_onFilterChanged">
-        <input
-            id="filterInput"
-            is="iron-input"
-            placeholder="Filter"
-            on-bind-value-changed="_onFilterChanged">
+      <iron-input placeholder="Filter" on-bind-value-changed="_onFilterChanged">
+        <input id="filterInput" is="iron-input" placeholder="Filter" on-bind-value-changed="_onFilterChanged">
       </iron-input>
     </header>
-    <div class$="[[_computeLoadingClass(_loaded)]]">Loading...</div>
-    <template
-        is="dom-repeat"
-        items="[[_computeGroups(_includedIn, _filterText)]]"
-        as="group">
+    <div class\$="[[_computeLoadingClass(_loaded)]]">Loading...</div>
+    <template is="dom-repeat" items="[[_computeGroups(_includedIn, _filterText)]]" as="group">
       <div>
         <span>[[group.title]]:</span>
         <ul>
@@ -105,6 +87,4 @@
       </div>
     </template>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-included-in-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
index bec6c7b..deb00bd 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-included-in-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-included-in-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-included-in-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-included-in-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,54 +40,56 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-included-in-dialog', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-included-in-dialog.js';
+suite('gr-included-in-dialog', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('_computeGroups', () => {
-      const includedIn = {branches: [], tags: []};
-      let filterText = '';
-      assert.deepEqual(element._computeGroups(includedIn, filterText), []);
-
-      includedIn.branches.push('master', 'development', 'stable-2.0');
-      includedIn.tags.push('v1.9', 'v2.0', 'v2.1');
-      assert.deepEqual(element._computeGroups(includedIn, filterText), [
-        {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
-        {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
-      ]);
-
-      includedIn.external = {};
-      assert.deepEqual(element._computeGroups(includedIn, filterText), [
-        {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
-        {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
-      ]);
-
-      includedIn.external.foo = ['abc', 'def', 'ghi'];
-      assert.deepEqual(element._computeGroups(includedIn, filterText), [
-        {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
-        {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
-        {title: 'foo', items: ['abc', 'def', 'ghi']},
-      ]);
-
-      filterText = 'v2';
-      assert.deepEqual(element._computeGroups(includedIn, filterText), [
-        {title: 'Tags', items: ['v2.0', 'v2.1']},
-      ]);
-
-      // Filtering is case-insensitive.
-      filterText = 'V2';
-      assert.deepEqual(element._computeGroups(includedIn, filterText), [
-        {title: 'Tags', items: ['v2.0', 'v2.1']},
-      ]);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('_computeGroups', () => {
+    const includedIn = {branches: [], tags: []};
+    let filterText = '';
+    assert.deepEqual(element._computeGroups(includedIn, filterText), []);
+
+    includedIn.branches.push('master', 'development', 'stable-2.0');
+    includedIn.tags.push('v1.9', 'v2.0', 'v2.1');
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
+      {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+    ]);
+
+    includedIn.external = {};
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
+      {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+    ]);
+
+    includedIn.external.foo = ['abc', 'def', 'ghi'];
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
+      {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+      {title: 'foo', items: ['abc', 'def', 'ghi']},
+    ]);
+
+    filterText = 'v2';
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Tags', items: ['v2.0', 'v2.1']},
+    ]);
+
+    // Filtering is case-insensitive.
+    filterText = 'V2';
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Tags', items: ['v2.0', 'v2.1']},
+    ]);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
index 0316428..8541840 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
@@ -14,167 +14,176 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrLabelScoreRow extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-label-score-row'; }
+import '@polymer/iron-selector/iron-selector.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../../styles/gr-voting-styles.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-label-score-row_html.js';
+
+/** @extends Polymer.Element */
+class GrLabelScoreRow extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-label-score-row'; }
+  /**
+   * Fired when any label is changed.
+   *
+   * @event labels-changed
+   */
+
+  static get properties() {
+    return {
     /**
-     * Fired when any label is changed.
-     *
-     * @event labels-changed
+     * @type {{ name: string }}
      */
+      label: Object,
+      labels: Object,
+      name: {
+        type: String,
+        reflectToAttribute: true,
+      },
+      permittedLabels: Object,
+      labelValues: Object,
+      _selectedValueText: {
+        type: String,
+        value: 'No value selected',
+      },
+      _items: {
+        type: Array,
+        computed: '_computePermittedLabelValues(permittedLabels, label.name)',
+      },
+    };
+  }
 
-    static get properties() {
-      return {
-      /**
-       * @type {{ name: string }}
-       */
-        label: Object,
-        labels: Object,
-        name: {
-          type: String,
-          reflectToAttribute: true,
-        },
-        permittedLabels: Object,
-        labelValues: Object,
-        _selectedValueText: {
-          type: String,
-          value: 'No value selected',
-        },
-        _items: {
-          type: Array,
-          computed: '_computePermittedLabelValues(permittedLabels, label.name)',
-        },
-      };
+  get selectedItem() {
+    if (!this._ironSelector) { return undefined; }
+    return this._ironSelector.selectedItem;
+  }
+
+  get selectedValue() {
+    if (!this._ironSelector) { return undefined; }
+    return this._ironSelector.selected;
+  }
+
+  setSelectedValue(value) {
+    // The selector may not be present if it’s not at the latest patch set.
+    if (!this._ironSelector) { return; }
+    this._ironSelector.select(value);
+  }
+
+  get _ironSelector() {
+    return this.$ && this.$.labelSelector;
+  }
+
+  _computeBlankItems(permittedLabels, label, side) {
+    if (!permittedLabels || !permittedLabels[label] ||
+        !permittedLabels[label].length || !this.labelValues ||
+        !Object.keys(this.labelValues).length) {
+      return [];
     }
-
-    get selectedItem() {
-      if (!this._ironSelector) { return undefined; }
-      return this._ironSelector.selectedItem;
+    const startPosition = this.labelValues[parseInt(
+        permittedLabels[label][0], 10)];
+    if (side === 'start') {
+      return new Array(startPosition);
     }
+    const endPosition = this.labelValues[parseInt(
+        permittedLabels[label][permittedLabels[label].length - 1], 10)];
+    return new Array(Object.keys(this.labelValues).length - endPosition - 1);
+  }
 
-    get selectedValue() {
-      if (!this._ironSelector) { return undefined; }
-      return this._ironSelector.selected;
-    }
-
-    setSelectedValue(value) {
-      // The selector may not be present if it’s not at the latest patch set.
-      if (!this._ironSelector) { return; }
-      this._ironSelector.select(value);
-    }
-
-    get _ironSelector() {
-      return this.$ && this.$.labelSelector;
-    }
-
-    _computeBlankItems(permittedLabels, label, side) {
-      if (!permittedLabels || !permittedLabels[label] ||
-          !permittedLabels[label].length || !this.labelValues ||
-          !Object.keys(this.labelValues).length) {
-        return [];
-      }
-      const startPosition = this.labelValues[parseInt(
-          permittedLabels[label][0], 10)];
-      if (side === 'start') {
-        return new Array(startPosition);
-      }
-      const endPosition = this.labelValues[parseInt(
-          permittedLabels[label][permittedLabels[label].length - 1], 10)];
-      return new Array(Object.keys(this.labelValues).length - endPosition - 1);
-    }
-
-    _getLabelValue(labels, permittedLabels, label) {
-      if (label.value) {
-        return label.value;
-      } else if (labels[label.name].hasOwnProperty('default_value') &&
-                 permittedLabels.hasOwnProperty(label.name)) {
-        // default_value is an int, convert it to string label, e.g. "+1".
-        return permittedLabels[label.name].find(
-            value => parseInt(value, 10) === labels[label.name].default_value);
-      }
-    }
-
-    /**
-     * Maps the label value to exactly one of: min, max, positive, negative,
-     * neutral. Used for the 'vote' attribute, because we don't want to
-     * interfere with <iron-selector> using the 'class' attribute for setting
-     * 'iron-selected'.
-     */
-    _computeVoteAttribute(value, index, totalItems) {
-      if (value < 0 && index === 0) {
-        return 'min';
-      } else if (value < 0) {
-        return 'negative';
-      } else if (value > 0 && index === totalItems - 1) {
-        return 'max';
-      } else if (value > 0) {
-        return 'positive';
-      } else {
-        return 'neutral';
-      }
-    }
-
-    _computeLabelValue(labels, permittedLabels, label) {
-      if ([labels, permittedLabels, label].some(arg => arg === undefined)) {
-        return null;
-      }
-      if (!labels[label.name]) { return null; }
-      const labelValue = this._getLabelValue(labels, permittedLabels, label);
-      const len = permittedLabels[label.name] != null ?
-        permittedLabels[label.name].length : 0;
-      for (let i = 0; i < len; i++) {
-        const val = permittedLabels[label.name][i];
-        if (val === labelValue) {
-          return val;
-        }
-      }
-      return null;
-    }
-
-    _setSelectedValueText(e) {
-      // Needed because when the selected item changes, it first changes to
-      // nothing and then to the new item.
-      if (!e.target.selectedItem) { return; }
-      this._selectedValueText = e.target.selectedItem.getAttribute('title');
-      // Needed to update the style of the selected button.
-      this.updateStyles();
-      const name = e.target.selectedItem.dataset.name;
-      const value = e.target.selectedItem.dataset.value;
-      this.dispatchEvent(new CustomEvent(
-          'labels-changed',
-          {detail: {name, value}, bubbles: true, composed: true}));
-    }
-
-    _computeAnyPermittedLabelValues(permittedLabels, label) {
-      return permittedLabels && permittedLabels.hasOwnProperty(label) &&
-        permittedLabels[label].length;
-    }
-
-    _computeHiddenClass(permittedLabels, label) {
-      return !this._computeAnyPermittedLabelValues(permittedLabels, label) ?
-        'hidden' : '';
-    }
-
-    _computePermittedLabelValues(permittedLabels, label) {
-      // Polymer 2: check for undefined
-      if ([permittedLabels, label].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      return permittedLabels[label];
-    }
-
-    _computeLabelValueTitle(labels, label, value) {
-      return labels[label] &&
-        labels[label].values &&
-        labels[label].values[value];
+  _getLabelValue(labels, permittedLabels, label) {
+    if (label.value) {
+      return label.value;
+    } else if (labels[label.name].hasOwnProperty('default_value') &&
+               permittedLabels.hasOwnProperty(label.name)) {
+      // default_value is an int, convert it to string label, e.g. "+1".
+      return permittedLabels[label.name].find(
+          value => parseInt(value, 10) === labels[label.name].default_value);
     }
   }
 
-  customElements.define(GrLabelScoreRow.is, GrLabelScoreRow);
-})();
+  /**
+   * Maps the label value to exactly one of: min, max, positive, negative,
+   * neutral. Used for the 'vote' attribute, because we don't want to
+   * interfere with <iron-selector> using the 'class' attribute for setting
+   * 'iron-selected'.
+   */
+  _computeVoteAttribute(value, index, totalItems) {
+    if (value < 0 && index === 0) {
+      return 'min';
+    } else if (value < 0) {
+      return 'negative';
+    } else if (value > 0 && index === totalItems - 1) {
+      return 'max';
+    } else if (value > 0) {
+      return 'positive';
+    } else {
+      return 'neutral';
+    }
+  }
+
+  _computeLabelValue(labels, permittedLabels, label) {
+    if ([labels, permittedLabels, label].some(arg => arg === undefined)) {
+      return null;
+    }
+    if (!labels[label.name]) { return null; }
+    const labelValue = this._getLabelValue(labels, permittedLabels, label);
+    const len = permittedLabels[label.name] != null ?
+      permittedLabels[label.name].length : 0;
+    for (let i = 0; i < len; i++) {
+      const val = permittedLabels[label.name][i];
+      if (val === labelValue) {
+        return val;
+      }
+    }
+    return null;
+  }
+
+  _setSelectedValueText(e) {
+    // Needed because when the selected item changes, it first changes to
+    // nothing and then to the new item.
+    if (!e.target.selectedItem) { return; }
+    this._selectedValueText = e.target.selectedItem.getAttribute('title');
+    // Needed to update the style of the selected button.
+    this.updateStyles();
+    const name = e.target.selectedItem.dataset.name;
+    const value = e.target.selectedItem.dataset.value;
+    this.dispatchEvent(new CustomEvent(
+        'labels-changed',
+        {detail: {name, value}, bubbles: true, composed: true}));
+  }
+
+  _computeAnyPermittedLabelValues(permittedLabels, label) {
+    return permittedLabels && permittedLabels.hasOwnProperty(label) &&
+      permittedLabels[label].length;
+  }
+
+  _computeHiddenClass(permittedLabels, label) {
+    return !this._computeAnyPermittedLabelValues(permittedLabels, label) ?
+      'hidden' : '';
+  }
+
+  _computePermittedLabelValues(permittedLabels, label) {
+    // Polymer 2: check for undefined
+    if ([permittedLabels, label].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    return permittedLabels[label];
+  }
+
+  _computeLabelValueTitle(labels, label, value) {
+    return labels[label] &&
+      labels[label].values &&
+      labels[label].values[value];
+  }
+}
+
+customElements.define(GrLabelScoreRow.is, GrLabelScoreRow);
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js
index 50c01aa..1b5b425 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-selector/iron-selector.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../../styles/gr-voting-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-label-score-row">
-  <template>
+export const htmlTemplate = html`
     <style include="gr-voting-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -99,42 +93,23 @@
     </style>
     <span class="labelNameCell">[[label.name]]</span>
     <div class="buttonsCell">
-      <template is="dom-repeat"
-          items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]"
-          as="value">
-        <span class="placeholder" data-label$="[[label.name]]"></span>
+      <template is="dom-repeat" items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]" as="value">
+        <span class="placeholder" data-label\$="[[label.name]]"></span>
       </template>
-      <iron-selector
-          id="labelSelector"
-          attr-for-selected="data-value"
-          selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
-          hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
-          on-selected-item-changed="_setSelectedValueText">
-        <template is="dom-repeat"
-            items="[[_items]]"
-            as="value">
-          <gr-button
-              vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
-              has-tooltip
-              data-name$="[[label.name]]"
-              data-value$="[[value]]"
-              title$="[[_computeLabelValueTitle(labels, label.name, value)]]">
+      <iron-selector id="labelSelector" attr-for-selected="data-value" selected="[[_computeLabelValue(labels, permittedLabels, label)]]" hidden\$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]" on-selected-item-changed="_setSelectedValueText">
+        <template is="dom-repeat" items="[[_items]]" as="value">
+          <gr-button vote\$="[[_computeVoteAttribute(value, index, _items.length)]]" has-tooltip="" data-name\$="[[label.name]]" data-value\$="[[value]]" title\$="[[_computeLabelValueTitle(labels, label.name, value)]]">
             [[value]]</gr-button>
         </template>
       </iron-selector>
-      <template is="dom-repeat"
-          items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]"
-          as="value">
-        <span class="placeholder" data-label$="[[label.name]]"></span>
+      <template is="dom-repeat" items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]" as="value">
+        <span class="placeholder" data-label\$="[[label.name]]"></span>
       </template>
-      <span class="labelMessage"
-          hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
+      <span class="labelMessage" hidden\$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
         You don't have permission to edit this label.
       </span>
     </div>
-    <div class$="selectedValueCell [[_computeHiddenClass(permittedLabels, label.name)]]">
+    <div class\$="selectedValueCell [[_computeHiddenClass(permittedLabels, label.name)]]">
       <span id="selectedValueLabel">[[_selectedValueText]]</span>
     </div>
-  </template>
-  <script src="gr-label-score-row.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
index b10b932..18ba2a5 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-label-score-row</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-label-score-row.html">
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-label-score-row.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-label-score-row.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,340 +41,343 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-label-row-score tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-label-score-row.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-label-row-score tests', () => {
+  let element;
+  let sandbox;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.labels = {
-        'Code-Review': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-          value: 1,
-          all: [{
-            _account_id: 123,
-            value: 1,
-          }],
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
         },
-        'Verified': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
+        default_value: 0,
+        value: 1,
+        all: [{
+          _account_id: 123,
           value: 1,
-          all: [{
-            _account_id: 123,
-            value: 1,
-          }],
+        }],
+      },
+      'Verified': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
         },
-      };
+        default_value: 0,
+        value: 1,
+        all: [{
+          _account_id: 123,
+          value: 1,
+        }],
+      },
+    };
+
+    element.permittedLabels = {
+      'Code-Review': [
+        '-2',
+        '-1',
+        ' 0',
+        '+1',
+        '+2',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+
+    element.labelValues = {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
+
+    element.label = {
+      name: 'Verified',
+      value: '+1',
+    };
+
+    flush(done);
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('label picker', () => {
+    const labelsChangedHandler = sandbox.stub();
+    element.addEventListener('labels-changed', labelsChangedHandler);
+    assert.ok(element.$.labelSelector);
+    MockInteractions.tap(element.shadowRoot
+        .querySelector(
+            'gr-button[data-value="-1"]'));
+    flushAsynchronousOperations();
+    assert.strictEqual(element.selectedValue, '-1');
+    assert.strictEqual(element.selectedItem
+        .textContent.trim(), '-1');
+    assert.strictEqual(
+        element.$.selectedValueLabel.textContent.trim(), 'bad');
+    const detail = labelsChangedHandler.args[0][0].detail;
+    assert.equal(detail.name, 'Verified');
+    assert.equal(detail.value, '-1');
+  });
+
+  test('_computeVoteAttribute', () => {
+    let value = 1;
+    let index = 0;
+    const totalItems = 5;
+    // positive and first position
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'positive');
+    // negative and first position
+    value = -1;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'min');
+    // negative but not first position
+    index = 1;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'negative');
+    // neutral
+    value = 0;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'neutral');
+    // positive but not last position
+    value = 1;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'positive');
+    // positive and last position
+    index = 4;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'max');
+    // negative and last position
+    value = -1;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'negative');
+  });
+
+  test('correct item is selected', () => {
+    // 1 should be the value of the selected item
+    assert.strictEqual(element.$.labelSelector.selected, '+1');
+    assert.strictEqual(
+        element.$.labelSelector.selectedItem
+            .textContent.trim(), '+1');
+    assert.strictEqual(
+        element.$.selectedValueLabel.textContent.trim(), 'good');
+  });
+
+  test('do not display tooltips on touch devices', () => {
+    const verifiedBtn = element.shadowRoot
+        .querySelector(
+            'iron-selector > gr-button[data-value="-1"]');
+
+    // On touch devices, tooltips should not be shown.
+    verifiedBtn._isTouchDevice = true;
+    verifiedBtn._handleShowTooltip();
+    assert.isNotOk(verifiedBtn._tooltip);
+    verifiedBtn._handleHideTooltip();
+    assert.isNotOk(verifiedBtn._tooltip);
+
+    // On other devices, tooltips should be shown.
+    verifiedBtn._isTouchDevice = false;
+    verifiedBtn._handleShowTooltip();
+    assert.isOk(verifiedBtn._tooltip);
+    verifiedBtn._handleHideTooltip();
+    assert.isNotOk(verifiedBtn._tooltip);
+  });
+
+  test('_computeLabelValue', () => {
+    assert.strictEqual(element._computeLabelValue(element.labels,
+        element.permittedLabels,
+        element.label), '+1');
+  });
+
+  test('_computeBlankItems', () => {
+    element.labelValues = {
+      '-2': 0,
+      '-1': 1,
+      '0': 2,
+      '1': 3,
+      '2': 4,
+    };
+
+    assert.strictEqual(element._computeBlankItems(element.permittedLabels,
+        'Code-Review').length, 0);
+
+    assert.strictEqual(element._computeBlankItems(element.permittedLabels,
+        'Verified').length, 1);
+  });
+
+  test('labelValues returns no keys', () => {
+    element.labelValues = {};
+
+    assert.deepEqual(element._computeBlankItems(element.permittedLabels,
+        'Code-Review'), []);
+  });
+
+  test('changes in label score are reflected in the DOM', () => {
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+      },
+      'Verified': {
+        values: {
+          ' 0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+      },
+    };
+    const selector = element.$.labelSelector;
+    element.set('label', {name: 'Verified', value: ' 0'});
+    flushAsynchronousOperations();
+    assert.strictEqual(selector.selected, ' 0');
+    assert.strictEqual(
+        element.$.selectedValueLabel.textContent.trim(), 'No score');
+  });
+
+  test('without permitted labels', () => {
+    element.permittedLabels = {
+      Verified: [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    flushAsynchronousOperations();
+    assert.isOk(element.$.labelSelector);
+    assert.isFalse(element.$.labelSelector.hidden);
+
+    element.permittedLabels = {};
+    flushAsynchronousOperations();
+    assert.isOk(element.$.labelSelector);
+    assert.isTrue(element.$.labelSelector.hidden);
+
+    element.permittedLabels = {Verified: []};
+    flushAsynchronousOperations();
+    assert.isOk(element.$.labelSelector);
+    assert.isTrue(element.$.labelSelector.hidden);
+  });
+
+  test('asymetrical labels', done => {
+    element.permittedLabels = {
+      'Code-Review': [
+        '-2',
+        '-1',
+        ' 0',
+        '+1',
+        '+2',
+      ],
+      'Verified': [
+        ' 0',
+        '+1',
+      ],
+    };
+    flush(() => {
+      assert.strictEqual(element.$.labelSelector
+          .items.length, 2);
+      assert.strictEqual(
+          dom(element.root).querySelectorAll('.placeholder').length,
+          3);
 
       element.permittedLabels = {
         'Code-Review': [
+          ' 0',
+          '+1',
+        ],
+        'Verified': [
           '-2',
           '-1',
           ' 0',
           '+1',
           '+2',
         ],
-        'Verified': [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
-
-      element.labelValues = {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
-
-      element.label = {
-        name: 'Verified',
-        value: '+1',
-      };
-
-      flush(done);
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('label picker', () => {
-      const labelsChangedHandler = sandbox.stub();
-      element.addEventListener('labels-changed', labelsChangedHandler);
-      assert.ok(element.$.labelSelector);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector(
-              'gr-button[data-value="-1"]'));
-      flushAsynchronousOperations();
-      assert.strictEqual(element.selectedValue, '-1');
-      assert.strictEqual(element.selectedItem
-          .textContent.trim(), '-1');
-      assert.strictEqual(
-          element.$.selectedValueLabel.textContent.trim(), 'bad');
-      const detail = labelsChangedHandler.args[0][0].detail;
-      assert.equal(detail.name, 'Verified');
-      assert.equal(detail.value, '-1');
-    });
-
-    test('_computeVoteAttribute', () => {
-      let value = 1;
-      let index = 0;
-      const totalItems = 5;
-      // positive and first position
-      assert.equal(element._computeVoteAttribute(value, index,
-          totalItems), 'positive');
-      // negative and first position
-      value = -1;
-      assert.equal(element._computeVoteAttribute(value, index,
-          totalItems), 'min');
-      // negative but not first position
-      index = 1;
-      assert.equal(element._computeVoteAttribute(value, index,
-          totalItems), 'negative');
-      // neutral
-      value = 0;
-      assert.equal(element._computeVoteAttribute(value, index,
-          totalItems), 'neutral');
-      // positive but not last position
-      value = 1;
-      assert.equal(element._computeVoteAttribute(value, index,
-          totalItems), 'positive');
-      // positive and last position
-      index = 4;
-      assert.equal(element._computeVoteAttribute(value, index,
-          totalItems), 'max');
-      // negative and last position
-      value = -1;
-      assert.equal(element._computeVoteAttribute(value, index,
-          totalItems), 'negative');
-    });
-
-    test('correct item is selected', () => {
-      // 1 should be the value of the selected item
-      assert.strictEqual(element.$.labelSelector.selected, '+1');
-      assert.strictEqual(
-          element.$.labelSelector.selectedItem
-              .textContent.trim(), '+1');
-      assert.strictEqual(
-          element.$.selectedValueLabel.textContent.trim(), 'good');
-    });
-
-    test('do not display tooltips on touch devices', () => {
-      const verifiedBtn = element.shadowRoot
-          .querySelector(
-              'iron-selector > gr-button[data-value="-1"]');
-
-      // On touch devices, tooltips should not be shown.
-      verifiedBtn._isTouchDevice = true;
-      verifiedBtn._handleShowTooltip();
-      assert.isNotOk(verifiedBtn._tooltip);
-      verifiedBtn._handleHideTooltip();
-      assert.isNotOk(verifiedBtn._tooltip);
-
-      // On other devices, tooltips should be shown.
-      verifiedBtn._isTouchDevice = false;
-      verifiedBtn._handleShowTooltip();
-      assert.isOk(verifiedBtn._tooltip);
-      verifiedBtn._handleHideTooltip();
-      assert.isNotOk(verifiedBtn._tooltip);
-    });
-
-    test('_computeLabelValue', () => {
-      assert.strictEqual(element._computeLabelValue(element.labels,
-          element.permittedLabels,
-          element.label), '+1');
-    });
-
-    test('_computeBlankItems', () => {
-      element.labelValues = {
-        '-2': 0,
-        '-1': 1,
-        '0': 2,
-        '1': 3,
-        '2': 4,
-      };
-
-      assert.strictEqual(element._computeBlankItems(element.permittedLabels,
-          'Code-Review').length, 0);
-
-      assert.strictEqual(element._computeBlankItems(element.permittedLabels,
-          'Verified').length, 1);
-    });
-
-    test('labelValues returns no keys', () => {
-      element.labelValues = {};
-
-      assert.deepEqual(element._computeBlankItems(element.permittedLabels,
-          'Code-Review'), []);
-    });
-
-    test('changes in label score are reflected in the DOM', () => {
-      element.labels = {
-        'Code-Review': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-        },
-        'Verified': {
-          values: {
-            ' 0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-        },
-      };
-      const selector = element.$.labelSelector;
-      element.set('label', {name: 'Verified', value: ' 0'});
-      flushAsynchronousOperations();
-      assert.strictEqual(selector.selected, ' 0');
-      assert.strictEqual(
-          element.$.selectedValueLabel.textContent.trim(), 'No score');
-    });
-
-    test('without permitted labels', () => {
-      element.permittedLabels = {
-        Verified: [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
-      flushAsynchronousOperations();
-      assert.isOk(element.$.labelSelector);
-      assert.isFalse(element.$.labelSelector.hidden);
-
-      element.permittedLabels = {};
-      flushAsynchronousOperations();
-      assert.isOk(element.$.labelSelector);
-      assert.isTrue(element.$.labelSelector.hidden);
-
-      element.permittedLabels = {Verified: []};
-      flushAsynchronousOperations();
-      assert.isOk(element.$.labelSelector);
-      assert.isTrue(element.$.labelSelector.hidden);
-    });
-
-    test('asymetrical labels', done => {
-      element.permittedLabels = {
-        'Code-Review': [
-          '-2',
-          '-1',
-          ' 0',
-          '+1',
-          '+2',
-        ],
-        'Verified': [
-          ' 0',
-          '+1',
-        ],
       };
       flush(() => {
         assert.strictEqual(element.$.labelSelector
-            .items.length, 2);
+            .items.length, 5);
         assert.strictEqual(
-            Polymer.dom(element.root).querySelectorAll('.placeholder').length,
-            3);
-
-        element.permittedLabels = {
-          'Code-Review': [
-            ' 0',
-            '+1',
-          ],
-          'Verified': [
-            '-2',
-            '-1',
-            ' 0',
-            '+1',
-            '+2',
-          ],
-        };
-        flush(() => {
-          assert.strictEqual(element.$.labelSelector
-              .items.length, 5);
-          assert.strictEqual(
-              Polymer.dom(element.root).querySelectorAll('.placeholder').length,
-              0);
-          done();
-        });
+            dom(element.root).querySelectorAll('.placeholder').length,
+            0);
+        done();
       });
     });
-
-    test('default_value', () => {
-      element.permittedLabels = {
-        Verified: [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
-      element.labels = {
-        Verified: {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: -1,
-        },
-      };
-      element.label = {
-        name: 'Verified',
-        value: null,
-      };
-      flushAsynchronousOperations();
-      assert.strictEqual(element.selectedValue, '-1');
-    });
-
-    test('default_value is null if not permitted', () => {
-      element.permittedLabels = {
-        Verified: [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
-      element.labels = {
-        'Code-Review': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: -1,
-        },
-      };
-      element.label = {
-        name: 'Code-Review',
-        value: null,
-      };
-      flushAsynchronousOperations();
-      assert.isNull(element.selectedValue);
-    });
   });
+
+  test('default_value', () => {
+    element.permittedLabels = {
+      Verified: [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    element.labels = {
+      Verified: {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: -1,
+      },
+    };
+    element.label = {
+      name: 'Verified',
+      value: null,
+    };
+    flushAsynchronousOperations();
+    assert.strictEqual(element.selectedValue, '-1');
+  });
+
+  test('default_value is null if not permitted', () => {
+    element.permittedLabels = {
+      Verified: [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: -1,
+      },
+    };
+    element.label = {
+      name: 'Code-Review',
+      value: null,
+    };
+    flushAsynchronousOperations();
+    assert.isNull(element.selectedValue);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
index dbfdb6a..2d6825b 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
@@ -14,135 +14,143 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrLabelScores extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-label-scores'; }
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-label-score-row/gr-label-score-row.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-label-scores_html.js';
 
-    static get properties() {
-      return {
-        _labels: {
-          type: Array,
-          computed: '_computeLabels(change.labels.*, account)',
-        },
-        permittedLabels: {
-          type: Object,
-          observer: '_computeColumns',
-        },
-        /** @type {?} */
-        change: Object,
-        /** @type {?} */
-        account: Object,
+/** @extends Polymer.Element */
+class GrLabelScores extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-        _labelValues: Object,
-      };
-    }
+  static get is() { return 'gr-label-scores'; }
 
-    getLabelValues() {
-      const labels = {};
-      for (const label in this.permittedLabels) {
-        if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
+  static get properties() {
+    return {
+      _labels: {
+        type: Array,
+        computed: '_computeLabels(change.labels.*, account)',
+      },
+      permittedLabels: {
+        type: Object,
+        observer: '_computeColumns',
+      },
+      /** @type {?} */
+      change: Object,
+      /** @type {?} */
+      account: Object,
 
-        const selectorEl = this.shadowRoot
-            .querySelector(`gr-label-score-row[name="${label}"]`);
-        if (!selectorEl) { continue; }
-
-        // The user may have not voted on this label.
-        if (!selectorEl.selectedItem) { continue; }
-
-        const selectedVal = parseInt(selectorEl.selectedValue, 10);
-
-        // Only send the selection if the user changed it.
-        let prevVal = this._getVoteForAccount(this.change.labels, label,
-            this.account);
-        if (prevVal !== null) {
-          prevVal = parseInt(prevVal, 10);
-        }
-        if (selectedVal !== prevVal) {
-          labels[label] = selectedVal;
-        }
-      }
-      return labels;
-    }
-
-    _getStringLabelValue(labels, labelName, numberValue) {
-      for (const k in labels[labelName].values) {
-        if (parseInt(k, 10) === numberValue) {
-          return k;
-        }
-      }
-      return numberValue;
-    }
-
-    _getVoteForAccount(labels, labelName, account) {
-      const votes = labels[labelName];
-      if (votes.all && votes.all.length > 0) {
-        for (let i = 0; i < votes.all.length; i++) {
-          if (votes.all[i]._account_id == account._account_id) {
-            return this._getStringLabelValue(
-                labels, labelName, votes.all[i].value);
-          }
-        }
-      }
-      return null;
-    }
-
-    _computeLabels(labelRecord, account) {
-      // Polymer 2: check for undefined
-      if ([labelRecord, account].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      const labelsObj = labelRecord.base;
-      if (!labelsObj) { return []; }
-      return Object.keys(labelsObj).sort()
-          .map(key => {
-            return {
-              name: key,
-              value: this._getVoteForAccount(labelsObj, key, this.account),
-            };
-          });
-    }
-
-    _computeColumns(permittedLabels) {
-      const labels = Object.keys(permittedLabels);
-      const values = {};
-      for (const label of labels) {
-        for (const value of permittedLabels[label]) {
-          values[parseInt(value, 10)] = true;
-        }
-      }
-
-      const orderedValues = Object.keys(values).sort((a, b) => a - b);
-
-      for (let i = 0; i < orderedValues.length; i++) {
-        values[orderedValues[i]] = i;
-      }
-      this._labelValues = values;
-    }
-
-    _changeIsMerged(changeStatus) {
-      return changeStatus === 'MERGED';
-    }
-
-    /**
-     * @param {string|undefined} label
-     * @param {Object|undefined} permittedLabels
-     * @return {string}
-     */
-    _computeLabelAccessClass(label, permittedLabels) {
-      if (label == null || permittedLabels == null) {
-        return '';
-      }
-
-      return permittedLabels.hasOwnProperty(label) &&
-        permittedLabels[label].length ? 'access' : 'no-access';
-    }
+      _labelValues: Object,
+    };
   }
 
-  customElements.define(GrLabelScores.is, GrLabelScores);
-})();
+  getLabelValues() {
+    const labels = {};
+    for (const label in this.permittedLabels) {
+      if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
+
+      const selectorEl = this.shadowRoot
+          .querySelector(`gr-label-score-row[name="${label}"]`);
+      if (!selectorEl) { continue; }
+
+      // The user may have not voted on this label.
+      if (!selectorEl.selectedItem) { continue; }
+
+      const selectedVal = parseInt(selectorEl.selectedValue, 10);
+
+      // Only send the selection if the user changed it.
+      let prevVal = this._getVoteForAccount(this.change.labels, label,
+          this.account);
+      if (prevVal !== null) {
+        prevVal = parseInt(prevVal, 10);
+      }
+      if (selectedVal !== prevVal) {
+        labels[label] = selectedVal;
+      }
+    }
+    return labels;
+  }
+
+  _getStringLabelValue(labels, labelName, numberValue) {
+    for (const k in labels[labelName].values) {
+      if (parseInt(k, 10) === numberValue) {
+        return k;
+      }
+    }
+    return numberValue;
+  }
+
+  _getVoteForAccount(labels, labelName, account) {
+    const votes = labels[labelName];
+    if (votes.all && votes.all.length > 0) {
+      for (let i = 0; i < votes.all.length; i++) {
+        if (votes.all[i]._account_id == account._account_id) {
+          return this._getStringLabelValue(
+              labels, labelName, votes.all[i].value);
+        }
+      }
+    }
+    return null;
+  }
+
+  _computeLabels(labelRecord, account) {
+    // Polymer 2: check for undefined
+    if ([labelRecord, account].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const labelsObj = labelRecord.base;
+    if (!labelsObj) { return []; }
+    return Object.keys(labelsObj).sort()
+        .map(key => {
+          return {
+            name: key,
+            value: this._getVoteForAccount(labelsObj, key, this.account),
+          };
+        });
+  }
+
+  _computeColumns(permittedLabels) {
+    const labels = Object.keys(permittedLabels);
+    const values = {};
+    for (const label of labels) {
+      for (const value of permittedLabels[label]) {
+        values[parseInt(value, 10)] = true;
+      }
+    }
+
+    const orderedValues = Object.keys(values).sort((a, b) => a - b);
+
+    for (let i = 0; i < orderedValues.length; i++) {
+      values[orderedValues[i]] = i;
+    }
+    this._labelValues = values;
+  }
+
+  _changeIsMerged(changeStatus) {
+    return changeStatus === 'MERGED';
+  }
+
+  /**
+   * @param {string|undefined} label
+   * @param {Object|undefined} permittedLabels
+   * @return {string}
+   */
+  _computeLabelAccessClass(label, permittedLabels) {
+    if (label == null || permittedLabels == null) {
+      return '';
+    }
+
+    return permittedLabels.hasOwnProperty(label) &&
+      permittedLabels[label].length ? 'access' : 'no-access';
+  }
+}
+
+customElements.define(GrLabelScores.is, GrLabelScores);
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.js
index 8a8a9d5..b9c53c3 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.js
@@ -1,27 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-label-score-row/gr-label-score-row.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-label-scores">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .scoresTable {
         display: table;
@@ -44,19 +39,10 @@
     </style>
     <div class="scoresTable">
       <template is="dom-repeat" items="[[_labels]]" as="label">
-        <gr-label-score-row
-            class$="[[_computeLabelAccessClass(label.name, permittedLabels)]]"
-            label="[[label]]"
-            name="[[label.name]]"
-            labels="[[change.labels]]"
-            permitted-labels="[[permittedLabels]]"
-            label-values="[[_labelValues]]"></gr-label-score-row>
+        <gr-label-score-row class\$="[[_computeLabelAccessClass(label.name, permittedLabels)]]" label="[[label]]" name="[[label.name]]" labels="[[change.labels]]" permitted-labels="[[permittedLabels]]" label-values="[[_labelValues]]"></gr-label-score-row>
       </template>
     </div>
-    <div class="mergedMessage"
-        hidden$="[[!_changeIsMerged(change.status)]]">
+    <div class="mergedMessage" hidden\$="[[!_changeIsMerged(change.status)]]">
       Because this change has been merged, votes may not be decreased.
     </div>
-  </template>
-  <script src="gr-label-scores.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
index 9e93110..b6075ee 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-label-scores</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-label-scores.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-label-scores.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-label-scores.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,166 +40,167 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-label-scores tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-label-scores.js';
+suite('gr-label-scores tests', () => {
+  let element;
+  let sandbox;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-      element = fixture('basic');
-      element.change = {
-        _number: '123',
-        labels: {
-          'Code-Review': {
-            values: {
-              '0': 'No score',
-              '+1': 'good',
-              '+2': 'excellent',
-              '-1': 'bad',
-              '-2': 'terrible',
-            },
-            default_value: 0,
-            value: 1,
-            all: [{
-              _account_id: 123,
-              value: 1,
-            }],
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+    element = fixture('basic');
+    element.change = {
+      _number: '123',
+      labels: {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
           },
-          'Verified': {
-            values: {
-              '0': 'No score',
-              '+1': 'good',
-              '+2': 'excellent',
-              '-1': 'bad',
-              '-2': 'terrible',
-            },
-            default_value: 0,
+          default_value: 0,
+          value: 1,
+          all: [{
+            _account_id: 123,
             value: 1,
-            all: [{
-              _account_id: 123,
-              value: 1,
-            }],
-          },
+          }],
         },
-      };
+        'Verified': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+          value: 1,
+          all: [{
+            _account_id: 123,
+            value: 1,
+          }],
+        },
+      },
+    };
 
-      element.account = {
-        _account_id: 123,
-      };
+    element.account = {
+      _account_id: 123,
+    };
 
-      element.permittedLabels = {
-        'Code-Review': [
-          '-2',
-          '-1',
-          ' 0',
-          '+1',
-          '+2',
-        ],
-        'Verified': [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
-      flush(done);
-    });
+    element.permittedLabels = {
+      'Code-Review': [
+        '-2',
+        '-1',
+        ' 0',
+        '+1',
+        '+2',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    flush(done);
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('get and set label scores', () => {
-      for (const label in element.permittedLabels) {
-        if (element.permittedLabels.hasOwnProperty(label)) {
-          const row = element.shadowRoot
-              .querySelector('gr-label-score-row[name="' + label + '"]');
-          row.setSelectedValue(-1);
-        }
+  test('get and set label scores', () => {
+    for (const label in element.permittedLabels) {
+      if (element.permittedLabels.hasOwnProperty(label)) {
+        const row = element.shadowRoot
+            .querySelector('gr-label-score-row[name="' + label + '"]');
+        row.setSelectedValue(-1);
       }
-      assert.deepEqual(element.getLabelValues(), {
-        'Code-Review': -1,
-        'Verified': -1,
-      });
-    });
-
-    test('_getVoteForAccount', () => {
-      const labelName = 'Code-Review';
-      assert.strictEqual(element._getVoteForAccount(
-          element.change.labels, labelName, element.account),
-      '+1');
-    });
-
-    test('_computeColumns', () => {
-      element._computeColumns(element.permittedLabels);
-      assert.deepEqual(element._labelValues, {
-        '-2': 0,
-        '-1': 1,
-        '0': 2,
-        '1': 3,
-        '2': 4,
-      });
-    });
-
-    test('_computeLabelAccessClass undefined case', () => {
-      assert.strictEqual(
-          element._computeLabelAccessClass(undefined, undefined), '');
-      assert.strictEqual(
-          element._computeLabelAccessClass('', undefined), '');
-      assert.strictEqual(
-          element._computeLabelAccessClass(undefined, {}), '');
-    });
-
-    test('_computeLabelAccessClass has access', () => {
-      assert.strictEqual(
-          element._computeLabelAccessClass('foo', {foo: ['']}), 'access');
-    });
-
-    test('_computeLabelAccessClass no access', () => {
-      assert.strictEqual(
-          element._computeLabelAccessClass('zap', {foo: ['']}), 'no-access');
-    });
-
-    test('changes in label score are reflected in _labels', () => {
-      element.change = {
-        _number: '123',
-        labels: {
-          'Code-Review': {
-            values: {
-              '0': 'No score',
-              '+1': 'good',
-              '+2': 'excellent',
-              '-1': 'bad',
-              '-2': 'terrible',
-            },
-            default_value: 0,
-          },
-          'Verified': {
-            values: {
-              '0': 'No score',
-              '+1': 'good',
-              '+2': 'excellent',
-              '-1': 'bad',
-              '-2': 'terrible',
-            },
-            default_value: 0,
-          },
-        },
-      };
-      assert.deepEqual(element._labels [
-          {name: 'Code-Review', value: null},
-          {name: 'Verified', value: null}
-      ]);
-      element.set(['change', 'labels', 'Verified', 'all'],
-          [{_account_id: 123, value: 1}]);
-      assert.deepEqual(element._labels, [
-        {name: 'Code-Review', value: null},
-        {name: 'Verified', value: '+1'},
-      ]);
+    }
+    assert.deepEqual(element.getLabelValues(), {
+      'Code-Review': -1,
+      'Verified': -1,
     });
   });
+
+  test('_getVoteForAccount', () => {
+    const labelName = 'Code-Review';
+    assert.strictEqual(element._getVoteForAccount(
+        element.change.labels, labelName, element.account),
+    '+1');
+  });
+
+  test('_computeColumns', () => {
+    element._computeColumns(element.permittedLabels);
+    assert.deepEqual(element._labelValues, {
+      '-2': 0,
+      '-1': 1,
+      '0': 2,
+      '1': 3,
+      '2': 4,
+    });
+  });
+
+  test('_computeLabelAccessClass undefined case', () => {
+    assert.strictEqual(
+        element._computeLabelAccessClass(undefined, undefined), '');
+    assert.strictEqual(
+        element._computeLabelAccessClass('', undefined), '');
+    assert.strictEqual(
+        element._computeLabelAccessClass(undefined, {}), '');
+  });
+
+  test('_computeLabelAccessClass has access', () => {
+    assert.strictEqual(
+        element._computeLabelAccessClass('foo', {foo: ['']}), 'access');
+  });
+
+  test('_computeLabelAccessClass no access', () => {
+    assert.strictEqual(
+        element._computeLabelAccessClass('zap', {foo: ['']}), 'no-access');
+  });
+
+  test('changes in label score are reflected in _labels', () => {
+    element.change = {
+      _number: '123',
+      labels: {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        },
+        'Verified': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        },
+      },
+    };
+    assert.deepEqual(element._labels [
+        ({name: 'Code-Review', value: null}, {name: 'Verified', value: null})
+    ]);
+    element.set(['change', 'labels', 'Verified', 'all'],
+        [{_account_id: 123, value: 1}]);
+    assert.deepEqual(element._labels, [
+      {name: 'Code-Review', value: null},
+      {name: 'Verified', value: '+1'},
+    ]);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index 13a4213..e5cbaf1 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,379 +14,396 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
-  const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../shared/gr-account-label/gr-account-label.js';
+import '../../shared/gr-account-chip/gr-account-chip.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-formatted-text/gr-formatted-text.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import '../../../styles/gr-voting-styles.js';
+import '../gr-comment-list/gr-comment-list.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-message_html.js';
+
+const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
+const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrMessage extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-message'; }
+  /**
+   * Fired when this message's reply link is tapped.
+   *
+   * @event reply
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the message's timestamp is tapped.
+   *
+   * @event message-anchor-tap
    */
-  class GrMessage extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-message'; }
-    /**
-     * Fired when this message's reply link is tapped.
-     *
-     * @event reply
-     */
 
-    /**
-     * Fired when the message's timestamp is tapped.
-     *
-     * @event message-anchor-tap
-     */
+  /**
+   * Fired when a change message is deleted.
+   *
+   * @event change-message-deleted
+   */
 
-    /**
-     * Fired when a change message is deleted.
-     *
-     * @event change-message-deleted
-     */
+  static get properties() {
+    return {
+      changeNum: Number,
+      /** @type {?} */
+      message: Object,
+      author: {
+        type: Object,
+        computed: '_computeAuthor(message)',
+      },
+      comments: {
+        type: Object,
+        observer: '_commentsChanged',
+      },
+      config: Object,
+      hideAutomated: {
+        type: Boolean,
+        value: false,
+      },
+      hidden: {
+        type: Boolean,
+        computed: '_computeIsHidden(hideAutomated, isAutomated)',
+        reflectToAttribute: true,
+      },
+      isAutomated: {
+        type: Boolean,
+        computed: '_computeIsAutomated(message)',
+      },
+      showOnBehalfOf: {
+        type: Boolean,
+        computed: '_computeShowOnBehalfOf(message)',
+      },
+      showReplyButton: {
+        type: Boolean,
+        computed: '_computeShowReplyButton(message, _loggedIn)',
+      },
+      projectName: {
+        type: String,
+        observer: '_projectNameChanged',
+      },
 
-    static get properties() {
-      return {
-        changeNum: Number,
-        /** @type {?} */
-        message: Object,
-        author: {
-          type: Object,
-          computed: '_computeAuthor(message)',
-        },
-        comments: {
-          type: Object,
-          observer: '_commentsChanged',
-        },
-        config: Object,
-        hideAutomated: {
-          type: Boolean,
-          value: false,
-        },
-        hidden: {
-          type: Boolean,
-          computed: '_computeIsHidden(hideAutomated, isAutomated)',
-          reflectToAttribute: true,
-        },
-        isAutomated: {
-          type: Boolean,
-          computed: '_computeIsAutomated(message)',
-        },
-        showOnBehalfOf: {
-          type: Boolean,
-          computed: '_computeShowOnBehalfOf(message)',
-        },
-        showReplyButton: {
-          type: Boolean,
-          computed: '_computeShowReplyButton(message, _loggedIn)',
-        },
-        projectName: {
-          type: String,
-          observer: '_projectNameChanged',
-        },
+      /**
+       * A mapping from label names to objects representing the minimum and
+       * maximum possible values for that label.
+       */
+      labelExtremes: Object,
 
-        /**
-         * A mapping from label names to objects representing the minimum and
-         * maximum possible values for that label.
-         */
-        labelExtremes: Object,
+      /**
+       * @type {{ commentlinks: Array }}
+       */
+      _projectConfig: Object,
+      // Computed property needed to trigger Polymer value observing.
+      _expanded: {
+        type: Object,
+        computed: '_computeExpanded(message.expanded)',
+      },
+      _messageContentExpanded: {
+        type: String,
+        computed:
+            '_computeMessageContentExpanded(message.message, message.tag)',
+      },
+      _messageContentCollapsed: {
+        type: String,
+        computed:
+            '_computeMessageContentCollapsed(message.message, message.tag)',
+      },
+      _commentCountText: {
+        type: Number,
+        computed: '_computeCommentCountText(comments)',
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+      _isDeletingChangeMsg: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-        /**
-         * @type {{ commentlinks: Array }}
-         */
-        _projectConfig: Object,
-        // Computed property needed to trigger Polymer value observing.
-        _expanded: {
-          type: Object,
-          computed: '_computeExpanded(message.expanded)',
-        },
-        _messageContentExpanded: {
-          type: String,
-          computed:
-              '_computeMessageContentExpanded(message.message, message.tag)',
-        },
-        _messageContentCollapsed: {
-          type: String,
-          computed:
-              '_computeMessageContentCollapsed(message.message, message.tag)',
-        },
-        _commentCountText: {
-          type: Number,
-          computed: '_computeCommentCountText(comments)',
-        },
-        _loggedIn: {
-          type: Boolean,
-          value: false,
-        },
-        _isAdmin: {
-          type: Boolean,
-          value: false,
-        },
-        _isDeletingChangeMsg: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
+  static get observers() {
+    return [
+      '_updateExpandedClass(message.expanded)',
+    ];
+  }
 
-    static get observers() {
-      return [
-        '_updateExpandedClass(message.expanded)',
-      ];
-    }
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('click',
+        e => this._handleClick(e));
+  }
 
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('click',
-          e => this._handleClick(e));
-    }
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.restAPI.getConfig().then(config => {
+      this.config = config;
+    });
+    this.$.restAPI.getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+    this.$.restAPI.getIsAdmin().then(isAdmin => {
+      this._isAdmin = isAdmin;
+    });
+  }
 
-    /** @override */
-    ready() {
-      super.ready();
-      this.$.restAPI.getConfig().then(config => {
-        this.config = config;
-      });
-      this.$.restAPI.getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-      });
-      this.$.restAPI.getIsAdmin().then(isAdmin => {
-        this._isAdmin = isAdmin;
-      });
-    }
-
-    _updateExpandedClass(expanded) {
-      if (expanded) {
-        this.classList.add('expanded');
-      } else {
-        this.classList.remove('expanded');
-      }
-    }
-
-    _computeCommentCountText(comments) {
-      if (!comments) return undefined;
-      let count = 0;
-      for (const file in comments) {
-        if (comments.hasOwnProperty(file)) {
-          const commentArray = comments[file] || [];
-          count += commentArray.length;
-        }
-      }
-      if (count === 0) {
-        return undefined;
-      } else if (count === 1) {
-        return '1 comment';
-      } else {
-        return `${count} comments`;
-      }
-    }
-
-    _computeMessageContentExpanded(content, tag) {
-      return this._computeMessageContent(content, tag, true);
-    }
-
-    _computeMessageContentCollapsed(content, tag) {
-      return this._computeMessageContent(content, tag, false);
-    }
-
-    _computeMessageContent(content, tag, isExpanded) {
-      content = content || '';
-      tag = tag || '';
-      const isNewPatchSet = tag.endsWith(':newPatchSet') ||
-          tag.endsWith(':newWipPatchSet');
-      const lines = content.split('\n');
-      const filteredLines = lines.filter(line => {
-        if (!isExpanded && line.startsWith('>')) {
-          return false;
-        }
-        if (line.startsWith('(') && line.endsWith(' comment)')) {
-          return false;
-        }
-        if (line.startsWith('(') && line.endsWith(' comments)')) {
-          return false;
-        }
-        if (!isNewPatchSet && line.match(PATCH_SET_PREFIX_PATTERN)) {
-          return false;
-        }
-        return true;
-      });
-      const mappedLines = filteredLines.map(line => {
-        // The change message formatting is not very consistent, so
-        // unfortunately we have to do a bit of tweaking here:
-        //   Labels should be stripped from lines like this:
-        //     Patch Set 29: Verified+1
-        //   Rebase messages (which have a ':newPatchSet' tag) should be kept on
-        //   lines like this:
-        //     Patch Set 27: Patch Set 26 was rebased
-        if (isNewPatchSet) {
-          line = line.replace(PATCH_SET_PREFIX_PATTERN, '$1');
-        }
-        return line;
-      });
-      return mappedLines.join('\n').trim();
-    }
-
-    _isMessageContentEmpty() {
-      return !this._messageContentExpanded
-          || this._messageContentExpanded.length === 0;
-    }
-
-    _computeAuthor(message) {
-      return message.author || message.updated_by;
-    }
-
-    _computeShowOnBehalfOf(message) {
-      const author = message.author || message.updated_by;
-      return !!(author && message.real_author &&
-          author._account_id != message.real_author._account_id);
-    }
-
-    _computeShowReplyButton(message, loggedIn) {
-      return message && !!message.message && loggedIn &&
-          !this._computeIsAutomated(message);
-    }
-
-    _computeExpanded(expanded) {
-      return expanded;
-    }
-
-    /**
-     * If there is no value set on the message object as to whether _expanded
-     * should be true or not, then _expanded is set to true if there are
-     * inline comments (otherwise false).
-     */
-    _commentsChanged(value) {
-      if (this.message && this.message.expanded === undefined) {
-        this.set('message.expanded', Object.keys(value || {}).length > 0);
-      }
-    }
-
-    _handleClick(e) {
-      if (this.message.expanded) { return; }
-      e.stopPropagation();
-      this.set('message.expanded', true);
-    }
-
-    _handleAuthorClick(e) {
-      if (!this.message.expanded) { return; }
-      e.stopPropagation();
-      this.set('message.expanded', false);
-    }
-
-    _computeIsAutomated(message) {
-      return !!(message.reviewer ||
-          this._computeIsReviewerUpdate(message) ||
-          (message.tag && message.tag.startsWith('autogenerated')));
-    }
-
-    _computeIsHidden(hideAutomated, isAutomated) {
-      return hideAutomated && isAutomated;
-    }
-
-    _computeIsReviewerUpdate(event) {
-      return event.type === 'REVIEWER_UPDATE';
-    }
-
-    _getScores(message, labelExtremes) {
-      if (!message || !message.message || !labelExtremes) {
-        return [];
-      }
-      const line = message.message.split('\n', 1)[0];
-      const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
-      if (!line.match(patchSetPrefix)) {
-        return [];
-      }
-      const scoresRaw = line.split(patchSetPrefix)[1];
-      if (!scoresRaw) {
-        return [];
-      }
-      return scoresRaw.split(' ')
-          .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
-          .filter(ms =>
-            ms && ms.length === 4 && labelExtremes.hasOwnProperty(ms[2]))
-          .map(ms => {
-            const label = ms[2];
-            const value = ms[1] === '-' ? 'removed' : ms[3];
-            return {label, value};
-          });
-    }
-
-    _computeScoreClass(score, labelExtremes) {
-      // Polymer 2: check for undefined
-      if ([score, labelExtremes].some(arg => arg === undefined)) {
-        return '';
-      }
-      if (score.value === 'removed') {
-        return 'removed';
-      }
-      const classes = [];
-      if (score.value > 0) {
-        classes.push('positive');
-      } else if (score.value < 0) {
-        classes.push('negative');
-      }
-      const extremes = labelExtremes[score.label];
-      if (extremes) {
-        const intScore = parseInt(score.value, 10);
-        if (intScore === extremes.max) {
-          classes.push('max');
-        } else if (intScore === extremes.min) {
-          classes.push('min');
-        }
-      }
-      return classes.join(' ');
-    }
-
-    _computeClass(expanded) {
-      const classes = [];
-      classes.push(expanded ? 'expanded' : 'collapsed');
-      return classes.join(' ');
-    }
-
-    _handleAnchorClick(e) {
-      e.preventDefault();
-      this.dispatchEvent(new CustomEvent('message-anchor-tap', {
-        bubbles: true,
-        composed: true,
-        detail: {id: this.message.id},
-      }));
-    }
-
-    _handleReplyTap(e) {
-      e.preventDefault();
-      this.fire('reply', {message: this.message});
-    }
-
-    _handleDeleteMessage(e) {
-      e.preventDefault();
-      if (!this.message || !this.message.id) return;
-      this._isDeletingChangeMsg = true;
-      this.$.restAPI.deleteChangeCommitMessage(this.changeNum, this.message.id)
-          .then(() => {
-            this._isDeletingChangeMsg = false;
-            this.fire('change-message-deleted', {message: this.message});
-          });
-    }
-
-    _projectNameChanged(name) {
-      this.$.restAPI.getProjectConfig(name).then(config => {
-        this._projectConfig = config;
-      });
-    }
-
-    _computeExpandToggleIcon(expanded) {
-      return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
-    }
-
-    _toggleExpanded(e) {
-      e.stopPropagation();
-      this.set('message.expanded', !this.message.expanded);
+  _updateExpandedClass(expanded) {
+    if (expanded) {
+      this.classList.add('expanded');
+    } else {
+      this.classList.remove('expanded');
     }
   }
 
-  customElements.define(GrMessage.is, GrMessage);
-})();
+  _computeCommentCountText(comments) {
+    if (!comments) return undefined;
+    let count = 0;
+    for (const file in comments) {
+      if (comments.hasOwnProperty(file)) {
+        const commentArray = comments[file] || [];
+        count += commentArray.length;
+      }
+    }
+    if (count === 0) {
+      return undefined;
+    } else if (count === 1) {
+      return '1 comment';
+    } else {
+      return `${count} comments`;
+    }
+  }
+
+  _computeMessageContentExpanded(content, tag) {
+    return this._computeMessageContent(content, tag, true);
+  }
+
+  _computeMessageContentCollapsed(content, tag) {
+    return this._computeMessageContent(content, tag, false);
+  }
+
+  _computeMessageContent(content, tag, isExpanded) {
+    content = content || '';
+    tag = tag || '';
+    const isNewPatchSet = tag.endsWith(':newPatchSet') ||
+        tag.endsWith(':newWipPatchSet');
+    const lines = content.split('\n');
+    const filteredLines = lines.filter(line => {
+      if (!isExpanded && line.startsWith('>')) {
+        return false;
+      }
+      if (line.startsWith('(') && line.endsWith(' comment)')) {
+        return false;
+      }
+      if (line.startsWith('(') && line.endsWith(' comments)')) {
+        return false;
+      }
+      if (!isNewPatchSet && line.match(PATCH_SET_PREFIX_PATTERN)) {
+        return false;
+      }
+      return true;
+    });
+    const mappedLines = filteredLines.map(line => {
+      // The change message formatting is not very consistent, so
+      // unfortunately we have to do a bit of tweaking here:
+      //   Labels should be stripped from lines like this:
+      //     Patch Set 29: Verified+1
+      //   Rebase messages (which have a ':newPatchSet' tag) should be kept on
+      //   lines like this:
+      //     Patch Set 27: Patch Set 26 was rebased
+      if (isNewPatchSet) {
+        line = line.replace(PATCH_SET_PREFIX_PATTERN, '$1');
+      }
+      return line;
+    });
+    return mappedLines.join('\n').trim();
+  }
+
+  _isMessageContentEmpty() {
+    return !this._messageContentExpanded
+        || this._messageContentExpanded.length === 0;
+  }
+
+  _computeAuthor(message) {
+    return message.author || message.updated_by;
+  }
+
+  _computeShowOnBehalfOf(message) {
+    const author = message.author || message.updated_by;
+    return !!(author && message.real_author &&
+        author._account_id != message.real_author._account_id);
+  }
+
+  _computeShowReplyButton(message, loggedIn) {
+    return message && !!message.message && loggedIn &&
+        !this._computeIsAutomated(message);
+  }
+
+  _computeExpanded(expanded) {
+    return expanded;
+  }
+
+  /**
+   * If there is no value set on the message object as to whether _expanded
+   * should be true or not, then _expanded is set to true if there are
+   * inline comments (otherwise false).
+   */
+  _commentsChanged(value) {
+    if (this.message && this.message.expanded === undefined) {
+      this.set('message.expanded', Object.keys(value || {}).length > 0);
+    }
+  }
+
+  _handleClick(e) {
+    if (this.message.expanded) { return; }
+    e.stopPropagation();
+    this.set('message.expanded', true);
+  }
+
+  _handleAuthorClick(e) {
+    if (!this.message.expanded) { return; }
+    e.stopPropagation();
+    this.set('message.expanded', false);
+  }
+
+  _computeIsAutomated(message) {
+    return !!(message.reviewer ||
+        this._computeIsReviewerUpdate(message) ||
+        (message.tag && message.tag.startsWith('autogenerated')));
+  }
+
+  _computeIsHidden(hideAutomated, isAutomated) {
+    return hideAutomated && isAutomated;
+  }
+
+  _computeIsReviewerUpdate(event) {
+    return event.type === 'REVIEWER_UPDATE';
+  }
+
+  _getScores(message, labelExtremes) {
+    if (!message || !message.message || !labelExtremes) {
+      return [];
+    }
+    const line = message.message.split('\n', 1)[0];
+    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
+    if (!line.match(patchSetPrefix)) {
+      return [];
+    }
+    const scoresRaw = line.split(patchSetPrefix)[1];
+    if (!scoresRaw) {
+      return [];
+    }
+    return scoresRaw.split(' ')
+        .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
+        .filter(ms =>
+          ms && ms.length === 4 && labelExtremes.hasOwnProperty(ms[2]))
+        .map(ms => {
+          const label = ms[2];
+          const value = ms[1] === '-' ? 'removed' : ms[3];
+          return {label, value};
+        });
+  }
+
+  _computeScoreClass(score, labelExtremes) {
+    // Polymer 2: check for undefined
+    if ([score, labelExtremes].some(arg => arg === undefined)) {
+      return '';
+    }
+    if (score.value === 'removed') {
+      return 'removed';
+    }
+    const classes = [];
+    if (score.value > 0) {
+      classes.push('positive');
+    } else if (score.value < 0) {
+      classes.push('negative');
+    }
+    const extremes = labelExtremes[score.label];
+    if (extremes) {
+      const intScore = parseInt(score.value, 10);
+      if (intScore === extremes.max) {
+        classes.push('max');
+      } else if (intScore === extremes.min) {
+        classes.push('min');
+      }
+    }
+    return classes.join(' ');
+  }
+
+  _computeClass(expanded) {
+    const classes = [];
+    classes.push(expanded ? 'expanded' : 'collapsed');
+    return classes.join(' ');
+  }
+
+  _handleAnchorClick(e) {
+    e.preventDefault();
+    this.dispatchEvent(new CustomEvent('message-anchor-tap', {
+      bubbles: true,
+      composed: true,
+      detail: {id: this.message.id},
+    }));
+  }
+
+  _handleReplyTap(e) {
+    e.preventDefault();
+    this.fire('reply', {message: this.message});
+  }
+
+  _handleDeleteMessage(e) {
+    e.preventDefault();
+    if (!this.message || !this.message.id) return;
+    this._isDeletingChangeMsg = true;
+    this.$.restAPI.deleteChangeCommitMessage(this.changeNum, this.message.id)
+        .then(() => {
+          this._isDeletingChangeMsg = false;
+          this.fire('change-message-deleted', {message: this.message});
+        });
+  }
+
+  _projectNameChanged(name) {
+    this.$.restAPI.getProjectConfig(name).then(config => {
+      this._projectConfig = config;
+    });
+  }
+
+  _computeExpandToggleIcon(expanded) {
+    return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
+  }
+
+  _toggleExpanded(e) {
+    e.stopPropagation();
+    this.set('message.expanded', !this.message.expanded);
+  }
+}
+
+customElements.define(GrMessage.is, GrMessage);
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
index f7ef7e6..49ced2a 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
@@ -1,36 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../shared/gr-account-label/gr-account-label.html">
-<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../styles/gr-voting-styles.html">
-
-<link rel="import" href="../gr-comment-list/gr-comment-list.html">
-
-<dom-module id="gr-message">
-  <template>
+export const htmlTemplate = html`
     <style include="gr-voting-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -193,16 +179,16 @@
         }
       }
     </style>
-    <div class$="[[_computeClass(_expanded)]]">
+    <div class\$="[[_computeClass(_expanded)]]">
       <div class="contentContainer">
         <div class="author" on-click="_handleAuthorClick">
-          <span hidden$="[[!showOnBehalfOf]]">
+          <span hidden\$="[[!showOnBehalfOf]]">
             <span class="name">[[message.real_author.name]]</span>
             on behalf of
           </span>
           <gr-account-label account="[[author]]" class="authorLabel"></gr-account-label>
           <template is="dom-repeat" items="[[_getScores(message, labelExtremes)]]" as="score">
-            <span class$="score [[_computeScoreClass(score, labelExtremes)]]">
+            <span class\$="score [[_computeScoreClass(score, labelExtremes)]]">
               [[score.label]] [[score.value]]
             </span>
           </template>
@@ -216,32 +202,18 @@
         <template is="dom-if" if="[[message.message]]">
           <div class="content">
             <div class="message hideOnOpen">[[_messageContentCollapsed]]</div>
-            <gr-formatted-text
-                no-trailing-margin
-                class="message hideOnCollapsed"
-                content="[[_messageContentExpanded]]"
-                config="[[_projectConfig.commentlinks]]"></gr-formatted-text>
+            <gr-formatted-text no-trailing-margin="" class="message hideOnCollapsed" content="[[_messageContentExpanded]]" config="[[_projectConfig.commentlinks]]"></gr-formatted-text>
             <template is="dom-if" if="[[!_isMessageContentEmpty()]]">
-              <div class="replyActionContainer" hidden$="[[!showReplyButton]]" hidden>
-                  <gr-button
-                     class="replyBtn"
-                     link small on-click="_handleReplyTap">
+              <div class="replyActionContainer" hidden\$="[[!showReplyButton]]" hidden="">
+                  <gr-button class="replyBtn" link="" small="" on-click="_handleReplyTap">
                     Reply
                   </gr-button>
-                  <gr-button
-                    disabled$=[[_isDeletingChangeMsg]]
-                    class="deleteBtn" hidden$="[[!_isAdmin]]" hidden
-                    link small on-click="_handleDeleteMessage">
+                  <gr-button disabled\$="[[_isDeletingChangeMsg]]" class="deleteBtn" hidden\$="[[!_isAdmin]]" hidden="" link="" small="" on-click="_handleDeleteMessage">
                     Delete
                   </gr-button>
               </div>
             </template>
-            <gr-comment-list
-                comments="[[comments]]"
-                change-num="[[changeNum]]"
-                patch-num="[[message._revision_number]]"
-                project-name="[[projectName]]"
-                project-config="[[_projectConfig]]"></gr-comment-list>
+            <gr-comment-list comments="[[comments]]" change-num="[[changeNum]]" patch-num="[[message._revision_number]]" project-name="[[projectName]]" project-config="[[_projectConfig]]"></gr-comment-list>
           </div>
         </template>
         <template is="dom-if" if="[[_computeIsReviewerUpdate(message)]]">
@@ -249,8 +221,7 @@
             <template is="dom-repeat" items="[[message.updates]]" as="update">
               <div class="updateCategory">
                 [[update.message]]
-                <template
-                    is="dom-repeat" items="[[update.reviewers]]" as="reviewer">
+                <template is="dom-repeat" items="[[update.reviewers]]" as="reviewer">
                   <gr-account-chip account="[[reviewer]]">
                   </gr-account-chip>
                 </template>
@@ -264,29 +235,17 @@
           </template>
           <template is="dom-if" if="[[!message.id]]">
             <span class="date">
-              <gr-date-formatter
-                  has-tooltip
-                  show-date-and-time
-                  date-str="[[message.date]]"></gr-date-formatter>
+              <gr-date-formatter has-tooltip="" show-date-and-time="" date-str="[[message.date]]"></gr-date-formatter>
             </span>
           </template>
           <template is="dom-if" if="[[message.id]]">
             <span class="date" on-click="_handleAnchorClick">
-              <gr-date-formatter
-                  has-tooltip
-                  show-date-and-time
-                  date-str="[[message.date]]"></gr-date-formatter>
+              <gr-date-formatter has-tooltip="" show-date-and-time="" date-str="[[message.date]]"></gr-date-formatter>
             </span>
           </template>
-          <iron-icon
-              id="expandToggle"
-              on-click="_toggleExpanded"
-              title="Toggle expanded state"
-              icon="[[_computeExpandToggleIcon(_expanded)]]"></iron-icon>
+          <iron-icon id="expandToggle" on-click="_toggleExpanded" title="Toggle expanded state" icon="[[_computeExpandToggleIcon(_expanded)]]"></iron-icon>
         </span>
       </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-message.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index f22f17e..04f12c2f 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-message</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-message.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-message.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-message.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,386 +40,389 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-message tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-message.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-message tests', () => {
+  let element;
 
-    suite('when admin and logged in', () => {
-      setup(done => {
-        stub('gr-rest-api-interface', {
-          getLoggedIn() { return Promise.resolve(true); },
-          getPreferences() { return Promise.resolve({}); },
-          getConfig() { return Promise.resolve({}); },
-          getIsAdmin() { return Promise.resolve(true); },
-          deleteChangeCommitMessage() { return Promise.resolve({}); },
-        });
-        element = fixture('basic');
-        flush(done);
+  suite('when admin and logged in', () => {
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
+        getIsAdmin() { return Promise.resolve(true); },
+        deleteChangeCommitMessage() { return Promise.resolve({}); },
       });
+      element = fixture('basic');
+      flush(done);
+    });
 
-      test('reply event', done => {
-        element.message = {
-          id: '47c43261_55aa2c41',
-          author: {
-            _account_id: 1115495,
-            name: 'Andrew Bonventre',
-            email: 'andybons@chromium.org',
-          },
-          date: '2016-01-12 20:24:49.448000000',
-          message: 'Uploaded patch set 1.',
-          _revision_number: 1,
-        };
+    test('reply event', done => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+      };
 
-        element.addEventListener('reply', e => {
-          assert.deepEqual(e.detail.message, element.message);
-          done();
-        });
-        flushAsynchronousOperations();
-        assert.isFalse(
-            element.shadowRoot.querySelector('.replyActionContainer').hidden
-        );
-        MockInteractions.tap(element.shadowRoot.querySelector('.replyBtn'));
+      element.addEventListener('reply', e => {
+        assert.deepEqual(e.detail.message, element.message);
+        done();
       });
+      flushAsynchronousOperations();
+      assert.isFalse(
+          element.shadowRoot.querySelector('.replyActionContainer').hidden
+      );
+      MockInteractions.tap(element.shadowRoot.querySelector('.replyBtn'));
+    });
 
-      test('can see delete button', () => {
-        element.message = {
-          id: '47c43261_55aa2c41',
-          author: {
-            _account_id: 1115495,
-            name: 'Andrew Bonventre',
-            email: 'andybons@chromium.org',
-          },
-          date: '2016-01-12 20:24:49.448000000',
-          message: 'Uploaded patch set 1.',
-          _revision_number: 1,
-        };
+    test('can see delete button', () => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+      };
 
-        flushAsynchronousOperations();
-        assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').hidden);
+      flushAsynchronousOperations();
+      assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').hidden);
+    });
+
+    test('delete change message', done => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+      };
+
+      element.addEventListener('change-message-deleted', e => {
+        assert.deepEqual(e.detail.message, element.message);
+        assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').disabled);
+        done();
       });
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.shadowRoot.querySelector('.deleteBtn'));
+      assert.isTrue(element.shadowRoot.querySelector('.deleteBtn').disabled);
+    });
 
-      test('delete change message', done => {
-        element.message = {
-          id: '47c43261_55aa2c41',
-          author: {
-            _account_id: 1115495,
-            name: 'Andrew Bonventre',
-            email: 'andybons@chromium.org',
-          },
-          date: '2016-01-12 20:24:49.448000000',
-          message: 'Uploaded patch set 1.',
-          _revision_number: 1,
-        };
+    test('autogenerated prefix hiding', () => {
+      element.message = {
+        tag: 'autogenerated:gerrit:test',
+        updated: '2016-01-12 20:24:49.448000000',
+      };
 
-        element.addEventListener('change-message-deleted', e => {
-          assert.deepEqual(e.detail.message, element.message);
-          assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').disabled);
-          done();
-        });
-        flushAsynchronousOperations();
-        MockInteractions.tap(element.shadowRoot.querySelector('.deleteBtn'));
-        assert.isTrue(element.shadowRoot.querySelector('.deleteBtn').disabled);
-      });
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
 
-      test('autogenerated prefix hiding', () => {
-        element.message = {
-          tag: 'autogenerated:gerrit:test',
-          updated: '2016-01-12 20:24:49.448000000',
-        };
+      element.hideAutomated = true;
 
-        assert.isTrue(element.isAutomated);
-        assert.isFalse(element.hidden);
+      assert.isTrue(element.hidden);
+    });
 
-        element.hideAutomated = true;
+    test('reviewer message treated as autogenerated', () => {
+      element.message = {
+        tag: 'autogenerated:gerrit:test',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+      };
 
-        assert.isTrue(element.hidden);
-      });
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
 
-      test('reviewer message treated as autogenerated', () => {
-        element.message = {
-          tag: 'autogenerated:gerrit:test',
-          updated: '2016-01-12 20:24:49.448000000',
-          reviewer: {},
-        };
+      element.hideAutomated = true;
 
-        assert.isTrue(element.isAutomated);
-        assert.isFalse(element.hidden);
+      assert.isTrue(element.hidden);
+    });
 
-        element.hideAutomated = true;
+    test('batch reviewer message treated as autogenerated', () => {
+      element.message = {
+        type: 'REVIEWER_UPDATE',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+      };
 
-        assert.isTrue(element.hidden);
-      });
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
 
-      test('batch reviewer message treated as autogenerated', () => {
-        element.message = {
-          type: 'REVIEWER_UPDATE',
-          updated: '2016-01-12 20:24:49.448000000',
-          reviewer: {},
-        };
+      element.hideAutomated = true;
 
-        assert.isTrue(element.isAutomated);
-        assert.isFalse(element.hidden);
+      assert.isTrue(element.hidden);
+    });
 
-        element.hideAutomated = true;
+    test('tag that is not autogenerated prefix does not hide', () => {
+      element.message = {
+        tag: 'something',
+        updated: '2016-01-12 20:24:49.448000000',
+      };
 
-        assert.isTrue(element.hidden);
-      });
+      assert.isFalse(element.isAutomated);
+      assert.isFalse(element.hidden);
 
-      test('tag that is not autogenerated prefix does not hide', () => {
-        element.message = {
-          tag: 'something',
-          updated: '2016-01-12 20:24:49.448000000',
-        };
+      element.hideAutomated = true;
 
-        assert.isFalse(element.isAutomated);
-        assert.isFalse(element.hidden);
+      assert.isFalse(element.hidden);
+    });
 
-        element.hideAutomated = true;
+    test('reply button hidden unless logged in', () => {
+      const message = {
+        message: 'Uploaded patch set 1.',
+      };
+      assert.isFalse(element._computeShowReplyButton(message, false));
+      assert.isTrue(element._computeShowReplyButton(message, true));
+    });
 
-        assert.isFalse(element.hidden);
-      });
+    test('_computeShowOnBehalfOf', () => {
+      const message = {
+        message: '...',
+      };
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author._account_id = 123456;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      message.updated_by = message.author;
+      delete message.author;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      delete message.updated_by;
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+    });
 
-      test('reply button hidden unless logged in', () => {
-        const message = {
-          message: 'Uploaded patch set 1.',
-        };
-        assert.isFalse(element._computeShowReplyButton(message, false));
-        assert.isTrue(element._computeShowReplyButton(message, true));
-      });
-
-      test('_computeShowOnBehalfOf', () => {
-        const message = {
-          message: '...',
-        };
-        assert.isNotOk(element._computeShowOnBehalfOf(message));
-        message.author = {_account_id: 1115495};
-        assert.isNotOk(element._computeShowOnBehalfOf(message));
-        message.real_author = {_account_id: 1115495};
-        assert.isNotOk(element._computeShowOnBehalfOf(message));
-        message.real_author._account_id = 123456;
-        assert.isOk(element._computeShowOnBehalfOf(message));
-        message.updated_by = message.author;
-        delete message.author;
-        assert.isOk(element._computeShowOnBehalfOf(message));
-        delete message.updated_by;
-        assert.isNotOk(element._computeShowOnBehalfOf(message));
-      });
-
-      ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'].forEach(label => {
-        test(`${label} ignored for color voting`, () => {
-          element.message = {
-            author: {},
-            expanded: false,
-            message: `Patch Set 1: ${label}+1`,
-          };
-          assert.isNotOk(
-              Polymer.dom(element.root).querySelector('.negativeVote'));
-          assert.isNotOk(
-              Polymer.dom(element.root).querySelector('.positiveVote'));
-        });
-      });
-
-      test('clicking on date link fires event', () => {
-        element.message = {
-          type: 'REVIEWER_UPDATE',
-          updated: '2016-01-12 20:24:49.448000000',
-          reviewer: {},
-          id: '47c43261_55aa2c41',
-        };
-        flushAsynchronousOperations();
-        const stub = sinon.stub();
-        element.addEventListener('message-anchor-tap', stub);
-        const dateEl = element.shadowRoot
-            .querySelector('.date');
-        assert.ok(dateEl);
-        MockInteractions.tap(dateEl);
-
-        assert.isTrue(stub.called);
-        assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
-      });
-
-      suite('compute messages', () => {
-        test('empty', () => {
-          assert.equal(element._computeMessageContent('', '', true), '');
-          assert.equal(element._computeMessageContent('', '', false), '');
-        });
-
-        test('new patchset', () => {
-          const original = 'Uploaded patch set 1.';
-          const tag = 'autogenerated:gerrit:newPatchSet';
-          let actual = element._computeMessageContent(original, tag, true);
-          assert.equal(actual, original);
-          actual = element._computeMessageContent(original, tag, false);
-          assert.equal(actual, original);
-        });
-
-        test('new patchset rebased', () => {
-          const original = 'Patch Set 27: Patch Set 26 was rebased';
-          const tag = 'autogenerated:gerrit:newPatchSet';
-          const expected = 'Patch Set 26 was rebased';
-          let actual = element._computeMessageContent(original, tag, true);
-          assert.equal(actual, expected);
-          actual = element._computeMessageContent(original, tag, false);
-          assert.equal(actual, expected);
-        });
-
-        test('ready for review', () => {
-          const original = 'Patch Set 1:\n\nThis change is ready for review.';
-          const tag = undefined;
-          const expected = 'This change is ready for review.';
-          let actual = element._computeMessageContent(original, tag, true);
-          assert.equal(actual, expected);
-          actual = element._computeMessageContent(original, tag, false);
-          assert.equal(actual, expected);
-        });
-
-        test('vote', () => {
-          const original = 'Patch Set 1: Code-Style+1';
-          const tag = undefined;
-          const expected = '';
-          let actual = element._computeMessageContent(original, tag, true);
-          assert.equal(actual, expected);
-          actual = element._computeMessageContent(original, tag, false);
-          assert.equal(actual, expected);
-        });
-
-        test('comments', () => {
-          const original = 'Patch Set 1:\n\n(3 comments)';
-          const tag = undefined;
-          const expected = '';
-          let actual = element._computeMessageContent(original, tag, true);
-          assert.equal(actual, expected);
-          actual = element._computeMessageContent(original, tag, false);
-          assert.equal(actual, expected);
-        });
-      });
-
-      test('votes', () => {
+    ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'].forEach(label => {
+      test(`${label} ignored for color voting`, () => {
         element.message = {
           author: {},
           expanded: false,
-          message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
+          message: `Patch Set 1: ${label}+1`,
         };
-        element.labelExtremes = {
-          'Verified': {max: 1, min: -1},
-          'Code-Review': {max: 2, min: -2},
-          'Trybot-Label3': {max: 3, min: 0},
-        };
-        flushAsynchronousOperations();
-        const scoreChips = Polymer.dom(element.root).querySelectorAll('.score');
-        assert.equal(scoreChips.length, 3);
-
-        assert.isTrue(scoreChips[0].classList.contains('positive'));
-        assert.isTrue(scoreChips[0].classList.contains('max'));
-
-        assert.isTrue(scoreChips[1].classList.contains('negative'));
-        assert.isTrue(scoreChips[1].classList.contains('min'));
-
-        assert.isTrue(scoreChips[2].classList.contains('positive'));
-        assert.isFalse(scoreChips[2].classList.contains('min'));
-      });
-
-      test('removed votes', () => {
-        element.message = {
-          author: {},
-          expanded: false,
-          message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
-        };
-        element.labelExtremes = {
-          'Verified': {max: 1, min: -1},
-          'Code-Review': {max: 2, min: -2},
-          'Commit-Queue': {max: 3, min: 0},
-        };
-        flushAsynchronousOperations();
-        const scoreChips = Polymer.dom(element.root).querySelectorAll('.score');
-        assert.equal(scoreChips.length, 3);
-
-        assert.isTrue(scoreChips[1].classList.contains('removed'));
-        assert.isTrue(scoreChips[2].classList.contains('removed'));
-      });
-
-      test('false negative vote', () => {
-        element.message = {
-          author: {},
-          expanded: false,
-          message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
-        };
-        element.labelExtremes = {};
-        const scoreChips = Polymer.dom(element.root).querySelectorAll('.score');
-        assert.equal(scoreChips.length, 0);
+        assert.isNotOk(
+            dom(element.root).querySelector('.negativeVote'));
+        assert.isNotOk(
+            dom(element.root).querySelector('.positiveVote'));
       });
     });
 
-    suite('when not logged in', () => {
-      setup(done => {
-        stub('gr-rest-api-interface', {
-          getLoggedIn() { return Promise.resolve(false); },
-          getPreferences() { return Promise.resolve({}); },
-          getConfig() { return Promise.resolve({}); },
-          getIsAdmin() { return Promise.resolve(false); },
-          deleteChangeCommitMessage() { return Promise.resolve({}); },
-        });
-        element = fixture('basic');
-        flush(done);
+    test('clicking on date link fires event', () => {
+      element.message = {
+        type: 'REVIEWER_UPDATE',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+        id: '47c43261_55aa2c41',
+      };
+      flushAsynchronousOperations();
+      const stub = sinon.stub();
+      element.addEventListener('message-anchor-tap', stub);
+      const dateEl = element.shadowRoot
+          .querySelector('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
+    });
+
+    suite('compute messages', () => {
+      test('empty', () => {
+        assert.equal(element._computeMessageContent('', '', true), '');
+        assert.equal(element._computeMessageContent('', '', false), '');
       });
 
-      test('reply and delete button should be hidden', () => {
-        element.message = {
-          id: '47c43261_55aa2c41',
-          author: {
-            _account_id: 1115495,
-            name: 'Andrew Bonventre',
-            email: 'andybons@chromium.org',
-          },
-          date: '2016-01-12 20:24:49.448000000',
-          message: 'Uploaded patch set 1.',
-          _revision_number: 1,
-        };
+      test('new patchset', () => {
+        const original = 'Uploaded patch set 1.';
+        const tag = 'autogenerated:gerrit:newPatchSet';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, original);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, original);
+      });
 
-        flushAsynchronousOperations();
-        assert.isTrue(
-            element.shadowRoot.querySelector('.replyActionContainer').hidden
-        );
-        assert.isTrue(
-            element.shadowRoot.querySelector('.deleteBtn').hidden
-        );
+      test('new patchset rebased', () => {
+        const original = 'Patch Set 27: Patch Set 26 was rebased';
+        const tag = 'autogenerated:gerrit:newPatchSet';
+        const expected = 'Patch Set 26 was rebased';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, expected);
+      });
+
+      test('ready for review', () => {
+        const original = 'Patch Set 1:\n\nThis change is ready for review.';
+        const tag = undefined;
+        const expected = 'This change is ready for review.';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, expected);
+      });
+
+      test('vote', () => {
+        const original = 'Patch Set 1: Code-Style+1';
+        const tag = undefined;
+        const expected = '';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, expected);
+      });
+
+      test('comments', () => {
+        const original = 'Patch Set 1:\n\n(3 comments)';
+        const tag = undefined;
+        const expected = '';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, expected);
       });
     });
 
-    suite('when logged in but not admin', () => {
-      setup(done => {
-        stub('gr-rest-api-interface', {
-          getLoggedIn() { return Promise.resolve(true); },
-          getConfig() { return Promise.resolve({}); },
-          getIsAdmin() { return Promise.resolve(false); },
-          deleteChangeCommitMessage() { return Promise.resolve({}); },
-        });
-        element = fixture('basic');
-        flush(done);
-      });
+    test('votes', () => {
+      element.message = {
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
+      };
+      element.labelExtremes = {
+        'Verified': {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Trybot-Label3': {max: 3, min: 0},
+      };
+      flushAsynchronousOperations();
+      const scoreChips = dom(element.root).querySelectorAll('.score');
+      assert.equal(scoreChips.length, 3);
 
-      test('can see reply but not delete button', () => {
-        element.message = {
-          id: '47c43261_55aa2c41',
-          author: {
-            _account_id: 1115495,
-            name: 'Andrew Bonventre',
-            email: 'andybons@chromium.org',
-          },
-          date: '2016-01-12 20:24:49.448000000',
-          message: 'Uploaded patch set 1.',
-          _revision_number: 1,
-        };
+      assert.isTrue(scoreChips[0].classList.contains('positive'));
+      assert.isTrue(scoreChips[0].classList.contains('max'));
 
-        flushAsynchronousOperations();
-        assert.isFalse(
-            element.shadowRoot.querySelector('.replyActionContainer').hidden
-        );
-        assert.isTrue(
-            element.shadowRoot.querySelector('.deleteBtn').hidden
-        );
-      });
+      assert.isTrue(scoreChips[1].classList.contains('negative'));
+      assert.isTrue(scoreChips[1].classList.contains('min'));
+
+      assert.isTrue(scoreChips[2].classList.contains('positive'));
+      assert.isFalse(scoreChips[2].classList.contains('min'));
+    });
+
+    test('removed votes', () => {
+      element.message = {
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
+      };
+      element.labelExtremes = {
+        'Verified': {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Commit-Queue': {max: 3, min: 0},
+      };
+      flushAsynchronousOperations();
+      const scoreChips = dom(element.root).querySelectorAll('.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[1].classList.contains('removed'));
+      assert.isTrue(scoreChips[2].classList.contains('removed'));
+    });
+
+    test('false negative vote', () => {
+      element.message = {
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
+      };
+      element.labelExtremes = {};
+      const scoreChips = dom(element.root).querySelectorAll('.score');
+      assert.equal(scoreChips.length, 0);
     });
   });
+
+  suite('when not logged in', () => {
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+        getPreferences() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
+        getIsAdmin() { return Promise.resolve(false); },
+        deleteChangeCommitMessage() { return Promise.resolve({}); },
+      });
+      element = fixture('basic');
+      flush(done);
+    });
+
+    test('reply and delete button should be hidden', () => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+      };
+
+      flushAsynchronousOperations();
+      assert.isTrue(
+          element.shadowRoot.querySelector('.replyActionContainer').hidden
+      );
+      assert.isTrue(
+          element.shadowRoot.querySelector('.deleteBtn').hidden
+      );
+    });
+  });
+
+  suite('when logged in but not admin', () => {
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getConfig() { return Promise.resolve({}); },
+        getIsAdmin() { return Promise.resolve(false); },
+        deleteChangeCommitMessage() { return Promise.resolve({}); },
+      });
+      element = fixture('basic');
+      flush(done);
+    });
+
+    test('can see reply but not delete button', () => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+      };
+
+      flushAsynchronousOperations();
+      assert.isFalse(
+          element.shadowRoot.querySelector('.replyActionContainer').hidden
+      );
+      assert.isTrue(
+          element.shadowRoot.querySelector('.deleteBtn').hidden
+      );
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index 6f74e4b..eaac988 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,416 +14,429 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const MAX_INITIAL_SHOWN_MESSAGES = 20;
-  const MESSAGES_INCREMENT = 5;
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../shared/gr-button/gr-button.js';
+import '../gr-message/gr-message.js';
+import '../../../styles/shared-styles.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-messages-list_html.js';
 
-  const ReportingEvent = {
-    SHOW_ALL: 'show-all-messages',
-    SHOW_MORE: 'show-more-messages',
-  };
+const MAX_INITIAL_SHOWN_MESSAGES = 20;
+const MESSAGES_INCREMENT = 5;
 
-  /**
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @extends Polymer.Element
-   */
-  class GrMessagesList extends Polymer.mixinBehaviors( [
-    Gerrit.KeyboardShortcutBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-messages-list'; }
+const ReportingEvent = {
+  SHOW_ALL: 'show-all-messages',
+  SHOW_MORE: 'show-more-messages',
+};
 
-    static get properties() {
-      return {
-        changeNum: Number,
-        messages: {
-          type: Array,
-          value() { return []; },
-        },
-        reviewerUpdates: {
-          type: Array,
-          value() { return []; },
-        },
-        changeComments: Object,
-        projectName: String,
-        showReplyButtons: {
-          type: Boolean,
-          value: false,
-        },
-        labels: Object,
+/**
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @extends Polymer.Element
+ */
+class GrMessagesList extends mixinBehaviors( [
+  Gerrit.KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-        _expanded: {
-          type: Boolean,
-          value: false,
-          observer: '_expandedChanged',
-        },
+  static get is() { return 'gr-messages-list'; }
 
-        _expandCollapseTitle: {
-          type: String,
-        },
+  static get properties() {
+    return {
+      changeNum: Number,
+      messages: {
+        type: Array,
+        value() { return []; },
+      },
+      reviewerUpdates: {
+        type: Array,
+        value() { return []; },
+      },
+      changeComments: Object,
+      projectName: String,
+      showReplyButtons: {
+        type: Boolean,
+        value: false,
+      },
+      labels: Object,
 
-        _hideAutomated: {
-          type: Boolean,
-          value: false,
-        },
-        /**
-         * The messages after processing and including merged reviewer updates.
-         */
-        _processedMessages: {
-          type: Array,
-          computed: '_computeItems(messages, reviewerUpdates)',
-          observer: '_processedMessagesChanged',
-        },
-        /**
-         * The subset of _processedMessages that is visible to the user.
-         */
-        _visibleMessages: {
-          type: Array,
-          value() { return []; },
-        },
+      _expanded: {
+        type: Boolean,
+        value: false,
+        observer: '_expandedChanged',
+      },
 
-        _labelExtremes: {
-          type: Object,
-          computed: '_computeLabelExtremes(labels.*)',
-        },
-      };
-    }
+      _expandCollapseTitle: {
+        type: String,
+      },
 
-    scrollToMessage(messageID) {
-      let el = this.shadowRoot
-          .querySelector('[data-message-id="' + messageID + '"]');
-      // If the message is hidden, expand the hidden messages back to that
-      // point.
-      if (!el) {
-        let index;
-        for (index = 0; index < this._processedMessages.length; index++) {
-          if (this._processedMessages[index].id === messageID) {
-            break;
-          }
-        }
-        if (index === this._processedMessages.length) { return; }
+      _hideAutomated: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * The messages after processing and including merged reviewer updates.
+       */
+      _processedMessages: {
+        type: Array,
+        computed: '_computeItems(messages, reviewerUpdates)',
+        observer: '_processedMessagesChanged',
+      },
+      /**
+       * The subset of _processedMessages that is visible to the user.
+       */
+      _visibleMessages: {
+        type: Array,
+        value() { return []; },
+      },
 
-        const newMessages = this._processedMessages.slice(index,
-            -this._visibleMessages.length);
-        // Add newMessages to the beginning of _visibleMessages.
-        this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
-        // Allow the dom-repeat to stamp.
-        Polymer.dom.flush();
-        el = this.shadowRoot
-            .querySelector('[data-message-id="' + messageID + '"]');
-      }
+      _labelExtremes: {
+        type: Object,
+        computed: '_computeLabelExtremes(labels.*)',
+      },
+    };
+  }
 
-      el.set('message.expanded', true);
-      let top = el.offsetTop;
-      for (let offsetParent = el.offsetParent;
-        offsetParent;
-        offsetParent = offsetParent.offsetParent) {
-        top += offsetParent.offsetTop;
-      }
-      window.scrollTo(0, top);
-      this._highlightEl(el);
-    }
-
-    _isAutomated(message) {
-      return !!(message.reviewer ||
-          (message.tag && message.tag.startsWith('autogenerated')));
-    }
-
-    _computeItems(messages, reviewerUpdates) {
-      // Polymer 2: check for undefined
-      if ([messages, reviewerUpdates].some(arg => arg === undefined)) {
-        return [];
-      }
-
-      messages = messages || [];
-      reviewerUpdates = reviewerUpdates || [];
-      let mi = 0;
-      let ri = 0;
-      let result = [];
-      let mDate;
-      let rDate;
-      for (let i = 0; i < messages.length; i++) {
-        messages[i]._index = i;
-      }
-
-      while (mi < messages.length || ri < reviewerUpdates.length) {
-        if (mi >= messages.length) {
-          result = result.concat(reviewerUpdates.slice(ri));
+  scrollToMessage(messageID) {
+    let el = this.shadowRoot
+        .querySelector('[data-message-id="' + messageID + '"]');
+    // If the message is hidden, expand the hidden messages back to that
+    // point.
+    if (!el) {
+      let index;
+      for (index = 0; index < this._processedMessages.length; index++) {
+        if (this._processedMessages[index].id === messageID) {
           break;
         }
-        if (ri >= reviewerUpdates.length) {
-          result = result.concat(messages.slice(mi));
-          break;
-        }
-        mDate = mDate || util.parseDate(messages[mi].date);
-        rDate = rDate || util.parseDate(reviewerUpdates[ri].date);
-        if (rDate < mDate) {
-          result.push(reviewerUpdates[ri++]);
-          rDate = null;
-        } else {
-          result.push(messages[mi++]);
-          mDate = null;
-        }
       }
-      return result;
-    }
+      if (index === this._processedMessages.length) { return; }
 
-    _expandedChanged(exp) {
-      if (this._processedMessages) {
-        for (let i = 0; i < this._processedMessages.length; i++) {
-          this._processedMessages[i].expanded = exp;
-        }
-      }
-      // _visibleMessages is a subarray of _processedMessages
-      // _processedMessages contains all items from _visibleMessages
-      // At this point all _visibleMessages.expanded values are set,
-      // and notifyPath must be used to notify Polymer about changes.
-      if (this._visibleMessages) {
-        for (let i = 0; i < this._visibleMessages.length; i++) {
-          this.notifyPath(`_visibleMessages.${i}.expanded`);
-        }
-      }
-
-      if (this._expanded) {
-        this._expandCollapseTitle = this.createTitle(
-            this.Shortcut.COLLAPSE_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
-      } else {
-        this._expandCollapseTitle = this.createTitle(
-            this.Shortcut.EXPAND_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
-      }
-    }
-
-    _highlightEl(el) {
-      const highlightedEls =
-          Polymer.dom(this.root).querySelectorAll('.highlighted');
-      for (const highlighedEl of highlightedEls) {
-        highlighedEl.classList.remove('highlighted');
-      }
-      function handleAnimationEnd() {
-        el.removeEventListener('animationend', handleAnimationEnd);
-        el.classList.remove('highlighted');
-      }
-      el.addEventListener('animationend', handleAnimationEnd);
-      el.classList.add('highlighted');
-    }
-
-    /**
-     * @param {boolean} expand
-     */
-    handleExpandCollapse(expand) {
-      this._expanded = expand;
-    }
-
-    _handleExpandCollapseTap(e) {
-      e.preventDefault();
-      this.handleExpandCollapse(!this._expanded);
-    }
-
-    _handleAnchorClick(e) {
-      this.scrollToMessage(e.detail.id);
-    }
-
-    _hasAutomatedMessages(messages) {
-      if (!messages) { return false; }
-      for (const message of messages) {
-        if (this._isAutomated(message)) {
-          return true;
-        }
-      }
-      return false;
-    }
-
-    _computeExpandCollapseMessage(expanded) {
-      return expanded ? 'Collapse all' : 'Expand all';
-    }
-
-    /**
-     * Computes message author's file comments for change's message.
-     * Method uses this.messages to find next message and relies on messages
-     * to be sorted by date field descending.
-     *
-     * @param {!Object} changeComments changeComment object, which includes
-     *     a method to get all published comments (including robot comments),
-     *     which returns a Hash of arrays of comments, filename as key.
-     * @param {!Object} message
-     * @return {!Object} Hash of arrays of comments, filename as key.
-     */
-    _computeCommentsForMessage(changeComments, message) {
-      if ([changeComments, message].some(arg => arg === undefined)) {
-        return [];
-      }
-      const comments = changeComments.getAllPublishedComments();
-      if (message._index === undefined || !comments || !this.messages) {
-        return [];
-      }
-      const messages = this.messages || [];
-      const index = message._index;
-      const authorId = message.author && message.author._account_id;
-      const mDate = util.parseDate(message.date).getTime();
-      // NB: Messages array has oldest messages first.
-      let nextMDate;
-      if (index > 0) {
-        for (let i = index - 1; i >= 0; i--) {
-          if (messages[i] && messages[i].author &&
-              messages[i].author._account_id === authorId) {
-            nextMDate = util.parseDate(messages[i].date).getTime();
-            break;
-          }
-        }
-      }
-      const msgComments = {};
-      for (const file in comments) {
-        if (!comments.hasOwnProperty(file)) { continue; }
-        const fileComments = comments[file];
-        for (let i = 0; i < fileComments.length; i++) {
-          if (fileComments[i].author &&
-              fileComments[i].author._account_id !== authorId) {
-            continue;
-          }
-          const cDate = util.parseDate(fileComments[i].updated).getTime();
-          if (cDate <= mDate) {
-            if (nextMDate && cDate <= nextMDate) {
-              continue;
-            }
-            msgComments[file] = msgComments[file] || [];
-            msgComments[file].push(fileComments[i]);
-          }
-        }
-      }
-      return msgComments;
-    }
-
-    /**
-     * Returns the number of messages to splice to the beginning of
-     * _visibleMessages. This is the minimum of the total number of messages
-     * remaining in the list and the number of messages needed to display five
-     * more visible messages in the list.
-     */
-    _getDelta(visibleMessages, messages, hideAutomated) {
-      if ([visibleMessages, messages].some(arg => arg === undefined)) {
-        return 0;
-      }
-
-      let delta = MESSAGES_INCREMENT;
-      const msgsRemaining = messages.length - visibleMessages.length;
-
-      if (hideAutomated) {
-        let counter = 0;
-        let i;
-        for (i = msgsRemaining; i > 0 && counter < MESSAGES_INCREMENT; i--) {
-          if (!this._isAutomated(messages[i - 1])) { counter++; }
-        }
-        delta = msgsRemaining - i;
-      }
-      return Math.min(msgsRemaining, delta);
-    }
-
-    /**
-     * Gets the number of messages that would be visible, but do not currently
-     * exist in _visibleMessages.
-     */
-    _numRemaining(visibleMessages, messages, hideAutomated) {
-      if ([visibleMessages, messages].some(arg => arg === undefined)) {
-        return 0;
-      }
-
-      if (hideAutomated) {
-        return this._getHumanMessages(messages).length -
-            this._getHumanMessages(visibleMessages).length;
-      }
-      return messages.length - visibleMessages.length;
-    }
-
-    _computeIncrementText(visibleMessages, messages, hideAutomated) {
-      let delta = this._getDelta(visibleMessages, messages, hideAutomated);
-      delta = Math.min(
-          this._numRemaining(visibleMessages, messages, hideAutomated), delta);
-      return 'Show ' + Math.min(MESSAGES_INCREMENT, delta) + ' more';
-    }
-
-    _getHumanMessages(messages) {
-      return messages.filter(msg => !this._isAutomated(msg));
-    }
-
-    _computeShowHideTextHidden(visibleMessages, messages,
-        hideAutomated) {
-      if ([visibleMessages, messages].some(arg => arg === undefined)) {
-        return 0;
-      }
-
-      if (hideAutomated) {
-        messages = this._getHumanMessages(messages);
-        visibleMessages = this._getHumanMessages(visibleMessages);
-      }
-      return visibleMessages.length >= messages.length;
-    }
-
-    _handleShowAllTap() {
-      this._visibleMessages = this._processedMessages;
-      this.$.reporting.reportInteraction(ReportingEvent.SHOW_ALL);
-    }
-
-    _handleIncrementShownMessages() {
-      const delta = this._getDelta(this._visibleMessages,
-          this._processedMessages, this._hideAutomated);
-      const len = this._visibleMessages.length;
-      const newMessages = this._processedMessages.slice(-(len + delta), -len);
-      // Add newMessages to the beginning of _visibleMessages
+      const newMessages = this._processedMessages.slice(index,
+          -this._visibleMessages.length);
+      // Add newMessages to the beginning of _visibleMessages.
       this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
-      this.$.reporting.reportInteraction(ReportingEvent.SHOW_MORE);
+      // Allow the dom-repeat to stamp.
+      flush();
+      el = this.shadowRoot
+          .querySelector('[data-message-id="' + messageID + '"]');
     }
 
-    _processedMessagesChanged(messages) {
-      if (messages) {
-        this._visibleMessages = messages.slice(-MAX_INITIAL_SHOWN_MESSAGES);
+    el.set('message.expanded', true);
+    let top = el.offsetTop;
+    for (let offsetParent = el.offsetParent;
+      offsetParent;
+      offsetParent = offsetParent.offsetParent) {
+      top += offsetParent.offsetTop;
+    }
+    window.scrollTo(0, top);
+    this._highlightEl(el);
+  }
 
-        if (messages.length === 0) return;
-        const tags = messages.map(message => message.tag || message.type ||
-            (message.comments ? 'comments' : 'none'));
-        const tagsCounted = tags.reduce((acc, val) => {
-          acc[val] = (acc[val] || 0) + 1;
-          return acc;
-        }, {all: messages.length});
-        this.$.reporting.reportInteraction('messages-count', tagsCounted);
+  _isAutomated(message) {
+    return !!(message.reviewer ||
+        (message.tag && message.tag.startsWith('autogenerated')));
+  }
+
+  _computeItems(messages, reviewerUpdates) {
+    // Polymer 2: check for undefined
+    if ([messages, reviewerUpdates].some(arg => arg === undefined)) {
+      return [];
+    }
+
+    messages = messages || [];
+    reviewerUpdates = reviewerUpdates || [];
+    let mi = 0;
+    let ri = 0;
+    let result = [];
+    let mDate;
+    let rDate;
+    for (let i = 0; i < messages.length; i++) {
+      messages[i]._index = i;
+    }
+
+    while (mi < messages.length || ri < reviewerUpdates.length) {
+      if (mi >= messages.length) {
+        result = result.concat(reviewerUpdates.slice(ri));
+        break;
+      }
+      if (ri >= reviewerUpdates.length) {
+        result = result.concat(messages.slice(mi));
+        break;
+      }
+      mDate = mDate || util.parseDate(messages[mi].date);
+      rDate = rDate || util.parseDate(reviewerUpdates[ri].date);
+      if (rDate < mDate) {
+        result.push(reviewerUpdates[ri++]);
+        rDate = null;
+      } else {
+        result.push(messages[mi++]);
+        mDate = null;
+      }
+    }
+    return result;
+  }
+
+  _expandedChanged(exp) {
+    if (this._processedMessages) {
+      for (let i = 0; i < this._processedMessages.length; i++) {
+        this._processedMessages[i].expanded = exp;
+      }
+    }
+    // _visibleMessages is a subarray of _processedMessages
+    // _processedMessages contains all items from _visibleMessages
+    // At this point all _visibleMessages.expanded values are set,
+    // and notifyPath must be used to notify Polymer about changes.
+    if (this._visibleMessages) {
+      for (let i = 0; i < this._visibleMessages.length; i++) {
+        this.notifyPath(`_visibleMessages.${i}.expanded`);
       }
     }
 
-    _computeNumMessagesText(visibleMessages, messages,
-        hideAutomated) {
-      const total =
-          this._numRemaining(visibleMessages, messages, hideAutomated);
-      return total === 1 ? 'Show 1 message' : 'Show all ' + total + ' messages';
-    }
-
-    _computeIncrementHidden(visibleMessages, messages,
-        hideAutomated) {
-      const total =
-          this._numRemaining(visibleMessages, messages, hideAutomated);
-      return total <= this._getDelta(visibleMessages, messages, hideAutomated);
-    }
-
-    /**
-     * Compute a mapping from label name to objects representing the minimum and
-     * maximum possible values for that label.
-     */
-    _computeLabelExtremes(labelRecord) {
-      const extremes = {};
-      const labels = labelRecord.base;
-      if (!labels) { return extremes; }
-      for (const key of Object.keys(labels)) {
-        if (!labels[key] || !labels[key].values) { continue; }
-        const values = Object.keys(labels[key].values)
-            .map(v => parseInt(v, 10));
-        values.sort((a, b) => a - b);
-        if (!values.length) { continue; }
-        extremes[key] = {min: values[0], max: values[values.length - 1]};
-      }
-      return extremes;
+    if (this._expanded) {
+      this._expandCollapseTitle = this.createTitle(
+          this.Shortcut.COLLAPSE_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
+    } else {
+      this._expandCollapseTitle = this.createTitle(
+          this.Shortcut.EXPAND_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
     }
   }
 
-  customElements.define(GrMessagesList.is, GrMessagesList);
-})();
+  _highlightEl(el) {
+    const highlightedEls =
+        dom(this.root).querySelectorAll('.highlighted');
+    for (const highlighedEl of highlightedEls) {
+      highlighedEl.classList.remove('highlighted');
+    }
+    function handleAnimationEnd() {
+      el.removeEventListener('animationend', handleAnimationEnd);
+      el.classList.remove('highlighted');
+    }
+    el.addEventListener('animationend', handleAnimationEnd);
+    el.classList.add('highlighted');
+  }
+
+  /**
+   * @param {boolean} expand
+   */
+  handleExpandCollapse(expand) {
+    this._expanded = expand;
+  }
+
+  _handleExpandCollapseTap(e) {
+    e.preventDefault();
+    this.handleExpandCollapse(!this._expanded);
+  }
+
+  _handleAnchorClick(e) {
+    this.scrollToMessage(e.detail.id);
+  }
+
+  _hasAutomatedMessages(messages) {
+    if (!messages) { return false; }
+    for (const message of messages) {
+      if (this._isAutomated(message)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  _computeExpandCollapseMessage(expanded) {
+    return expanded ? 'Collapse all' : 'Expand all';
+  }
+
+  /**
+   * Computes message author's file comments for change's message.
+   * Method uses this.messages to find next message and relies on messages
+   * to be sorted by date field descending.
+   *
+   * @param {!Object} changeComments changeComment object, which includes
+   *     a method to get all published comments (including robot comments),
+   *     which returns a Hash of arrays of comments, filename as key.
+   * @param {!Object} message
+   * @return {!Object} Hash of arrays of comments, filename as key.
+   */
+  _computeCommentsForMessage(changeComments, message) {
+    if ([changeComments, message].some(arg => arg === undefined)) {
+      return [];
+    }
+    const comments = changeComments.getAllPublishedComments();
+    if (message._index === undefined || !comments || !this.messages) {
+      return [];
+    }
+    const messages = this.messages || [];
+    const index = message._index;
+    const authorId = message.author && message.author._account_id;
+    const mDate = util.parseDate(message.date).getTime();
+    // NB: Messages array has oldest messages first.
+    let nextMDate;
+    if (index > 0) {
+      for (let i = index - 1; i >= 0; i--) {
+        if (messages[i] && messages[i].author &&
+            messages[i].author._account_id === authorId) {
+          nextMDate = util.parseDate(messages[i].date).getTime();
+          break;
+        }
+      }
+    }
+    const msgComments = {};
+    for (const file in comments) {
+      if (!comments.hasOwnProperty(file)) { continue; }
+      const fileComments = comments[file];
+      for (let i = 0; i < fileComments.length; i++) {
+        if (fileComments[i].author &&
+            fileComments[i].author._account_id !== authorId) {
+          continue;
+        }
+        const cDate = util.parseDate(fileComments[i].updated).getTime();
+        if (cDate <= mDate) {
+          if (nextMDate && cDate <= nextMDate) {
+            continue;
+          }
+          msgComments[file] = msgComments[file] || [];
+          msgComments[file].push(fileComments[i]);
+        }
+      }
+    }
+    return msgComments;
+  }
+
+  /**
+   * Returns the number of messages to splice to the beginning of
+   * _visibleMessages. This is the minimum of the total number of messages
+   * remaining in the list and the number of messages needed to display five
+   * more visible messages in the list.
+   */
+  _getDelta(visibleMessages, messages, hideAutomated) {
+    if ([visibleMessages, messages].some(arg => arg === undefined)) {
+      return 0;
+    }
+
+    let delta = MESSAGES_INCREMENT;
+    const msgsRemaining = messages.length - visibleMessages.length;
+
+    if (hideAutomated) {
+      let counter = 0;
+      let i;
+      for (i = msgsRemaining; i > 0 && counter < MESSAGES_INCREMENT; i--) {
+        if (!this._isAutomated(messages[i - 1])) { counter++; }
+      }
+      delta = msgsRemaining - i;
+    }
+    return Math.min(msgsRemaining, delta);
+  }
+
+  /**
+   * Gets the number of messages that would be visible, but do not currently
+   * exist in _visibleMessages.
+   */
+  _numRemaining(visibleMessages, messages, hideAutomated) {
+    if ([visibleMessages, messages].some(arg => arg === undefined)) {
+      return 0;
+    }
+
+    if (hideAutomated) {
+      return this._getHumanMessages(messages).length -
+          this._getHumanMessages(visibleMessages).length;
+    }
+    return messages.length - visibleMessages.length;
+  }
+
+  _computeIncrementText(visibleMessages, messages, hideAutomated) {
+    let delta = this._getDelta(visibleMessages, messages, hideAutomated);
+    delta = Math.min(
+        this._numRemaining(visibleMessages, messages, hideAutomated), delta);
+    return 'Show ' + Math.min(MESSAGES_INCREMENT, delta) + ' more';
+  }
+
+  _getHumanMessages(messages) {
+    return messages.filter(msg => !this._isAutomated(msg));
+  }
+
+  _computeShowHideTextHidden(visibleMessages, messages,
+      hideAutomated) {
+    if ([visibleMessages, messages].some(arg => arg === undefined)) {
+      return 0;
+    }
+
+    if (hideAutomated) {
+      messages = this._getHumanMessages(messages);
+      visibleMessages = this._getHumanMessages(visibleMessages);
+    }
+    return visibleMessages.length >= messages.length;
+  }
+
+  _handleShowAllTap() {
+    this._visibleMessages = this._processedMessages;
+    this.$.reporting.reportInteraction(ReportingEvent.SHOW_ALL);
+  }
+
+  _handleIncrementShownMessages() {
+    const delta = this._getDelta(this._visibleMessages,
+        this._processedMessages, this._hideAutomated);
+    const len = this._visibleMessages.length;
+    const newMessages = this._processedMessages.slice(-(len + delta), -len);
+    // Add newMessages to the beginning of _visibleMessages
+    this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
+    this.$.reporting.reportInteraction(ReportingEvent.SHOW_MORE);
+  }
+
+  _processedMessagesChanged(messages) {
+    if (messages) {
+      this._visibleMessages = messages.slice(-MAX_INITIAL_SHOWN_MESSAGES);
+
+      if (messages.length === 0) return;
+      const tags = messages.map(message => message.tag || message.type ||
+          (message.comments ? 'comments' : 'none'));
+      const tagsCounted = tags.reduce((acc, val) => {
+        acc[val] = (acc[val] || 0) + 1;
+        return acc;
+      }, {all: messages.length});
+      this.$.reporting.reportInteraction('messages-count', tagsCounted);
+    }
+  }
+
+  _computeNumMessagesText(visibleMessages, messages,
+      hideAutomated) {
+    const total =
+        this._numRemaining(visibleMessages, messages, hideAutomated);
+    return total === 1 ? 'Show 1 message' : 'Show all ' + total + ' messages';
+  }
+
+  _computeIncrementHidden(visibleMessages, messages,
+      hideAutomated) {
+    const total =
+        this._numRemaining(visibleMessages, messages, hideAutomated);
+    return total <= this._getDelta(visibleMessages, messages, hideAutomated);
+  }
+
+  /**
+   * Compute a mapping from label name to objects representing the minimum and
+   * maximum possible values for that label.
+   */
+  _computeLabelExtremes(labelRecord) {
+    const extremes = {};
+    const labels = labelRecord.base;
+    if (!labels) { return extremes; }
+    for (const key of Object.keys(labels)) {
+      if (!labels[key] || !labels[key].values) { continue; }
+      const values = Object.keys(labels[key].values)
+          .map(v => parseInt(v, 10));
+      values.sort((a, b) => a - b);
+      if (!values.length) { continue; }
+      extremes[key] = {min: values[0], max: values[values.length - 1]};
+    }
+    return extremes;
+  }
+}
+
+customElements.define(GrMessagesList.is, GrMessagesList);
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
index 60ec6b0..3e9f7b5 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../gr-message/gr-message.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-messages-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host,
       .messageListControls {
@@ -75,55 +67,27 @@
       }
     </style>
     <div class="header">
-        <span
-            id="automatedMessageToggleContainer"
-            class="container"
-            hidden$="[[!_hasAutomatedMessages(messages)]]">
-          <paper-toggle-button
-              id="automatedMessageToggle"
-              checked="{{_hideAutomated}}"></paper-toggle-button>Only comments
+        <span id="automatedMessageToggleContainer" class="container" hidden\$="[[!_hasAutomatedMessages(messages)]]">
+          <paper-toggle-button id="automatedMessageToggle" checked="{{_hideAutomated}}"></paper-toggle-button>Only comments
           <span class="transparent separator"></span>
         </span>
-        <gr-button
-            id="collapse-messages"
-            link
-            title="[[_expandCollapseTitle]]"
-            on-click="_handleExpandCollapseTap">
+        <gr-button id="collapse-messages" link="" title="[[_expandCollapseTitle]]" on-click="_handleExpandCollapseTap">
           [[_computeExpandCollapseMessage(_expanded)]]
         </gr-button>
       </div>
-    <span
-        id="messageControlsContainer"
-        hidden$="[[_computeShowHideTextHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
-      <gr-button id="oldMessagesBtn" link on-click="_handleShowAllTap">
+    <span id="messageControlsContainer" hidden\$="[[_computeShowHideTextHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
+      <gr-button id="oldMessagesBtn" link="" on-click="_handleShowAllTap">
           [[_computeNumMessagesText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
       </gr-button>
-      <span
-          class="container"
-          hidden$="[[_computeIncrementHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
+      <span class="container" hidden\$="[[_computeIncrementHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
         <span class="transparent separator"></span>
-        <gr-button id="incrementMessagesBtn" link
-            on-click="_handleIncrementShownMessages">
+        <gr-button id="incrementMessagesBtn" link="" on-click="_handleIncrementShownMessages">
           [[_computeIncrementText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
         </gr-button>
       </span>
     </span>
-    <template
-        is="dom-repeat"
-        items="[[_visibleMessages]]"
-        as="message">
-      <gr-message
-          change-num="[[changeNum]]"
-          message="[[message]]"
-          comments="[[_computeCommentsForMessage(changeComments, message)]]"
-          hide-automated="[[_hideAutomated]]"
-          project-name="[[projectName]]"
-          show-reply-button="[[showReplyButtons]]"
-          on-message-anchor-tap="_handleAnchorClick"
-          label-extremes="[[_labelExtremes]]"
-          data-message-id$="[[message.id]]"></gr-message>
+    <template is="dom-repeat" items="[[_visibleMessages]]" as="message">
+      <gr-message change-num="[[changeNum]]" message="[[message]]" comments="[[_computeCommentsForMessage(changeComments, message)]]" hide-automated="[[_hideAutomated]]" project-name="[[projectName]]" show-reply-button="[[showReplyButtons]]" on-message-anchor-tap="_handleAnchorClick" label-extremes="[[_labelExtremes]]" data-message-id\$="[[message.id]]"></gr-message>
     </template>
     <gr-reporting id="reporting" category="message-list"></gr-reporting>
-  </template>
-  <script src="gr-messages-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
index 2ee2a81..961abb9 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -19,17 +19,24 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-messages-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../diff/gr-comment-api/gr-comment-api.js"></script>
 
-<link rel="import" href="gr-messages-list.html">
+<script type="module" src="./gr-messages-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import './gr-messages-list.js';
+import '../../diff/gr-comment-api/gr-comment-api-mock_test.js';
+void(0);
+</script>
 
 <dom-module id="comment-api-mock">
   <template>
@@ -38,7 +45,7 @@
         change-comments="[[_changeComments]]"></gr-messages-list>
     <gr-comment-api id="commentAPI"></gr-comment-api>
   </template>
-  <script src="../../diff/gr-comment-api/gr-comment-api-mock_test.js"></script>
+  <script type="module" src="../../diff/gr-comment-api/gr-comment-api-mock_test.js"></script>
 </dom-module>
 
 <test-fixture id="basic">
@@ -49,574 +56,579 @@
   </template>
 </test-fixture>
 
-<script>
-  const randomMessage = function(opt_params) {
-    const params = opt_params || {};
-    const author1 = {
-      _account_id: 1115495,
-      name: 'Andrew Bonventre',
-      email: 'andybons@chromium.org',
-    };
-    return {
-      id: params.id || Math.random().toString(),
-      date: params.date || '2016-01-12 20:28:33.038000',
-      message: params.message || Math.random().toString(),
-      _revision_number: params._revision_number || 1,
-      author: params.author || author1,
-    };
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import './gr-messages-list.js';
+import '../../diff/gr-comment-api/gr-comment-api-mock_test.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+const randomMessage = function(opt_params) {
+  const params = opt_params || {};
+  const author1 = {
+    _account_id: 1115495,
+    name: 'Andrew Bonventre',
+    email: 'andybons@chromium.org',
+  };
+  return {
+    id: params.id || Math.random().toString(),
+    date: params.date || '2016-01-12 20:28:33.038000',
+    message: params.message || Math.random().toString(),
+    _revision_number: params._revision_number || 1,
+    author: params.author || author1,
+  };
+};
+
+const randomAutomated = function(opt_params) {
+  return Object.assign({tag: 'autogenerated:gerrit:replace'},
+      randomMessage(opt_params));
+};
+
+suite('gr-messages-list tests', () => {
+  let element;
+  let messages;
+  let sandbox;
+  let commentApiWrapper;
+
+  const getMessages = function() {
+    return dom(element.root).querySelectorAll('gr-message');
   };
 
-  const randomAutomated = function(opt_params) {
-    return Object.assign({tag: 'autogenerated:gerrit:replace'},
-        randomMessage(opt_params));
+  const author = {
+    _account_id: 42,
+    name: 'Marvin the Paranoid Android',
+    email: 'marvin@sirius.org',
   };
 
-  suite('gr-messages-list tests', async () => {
-    await readyToTest();
+  const comments = {
+    file1: [
+      {
+        message: 'message text',
+        updated: '2016-09-27 00:18:03.000000000',
+        in_reply_to: '6505d749_f0bec0aa',
+        line: 62,
+        id: '6505d749_10ed44b2',
+        patch_set: 2,
+        author: {
+          email: 'some@email.com',
+          _account_id: 123,
+        },
+      },
+      {
+        message: 'message text',
+        updated: '2016-09-27 00:18:03.000000000',
+        in_reply_to: 'c5912363_6b820105',
+        line: 42,
+        id: '450a935e_0f1c05db',
+        patch_set: 2,
+        author,
+      },
+      {
+        message: 'message text',
+        updated: '2016-09-27 00:18:03.000000000',
+        in_reply_to: '6505d749_f0bec0aa',
+        line: 62,
+        id: '6505d749_10ed44b2',
+        patch_set: 2,
+        author,
+      },
+    ],
+    file2: [
+      {
+        message: 'message text',
+        updated: '2016-09-27 00:18:03.000000000',
+        in_reply_to: 'c5912363_4b7d450a',
+        line: 132,
+        id: '450a935e_4f260d25',
+        patch_set: 2,
+        author,
+      },
+    ],
+  };
+
+  suite('basic tests', () => {
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getDiffComments() { return Promise.resolve(comments); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+      sandbox = sinon.sandbox.create();
+      messages = _.times(3, randomMessage);
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = fixture('basic');
+      element = commentApiWrapper.$.messagesList;
+      element.messages = messages;
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      return commentApiWrapper.loadComments();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('show some old messages', () => {
+      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
+      element.messages = _.times(26, randomMessage);
+      flushAsynchronousOperations();
+
+      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
+      assert.equal(getMessages().length, 20);
+      assert.equal(element.$.incrementMessagesBtn.innerText.toUpperCase()
+          .trim(), 'SHOW 5 MORE');
+      MockInteractions.tap(element.$.incrementMessagesBtn);
+      flushAsynchronousOperations();
+
+      assert.equal(getMessages().length, 25);
+      assert.equal(element.$.incrementMessagesBtn.innerText.toUpperCase()
+          .trim(), 'SHOW 1 MORE');
+      MockInteractions.tap(element.$.incrementMessagesBtn);
+      flushAsynchronousOperations();
+
+      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
+      assert.equal(getMessages().length, 26);
+    });
+
+    test('show all old messages', () => {
+      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
+      element.messages = _.times(26, randomMessage);
+      flushAsynchronousOperations();
+
+      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
+      assert.equal(getMessages().length, 20);
+      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
+          'SHOW ALL 6 MESSAGES');
+      MockInteractions.tap(element.$.oldMessagesBtn);
+      flushAsynchronousOperations();
+
+      assert.equal(getMessages().length, 26);
+      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
+    });
+
+    test('message count respects automated', () => {
+      element.messages = _.times(10, randomAutomated)
+          .concat(_.times(11, randomMessage));
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
+          'SHOW 1 MESSAGE');
+      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
+      MockInteractions.tap(element.$.automatedMessageToggle);
+      flushAsynchronousOperations();
+
+      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
+    });
+
+    test('message count still respects non-automated on toggle', () => {
+      element.messages = _.times(10, randomMessage)
+          .concat(_.times(11, randomAutomated));
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
+          'SHOW 1 MESSAGE');
+      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
+      MockInteractions.tap(element.$.automatedMessageToggle);
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
+          'SHOW 1 MESSAGE');
+      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
+    });
+
+    test('show all messages respects expand', () => {
+      element.messages = _.times(10, randomAutomated)
+          .concat(_.times(11, randomMessage));
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages')); // Expand all.
+      flushAsynchronousOperations();
+
+      let messages = getMessages();
+      assert.equal(messages.length, 20);
+      for (const message of messages) {
+        assert.isTrue(message._expanded);
+      }
+
+      MockInteractions.tap(element.$.oldMessagesBtn);
+      flushAsynchronousOperations();
+
+      messages = getMessages();
+      assert.equal(messages.length, 21);
+      for (const message of messages) {
+        assert.isTrue(message._expanded);
+      }
+    });
+
+    test('show all messages respects collapse', () => {
+      element.messages = _.times(10, randomAutomated)
+          .concat(_.times(11, randomMessage));
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages')); // Expand all.
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages')); // Collapse all.
+      flushAsynchronousOperations();
+
+      let messages = getMessages();
+      assert.equal(messages.length, 20);
+      for (const message of messages) {
+        assert.isFalse(message._expanded);
+      }
+
+      MockInteractions.tap(element.$.oldMessagesBtn);
+      flushAsynchronousOperations();
+
+      messages = getMessages();
+      assert.equal(messages.length, 21);
+      for (const message of messages) {
+        assert.isFalse(message._expanded);
+      }
+    });
+
+    test('expand/collapse all', () => {
+      let allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        message._expanded = false;
+      }
+      MockInteractions.tap(allMessageEls[1]);
+      assert.isTrue(allMessageEls[1]._expanded);
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages'));
+      allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isTrue(message._expanded);
+      }
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages'));
+      allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded);
+      }
+    });
+
+    test('expand/collapse from external keypress', () => {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages'));
+      let allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isTrue(message._expanded);
+      }
+
+      // Expand/collapse all text also changes.
+      assert.equal(element.shadowRoot
+          .querySelector('#collapse-messages').textContent.trim(),
+      'Collapse all');
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages'));
+      allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded);
+      }
+      // Expand/collapse all text also changes.
+      assert.equal(element.shadowRoot
+          .querySelector('#collapse-messages').textContent.trim(),
+      'Expand all');
+    });
+
+    test('hide messages does not appear when no automated messages', () => {
+      assert.isOk(element.shadowRoot
+          .querySelector('#automatedMessageToggleContainer[hidden]'));
+    });
+
+    test('scroll to message', () => {
+      const allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        message.set('message.expanded', false);
+      }
+
+      const scrollToStub = sandbox.stub(window, 'scrollTo');
+      const highlightStub = sandbox.stub(element, '_highlightEl');
+
+      element.scrollToMessage('invalid');
+
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded,
+            'expected gr-message to not be expanded');
+      }
+
+      const messageID = messages[1].id;
+      element.scrollToMessage(messageID);
+      assert.isTrue(
+          element.shadowRoot
+              .querySelector('[data-message-id="' + messageID + '"]')
+              ._expanded);
+
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+    });
+
+    test('scroll to message offscreen', () => {
+      const scrollToStub = sandbox.stub(window, 'scrollTo');
+      const highlightStub = sandbox.stub(element, '_highlightEl');
+      element.messages = _.times(25, randomMessage);
+      flushAsynchronousOperations();
+      assert.isFalse(scrollToStub.called);
+      assert.isFalse(highlightStub.called);
+
+      const messageID = element.messages[1].id;
+      element.scrollToMessage(messageID);
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+      assert.equal(element._visibleMessages.length, 24);
+      assert.isTrue(
+          element.shadowRoot
+              .querySelector('[data-message-id="' + messageID + '"]')
+              ._expanded);
+    });
+
+    test('messages', () => {
+      const messages = [].concat(
+          randomMessage(),
+          {
+            _index: 5,
+            _revision_number: 4,
+            message: 'Uploaded patch set 4.',
+            date: '2016-09-28 13:36:33.000000000',
+            author,
+            id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
+          },
+          {
+            _index: 6,
+            _revision_number: 4,
+            message: 'Patch Set 4:\n\n(6 comments)',
+            date: '2016-09-28 13:36:33.000000000',
+            author,
+            id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
+          }
+      );
+      element.messages = messages;
+      const isAuthor = function(author, message) {
+        return message.author._account_id === author._account_id;
+      };
+      const isMarvin = isAuthor.bind(null, author);
+      flushAsynchronousOperations();
+      const messageElements = getMessages();
+      assert.equal(messageElements.length, messages.length);
+      assert.deepEqual(messageElements[1].message, messages[1]);
+      assert.deepEqual(messageElements[2].message, messages[2]);
+      assert.deepEqual(messageElements[1].comments.file1,
+          comments.file1.filter(isMarvin));
+      assert.deepEqual(messageElements[1].comments.file2,
+          comments.file2.filter(isMarvin));
+      assert.deepEqual(messageElements[2].comments, {});
+    });
+
+    test('messages without author do not throw', () => {
+      const messages = [{
+        _index: 5,
+        _revision_number: 4,
+        message: 'Uploaded patch set 4.',
+        date: '2016-09-28 13:36:33.000000000',
+        id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
+      }];
+      element.messages = messages;
+      flushAsynchronousOperations();
+      const messageEls = getMessages();
+      assert.equal(messageEls.length, 1);
+      assert.equal(messageEls[0].message.message, messages[0].message);
+    });
+
+    test('hide increment text if increment >= total remaining', () => {
+      // Test with stubbed return values, as _numRemaining and _getDelta have
+      // their own tests.
+      sandbox.stub(element, '_getDelta').returns(5);
+      const remainingStub = sandbox.stub(element, '_numRemaining').returns(6);
+      assert.isFalse(element._computeIncrementHidden(null, null, null));
+      remainingStub.restore();
+
+      sandbox.stub(element, '_numRemaining').returns(4);
+      assert.isTrue(element._computeIncrementHidden(null, null, null));
+    });
+  });
+
+  suite('gr-messages-list automate tests', () => {
     let element;
     let messages;
     let sandbox;
     let commentApiWrapper;
 
     const getMessages = function() {
-      return Polymer.dom(element.root).querySelectorAll('gr-message');
+      return dom(element.root).querySelectorAll('gr-message');
+    };
+    const getHiddenMessages = function() {
+      return dom(element.root).querySelectorAll('gr-message[hidden]');
     };
 
-    const author = {
-      _account_id: 42,
-      name: 'Marvin the Paranoid Android',
-      email: 'marvin@sirius.org',
+    const randomMessageReviewer = {
+      reviewer: {},
+      date: '2016-01-13 20:30:33.038000',
     };
 
-    const comments = {
-      file1: [
-        {
-          message: 'message text',
-          updated: '2016-09-27 00:18:03.000000000',
-          in_reply_to: '6505d749_f0bec0aa',
-          line: 62,
-          id: '6505d749_10ed44b2',
-          patch_set: 2,
-          author: {
-            email: 'some@email.com',
-            _account_id: 123,
-          },
-        },
-        {
-          message: 'message text',
-          updated: '2016-09-27 00:18:03.000000000',
-          in_reply_to: 'c5912363_6b820105',
-          line: 42,
-          id: '450a935e_0f1c05db',
-          patch_set: 2,
-          author,
-        },
-        {
-          message: 'message text',
-          updated: '2016-09-27 00:18:03.000000000',
-          in_reply_to: '6505d749_f0bec0aa',
-          line: 62,
-          id: '6505d749_10ed44b2',
-          patch_set: 2,
-          author,
-        },
-      ],
-      file2: [
-        {
-          message: 'message text',
-          updated: '2016-09-27 00:18:03.000000000',
-          in_reply_to: 'c5912363_4b7d450a',
-          line: 132,
-          id: '450a935e_4f260d25',
-          patch_set: 2,
-          author,
-        },
-      ],
-    };
-
-    suite('basic tests', () => {
-      setup(() => {
-        stub('gr-rest-api-interface', {
-          getConfig() { return Promise.resolve({}); },
-          getLoggedIn() { return Promise.resolve(false); },
-          getDiffComments() { return Promise.resolve(comments); },
-          getDiffRobotComments() { return Promise.resolve({}); },
-          getDiffDrafts() { return Promise.resolve({}); },
-        });
-        sandbox = sinon.sandbox.create();
-        messages = _.times(3, randomMessage);
-        // Element must be wrapped in an element with direct access to the
-        // comment API.
-        commentApiWrapper = fixture('basic');
-        element = commentApiWrapper.$.messagesList;
-        element.messages = messages;
-
-        // Stub methods on the changeComments object after changeComments has
-        // been initialized.
-        return commentApiWrapper.loadComments();
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
       });
 
-      teardown(() => {
-        sandbox.restore();
-      });
+      sandbox = sinon.sandbox.create();
+      messages = _.times(2, randomAutomated);
+      messages.push(randomMessageReviewer);
 
-      test('show some old messages', () => {
-        assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-        element.messages = _.times(26, randomMessage);
-        flushAsynchronousOperations();
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = fixture('basic');
+      element = commentApiWrapper.$.messagesList;
+      sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+      element.messages = messages;
 
-        assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-        assert.equal(getMessages().length, 20);
-        assert.equal(element.$.incrementMessagesBtn.innerText.toUpperCase()
-            .trim(), 'SHOW 5 MORE');
-        MockInteractions.tap(element.$.incrementMessagesBtn);
-        flushAsynchronousOperations();
-
-        assert.equal(getMessages().length, 25);
-        assert.equal(element.$.incrementMessagesBtn.innerText.toUpperCase()
-            .trim(), 'SHOW 1 MORE');
-        MockInteractions.tap(element.$.incrementMessagesBtn);
-        flushAsynchronousOperations();
-
-        assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-        assert.equal(getMessages().length, 26);
-      });
-
-      test('show all old messages', () => {
-        assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-        element.messages = _.times(26, randomMessage);
-        flushAsynchronousOperations();
-
-        assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-        assert.equal(getMessages().length, 20);
-        assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
-            'SHOW ALL 6 MESSAGES');
-        MockInteractions.tap(element.$.oldMessagesBtn);
-        flushAsynchronousOperations();
-
-        assert.equal(getMessages().length, 26);
-        assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-      });
-
-      test('message count respects automated', () => {
-        element.messages = _.times(10, randomAutomated)
-            .concat(_.times(11, randomMessage));
-        flushAsynchronousOperations();
-
-        assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
-            'SHOW 1 MESSAGE');
-        assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-        MockInteractions.tap(element.$.automatedMessageToggle);
-        flushAsynchronousOperations();
-
-        assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-      });
-
-      test('message count still respects non-automated on toggle', () => {
-        element.messages = _.times(10, randomMessage)
-            .concat(_.times(11, randomAutomated));
-        flushAsynchronousOperations();
-
-        assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
-            'SHOW 1 MESSAGE');
-        assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-        MockInteractions.tap(element.$.automatedMessageToggle);
-        flushAsynchronousOperations();
-
-        assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
-            'SHOW 1 MESSAGE');
-        assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-      });
-
-      test('show all messages respects expand', () => {
-        element.messages = _.times(10, randomAutomated)
-            .concat(_.times(11, randomMessage));
-        flushAsynchronousOperations();
-
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('#collapse-messages')); // Expand all.
-        flushAsynchronousOperations();
-
-        let messages = getMessages();
-        assert.equal(messages.length, 20);
-        for (const message of messages) {
-          assert.isTrue(message._expanded);
-        }
-
-        MockInteractions.tap(element.$.oldMessagesBtn);
-        flushAsynchronousOperations();
-
-        messages = getMessages();
-        assert.equal(messages.length, 21);
-        for (const message of messages) {
-          assert.isTrue(message._expanded);
-        }
-      });
-
-      test('show all messages respects collapse', () => {
-        element.messages = _.times(10, randomAutomated)
-            .concat(_.times(11, randomMessage));
-        flushAsynchronousOperations();
-
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('#collapse-messages')); // Expand all.
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('#collapse-messages')); // Collapse all.
-        flushAsynchronousOperations();
-
-        let messages = getMessages();
-        assert.equal(messages.length, 20);
-        for (const message of messages) {
-          assert.isFalse(message._expanded);
-        }
-
-        MockInteractions.tap(element.$.oldMessagesBtn);
-        flushAsynchronousOperations();
-
-        messages = getMessages();
-        assert.equal(messages.length, 21);
-        for (const message of messages) {
-          assert.isFalse(message._expanded);
-        }
-      });
-
-      test('expand/collapse all', () => {
-        let allMessageEls = getMessages();
-        for (const message of allMessageEls) {
-          message._expanded = false;
-        }
-        MockInteractions.tap(allMessageEls[1]);
-        assert.isTrue(allMessageEls[1]._expanded);
-
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('#collapse-messages'));
-        allMessageEls = getMessages();
-        for (const message of allMessageEls) {
-          assert.isTrue(message._expanded);
-        }
-
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('#collapse-messages'));
-        allMessageEls = getMessages();
-        for (const message of allMessageEls) {
-          assert.isFalse(message._expanded);
-        }
-      });
-
-      test('expand/collapse from external keypress', () => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('#collapse-messages'));
-        let allMessageEls = getMessages();
-        for (const message of allMessageEls) {
-          assert.isTrue(message._expanded);
-        }
-
-        // Expand/collapse all text also changes.
-        assert.equal(element.shadowRoot
-            .querySelector('#collapse-messages').textContent.trim(),
-        'Collapse all');
-
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('#collapse-messages'));
-        allMessageEls = getMessages();
-        for (const message of allMessageEls) {
-          assert.isFalse(message._expanded);
-        }
-        // Expand/collapse all text also changes.
-        assert.equal(element.shadowRoot
-            .querySelector('#collapse-messages').textContent.trim(),
-        'Expand all');
-      });
-
-      test('hide messages does not appear when no automated messages', () => {
-        assert.isOk(element.shadowRoot
-            .querySelector('#automatedMessageToggleContainer[hidden]'));
-      });
-
-      test('scroll to message', () => {
-        const allMessageEls = getMessages();
-        for (const message of allMessageEls) {
-          message.set('message.expanded', false);
-        }
-
-        const scrollToStub = sandbox.stub(window, 'scrollTo');
-        const highlightStub = sandbox.stub(element, '_highlightEl');
-
-        element.scrollToMessage('invalid');
-
-        for (const message of allMessageEls) {
-          assert.isFalse(message._expanded,
-              'expected gr-message to not be expanded');
-        }
-
-        const messageID = messages[1].id;
-        element.scrollToMessage(messageID);
-        assert.isTrue(
-            element.shadowRoot
-                .querySelector('[data-message-id="' + messageID + '"]')
-                ._expanded);
-
-        assert.isTrue(scrollToStub.calledOnce);
-        assert.isTrue(highlightStub.calledOnce);
-      });
-
-      test('scroll to message offscreen', () => {
-        const scrollToStub = sandbox.stub(window, 'scrollTo');
-        const highlightStub = sandbox.stub(element, '_highlightEl');
-        element.messages = _.times(25, randomMessage);
-        flushAsynchronousOperations();
-        assert.isFalse(scrollToStub.called);
-        assert.isFalse(highlightStub.called);
-
-        const messageID = element.messages[1].id;
-        element.scrollToMessage(messageID);
-        assert.isTrue(scrollToStub.calledOnce);
-        assert.isTrue(highlightStub.calledOnce);
-        assert.equal(element._visibleMessages.length, 24);
-        assert.isTrue(
-            element.shadowRoot
-                .querySelector('[data-message-id="' + messageID + '"]')
-                ._expanded);
-      });
-
-      test('messages', () => {
-        const messages = [].concat(
-            randomMessage(),
-            {
-              _index: 5,
-              _revision_number: 4,
-              message: 'Uploaded patch set 4.',
-              date: '2016-09-28 13:36:33.000000000',
-              author,
-              id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
-            },
-            {
-              _index: 6,
-              _revision_number: 4,
-              message: 'Patch Set 4:\n\n(6 comments)',
-              date: '2016-09-28 13:36:33.000000000',
-              author,
-              id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
-            }
-        );
-        element.messages = messages;
-        const isAuthor = function(author, message) {
-          return message.author._account_id === author._account_id;
-        };
-        const isMarvin = isAuthor.bind(null, author);
-        flushAsynchronousOperations();
-        const messageElements = getMessages();
-        assert.equal(messageElements.length, messages.length);
-        assert.deepEqual(messageElements[1].message, messages[1]);
-        assert.deepEqual(messageElements[2].message, messages[2]);
-        assert.deepEqual(messageElements[1].comments.file1,
-            comments.file1.filter(isMarvin));
-        assert.deepEqual(messageElements[1].comments.file2,
-            comments.file2.filter(isMarvin));
-        assert.deepEqual(messageElements[2].comments, {});
-      });
-
-      test('messages without author do not throw', () => {
-        const messages = [{
-          _index: 5,
-          _revision_number: 4,
-          message: 'Uploaded patch set 4.',
-          date: '2016-09-28 13:36:33.000000000',
-          id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
-        }];
-        element.messages = messages;
-        flushAsynchronousOperations();
-        const messageEls = getMessages();
-        assert.equal(messageEls.length, 1);
-        assert.equal(messageEls[0].message.message, messages[0].message);
-      });
-
-      test('hide increment text if increment >= total remaining', () => {
-        // Test with stubbed return values, as _numRemaining and _getDelta have
-        // their own tests.
-        sandbox.stub(element, '_getDelta').returns(5);
-        const remainingStub = sandbox.stub(element, '_numRemaining').returns(6);
-        assert.isFalse(element._computeIncrementHidden(null, null, null));
-        remainingStub.restore();
-
-        sandbox.stub(element, '_numRemaining').returns(4);
-        assert.isTrue(element._computeIncrementHidden(null, null, null));
-      });
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      return commentApiWrapper.loadComments();
     });
 
-    suite('gr-messages-list automate tests', () => {
-      let element;
-      let messages;
-      let sandbox;
-      let commentApiWrapper;
+    teardown(() => {
+      sandbox.restore();
+    });
 
-      const getMessages = function() {
-        return Polymer.dom(element.root).querySelectorAll('gr-message');
-      };
-      const getHiddenMessages = function() {
-        return Polymer.dom(element.root).querySelectorAll('gr-message[hidden]');
-      };
+    test('hide autogenerated button is not hidden', () => {
+      assert.isNotOk(element.shadowRoot
+          .querySelector('#automatedMessageToggle[hidden]'));
+    });
 
-      const randomMessageReviewer = {
-        reviewer: {},
-        date: '2016-01-13 20:30:33.038000',
-      };
+    test('autogenerated messages are not hidden initially', () => {
+      const allHiddenMessageEls = getHiddenMessages();
 
-      setup(() => {
-        stub('gr-rest-api-interface', {
-          getConfig() { return Promise.resolve({}); },
-          getLoggedIn() { return Promise.resolve(false); },
-          getDiffComments() { return Promise.resolve({}); },
-          getDiffRobotComments() { return Promise.resolve({}); },
-          getDiffDrafts() { return Promise.resolve({}); },
+      // There are no hidden messages.
+      assert.isFalse(!!allHiddenMessageEls.length);
+    });
+
+    test('autogenerated messages hidden after comments only toggle', () => {
+      let allHiddenMessageEls = getHiddenMessages();
+
+      element._hideAutomated = false;
+      MockInteractions.tap(element.$.automatedMessageToggle);
+      flushAsynchronousOperations();
+      const allMessageEls = getMessages();
+      allHiddenMessageEls = getHiddenMessages();
+
+      // Autogenerated messages are now hidden.
+      assert.equal(allHiddenMessageEls.length, allMessageEls.length);
+    });
+
+    test('autogenerated messages not hidden after comments only toggle',
+        () => {
+          let allHiddenMessageEls = getHiddenMessages();
+
+          element._hideAutomated = true;
+          MockInteractions.tap(element.$.automatedMessageToggle);
+          allHiddenMessageEls = getHiddenMessages();
+
+          // Autogenerated messages are now hidden.
+          assert.isFalse(!!allHiddenMessageEls.length);
         });
 
-        sandbox = sinon.sandbox.create();
-        messages = _.times(2, randomAutomated);
-        messages.push(randomMessageReviewer);
+    test('_getDelta', () => {
+      let messages = [randomMessage()];
+      assert.equal(element._getDelta([], messages, false), 1);
+      assert.equal(element._getDelta([], messages, true), 1);
 
-        // Element must be wrapped in an element with direct access to the
-        // comment API.
-        commentApiWrapper = fixture('basic');
-        element = commentApiWrapper.$.messagesList;
-        sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
-        element.messages = messages;
+      messages = _.times(7, randomMessage);
+      assert.equal(element._getDelta([], messages, false), 5);
+      assert.equal(element._getDelta([], messages, true), 5);
 
-        // Stub methods on the changeComments object after changeComments has
-        // been initialized.
-        return commentApiWrapper.loadComments();
-      });
+      messages = _.times(4, randomMessage)
+          .concat(_.times(2, randomAutomated))
+          .concat(_.times(3, randomMessage));
 
-      teardown(() => {
-        sandbox.restore();
-      });
+      const dummyArr = _.times(2, randomMessage);
+      assert.equal(element._getDelta(dummyArr, messages, false), 5);
+      assert.equal(element._getDelta(dummyArr, messages, true), 7);
+    });
 
-      test('hide autogenerated button is not hidden', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('#automatedMessageToggle[hidden]'));
-      });
+    test('_getHumanMessages', () => {
+      assert.equal(
+          element._getHumanMessages(_.times(5, randomAutomated)).length, 0);
+      assert.equal(
+          element._getHumanMessages(_.times(5, randomMessage)).length, 5);
 
-      test('autogenerated messages are not hidden initially', () => {
-        const allHiddenMessageEls = getHiddenMessages();
+      let messages = _.shuffle(_.times(5, randomMessage)
+          .concat(_.times(5, randomAutomated)));
+      messages = element._getHumanMessages(messages);
+      assert.equal(messages.length, 5);
+      assert.isFalse(element._hasAutomatedMessages(messages));
+    });
 
-        // There are no hidden messages.
-        assert.isFalse(!!allHiddenMessageEls.length);
-      });
-
-      test('autogenerated messages hidden after comments only toggle', () => {
-        let allHiddenMessageEls = getHiddenMessages();
-
-        element._hideAutomated = false;
-        MockInteractions.tap(element.$.automatedMessageToggle);
-        flushAsynchronousOperations();
-        const allMessageEls = getMessages();
-        allHiddenMessageEls = getHiddenMessages();
-
-        // Autogenerated messages are now hidden.
-        assert.equal(allHiddenMessageEls.length, allMessageEls.length);
-      });
-
-      test('autogenerated messages not hidden after comments only toggle',
-          () => {
-            let allHiddenMessageEls = getHiddenMessages();
-
-            element._hideAutomated = true;
-            MockInteractions.tap(element.$.automatedMessageToggle);
-            allHiddenMessageEls = getHiddenMessages();
-
-            // Autogenerated messages are now hidden.
-            assert.isFalse(!!allHiddenMessageEls.length);
+    test('initially show only 20 messages', () => {
+      sandbox.stub(element.$.reporting, 'reportInteraction',
+          (eventName, details) => {
+            assert.equal(typeof(eventName), 'string');
+            if (details) {
+              assert.equal(typeof(details), 'object');
+            }
           });
+      const messages = Array.from(Array(23).keys())
+          .map(() => {
+            return {};
+          });
+      element._processedMessagesChanged(messages);
 
-      test('_getDelta', () => {
-        let messages = [randomMessage()];
-        assert.equal(element._getDelta([], messages, false), 1);
-        assert.equal(element._getDelta([], messages, true), 1);
+      assert.equal(element._visibleMessages.length, 20);
+    });
 
-        messages = _.times(7, randomMessage);
-        assert.equal(element._getDelta([], messages, false), 5);
-        assert.equal(element._getDelta([], messages, true), 5);
+    test('_computeLabelExtremes', () => {
+      const computeSpy = sandbox.spy(element, '_computeLabelExtremes');
 
-        messages = _.times(4, randomMessage)
-            .concat(_.times(2, randomAutomated))
-            .concat(_.times(3, randomMessage));
+      element.labels = null;
+      assert.isTrue(computeSpy.calledOnce);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
 
-        const dummyArr = _.times(2, randomMessage);
-        assert.equal(element._getDelta(dummyArr, messages, false), 5);
-        assert.equal(element._getDelta(dummyArr, messages, true), 7);
-      });
+      element.labels = {};
+      assert.isTrue(computeSpy.calledTwice);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
 
-      test('_getHumanMessages', () => {
-        assert.equal(
-            element._getHumanMessages(_.times(5, randomAutomated)).length, 0);
-        assert.equal(
-            element._getHumanMessages(_.times(5, randomMessage)).length, 5);
+      element.labels = {'my-label': {}};
+      assert.isTrue(computeSpy.calledThrice);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
 
-        let messages = _.shuffle(_.times(5, randomMessage)
-            .concat(_.times(5, randomAutomated)));
-        messages = element._getHumanMessages(messages);
-        assert.equal(messages.length, 5);
-        assert.isFalse(element._hasAutomatedMessages(messages));
-      });
+      element.labels = {'my-label': {values: {}}};
+      assert.equal(computeSpy.callCount, 4);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
 
-      test('initially show only 20 messages', () => {
-        sandbox.stub(element.$.reporting, 'reportInteraction',
-            (eventName, details) => {
-              assert.equal(typeof(eventName), 'string');
-              if (details) {
-                assert.equal(typeof(details), 'object');
-              }
-            });
-        const messages = Array.from(Array(23).keys())
-            .map(() => {
-              return {};
-            });
-        element._processedMessagesChanged(messages);
+      element.labels = {'my-label': {values: {'-12': {}}}};
+      assert.equal(computeSpy.callCount, 5);
+      assert.deepEqual(computeSpy.lastCall.returnValue,
+          {'my-label': {min: -12, max: -12}});
 
-        assert.equal(element._visibleMessages.length, 20);
-      });
+      element.labels = {
+        'my-label': {values: {'-2': {}, '-1': {}, '0': {}, '+1': {}, '+2': {}}},
+      };
+      assert.equal(computeSpy.callCount, 6);
+      assert.deepEqual(computeSpy.lastCall.returnValue,
+          {'my-label': {min: -2, max: 2}});
 
-      test('_computeLabelExtremes', () => {
-        const computeSpy = sandbox.spy(element, '_computeLabelExtremes');
-
-        element.labels = null;
-        assert.isTrue(computeSpy.calledOnce);
-        assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-        element.labels = {};
-        assert.isTrue(computeSpy.calledTwice);
-        assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-        element.labels = {'my-label': {}};
-        assert.isTrue(computeSpy.calledThrice);
-        assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-        element.labels = {'my-label': {values: {}}};
-        assert.equal(computeSpy.callCount, 4);
-        assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-        element.labels = {'my-label': {values: {'-12': {}}}};
-        assert.equal(computeSpy.callCount, 5);
-        assert.deepEqual(computeSpy.lastCall.returnValue,
-            {'my-label': {min: -12, max: -12}});
-
-        element.labels = {
-          'my-label': {values: {'-2': {}, '-1': {}, '0': {}, '+1': {}, '+2': {}}},
-        };
-        assert.equal(computeSpy.callCount, 6);
-        assert.deepEqual(computeSpy.lastCall.returnValue,
-            {'my-label': {min: -2, max: 2}});
-
-        element.labels = {
-          'my-label': {values: {'-12': {}}},
-          'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
-        };
-        assert.equal(computeSpy.callCount, 7);
-        assert.deepEqual(computeSpy.lastCall.returnValue, {
-          'my-label': {min: -12, max: -12},
-          'other-label': {min: -1, max: 1},
-        });
+      element.labels = {
+        'my-label': {values: {'-12': {}}},
+        'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
+      };
+      assert.equal(computeSpy.callCount, 7);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {
+        'my-label': {min: -12, max: -12},
+        'other-label': {min: -1, max: 1},
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index d4a2398..c4af481 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -14,384 +14,397 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-related-changes-list_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @extends Polymer.Element
+ */
+class GrRelatedChangesList extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.PatchSetBehavior,
+  Gerrit.RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-related-changes-list'; }
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @extends Polymer.Element
+   * Fired when a new section is loaded so that the change view can determine
+   * a show more button is needed, sometimes before all the sections finish
+   * loading.
+   *
+   * @event new-section-loaded
    */
-  class GrRelatedChangesList extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.PatchSetBehavior,
-    Gerrit.RESTClientBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-related-changes-list'; }
-    /**
-     * Fired when a new section is loaded so that the change view can determine
-     * a show more button is needed, sometimes before all the sections finish
-     * loading.
-     *
-     * @event new-section-loaded
-     */
 
-    static get properties() {
-      return {
-        change: Object,
-        hasParent: {
-          type: Boolean,
-          notify: true,
-          value: false,
-        },
-        patchNum: String,
-        parentChange: Object,
-        hidden: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-        loading: {
-          type: Boolean,
-          notify: true,
-        },
-        mergeable: Boolean,
-        _connectedRevisions: {
-          type: Array,
-          computed: '_computeConnectedRevisions(change, patchNum, ' +
-            '_relatedResponse.changes)',
-        },
-        /** @type {?} */
-        _relatedResponse: {
-          type: Object,
-          value() { return {changes: []}; },
-        },
-        /** @type {?} */
-        _submittedTogether: {
-          type: Object,
-          value() { return {changes: []}; },
-        },
-        _conflicts: {
-          type: Array,
-          value() { return []; },
-        },
-        _cherryPicks: {
-          type: Array,
-          value() { return []; },
-        },
-        _sameTopic: {
-          type: Array,
-          value() { return []; },
-        },
-      };
-    }
-
-    static get observers() {
-      return [
-        '_resultsChanged(_relatedResponse, _submittedTogether, ' +
-          '_conflicts, _cherryPicks, _sameTopic)',
-      ];
-    }
-
-    clear() {
-      this.loading = true;
-      this.hidden = true;
-
-      this._relatedResponse = {changes: []};
-      this._submittedTogether = {changes: []};
-      this._conflicts = [];
-      this._cherryPicks = [];
-      this._sameTopic = [];
-    }
-
-    reload() {
-      if (!this.change || !this.patchNum) {
-        return Promise.resolve();
-      }
-      this.loading = true;
-      const promises = [
-        this._getRelatedChanges().then(response => {
-          this._relatedResponse = response;
-          this._fireReloadEvent();
-          this.hasParent = this._calculateHasParent(this.change.change_id,
-              response.changes);
-        }),
-        this._getSubmittedTogether().then(response => {
-          this._submittedTogether = response;
-          this._fireReloadEvent();
-        }),
-        this._getCherryPicks().then(response => {
-          this._cherryPicks = response;
-          this._fireReloadEvent();
-        }),
-      ];
-
-      // Get conflicts if change is open and is mergeable.
-      if (this.changeIsOpen(this.change) && this.mergeable) {
-        promises.push(this._getConflicts().then(response => {
-          // Because the server doesn't always return a response and the
-          // template expects an array, always return an array.
-          this._conflicts = response ? response : [];
-          this._fireReloadEvent();
-        }));
-      }
-
-      promises.push(this._getServerConfig().then(config => {
-        if (this.change.topic && !config.change.submit_whole_topic) {
-          return this._getChangesWithSameTopic().then(response => {
-            this._sameTopic = response;
-          });
-        } else {
-          this._sameTopic = [];
-        }
-        return this._sameTopic;
-      }));
-
-      return Promise.all(promises).then(() => {
-        this.loading = false;
-      });
-    }
-
-    _fireReloadEvent() {
-      // The listener on the change computes height of the related changes
-      // section, so they have to be rendered first, and inside a dom-repeat,
-      // that requires a flush.
-      Polymer.dom.flush();
-      this.dispatchEvent(new CustomEvent('new-section-loaded'));
-    }
-
-    /**
-     * Determines whether or not the given change has a parent change. If there
-     * is a relation chain, and the change id is not the last item of the
-     * relation chain, there is a parent.
-     *
-     * @param  {number} currentChangeId
-     * @param  {!Array} relatedChanges
-     * @return {boolean}
-     */
-    _calculateHasParent(currentChangeId, relatedChanges) {
-      return relatedChanges.length > 0 &&
-          relatedChanges[relatedChanges.length - 1].change_id !==
-          currentChangeId;
-    }
-
-    _getRelatedChanges() {
-      return this.$.restAPI.getRelatedChanges(this.change._number,
-          this.patchNum);
-    }
-
-    _getSubmittedTogether() {
-      return this.$.restAPI.getChangesSubmittedTogether(this.change._number);
-    }
-
-    _getServerConfig() {
-      return this.$.restAPI.getConfig();
-    }
-
-    _getConflicts() {
-      return this.$.restAPI.getChangeConflicts(this.change._number);
-    }
-
-    _getCherryPicks() {
-      return this.$.restAPI.getChangeCherryPicks(this.change.project,
-          this.change.change_id, this.change._number);
-    }
-
-    _getChangesWithSameTopic() {
-      return this.$.restAPI.getChangesWithSameTopic(this.change.topic,
-          this.change._number);
-    }
-
-    /**
-     * @param {number} changeNum
-     * @param {string} project
-     * @param {number=} opt_patchNum
-     * @return {string}
-     */
-    _computeChangeURL(changeNum, project, opt_patchNum) {
-      return Gerrit.Nav.getUrlForChangeById(changeNum, project, opt_patchNum);
-    }
-
-    _computeChangeContainerClass(currentChange, relatedChange) {
-      const classes = ['changeContainer'];
-      if ([relatedChange, currentChange].some(arg => arg === undefined)) {
-        return classes;
-      }
-      if (this._changesEqual(relatedChange, currentChange)) {
-        classes.push('thisChange');
-      }
-      return classes.join(' ');
-    }
-
-    /**
-     * Do the given objects describe the same change? Compares the changes by
-     * their numbers.
-     *
-     * @see /Documentation/rest-api-changes.html#change-info
-     * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
-     * @param {!Object} a Either ChangeInfo or RelatedChangeAndCommitInfo
-     * @param {!Object} b Either ChangeInfo or RelatedChangeAndCommitInfo
-     * @return {boolean}
-     */
-    _changesEqual(a, b) {
-      const aNum = this._getChangeNumber(a);
-      const bNum = this._getChangeNumber(b);
-      return aNum === bNum;
-    }
-
-    /**
-     * Get the change number from either a ChangeInfo (such as those included in
-     * SubmittedTogetherInfo responses) or get the change number from a
-     * RelatedChangeAndCommitInfo (such as those included in a
-     * RelatedChangesInfo response).
-     *
-     * @see /Documentation/rest-api-changes.html#change-info
-     * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
-     *
-     * @param {!Object} change Either a ChangeInfo or a
-     *     RelatedChangeAndCommitInfo object.
-     * @return {number}
-     */
-    _getChangeNumber(change) {
-      // Default to 0 if change property is not defined.
-      if (!change) return 0;
-
-      if (change.hasOwnProperty('_change_number')) {
-        return change._change_number;
-      }
-      return change._number;
-    }
-
-    _computeLinkClass(change) {
-      const statuses = [];
-      if (change.status == this.ChangeStatus.ABANDONED) {
-        statuses.push('strikethrough');
-      }
-      if (change.submittable) {
-        statuses.push('submittable');
-      }
-      return statuses.join(' ');
-    }
-
-    _computeChangeStatusClass(change) {
-      const classes = ['status'];
-      if (change._revision_number != change._current_revision_number) {
-        classes.push('notCurrent');
-      } else if (this._isIndirectAncestor(change)) {
-        classes.push('indirectAncestor');
-      } else if (change.submittable) {
-        classes.push('submittable');
-      } else if (change.status == this.ChangeStatus.NEW) {
-        classes.push('hidden');
-      }
-      return classes.join(' ');
-    }
-
-    _computeChangeStatus(change) {
-      switch (change.status) {
-        case this.ChangeStatus.MERGED:
-          return 'Merged';
-        case this.ChangeStatus.ABANDONED:
-          return 'Abandoned';
-      }
-      if (change._revision_number != change._current_revision_number) {
-        return 'Not current';
-      } else if (this._isIndirectAncestor(change)) {
-        return 'Indirect ancestor';
-      } else if (change.submittable) {
-        return 'Submittable';
-      }
-      return '';
-    }
-
-    _resultsChanged(related, submittedTogether, conflicts,
-        cherryPicks, sameTopic) {
-      // Polymer 2: check for undefined
-      if ([
-        related,
-        submittedTogether,
-        conflicts,
-        cherryPicks,
-        sameTopic,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      const results = [
-        related && related.changes,
-        submittedTogether && submittedTogether.changes,
-        conflicts,
-        cherryPicks,
-        sameTopic,
-      ];
-      for (let i = 0; i < results.length; i++) {
-        if (results[i] && results[i].length > 0) {
-          this.hidden = false;
-          this.fire('update', null, {bubbles: false});
-          return;
-        }
-      }
-      this.hidden = true;
-    }
-
-    _isIndirectAncestor(change) {
-      return !this._connectedRevisions.includes(change.commit.commit);
-    }
-
-    _computeConnectedRevisions(change, patchNum, relatedChanges) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum, relatedChanges].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      const connected = [];
-      let changeRevision;
-      if (!change) { return []; }
-      for (const rev in change.revisions) {
-        if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
-          changeRevision = rev;
-        }
-      }
-      const commits = relatedChanges.map(c => c.commit);
-      let pos = commits.length - 1;
-
-      while (pos >= 0) {
-        const commit = commits[pos].commit;
-        connected.push(commit);
-        if (commit == changeRevision) {
-          break;
-        }
-        pos--;
-      }
-      while (pos >= 0) {
-        for (let i = 0; i < commits[pos].parents.length; i++) {
-          if (connected.includes(commits[pos].parents[i].commit)) {
-            connected.push(commits[pos].commit);
-            break;
-          }
-        }
-        --pos;
-      }
-      return connected;
-    }
-
-    _computeSubmittedTogetherClass(submittedTogether) {
-      if (!submittedTogether || (
-        submittedTogether.changes.length === 0 &&
-          !submittedTogether.non_visible_changes)) {
-        return 'hidden';
-      }
-      return '';
-    }
-
-    _computeNonVisibleChangesNote(n) {
-      const noun = n === 1 ? 'change' : 'changes';
-      return `(+ ${n} non-visible ${noun})`;
-    }
+  static get properties() {
+    return {
+      change: Object,
+      hasParent: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+      patchNum: String,
+      parentChange: Object,
+      hidden: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      loading: {
+        type: Boolean,
+        notify: true,
+      },
+      mergeable: Boolean,
+      _connectedRevisions: {
+        type: Array,
+        computed: '_computeConnectedRevisions(change, patchNum, ' +
+          '_relatedResponse.changes)',
+      },
+      /** @type {?} */
+      _relatedResponse: {
+        type: Object,
+        value() { return {changes: []}; },
+      },
+      /** @type {?} */
+      _submittedTogether: {
+        type: Object,
+        value() { return {changes: []}; },
+      },
+      _conflicts: {
+        type: Array,
+        value() { return []; },
+      },
+      _cherryPicks: {
+        type: Array,
+        value() { return []; },
+      },
+      _sameTopic: {
+        type: Array,
+        value() { return []; },
+      },
+    };
   }
 
-  customElements.define(GrRelatedChangesList.is, GrRelatedChangesList);
-})();
+  static get observers() {
+    return [
+      '_resultsChanged(_relatedResponse, _submittedTogether, ' +
+        '_conflicts, _cherryPicks, _sameTopic)',
+    ];
+  }
+
+  clear() {
+    this.loading = true;
+    this.hidden = true;
+
+    this._relatedResponse = {changes: []};
+    this._submittedTogether = {changes: []};
+    this._conflicts = [];
+    this._cherryPicks = [];
+    this._sameTopic = [];
+  }
+
+  reload() {
+    if (!this.change || !this.patchNum) {
+      return Promise.resolve();
+    }
+    this.loading = true;
+    const promises = [
+      this._getRelatedChanges().then(response => {
+        this._relatedResponse = response;
+        this._fireReloadEvent();
+        this.hasParent = this._calculateHasParent(this.change.change_id,
+            response.changes);
+      }),
+      this._getSubmittedTogether().then(response => {
+        this._submittedTogether = response;
+        this._fireReloadEvent();
+      }),
+      this._getCherryPicks().then(response => {
+        this._cherryPicks = response;
+        this._fireReloadEvent();
+      }),
+    ];
+
+    // Get conflicts if change is open and is mergeable.
+    if (this.changeIsOpen(this.change) && this.mergeable) {
+      promises.push(this._getConflicts().then(response => {
+        // Because the server doesn't always return a response and the
+        // template expects an array, always return an array.
+        this._conflicts = response ? response : [];
+        this._fireReloadEvent();
+      }));
+    }
+
+    promises.push(this._getServerConfig().then(config => {
+      if (this.change.topic && !config.change.submit_whole_topic) {
+        return this._getChangesWithSameTopic().then(response => {
+          this._sameTopic = response;
+        });
+      } else {
+        this._sameTopic = [];
+      }
+      return this._sameTopic;
+    }));
+
+    return Promise.all(promises).then(() => {
+      this.loading = false;
+    });
+  }
+
+  _fireReloadEvent() {
+    // The listener on the change computes height of the related changes
+    // section, so they have to be rendered first, and inside a dom-repeat,
+    // that requires a flush.
+    flush();
+    this.dispatchEvent(new CustomEvent('new-section-loaded'));
+  }
+
+  /**
+   * Determines whether or not the given change has a parent change. If there
+   * is a relation chain, and the change id is not the last item of the
+   * relation chain, there is a parent.
+   *
+   * @param  {number} currentChangeId
+   * @param  {!Array} relatedChanges
+   * @return {boolean}
+   */
+  _calculateHasParent(currentChangeId, relatedChanges) {
+    return relatedChanges.length > 0 &&
+        relatedChanges[relatedChanges.length - 1].change_id !==
+        currentChangeId;
+  }
+
+  _getRelatedChanges() {
+    return this.$.restAPI.getRelatedChanges(this.change._number,
+        this.patchNum);
+  }
+
+  _getSubmittedTogether() {
+    return this.$.restAPI.getChangesSubmittedTogether(this.change._number);
+  }
+
+  _getServerConfig() {
+    return this.$.restAPI.getConfig();
+  }
+
+  _getConflicts() {
+    return this.$.restAPI.getChangeConflicts(this.change._number);
+  }
+
+  _getCherryPicks() {
+    return this.$.restAPI.getChangeCherryPicks(this.change.project,
+        this.change.change_id, this.change._number);
+  }
+
+  _getChangesWithSameTopic() {
+    return this.$.restAPI.getChangesWithSameTopic(this.change.topic,
+        this.change._number);
+  }
+
+  /**
+   * @param {number} changeNum
+   * @param {string} project
+   * @param {number=} opt_patchNum
+   * @return {string}
+   */
+  _computeChangeURL(changeNum, project, opt_patchNum) {
+    return Gerrit.Nav.getUrlForChangeById(changeNum, project, opt_patchNum);
+  }
+
+  _computeChangeContainerClass(currentChange, relatedChange) {
+    const classes = ['changeContainer'];
+    if ([relatedChange, currentChange].some(arg => arg === undefined)) {
+      return classes;
+    }
+    if (this._changesEqual(relatedChange, currentChange)) {
+      classes.push('thisChange');
+    }
+    return classes.join(' ');
+  }
+
+  /**
+   * Do the given objects describe the same change? Compares the changes by
+   * their numbers.
+   *
+   * @see /Documentation/rest-api-changes.html#change-info
+   * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
+   * @param {!Object} a Either ChangeInfo or RelatedChangeAndCommitInfo
+   * @param {!Object} b Either ChangeInfo or RelatedChangeAndCommitInfo
+   * @return {boolean}
+   */
+  _changesEqual(a, b) {
+    const aNum = this._getChangeNumber(a);
+    const bNum = this._getChangeNumber(b);
+    return aNum === bNum;
+  }
+
+  /**
+   * Get the change number from either a ChangeInfo (such as those included in
+   * SubmittedTogetherInfo responses) or get the change number from a
+   * RelatedChangeAndCommitInfo (such as those included in a
+   * RelatedChangesInfo response).
+   *
+   * @see /Documentation/rest-api-changes.html#change-info
+   * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
+   *
+   * @param {!Object} change Either a ChangeInfo or a
+   *     RelatedChangeAndCommitInfo object.
+   * @return {number}
+   */
+  _getChangeNumber(change) {
+    // Default to 0 if change property is not defined.
+    if (!change) return 0;
+
+    if (change.hasOwnProperty('_change_number')) {
+      return change._change_number;
+    }
+    return change._number;
+  }
+
+  _computeLinkClass(change) {
+    const statuses = [];
+    if (change.status == this.ChangeStatus.ABANDONED) {
+      statuses.push('strikethrough');
+    }
+    if (change.submittable) {
+      statuses.push('submittable');
+    }
+    return statuses.join(' ');
+  }
+
+  _computeChangeStatusClass(change) {
+    const classes = ['status'];
+    if (change._revision_number != change._current_revision_number) {
+      classes.push('notCurrent');
+    } else if (this._isIndirectAncestor(change)) {
+      classes.push('indirectAncestor');
+    } else if (change.submittable) {
+      classes.push('submittable');
+    } else if (change.status == this.ChangeStatus.NEW) {
+      classes.push('hidden');
+    }
+    return classes.join(' ');
+  }
+
+  _computeChangeStatus(change) {
+    switch (change.status) {
+      case this.ChangeStatus.MERGED:
+        return 'Merged';
+      case this.ChangeStatus.ABANDONED:
+        return 'Abandoned';
+    }
+    if (change._revision_number != change._current_revision_number) {
+      return 'Not current';
+    } else if (this._isIndirectAncestor(change)) {
+      return 'Indirect ancestor';
+    } else if (change.submittable) {
+      return 'Submittable';
+    }
+    return '';
+  }
+
+  _resultsChanged(related, submittedTogether, conflicts,
+      cherryPicks, sameTopic) {
+    // Polymer 2: check for undefined
+    if ([
+      related,
+      submittedTogether,
+      conflicts,
+      cherryPicks,
+      sameTopic,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    const results = [
+      related && related.changes,
+      submittedTogether && submittedTogether.changes,
+      conflicts,
+      cherryPicks,
+      sameTopic,
+    ];
+    for (let i = 0; i < results.length; i++) {
+      if (results[i] && results[i].length > 0) {
+        this.hidden = false;
+        this.fire('update', null, {bubbles: false});
+        return;
+      }
+    }
+    this.hidden = true;
+  }
+
+  _isIndirectAncestor(change) {
+    return !this._connectedRevisions.includes(change.commit.commit);
+  }
+
+  _computeConnectedRevisions(change, patchNum, relatedChanges) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum, relatedChanges].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const connected = [];
+    let changeRevision;
+    if (!change) { return []; }
+    for (const rev in change.revisions) {
+      if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
+        changeRevision = rev;
+      }
+    }
+    const commits = relatedChanges.map(c => c.commit);
+    let pos = commits.length - 1;
+
+    while (pos >= 0) {
+      const commit = commits[pos].commit;
+      connected.push(commit);
+      if (commit == changeRevision) {
+        break;
+      }
+      pos--;
+    }
+    while (pos >= 0) {
+      for (let i = 0; i < commits[pos].parents.length; i++) {
+        if (connected.includes(commits[pos].parents[i].commit)) {
+          connected.push(commits[pos].commit);
+          break;
+        }
+      }
+      --pos;
+    }
+    return connected;
+  }
+
+  _computeSubmittedTogetherClass(submittedTogether) {
+    if (!submittedTogether || (
+      submittedTogether.changes.length === 0 &&
+        !submittedTogether.non_visible_changes)) {
+      return 'hidden';
+    }
+    return '';
+  }
+
+  _computeNonVisibleChangesNote(n) {
+    const noun = n === 1 ? 'change' : 'changes';
+    return `(+ ${n} non-visible ${noun})`;
+  }
+}
+
+customElements.define(GrRelatedChangesList.is, GrRelatedChangesList);
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js
index 696ffdf..1d8551d 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-related-changes-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -104,39 +96,27 @@
       }
     </style>
     <div>
-      <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
+      <section class="relatedChanges" hidden\$="[[!_relatedResponse.changes.length]]" hidden="">
         <h4>Relation chain</h4>
-        <template
-            is="dom-repeat"
-            items="[[_relatedResponse.changes]]"
-            as="related">
-          <div class$="rightIndent [[_computeChangeContainerClass(change, related)]]">
-            <a href$="[[_computeChangeURL(related._change_number, related.project, related._revision_number)]]"
-                class$="[[_computeLinkClass(related)]]"
-                title$="[[related.commit.subject]]">
+        <template is="dom-repeat" items="[[_relatedResponse.changes]]" as="related">
+          <div class\$="rightIndent [[_computeChangeContainerClass(change, related)]]">
+            <a href\$="[[_computeChangeURL(related._change_number, related.project, related._revision_number)]]" class\$="[[_computeLinkClass(related)]]" title\$="[[related.commit.subject]]">
               [[related.commit.subject]]
             </a>
-            <span class$="[[_computeChangeStatusClass(related)]]">
+            <span class\$="[[_computeChangeStatusClass(related)]]">
               ([[_computeChangeStatus(related)]])
             </span>
           </div>
         </template>
       </section>
-      <section
-          id="submittedTogether"
-          class$="[[_computeSubmittedTogetherClass(_submittedTogether)]]">
+      <section id="submittedTogether" class\$="[[_computeSubmittedTogetherClass(_submittedTogether)]]">
         <h4>Submitted together</h4>
         <template is="dom-repeat" items="[[_submittedTogether.changes]]" as="related">
-          <div class$="[[_computeChangeContainerClass(change, related)]]">
-            <a href$="[[_computeChangeURL(related._number, related.project)]]"
-                class$="[[_computeLinkClass(related)]]"
-                title$="[[related.project]]: [[related.branch]]: [[related.subject]]">
+          <div class\$="[[_computeChangeContainerClass(change, related)]]">
+            <a href\$="[[_computeChangeURL(related._number, related.project)]]" class\$="[[_computeLinkClass(related)]]" title\$="[[related.project]]: [[related.branch]]: [[related.subject]]">
               [[related.project]]: [[related.branch]]: [[related.subject]]
             </a>
-            <span
-                tabindex="-1"
-                title="Submittable"
-                class$="submittableCheck [[_computeLinkClass(related)]]">✓</span>
+            <span tabindex="-1" title="Submittable" class\$="submittableCheck [[_computeLinkClass(related)]]">✓</span>
           </div>
         </template>
         <template is="dom-if" if="[[_submittedTogether.non_visible_changes]]">
@@ -145,45 +125,37 @@
           </div>
         </template>
       </section>
-      <section hidden$="[[!_sameTopic.length]]" hidden>
+      <section hidden\$="[[!_sameTopic.length]]" hidden="">
         <h4>Same topic</h4>
         <template is="dom-repeat" items="[[_sameTopic]]" as="change">
           <div>
-            <a href$="[[_computeChangeURL(change._number, change.project)]]"
-                class$="[[_computeLinkClass(change)]]"
-                title$="[[change.project]]: [[change.branch]]: [[change.subject]]">
+            <a href\$="[[_computeChangeURL(change._number, change.project)]]" class\$="[[_computeLinkClass(change)]]" title\$="[[change.project]]: [[change.branch]]: [[change.subject]]">
               [[change.project]]: [[change.branch]]: [[change.subject]]
             </a>
           </div>
         </template>
       </section>
-      <section hidden$="[[!_conflicts.length]]" hidden>
+      <section hidden\$="[[!_conflicts.length]]" hidden="">
         <h4>Merge conflicts</h4>
         <template is="dom-repeat" items="[[_conflicts]]" as="change">
           <div>
-            <a href$="[[_computeChangeURL(change._number, change.project)]]"
-                class$="[[_computeLinkClass(change)]]"
-                title$="[[change.subject]]">
+            <a href\$="[[_computeChangeURL(change._number, change.project)]]" class\$="[[_computeLinkClass(change)]]" title\$="[[change.subject]]">
               [[change.subject]]
             </a>
           </div>
         </template>
       </section>
-      <section hidden$="[[!_cherryPicks.length]]" hidden>
+      <section hidden\$="[[!_cherryPicks.length]]" hidden="">
         <h4>Cherry picks</h4>
         <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
           <div>
-            <a href$="[[_computeChangeURL(change._number, change.project)]]"
-                class$="[[_computeLinkClass(change)]]"
-                title$="[[change.branch]]: [[change.subject]]">
+            <a href\$="[[_computeChangeURL(change._number, change.project)]]" class\$="[[_computeLinkClass(change)]]" title\$="[[change.branch]]: [[change.subject]]">
               [[change.branch]]: [[change.subject]]
             </a>
           </div>
         </template>
       </section>
     </div>
-    <div hidden$="[[!loading]]">Loading...</div>
+    <div hidden\$="[[!loading]]">Loading...</div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-related-changes-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index 9b8ebed..43037af 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-related-changes-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-related-changes-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-related-changes-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-related-changes-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,238 +40,260 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-related-changes-list tests', async () => {
-    await readyToTest();
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-related-changes-list.js';
+suite('gr-related-changes-list tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('connected revisions', () => {
+    const change = {
+      revisions: {
+        'e3c6d60783bfdec9ebae7dcfec4662360433449e': {
+          _number: 1,
+        },
+        '26e5e4c9c7ae31cbd876271cca281ce22b413997': {
+          _number: 2,
+        },
+        'bf7884d695296ca0c91702ba3e2bc8df0f69a907': {
+          _number: 7,
+        },
+        'b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3': {
+          _number: 5,
+        },
+        'd6bcee67570859ccb684873a85cf50b1f0e96fda': {
+          _number: 6,
+        },
+        'cc960918a7f90388f4a9e05753d0f7b90ad44546': {
+          _number: 3,
+        },
+        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
+          _number: 4,
+        },
+      },
+    };
+    let patchNum = 7;
+    let relatedChanges = [
+      {
+        commit: {
+          commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+          parents: [
+            {
+              commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+          parents: [
+            {
+              commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+          parents: [
+            {
+              commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75',
+            },
+          ],
+        },
+      },
+    ];
+
+    let connectedChanges =
+        element._computeConnectedRevisions(change, patchNum, relatedChanges);
+    assert.deepEqual(connectedChanges, [
+      '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+      '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+      '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+      '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+    ]);
+
+    patchNum = 4;
+    relatedChanges = [
+      {
+        commit: {
+          commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+          parents: [
+            {
+              commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+          parents: [
+            {
+              commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
+          parents: [
+            {
+              commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c',
+            },
+          ],
+        },
+      },
+    ];
+
+    connectedChanges =
+        element._computeConnectedRevisions(change, patchNum, relatedChanges);
+    assert.deepEqual(connectedChanges, [
+      'af815dac54318826b7f1fa468acc76349ffc588e',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+    ]);
+  });
+
+  test('_computeChangeContainerClass', () => {
+    const change1 = {change_id: 123, _number: 0};
+    const change2 = {change_id: 456, _change_number: 1};
+    const change3 = {change_id: 123, _number: 2};
+
+    assert.notEqual(element._computeChangeContainerClass(
+        change1, change1).indexOf('thisChange'), -1);
+    assert.equal(element._computeChangeContainerClass(
+        change1, change2).indexOf('thisChange'), -1);
+    assert.equal(element._computeChangeContainerClass(
+        change1, change3).indexOf('thisChange'), -1);
+  });
+
+  test('_changesEqual', () => {
+    const change1 = {change_id: 123, _number: 0};
+    const change2 = {change_id: 456, _number: 1};
+    const change3 = {change_id: 123, _number: 2};
+    const change4 = {change_id: 123, _change_number: 1};
+
+    assert.isTrue(element._changesEqual(change1, change1));
+    assert.isFalse(element._changesEqual(change1, change2));
+    assert.isFalse(element._changesEqual(change1, change3));
+    assert.isTrue(element._changesEqual(change2, change4));
+  });
+
+  test('_getChangeNumber', () => {
+    const change1 = {change_id: 123, _number: 0};
+    const change2 = {change_id: 456, _change_number: 1};
+    assert.equal(element._getChangeNumber(change1), 0);
+    assert.equal(element._getChangeNumber(change2), 1);
+  });
+
+  test('event for section loaded fires for each section ', () => {
+    const loadedStub = sandbox.stub();
+    element.patchNum = 7;
+    element.change = {
+      change_id: 123,
+      status: 'NEW',
+    };
+    element.mergeable = true;
+    element.addEventListener('new-section-loaded', loadedStub);
+    sandbox.stub(element, '_getRelatedChanges')
+        .returns(Promise.resolve({changes: []}));
+    sandbox.stub(element, '_getSubmittedTogether')
+        .returns(Promise.resolve());
+    sandbox.stub(element, '_getCherryPicks')
+        .returns(Promise.resolve());
+    sandbox.stub(element, '_getConflicts')
+        .returns(Promise.resolve());
+
+    return element.reload().then(() => {
+      assert.equal(loadedStub.callCount, 4);
+    });
+  });
+
+  suite('_getConflicts resolves undefined', () => {
     let element;
-    let sandbox;
 
     setup(() => {
       element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('connected revisions', () => {
-      const change = {
-        revisions: {
-          'e3c6d60783bfdec9ebae7dcfec4662360433449e': {
-            _number: 1,
-          },
-          '26e5e4c9c7ae31cbd876271cca281ce22b413997': {
-            _number: 2,
-          },
-          'bf7884d695296ca0c91702ba3e2bc8df0f69a907': {
-            _number: 7,
-          },
-          'b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3': {
-            _number: 5,
-          },
-          'd6bcee67570859ccb684873a85cf50b1f0e96fda': {
-            _number: 6,
-          },
-          'cc960918a7f90388f4a9e05753d0f7b90ad44546': {
-            _number: 3,
-          },
-          '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
-            _number: 4,
-          },
-        },
-      };
-      let patchNum = 7;
-      let relatedChanges = [
-        {
-          commit: {
-            commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-            parents: [
-              {
-                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-            parents: [
-              {
-                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-            parents: [
-              {
-                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-            parents: [
-              {
-                commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-            parents: [
-              {
-                commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-            parents: [
-              {
-                commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75',
-              },
-            ],
-          },
-        },
-      ];
-
-      let connectedChanges =
-          element._computeConnectedRevisions(change, patchNum, relatedChanges);
-      assert.deepEqual(connectedChanges, [
-        '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-        'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-        'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-        'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-        '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-        '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-        '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-      ]);
-
-      patchNum = 4;
-      relatedChanges = [
-        {
-          commit: {
-            commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-            parents: [
-              {
-                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-            parents: [
-              {
-                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-            parents: [
-              {
-                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
-            parents: [
-              {
-                commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-            parents: [
-              {
-                commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
-            parents: [
-              {
-                commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c',
-              },
-            ],
-          },
-        },
-      ];
-
-      connectedChanges =
-          element._computeConnectedRevisions(change, patchNum, relatedChanges);
-      assert.deepEqual(connectedChanges, [
-        'af815dac54318826b7f1fa468acc76349ffc588e',
-        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-        'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
-      ]);
-    });
-
-    test('_computeChangeContainerClass', () => {
-      const change1 = {change_id: 123, _number: 0};
-      const change2 = {change_id: 456, _change_number: 1};
-      const change3 = {change_id: 123, _number: 2};
-
-      assert.notEqual(element._computeChangeContainerClass(
-          change1, change1).indexOf('thisChange'), -1);
-      assert.equal(element._computeChangeContainerClass(
-          change1, change2).indexOf('thisChange'), -1);
-      assert.equal(element._computeChangeContainerClass(
-          change1, change3).indexOf('thisChange'), -1);
-    });
-
-    test('_changesEqual', () => {
-      const change1 = {change_id: 123, _number: 0};
-      const change2 = {change_id: 456, _number: 1};
-      const change3 = {change_id: 123, _number: 2};
-      const change4 = {change_id: 123, _change_number: 1};
-
-      assert.isTrue(element._changesEqual(change1, change1));
-      assert.isFalse(element._changesEqual(change1, change2));
-      assert.isFalse(element._changesEqual(change1, change3));
-      assert.isTrue(element._changesEqual(change2, change4));
-    });
-
-    test('_getChangeNumber', () => {
-      const change1 = {change_id: 123, _number: 0};
-      const change2 = {change_id: 456, _change_number: 1};
-      assert.equal(element._getChangeNumber(change1), 0);
-      assert.equal(element._getChangeNumber(change2), 1);
-    });
-
-    test('event for section loaded fires for each section ', () => {
-      const loadedStub = sandbox.stub();
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'NEW',
-      };
-      element.mergeable = true;
-      element.addEventListener('new-section-loaded', loadedStub);
       sandbox.stub(element, '_getRelatedChanges')
           .returns(Promise.resolve({changes: []}));
       sandbox.stub(element, '_getSubmittedTogether')
@@ -275,283 +302,263 @@
           .returns(Promise.resolve());
       sandbox.stub(element, '_getConflicts')
           .returns(Promise.resolve());
-
-      return element.reload().then(() => {
-        assert.equal(loadedStub.callCount, 4);
-      });
     });
 
-    suite('_getConflicts resolves undefined', () => {
-      let element;
-
-      setup(() => {
-        element = fixture('basic');
-
-        sandbox.stub(element, '_getRelatedChanges')
-            .returns(Promise.resolve({changes: []}));
-        sandbox.stub(element, '_getSubmittedTogether')
-            .returns(Promise.resolve());
-        sandbox.stub(element, '_getCherryPicks')
-            .returns(Promise.resolve());
-        sandbox.stub(element, '_getConflicts')
-            .returns(Promise.resolve());
-      });
-
-      test('_conflicts are an empty array', () => {
-        element.patchNum = 7;
-        element.change = {
-          change_id: 123,
-          status: 'NEW',
-        };
-        element.mergeable = true;
-        element.reload();
-        assert.equal(element._conflicts.length, 0);
-      });
-    });
-
-    suite('get conflicts tests', () => {
-      let element;
-      let conflictsStub;
-
-      setup(() => {
-        element = fixture('basic');
-
-        sandbox.stub(element, '_getRelatedChanges')
-            .returns(Promise.resolve({changes: []}));
-        sandbox.stub(element, '_getSubmittedTogether')
-            .returns(Promise.resolve());
-        sandbox.stub(element, '_getCherryPicks')
-            .returns(Promise.resolve());
-        conflictsStub = sandbox.stub(element, '_getConflicts')
-            .returns(Promise.resolve());
-      });
-
-      test('request conflicts if open and mergeable', () => {
-        element.patchNum = 7;
-        element.change = {
-          change_id: 123,
-          status: 'NEW',
-        };
-        element.mergeable = true;
-        element.reload();
-        assert.isTrue(conflictsStub.called);
-      });
-
-      test('does not request conflicts if closed and mergeable', () => {
-        element.patchNum = 7;
-        element.change = {
-          change_id: 123,
-          status: 'MERGED',
-        };
-        element.reload();
-        assert.isFalse(conflictsStub.called);
-      });
-
-      test('does not request conflicts if open and not mergeable', () => {
-        element.patchNum = 7;
-        element.change = {
-          change_id: 123,
-          status: 'NEW',
-        };
-        element.mergeable = false;
-        element.reload();
-        assert.isFalse(conflictsStub.called);
-      });
-
-      test('doesnt request conflicts if closed and not mergeable', () => {
-        element.patchNum = 7;
-        element.change = {
-          change_id: 123,
-          status: 'MERGED',
-        };
-        element.mergeable = false;
-        element.reload();
-        assert.isFalse(conflictsStub.called);
-      });
-    });
-
-    test('_calculateHasParent', () => {
-      const changeId = 123;
-      const relatedChanges = [];
-
-      assert.equal(element._calculateHasParent(changeId, relatedChanges),
-          false);
-
-      relatedChanges.push({change_id: 123});
-      assert.equal(element._calculateHasParent(changeId, relatedChanges),
-          false);
-
-      relatedChanges.push({change_id: 234});
-      assert.equal(element._calculateHasParent(changeId, relatedChanges),
-          true);
-    });
-
-    suite('hidden attribute and update event', () => {
-      const changes = [{
-        project: 'foo/bar',
-        change_id: 'Ideadbeef',
-        commit: {
-          commit: 'deadbeef',
-          parents: [{commit: 'abc123'}],
-          author: {},
-          subject: 'do that thing',
-        },
-        _change_number: 12345,
-        _revision_number: 1,
-        _current_revision_number: 1,
-        status: 'NEW',
-      }];
-
-      test('clear and empties', () => {
-        element._relatedResponse = {changes};
-        element._submittedTogether = {changes};
-        element._conflicts = changes;
-        element._cherryPicks = changes;
-        element._sameTopic = changes;
-
-        element.hidden = false;
-        element.clear();
-        assert.isTrue(element.hidden);
-        assert.equal(element._relatedResponse.changes.length, 0);
-        assert.equal(element._submittedTogether.changes.length, 0);
-        assert.equal(element._conflicts.length, 0);
-        assert.equal(element._cherryPicks.length, 0);
-        assert.equal(element._sameTopic.length, 0);
-      });
-
-      test('update fires', () => {
-        const updateHandler = sandbox.stub();
-        element.addEventListener('update', updateHandler);
-
-        element._resultsChanged({}, {}, [], [], []);
-        assert.isTrue(element.hidden);
-        assert.isFalse(updateHandler.called);
-
-        element._resultsChanged({}, {}, [], [], ['test']);
-        assert.isFalse(element.hidden);
-        assert.isTrue(updateHandler.called);
-      });
-
-      suite('hiding and unhiding', () => {
-        test('related response', () => {
-          assert.isTrue(element.hidden);
-          element._resultsChanged({changes}, {}, [], [], []);
-          assert.isFalse(element.hidden);
-        });
-
-        test('submitted together', () => {
-          assert.isTrue(element.hidden);
-          element._resultsChanged({}, {changes}, [], [], []);
-          assert.isFalse(element.hidden);
-        });
-
-        test('conflicts', () => {
-          assert.isTrue(element.hidden);
-          element._resultsChanged({}, {}, changes, [], []);
-          assert.isFalse(element.hidden);
-        });
-
-        test('cherrypicks', () => {
-          assert.isTrue(element.hidden);
-          element._resultsChanged({}, {}, [], changes, []);
-          assert.isFalse(element.hidden);
-        });
-
-        test('same topic', () => {
-          assert.isTrue(element.hidden);
-          element._resultsChanged({}, {}, [], [], changes);
-          assert.isFalse(element.hidden);
-        });
-      });
-    });
-
-    test('_computeChangeURL uses Gerrit.Nav', () => {
-      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForChangeById');
-      element._computeChangeURL(123, 'abc/def', 12);
-      assert.isTrue(getUrlStub.called);
-    });
-
-    suite('submitted together changes', () => {
-      const change = {
-        project: 'foo/bar',
-        change_id: 'Ideadbeef',
-        commit: {
-          commit: 'deadbeef',
-          parents: [{commit: 'abc123'}],
-          author: {},
-          subject: 'do that thing',
-        },
-        _change_number: 12345,
-        _revision_number: 1,
-        _current_revision_number: 1,
+    test('_conflicts are an empty array', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
         status: 'NEW',
       };
+      element.mergeable = true;
+      element.reload();
+      assert.equal(element._conflicts.length, 0);
+    });
+  });
 
-      test('_computeSubmittedTogetherClass', () => {
-        assert.strictEqual(
-            element._computeSubmittedTogetherClass(undefined),
-            'hidden');
-        assert.strictEqual(
-            element._computeSubmittedTogetherClass({changes: []}),
-            'hidden');
-        assert.strictEqual(
-            element._computeSubmittedTogetherClass({changes: [{}]}),
-            '');
-        assert.strictEqual(
-            element._computeSubmittedTogetherClass({
-              changes: [],
-              non_visible_changes: 0,
-            }),
-            'hidden');
-        assert.strictEqual(
-            element._computeSubmittedTogetherClass({
-              changes: [],
-              non_visible_changes: 1,
-            }),
-            '');
-        assert.strictEqual(
-            element._computeSubmittedTogetherClass({
-              changes: [{}],
-              non_visible_changes: 1,
-            }),
-            '');
+  suite('get conflicts tests', () => {
+    let element;
+    let conflictsStub;
+
+    setup(() => {
+      element = fixture('basic');
+
+      sandbox.stub(element, '_getRelatedChanges')
+          .returns(Promise.resolve({changes: []}));
+      sandbox.stub(element, '_getSubmittedTogether')
+          .returns(Promise.resolve());
+      sandbox.stub(element, '_getCherryPicks')
+          .returns(Promise.resolve());
+      conflictsStub = sandbox.stub(element, '_getConflicts')
+          .returns(Promise.resolve());
+    });
+
+    test('request conflicts if open and mergeable', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'NEW',
+      };
+      element.mergeable = true;
+      element.reload();
+      assert.isTrue(conflictsStub.called);
+    });
+
+    test('does not request conflicts if closed and mergeable', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'MERGED',
+      };
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('does not request conflicts if open and not mergeable', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'NEW',
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('doesnt request conflicts if closed and not mergeable', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'MERGED',
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+  });
+
+  test('_calculateHasParent', () => {
+    const changeId = 123;
+    const relatedChanges = [];
+
+    assert.equal(element._calculateHasParent(changeId, relatedChanges),
+        false);
+
+    relatedChanges.push({change_id: 123});
+    assert.equal(element._calculateHasParent(changeId, relatedChanges),
+        false);
+
+    relatedChanges.push({change_id: 234});
+    assert.equal(element._calculateHasParent(changeId, relatedChanges),
+        true);
+  });
+
+  suite('hidden attribute and update event', () => {
+    const changes = [{
+      project: 'foo/bar',
+      change_id: 'Ideadbeef',
+      commit: {
+        commit: 'deadbeef',
+        parents: [{commit: 'abc123'}],
+        author: {},
+        subject: 'do that thing',
+      },
+      _change_number: 12345,
+      _revision_number: 1,
+      _current_revision_number: 1,
+      status: 'NEW',
+    }];
+
+    test('clear and empties', () => {
+      element._relatedResponse = {changes};
+      element._submittedTogether = {changes};
+      element._conflicts = changes;
+      element._cherryPicks = changes;
+      element._sameTopic = changes;
+
+      element.hidden = false;
+      element.clear();
+      assert.isTrue(element.hidden);
+      assert.equal(element._relatedResponse.changes.length, 0);
+      assert.equal(element._submittedTogether.changes.length, 0);
+      assert.equal(element._conflicts.length, 0);
+      assert.equal(element._cherryPicks.length, 0);
+      assert.equal(element._sameTopic.length, 0);
+    });
+
+    test('update fires', () => {
+      const updateHandler = sandbox.stub();
+      element.addEventListener('update', updateHandler);
+
+      element._resultsChanged({}, {}, [], [], []);
+      assert.isTrue(element.hidden);
+      assert.isFalse(updateHandler.called);
+
+      element._resultsChanged({}, {}, [], [], ['test']);
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+    });
+
+    suite('hiding and unhiding', () => {
+      test('related response', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({changes}, {}, [], [], []);
+        assert.isFalse(element.hidden);
       });
 
-      test('no submitted together changes', () => {
-        flushAsynchronousOperations();
-        assert.include(element.$.submittedTogether.className, 'hidden');
+      test('submitted together', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({}, {changes}, [], [], []);
+        assert.isFalse(element.hidden);
       });
 
-      test('no non-visible submitted together changes', () => {
-        element._submittedTogether = {changes: [change]};
-        flushAsynchronousOperations();
-        assert.notInclude(element.$.submittedTogether.className, 'hidden');
-        assert.isNull(element.shadowRoot
-            .querySelector('.note'));
+      test('conflicts', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({}, {}, changes, [], []);
+        assert.isFalse(element.hidden);
       });
 
-      test('no visible submitted together changes', () => {
-        // Technically this should never happen, but worth asserting the logic.
-        element._submittedTogether = {changes: [], non_visible_changes: 1};
-        flushAsynchronousOperations();
-        assert.notInclude(element.$.submittedTogether.className, 'hidden');
-        assert.isNotNull(element.shadowRoot
-            .querySelector('.note'));
-        assert.strictEqual(
-            element.shadowRoot
-                .querySelector('.note').innerText, '(+ 1 non-visible change)');
+      test('cherrypicks', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({}, {}, [], changes, []);
+        assert.isFalse(element.hidden);
       });
 
-      test('visible and non-visible submitted together changes', () => {
-        element._submittedTogether = {changes: [change], non_visible_changes: 2};
-        flushAsynchronousOperations();
-        assert.notInclude(element.$.submittedTogether.className, 'hidden');
-        assert.isNotNull(element.shadowRoot
-            .querySelector('.note'));
-        assert.strictEqual(
-            element.shadowRoot
-                .querySelector('.note').innerText, '(+ 2 non-visible changes)');
+      test('same topic', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({}, {}, [], [], changes);
+        assert.isFalse(element.hidden);
       });
     });
   });
+
+  test('_computeChangeURL uses Gerrit.Nav', () => {
+    const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForChangeById');
+    element._computeChangeURL(123, 'abc/def', 12);
+    assert.isTrue(getUrlStub.called);
+  });
+
+  suite('submitted together changes', () => {
+    const change = {
+      project: 'foo/bar',
+      change_id: 'Ideadbeef',
+      commit: {
+        commit: 'deadbeef',
+        parents: [{commit: 'abc123'}],
+        author: {},
+        subject: 'do that thing',
+      },
+      _change_number: 12345,
+      _revision_number: 1,
+      _current_revision_number: 1,
+      status: 'NEW',
+    };
+
+    test('_computeSubmittedTogetherClass', () => {
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass(undefined),
+          'hidden');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({changes: []}),
+          'hidden');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({changes: [{}]}),
+          '');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({
+            changes: [],
+            non_visible_changes: 0,
+          }),
+          'hidden');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({
+            changes: [],
+            non_visible_changes: 1,
+          }),
+          '');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({
+            changes: [{}],
+            non_visible_changes: 1,
+          }),
+          '');
+    });
+
+    test('no submitted together changes', () => {
+      flushAsynchronousOperations();
+      assert.include(element.$.submittedTogether.className, 'hidden');
+    });
+
+    test('no non-visible submitted together changes', () => {
+      element._submittedTogether = {changes: [change]};
+      flushAsynchronousOperations();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isNull(element.shadowRoot
+          .querySelector('.note'));
+    });
+
+    test('no visible submitted together changes', () => {
+      // Technically this should never happen, but worth asserting the logic.
+      element._submittedTogether = {changes: [], non_visible_changes: 1};
+      flushAsynchronousOperations();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isNotNull(element.shadowRoot
+          .querySelector('.note'));
+      assert.strictEqual(
+          element.shadowRoot
+              .querySelector('.note').innerText, '(+ 1 non-visible change)');
+    });
+
+    test('visible and non-visible submitted together changes', () => {
+      element._submittedTogether = {changes: [change], non_visible_changes: 2};
+      flushAsynchronousOperations();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isNotNull(element.shadowRoot
+          .querySelector('.note'));
+      assert.strictEqual(
+          element.shadowRoot
+              .querySelector('.note').innerText, '(+ 2 non-visible changes)');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
index 5fd3795..7e651c5 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reply-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../plugins/gr-plugin-host/gr-plugin-host.html">
-<link rel="import" href="gr-reply-dialog.html">
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../plugins/gr-plugin-host/gr-plugin-host.js"></script>
+<script type="module" src="./gr-reply-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../plugins/gr-plugin-host/gr-plugin-host.js';
+import './gr-reply-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -43,130 +49,134 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-reply-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let changeNum;
-    let patchNum;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../plugins/gr-plugin-host/gr-plugin-host.js';
+import './gr-reply-dialog.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-reply-dialog tests', () => {
+  let element;
+  let changeNum;
+  let patchNum;
 
-    let sandbox;
+  let sandbox;
 
-    const setupElement = element => {
-      element.change = {
-        _number: changeNum,
-        labels: {
-          'Verified': {
-            values: {
-              '-1': 'Fails',
-              ' 0': 'No score',
-              '+1': 'Verified',
-            },
-            default_value: 0,
+  const setupElement = element => {
+    element.change = {
+      _number: changeNum,
+      labels: {
+        'Verified': {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified',
           },
-          'Code-Review': {
-            values: {
-              '-2': 'Do not submit',
-              '-1': 'I would prefer that you didn\'t submit this',
-              ' 0': 'No score',
-              '+1': 'Looks good to me, but someone else must approve',
-              '+2': 'Looks good to me, approved',
-            },
-            all: [{_account_id: 42, value: 0}],
-            default_value: 0,
-          },
+          default_value: 0,
         },
-      };
-      element.patchNum = patchNum;
-      element.permittedLabels = {
-        'Code-Review': [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-        'Verified': [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
-      sandbox.stub(element, 'fetchChangeUpdates')
-          .returns(Promise.resolve({isLatest: true}));
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didn\'t submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          all: [{_account_id: 42, value: 0}],
+          default_value: 0,
+        },
+      },
     };
+    element.patchNum = patchNum;
+    element.permittedLabels = {
+      'Code-Review': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    sandbox.stub(element, 'fetchChangeUpdates')
+        .returns(Promise.resolve({isLatest: true}));
+  };
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
 
-      changeNum = 42;
-      patchNum = 1;
+    changeNum = 42;
+    patchNum = 1;
 
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getAccount() { return Promise.resolve({_account_id: 42}); },
-      });
-
-      element = fixture('basic');
-      setupElement(element);
-
-      // Allow the elements created by dom-repeat to be stamped.
-      flushAsynchronousOperations();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve({_account_id: 42}); },
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    element = fixture('basic');
+    setupElement(element);
 
-    test('_submit blocked when invalid email is supplied to ccs', () => {
-      const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
-      // Stub the below function to avoid side effects from the send promise
-      // resolving.
-      sandbox.stub(element, '_purgeReviewersPendingRemove');
+    // Allow the elements created by dom-repeat to be stamped.
+    flushAsynchronousOperations();
+  });
 
-      element.$.ccs.$.entry.setText('test');
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('gr-button.send'));
-      assert.isFalse(sendStub.called);
-      flushAsynchronousOperations();
+  teardown(() => {
+    sandbox.restore();
+  });
 
-      element.$.ccs.$.entry.setText('test@test.test');
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('gr-button.send'));
-      assert.isTrue(sendStub.called);
-    });
+  test('_submit blocked when invalid email is supplied to ccs', () => {
+    const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
+    // Stub the below function to avoid side effects from the send promise
+    // resolving.
+    sandbox.stub(element, '_purgeReviewersPendingRemove');
 
-    test('lgtm plugin', done => {
-      Gerrit._testOnly_resetPlugins();
-      const pluginHost = fixture('plugin-host');
-      pluginHost.config = {
-        plugin: {
-          js_resource_paths: [],
-          html_resource_paths: [
-            new URL('test/plugin.html?' + Math.random(),
-                window.location.href).toString(),
-          ],
-        },
-      };
-      element = fixture('basic');
-      setupElement(element);
-      const importSpy =
-          sandbox.spy(element.shadowRoot
-              .querySelector('gr-endpoint-decorator'), '_import');
-      Gerrit.awaitPluginsLoaded().then(() => {
-        Promise.all(importSpy.returnValues).then(() => {
-          flush(() => {
-            const textarea = element.$.textarea.getNativeTextarea();
-            textarea.value = 'LGTM';
-            textarea.dispatchEvent(new CustomEvent(
-                'input', {bubbles: true, composed: true}));
-            const labelScoreRows = Polymer.dom(element.$.labelScores.root)
-                .querySelector('gr-label-score-row[name="Code-Review"]');
-            const selectedBtn = Polymer.dom(labelScoreRows.root)
-                .querySelector('gr-button[data-value="+1"].iron-selected');
-            assert.isOk(selectedBtn);
-            done();
-          });
+    element.$.ccs.$.entry.setText('test');
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isFalse(sendStub.called);
+    flushAsynchronousOperations();
+
+    element.$.ccs.$.entry.setText('test@test.test');
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isTrue(sendStub.called);
+  });
+
+  test('lgtm plugin', done => {
+    Gerrit._testOnly_resetPlugins();
+    const pluginHost = fixture('plugin-host');
+    pluginHost.config = {
+      plugin: {
+        js_resource_paths: [],
+        html_resource_paths: [
+          new URL('test/plugin.html?' + Math.random(),
+              window.location.href).toString(),
+        ],
+      },
+    };
+    element = fixture('basic');
+    setupElement(element);
+    const importSpy =
+        sandbox.spy(element.shadowRoot
+            .querySelector('gr-endpoint-decorator'), '_import');
+    Gerrit.awaitPluginsLoaded().then(() => {
+      Promise.all(importSpy.returnValues).then(() => {
+        flush(() => {
+          const textarea = element.$.textarea.getNativeTextarea();
+          textarea.value = 'LGTM';
+          textarea.dispatchEvent(new CustomEvent(
+              'input', {bubbles: true, composed: true}));
+          const labelScoreRows = dom(element.$.labelScores.root)
+              .querySelector('gr-label-score-row[name="Code-Review"]');
+          const selectedBtn = dom(labelScoreRows.root)
+              .querySelector('gr-button[data-value="+1"].iron-selected');
+          assert.isOk(selectedBtn);
+          done();
         });
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index b1a05f5..305505d 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,887 +14,916 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../shared/gr-account-chip/gr-account-chip.js';
+import '../../shared/gr-textarea/gr-textarea.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-formatted-text/gr-formatted-text.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-storage/gr-storage.js';
+import '../../shared/gr-account-list/gr-account-list.js';
+import '../gr-label-scores/gr-label-scores.js';
+import '../gr-thread-list/gr-thread-list.js';
+import '../../../styles/shared-styles.js';
+import '../gr-comment-list/gr-comment-list.js';
+import '../../../scripts/gr-display-name-utils/gr-display-name-utils.js';
+import '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-reply-dialog_html.js';
 
-  const FocusTarget = {
-    ANY: 'any',
-    BODY: 'body',
-    CCS: 'cc',
-    REVIEWERS: 'reviewers',
-  };
+const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
-  const ReviewerTypes = {
-    REVIEWER: 'REVIEWER',
-    CC: 'CC',
-  };
+const FocusTarget = {
+  ANY: 'any',
+  BODY: 'body',
+  CCS: 'cc',
+  REVIEWERS: 'reviewers',
+};
 
-  const LatestPatchState = {
-    LATEST: 'latest',
-    CHECKING: 'checking',
-    NOT_LATEST: 'not-latest',
-  };
+const ReviewerTypes = {
+  REVIEWER: 'REVIEWER',
+  CC: 'CC',
+};
 
-  const ButtonLabels = {
-    START_REVIEW: 'Start review',
-    SEND: 'Send',
-  };
+const LatestPatchState = {
+  LATEST: 'latest',
+  CHECKING: 'checking',
+  NOT_LATEST: 'not-latest',
+};
 
-  const ButtonTooltips = {
-    SAVE: 'Save but do not send notification or change review state',
-    START_REVIEW: 'Mark as ready for review and send reply',
-    SEND: 'Send reply',
-  };
+const ButtonLabels = {
+  START_REVIEW: 'Start review',
+  SEND: 'Send',
+};
 
-  const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
+const ButtonTooltips = {
+  SAVE: 'Save but do not send notification or change review state',
+  START_REVIEW: 'Mark as ready for review and send reply',
+  SEND: 'Send reply',
+};
 
-  const SEND_REPLY_TIMING_LABEL = 'SendReply';
+const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
+
+const SEND_REPLY_TIMING_LABEL = 'SendReply';
+
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @extends Polymer.Element
+ */
+class GrReplyDialog extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+  Gerrit.PatchSetBehavior,
+  Gerrit.RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-reply-dialog'; }
+  /**
+   * Fired when a reply is successfully sent.
+   *
+   * @event send
+   */
 
   /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @extends Polymer.Element
+   * Fired when the user presses the cancel button.
+   *
+   * @event cancel
    */
-  class GrReplyDialog extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-    Gerrit.PatchSetBehavior,
-    Gerrit.RESTClientBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-reply-dialog'; }
+
+  /**
+   * Fired when the main textarea's value changes, which may have triggered
+   * a change in size for the dialog.
+   *
+   * @event autogrow
+   */
+
+  /**
+   * Fires to show an alert when a send is attempted on the non-latest patch.
+   *
+   * @event show-alert
+   */
+
+  /**
+   * Fires when the reply dialog believes that the server side diff drafts
+   * have been updated and need to be refreshed.
+   *
+   * @event comment-refresh
+   */
+
+  /**
+   * Fires when the state of the send button (enabled/disabled) changes.
+   *
+   * @event send-disabled-changed
+   */
+
+  constructor() {
+    super();
+    this.FocusTarget = FocusTarget;
+  }
+
+  static get properties() {
+    return {
     /**
-     * Fired when a reply is successfully sent.
-     *
-     * @event send
+     * @type {{ _number: number, removable_reviewers: Array }}
      */
-
-    /**
-     * Fired when the user presses the cancel button.
-     *
-     * @event cancel
-     */
-
-    /**
-     * Fired when the main textarea's value changes, which may have triggered
-     * a change in size for the dialog.
-     *
-     * @event autogrow
-     */
-
-    /**
-     * Fires to show an alert when a send is attempted on the non-latest patch.
-     *
-     * @event show-alert
-     */
-
-    /**
-     * Fires when the reply dialog believes that the server side diff drafts
-     * have been updated and need to be refreshed.
-     *
-     * @event comment-refresh
-     */
-
-    /**
-     * Fires when the state of the send button (enabled/disabled) changes.
-     *
-     * @event send-disabled-changed
-     */
-
-    constructor() {
-      super();
-      this.FocusTarget = FocusTarget;
-    }
-
-    static get properties() {
-      return {
+      change: Object,
+      patchNum: String,
+      canBeStarted: {
+        type: Boolean,
+        value: false,
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      draft: {
+        type: String,
+        value: '',
+        observer: '_draftChanged',
+      },
+      quote: {
+        type: String,
+        value: '',
+      },
+      /** @type {!Function} */
+      filterReviewerSuggestion: {
+        type: Function,
+        value() {
+          return this._filterReviewerSuggestionGenerator(false);
+        },
+      },
+      /** @type {!Function} */
+      filterCCSuggestion: {
+        type: Function,
+        value() {
+          return this._filterReviewerSuggestionGenerator(true);
+        },
+      },
+      permittedLabels: Object,
       /**
-       * @type {{ _number: number, removable_reviewers: Array }}
+       * @type {{ commentlinks: Array }}
        */
-        change: Object,
-        patchNum: String,
-        canBeStarted: {
-          type: Boolean,
-          value: false,
-        },
-        disabled: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-        draft: {
-          type: String,
-          value: '',
-          observer: '_draftChanged',
-        },
-        quote: {
-          type: String,
-          value: '',
-        },
-        /** @type {!Function} */
-        filterReviewerSuggestion: {
-          type: Function,
-          value() {
-            return this._filterReviewerSuggestionGenerator(false);
-          },
-        },
-        /** @type {!Function} */
-        filterCCSuggestion: {
-          type: Function,
-          value() {
-            return this._filterReviewerSuggestionGenerator(true);
-          },
-        },
-        permittedLabels: Object,
-        /**
-         * @type {{ commentlinks: Array }}
-         */
-        projectConfig: Object,
-        knownLatestState: String,
-        underReview: {
-          type: Boolean,
-          value: true,
-        },
+      projectConfig: Object,
+      knownLatestState: String,
+      underReview: {
+        type: Boolean,
+        value: true,
+      },
 
-        _account: Object,
-        _ccs: Array,
-        /** @type {?Object} */
-        _ccPendingConfirmation: {
-          type: Object,
-          observer: '_reviewerPendingConfirmationUpdated',
+      _account: Object,
+      _ccs: Array,
+      /** @type {?Object} */
+      _ccPendingConfirmation: {
+        type: Object,
+        observer: '_reviewerPendingConfirmationUpdated',
+      },
+      _messagePlaceholder: {
+        type: String,
+        computed: '_computeMessagePlaceholder(canBeStarted)',
+      },
+      _owner: Object,
+      /** @type {?} */
+      _pendingConfirmationDetails: Object,
+      _includeComments: {
+        type: Boolean,
+        value: true,
+      },
+      _reviewers: Array,
+      /** @type {?Object} */
+      _reviewerPendingConfirmation: {
+        type: Object,
+        observer: '_reviewerPendingConfirmationUpdated',
+      },
+      _previewFormatting: {
+        type: Boolean,
+        value: false,
+        observer: '_handleHeightChanged',
+      },
+      _reviewersPendingRemove: {
+        type: Object,
+        value: {
+          CC: [],
+          REVIEWER: [],
         },
-        _messagePlaceholder: {
-          type: String,
-          computed: '_computeMessagePlaceholder(canBeStarted)',
-        },
-        _owner: Object,
-        /** @type {?} */
-        _pendingConfirmationDetails: Object,
-        _includeComments: {
-          type: Boolean,
-          value: true,
-        },
-        _reviewers: Array,
-        /** @type {?Object} */
-        _reviewerPendingConfirmation: {
-          type: Object,
-          observer: '_reviewerPendingConfirmationUpdated',
-        },
-        _previewFormatting: {
-          type: Boolean,
-          value: false,
-          observer: '_handleHeightChanged',
-        },
-        _reviewersPendingRemove: {
-          type: Object,
-          value: {
-            CC: [],
-            REVIEWER: [],
-          },
-        },
-        _sendButtonLabel: {
-          type: String,
-          computed: '_computeSendButtonLabel(canBeStarted)',
-        },
-        _savingComments: Boolean,
-        _reviewersMutated: {
-          type: Boolean,
-          value: false,
-        },
-        _labelsChanged: {
-          type: Boolean,
-          value: false,
-        },
-        _saveTooltip: {
-          type: String,
-          value: ButtonTooltips.SAVE,
-          readOnly: true,
-        },
-        _pluginMessage: {
-          type: String,
-          value: '',
-        },
-        _sendDisabled: {
-          type: Boolean,
-          computed: '_computeSendButtonDisabled(_sendButtonLabel, ' +
-            'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
-            '_includeComments, disabled)',
-          observer: '_sendDisabledChanged',
-        },
-        draftCommentThreads: {
-          type: Array,
-          observer: '_handleHeightChanged',
-        },
-      };
-    }
+      },
+      _sendButtonLabel: {
+        type: String,
+        computed: '_computeSendButtonLabel(canBeStarted)',
+      },
+      _savingComments: Boolean,
+      _reviewersMutated: {
+        type: Boolean,
+        value: false,
+      },
+      _labelsChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _saveTooltip: {
+        type: String,
+        value: ButtonTooltips.SAVE,
+        readOnly: true,
+      },
+      _pluginMessage: {
+        type: String,
+        value: '',
+      },
+      _sendDisabled: {
+        type: Boolean,
+        computed: '_computeSendButtonDisabled(_sendButtonLabel, ' +
+          'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
+          '_includeComments, disabled)',
+        observer: '_sendDisabledChanged',
+      },
+      draftCommentThreads: {
+        type: Array,
+        observer: '_handleHeightChanged',
+      },
+    };
+  }
 
-    get keyBindings() {
-      return {
-        'esc': '_handleEscKey',
-        'ctrl+enter meta+enter': '_handleEnterKey',
-      };
-    }
+  get keyBindings() {
+    return {
+      'esc': '_handleEscKey',
+      'ctrl+enter meta+enter': '_handleEnterKey',
+    };
+  }
 
-    static get observers() {
-      return [
-        '_changeUpdated(change.reviewers.*, change.owner)',
-        '_ccsChanged(_ccs.splices)',
-        '_reviewersChanged(_reviewers.splices)',
-      ];
-    }
+  static get observers() {
+    return [
+      '_changeUpdated(change.reviewers.*, change.owner)',
+      '_ccsChanged(_ccs.splices)',
+      '_reviewersChanged(_reviewers.splices)',
+    ];
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this._getAccount().then(account => {
-        this._account = account || {};
-      });
-    }
+  /** @override */
+  attached() {
+    super.attached();
+    this._getAccount().then(account => {
+      this._account = account || {};
+    });
+  }
 
-    /** @override */
-    ready() {
-      super.ready();
-      this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this);
-    }
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this);
+  }
 
-    open(opt_focusTarget) {
-      this.knownLatestState = LatestPatchState.CHECKING;
-      this.fetchChangeUpdates(this.change, this.$.restAPI)
-          .then(result => {
-            this.knownLatestState = result.isLatest ?
-              LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
-          });
-
-      this._focusOn(opt_focusTarget);
-      if (this.quote && this.quote.length) {
-        // If a reply quote has been provided, use it and clear the property.
-        this.draft = this.quote;
-        this.quote = '';
-      } else {
-        // Otherwise, check for an unsaved draft in localstorage.
-        this.draft = this._loadStoredDraft();
-      }
-      if (this.$.restAPI.hasPendingDiffDrafts()) {
-        this._savingComments = true;
-        this.$.restAPI.awaitPendingDiffDrafts().then(() => {
-          this.fire('comment-refresh');
-          this._savingComments = false;
+  open(opt_focusTarget) {
+    this.knownLatestState = LatestPatchState.CHECKING;
+    this.fetchChangeUpdates(this.change, this.$.restAPI)
+        .then(result => {
+          this.knownLatestState = result.isLatest ?
+            LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
         });
-      }
+
+    this._focusOn(opt_focusTarget);
+    if (this.quote && this.quote.length) {
+      // If a reply quote has been provided, use it and clear the property.
+      this.draft = this.quote;
+      this.quote = '';
+    } else {
+      // Otherwise, check for an unsaved draft in localstorage.
+      this.draft = this._loadStoredDraft();
     }
-
-    focus() {
-      this._focusOn(FocusTarget.ANY);
-    }
-
-    getFocusStops() {
-      const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton;
-      return {
-        start: this.$.reviewers.focusStart,
-        end,
-      };
-    }
-
-    setLabelValue(label, value) {
-      const selectorEl =
-          this.$.labelScores.shadowRoot
-              .querySelector(`gr-label-score-row[name="${label}"]`);
-      if (!selectorEl) { return; }
-      selectorEl.setSelectedValue(value);
-    }
-
-    getLabelValue(label) {
-      const selectorEl =
-          this.$.labelScores.shadowRoot
-              .querySelector(`gr-label-score-row[name="${label}"]`);
-      if (!selectorEl) { return null; }
-
-      return selectorEl.selectedValue;
-    }
-
-    _handleEscKey(e) {
-      this.cancel();
-    }
-
-    _handleEnterKey(e) {
-      this._submit();
-    }
-
-    _ccsChanged(splices) {
-      this._reviewerTypeChanged(splices, ReviewerTypes.CC);
-    }
-
-    _reviewersChanged(splices) {
-      this._reviewerTypeChanged(splices, ReviewerTypes.REVIEWER);
-    }
-
-    _reviewerTypeChanged(splices, reviewerType) {
-      if (splices && splices.indexSplices) {
-        this._reviewersMutated = true;
-        this._processReviewerChange(splices.indexSplices,
-            reviewerType);
-        let key;
-        let index;
-        let account;
-        // Remove any accounts that already exist as a CC for reviewer
-        // or vice versa.
-        const isReviewer = ReviewerTypes.REVIEWER === reviewerType;
-        for (const splice of splices.indexSplices) {
-          for (let i = 0; i < splice.addedCount; i++) {
-            account = splice.object[splice.index + i];
-            key = this._accountOrGroupKey(account);
-            const array = isReviewer ? this._ccs : this._reviewers;
-            index = array.findIndex(
-                account => this._accountOrGroupKey(account) === key);
-            if (index >= 0) {
-              this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
-              const moveFrom = isReviewer ? 'CC' : 'reviewer';
-              const moveTo = isReviewer ? 'reviewer' : 'CC';
-              const message = (account.name || account.email || key) +
-                  ` moved from ${moveFrom} to ${moveTo}.`;
-              this.fire('show-alert', {message});
-            }
-          }
-        }
-      }
-    }
-
-    _processReviewerChange(indexSplices, type) {
-      for (const splice of indexSplices) {
-        for (const account of splice.removed) {
-          if (!this._reviewersPendingRemove[type]) {
-            console.err('Invalid type ' + type + ' for reviewer.');
-            return;
-          }
-          this._reviewersPendingRemove[type].push(account);
-        }
-      }
-    }
-
-    /**
-     * Resets the state of the _reviewersPendingRemove object, and removes
-     * accounts if necessary.
-     *
-     * @param {boolean} isCancel true if the action is a cancel.
-     * @param {Object=} opt_accountIdsTransferred map of account IDs that must
-     *     not be removed, because they have been readded in another state.
-     */
-    _purgeReviewersPendingRemove(isCancel, opt_accountIdsTransferred) {
-      let reviewerArr;
-      const keep = opt_accountIdsTransferred || {};
-      for (const type in this._reviewersPendingRemove) {
-        if (this._reviewersPendingRemove.hasOwnProperty(type)) {
-          if (!isCancel) {
-            reviewerArr = this._reviewersPendingRemove[type];
-            for (let i = 0; i < reviewerArr.length; i++) {
-              if (!keep[reviewerArr[i]._account_id]) {
-                this._removeAccount(reviewerArr[i], type);
-              }
-            }
-          }
-          this._reviewersPendingRemove[type] = [];
-        }
-      }
-    }
-
-    /**
-     * Removes an account from the change, both on the backend and the client.
-     * Does nothing if the account is a pending addition.
-     *
-     * @param {!Object} account
-     * @param {string} type
-     */
-    _removeAccount(account, type) {
-      if (account._pendingAdd) { return; }
-
-      return this.$.restAPI.removeChangeReviewer(this.change._number,
-          account._account_id).then(response => {
-        if (!response.ok) { return response; }
-
-        const reviewers = this.change.reviewers[type] || [];
-        for (let i = 0; i < reviewers.length; i++) {
-          if (reviewers[i]._account_id == account._account_id) {
-            this.splice(`change.reviewers.${type}`, i, 1);
-            break;
-          }
-        }
+    if (this.$.restAPI.hasPendingDiffDrafts()) {
+      this._savingComments = true;
+      this.$.restAPI.awaitPendingDiffDrafts().then(() => {
+        this.fire('comment-refresh');
+        this._savingComments = false;
       });
     }
-
-    _mapReviewer(reviewer) {
-      let reviewerId;
-      let confirmed;
-      if (reviewer.account) {
-        reviewerId = reviewer.account._account_id || reviewer.account.email;
-      } else if (reviewer.group) {
-        reviewerId = reviewer.group.id;
-        confirmed = reviewer.group.confirmed;
-      }
-      return {reviewer: reviewerId, confirmed};
-    }
-
-    send(includeComments, startReview) {
-      this.$.reporting.time(SEND_REPLY_TIMING_LABEL);
-      const labels = this.$.labelScores.getLabelValues();
-
-      const obj = {
-        drafts: includeComments ? 'PUBLISH_ALL_REVISIONS' : 'KEEP',
-        labels,
-      };
-
-      if (startReview) {
-        obj.ready = true;
-      }
-
-      if (this.draft != null) {
-        obj.message = this.draft;
-      }
-
-      const accountAdditions = {};
-      obj.reviewers = this.$.reviewers.additions().map(reviewer => {
-        if (reviewer.account) {
-          accountAdditions[reviewer.account._account_id] = true;
-        }
-        return this._mapReviewer(reviewer);
-      });
-      const ccsEl = this.$.ccs;
-      if (ccsEl) {
-        for (let reviewer of ccsEl.additions()) {
-          if (reviewer.account) {
-            accountAdditions[reviewer.account._account_id] = true;
-          }
-          reviewer = this._mapReviewer(reviewer);
-          reviewer.state = 'CC';
-          obj.reviewers.push(reviewer);
-        }
-      }
-
-      this.disabled = true;
-
-      const errFn = this._handle400Error.bind(this);
-      return this._saveReview(obj, errFn)
-          .then(response => {
-            if (!response) {
-              // Null or undefined response indicates that an error handler
-              // took responsibility, so just return.
-              return {};
-            }
-            if (!response.ok) {
-              this.fire('server-error', {response});
-              return {};
-            }
-
-            this.draft = '';
-            this._includeComments = true;
-            this.fire('send', null, {bubbles: false});
-            return accountAdditions;
-          })
-          .then(result => {
-            this.disabled = false;
-            return result;
-          })
-          .catch(err => {
-            this.disabled = false;
-            throw err;
-          });
-    }
-
-    _focusOn(section) {
-      // Safeguard- always want to focus on something.
-      if (!section || section === FocusTarget.ANY) {
-        section = this._chooseFocusTarget();
-      }
-      if (section === FocusTarget.BODY) {
-        const textarea = this.$.textarea;
-        textarea.async(textarea.getNativeTextarea()
-            .focus.bind(textarea.getNativeTextarea()));
-      } else if (section === FocusTarget.REVIEWERS) {
-        const reviewerEntry = this.$.reviewers.focusStart;
-        reviewerEntry.async(reviewerEntry.focus);
-      } else if (section === FocusTarget.CCS) {
-        const ccEntry = this.$.ccs.focusStart;
-        ccEntry.async(ccEntry.focus);
-      }
-    }
-
-    _chooseFocusTarget() {
-      // If we are the owner and the reviewers field is empty, focus on that.
-      if (this._account && this.change && this.change.owner &&
-          this._account._account_id === this.change.owner._account_id &&
-          (!this._reviewers || this._reviewers.length === 0)) {
-        return FocusTarget.REVIEWERS;
-      }
-
-      // Default to BODY.
-      return FocusTarget.BODY;
-    }
-
-    _handle400Error(response) {
-      // A call to _saveReview could fail with a server error if erroneous
-      // reviewers were requested. This is signalled with a 400 Bad Request
-      // status. The default gr-rest-api-interface error handling would
-      // result in a large JSON response body being displayed to the user in
-      // the gr-error-manager toast.
-      //
-      // We can modify the error handling behavior by passing this function
-      // through to restAPI as a custom error handling function. Since we're
-      // short-circuiting restAPI we can do our own response parsing and fire
-      // the server-error ourselves.
-      //
-      this.disabled = false;
-
-      // Using response.clone() here, because getResponseObject() and
-      // potentially the generic error handler will want to call text() on the
-      // response object, which can only be done once per object.
-      const jsonPromise = this.$.restAPI.getResponseObject(response.clone());
-      return jsonPromise.then(result => {
-        // Only perform custom error handling for 400s and a parseable
-        // ReviewResult response.
-        if (response.status === 400 && result) {
-          const errors = [];
-          for (const state of ['reviewers', 'ccs']) {
-            if (!result.hasOwnProperty(state)) { continue; }
-            for (const reviewer of Object.values(result[state])) {
-              if (reviewer.error) {
-                errors.push(reviewer.error);
-              }
-            }
-          }
-          response = {
-            ok: false,
-            status: response.status,
-            text() { return Promise.resolve(errors.join(', ')); },
-          };
-        }
-        this.fire('server-error', {response});
-        return null; // Means that the error has been handled.
-      });
-    }
-
-    _computeHideDraftList(draftCommentThreads) {
-      return draftCommentThreads.length === 0;
-    }
-
-    _computeDraftsTitle(draftCommentThreads) {
-      const total = draftCommentThreads.length;
-      if (total == 0) { return ''; }
-      if (total == 1) { return '1 Draft'; }
-      if (total > 1) { return total + ' Drafts'; }
-    }
-
-    _computeMessagePlaceholder(canBeStarted) {
-      return canBeStarted ?
-        'Add a note for your reviewers...' :
-        'Say something nice...';
-    }
-
-    _changeUpdated(changeRecord, owner) {
-      // Polymer 2: check for undefined
-      if ([changeRecord, owner].some(arg => arg === undefined)) {
-        return;
-      }
-
-      this._rebuildReviewerArrays(changeRecord.base, owner);
-    }
-
-    _rebuildReviewerArrays(change, owner) {
-      this._owner = owner;
-
-      const reviewers = [];
-      const ccs = [];
-
-      for (const key in change) {
-        if (change.hasOwnProperty(key)) {
-          if (key !== 'REVIEWER' && key !== 'CC') {
-            console.warn('unexpected reviewer state:', key);
-            continue;
-          }
-          for (const entry of change[key]) {
-            if (entry._account_id === owner._account_id) {
-              continue;
-            }
-            switch (key) {
-              case 'REVIEWER':
-                reviewers.push(entry);
-                break;
-              case 'CC':
-                ccs.push(entry);
-                break;
-            }
-          }
-        }
-      }
-
-      this._ccs = ccs;
-      this._reviewers = reviewers;
-    }
-
-    _accountOrGroupKey(entry) {
-      return entry.id || entry._account_id;
-    }
-
-    /**
-     * Generates a function to filter out reviewer/CC entries. When isCCs is
-     * truthy, the function filters out entries that already exist in this._ccs.
-     * When falsy, the function filters entries that exist in this._reviewers.
-     *
-     * @param {boolean} isCCs
-     * @return {!Function}
-     */
-    _filterReviewerSuggestionGenerator(isCCs) {
-      return suggestion => {
-        let entry;
-        if (suggestion.account) {
-          entry = suggestion.account;
-        } else if (suggestion.group) {
-          entry = suggestion.group;
-        } else {
-          console.warn(
-              'received suggestion that was neither account nor group:',
-              suggestion);
-        }
-        if (entry._account_id === this._owner._account_id) {
-          return false;
-        }
-
-        const key = this._accountOrGroupKey(entry);
-        const finder = entry => this._accountOrGroupKey(entry) === key;
-        if (isCCs) {
-          return this._ccs.find(finder) === undefined;
-        }
-        return this._reviewers.find(finder) === undefined;
-      };
-    }
-
-    _getAccount() {
-      return this.$.restAPI.getAccount();
-    }
-
-    _cancelTapHandler(e) {
-      e.preventDefault();
-      this.cancel();
-    }
-
-    cancel() {
-      this.fire('cancel', null, {bubbles: false});
-      this.$.textarea.closeDropdown();
-      this._purgeReviewersPendingRemove(true);
-      this._rebuildReviewerArrays(this.change.reviewers, this._owner);
-    }
-
-    _saveClickHandler(e) {
-      e.preventDefault();
-      if (!this.$.ccs.submitEntryText()) {
-        // Do not proceed with the save if there is an invalid email entry in
-        // the text field of the CC entry.
-        return;
-      }
-      this.send(this._includeComments, false).then(keepReviewers => {
-        this._purgeReviewersPendingRemove(false, keepReviewers);
-      });
-    }
-
-    _sendTapHandler(e) {
-      e.preventDefault();
-      this._submit();
-    }
-
-    _submit() {
-      if (!this.$.ccs.submitEntryText()) {
-        // Do not proceed with the send if there is an invalid email entry in
-        // the text field of the CC entry.
-        return;
-      }
-      if (this._sendDisabled) {
-        this.dispatchEvent(new CustomEvent('show-alert', {
-          bubbles: true,
-          composed: true,
-          detail: {message: EMPTY_REPLY_MESSAGE},
-        }));
-        return;
-      }
-      return this.send(this._includeComments, this.canBeStarted)
-          .then(keepReviewers => {
-            this._purgeReviewersPendingRemove(false, keepReviewers);
-          })
-          .catch(err => {
-            this.dispatchEvent(new CustomEvent('show-error', {
-              bubbles: true,
-              composed: true,
-              detail: {message: `Error submitting review ${err}`},
-            }));
-          });
-    }
-
-    _saveReview(review, opt_errFn) {
-      return this.$.restAPI.saveChangeReview(this.change._number, this.patchNum,
-          review, opt_errFn);
-    }
-
-    _reviewerPendingConfirmationUpdated(reviewer) {
-      if (reviewer === null) {
-        this.$.reviewerConfirmationOverlay.close();
-      } else {
-        this._pendingConfirmationDetails =
-            this._ccPendingConfirmation || this._reviewerPendingConfirmation;
-        this.$.reviewerConfirmationOverlay.open();
-      }
-    }
-
-    _confirmPendingReviewer() {
-      if (this._ccPendingConfirmation) {
-        this.$.ccs.confirmGroup(this._ccPendingConfirmation.group);
-        this._focusOn(FocusTarget.CCS);
-      } else {
-        this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
-        this._focusOn(FocusTarget.REVIEWERS);
-      }
-    }
-
-    _cancelPendingReviewer() {
-      this._ccPendingConfirmation = null;
-      this._reviewerPendingConfirmation = null;
-
-      const target =
-          this._ccPendingConfirmation ? FocusTarget.CCS : FocusTarget.REVIEWERS;
-      this._focusOn(target);
-    }
-
-    _getStorageLocation() {
-      // Tests trigger this method without setting change.
-      if (!this.change) { return {}; }
-      return {
-        changeNum: this.change._number,
-        patchNum: '@change',
-        path: '@change',
-      };
-    }
-
-    _loadStoredDraft() {
-      const draft = this.$.storage.getDraftComment(this._getStorageLocation());
-      return draft ? draft.message : '';
-    }
-
-    _handleAccountTextEntry() {
-      // When either of the account entries has input added to the autocomplete,
-      // it should trigger the save button to enable/
-      //
-      // Note: if the text is removed, the save button will not get disabled.
-      this._reviewersMutated = true;
-    }
-
-    _draftChanged(newDraft, oldDraft) {
-      this.debounce('store', () => {
-        if (!newDraft.length && oldDraft) {
-          // If the draft has been modified to be empty, then erase the storage
-          // entry.
-          this.$.storage.eraseDraftComment(this._getStorageLocation());
-        } else if (newDraft.length) {
-          this.$.storage.setDraftComment(this._getStorageLocation(),
-              this.draft);
-        }
-      }, STORAGE_DEBOUNCE_INTERVAL_MS);
-    }
-
-    _handleHeightChanged(e) {
-      this.fire('autogrow');
-    }
-
-    _handleLabelsChanged() {
-      this._labelsChanged = Object.keys(
-          this.$.labelScores.getLabelValues()).length !== 0;
-    }
-
-    _isState(knownLatestState, value) {
-      return knownLatestState === value;
-    }
-
-    _reload() {
-      // Load the current change without any patch range.
-      Gerrit.Nav.navigateToChange(this.change);
-      this.cancel();
-    }
-
-    _computeSendButtonLabel(canBeStarted) {
-      return canBeStarted ? ButtonLabels.START_REVIEW : ButtonLabels.SEND;
-    }
-
-    _computeSendButtonTooltip(canBeStarted) {
-      return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
-    }
-
-    _computeSavingLabelClass(savingComments) {
-      return savingComments ? 'saving' : '';
-    }
-
-    _computeSendButtonDisabled(
-        buttonLabel, draftCommentThreads, text, reviewersMutated,
-        labelsChanged, includeComments, disabled) {
-      // Polymer 2: check for undefined
-      if ([
-        buttonLabel,
-        draftCommentThreads,
-        text,
-        reviewersMutated,
-        labelsChanged,
-        includeComments,
-        disabled,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      if (disabled) { return true; }
-      if (buttonLabel === ButtonLabels.START_REVIEW) { return false; }
-      const hasDrafts = includeComments && draftCommentThreads.length;
-      return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
-    }
-
-    _computePatchSetWarning(patchNum, labelsChanged) {
-      let str = `Patch ${patchNum} is not latest.`;
-      if (labelsChanged) {
-        str += ' Voting on a non-latest patch will have no effect.';
-      }
-      return str;
-    }
-
-    setPluginMessage(message) {
-      this._pluginMessage = message;
-    }
-
-    _sendDisabledChanged(sendDisabled) {
-      this.dispatchEvent(new CustomEvent('send-disabled-changed'));
-    }
-
-    _getReviewerSuggestionsProvider(change) {
-      const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
-          change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
-      provider.init();
-      return provider;
-    }
-
-    _getCcSuggestionsProvider(change) {
-      const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
-          change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.CC);
-      provider.init();
-      return provider;
-    }
-
-    _onThreadListModified() {
-      // TODO(taoalpha): this won't propogate the changes to the files
-      // should consider replacing this with either top level events
-      // or gerrit level events
-
-      // emit the event so change-view can also get updated with latest changes
-      this.fire('comment-refresh');
-    }
   }
 
-  customElements.define(GrReplyDialog.is, GrReplyDialog);
-})();
+  focus() {
+    this._focusOn(FocusTarget.ANY);
+  }
+
+  getFocusStops() {
+    const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton;
+    return {
+      start: this.$.reviewers.focusStart,
+      end,
+    };
+  }
+
+  setLabelValue(label, value) {
+    const selectorEl =
+        this.$.labelScores.shadowRoot
+            .querySelector(`gr-label-score-row[name="${label}"]`);
+    if (!selectorEl) { return; }
+    selectorEl.setSelectedValue(value);
+  }
+
+  getLabelValue(label) {
+    const selectorEl =
+        this.$.labelScores.shadowRoot
+            .querySelector(`gr-label-score-row[name="${label}"]`);
+    if (!selectorEl) { return null; }
+
+    return selectorEl.selectedValue;
+  }
+
+  _handleEscKey(e) {
+    this.cancel();
+  }
+
+  _handleEnterKey(e) {
+    this._submit();
+  }
+
+  _ccsChanged(splices) {
+    this._reviewerTypeChanged(splices, ReviewerTypes.CC);
+  }
+
+  _reviewersChanged(splices) {
+    this._reviewerTypeChanged(splices, ReviewerTypes.REVIEWER);
+  }
+
+  _reviewerTypeChanged(splices, reviewerType) {
+    if (splices && splices.indexSplices) {
+      this._reviewersMutated = true;
+      this._processReviewerChange(splices.indexSplices,
+          reviewerType);
+      let key;
+      let index;
+      let account;
+      // Remove any accounts that already exist as a CC for reviewer
+      // or vice versa.
+      const isReviewer = ReviewerTypes.REVIEWER === reviewerType;
+      for (const splice of splices.indexSplices) {
+        for (let i = 0; i < splice.addedCount; i++) {
+          account = splice.object[splice.index + i];
+          key = this._accountOrGroupKey(account);
+          const array = isReviewer ? this._ccs : this._reviewers;
+          index = array.findIndex(
+              account => this._accountOrGroupKey(account) === key);
+          if (index >= 0) {
+            this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
+            const moveFrom = isReviewer ? 'CC' : 'reviewer';
+            const moveTo = isReviewer ? 'reviewer' : 'CC';
+            const message = (account.name || account.email || key) +
+                ` moved from ${moveFrom} to ${moveTo}.`;
+            this.fire('show-alert', {message});
+          }
+        }
+      }
+    }
+  }
+
+  _processReviewerChange(indexSplices, type) {
+    for (const splice of indexSplices) {
+      for (const account of splice.removed) {
+        if (!this._reviewersPendingRemove[type]) {
+          console.err('Invalid type ' + type + ' for reviewer.');
+          return;
+        }
+        this._reviewersPendingRemove[type].push(account);
+      }
+    }
+  }
+
+  /**
+   * Resets the state of the _reviewersPendingRemove object, and removes
+   * accounts if necessary.
+   *
+   * @param {boolean} isCancel true if the action is a cancel.
+   * @param {Object=} opt_accountIdsTransferred map of account IDs that must
+   *     not be removed, because they have been readded in another state.
+   */
+  _purgeReviewersPendingRemove(isCancel, opt_accountIdsTransferred) {
+    let reviewerArr;
+    const keep = opt_accountIdsTransferred || {};
+    for (const type in this._reviewersPendingRemove) {
+      if (this._reviewersPendingRemove.hasOwnProperty(type)) {
+        if (!isCancel) {
+          reviewerArr = this._reviewersPendingRemove[type];
+          for (let i = 0; i < reviewerArr.length; i++) {
+            if (!keep[reviewerArr[i]._account_id]) {
+              this._removeAccount(reviewerArr[i], type);
+            }
+          }
+        }
+        this._reviewersPendingRemove[type] = [];
+      }
+    }
+  }
+
+  /**
+   * Removes an account from the change, both on the backend and the client.
+   * Does nothing if the account is a pending addition.
+   *
+   * @param {!Object} account
+   * @param {string} type
+   */
+  _removeAccount(account, type) {
+    if (account._pendingAdd) { return; }
+
+    return this.$.restAPI.removeChangeReviewer(this.change._number,
+        account._account_id).then(response => {
+      if (!response.ok) { return response; }
+
+      const reviewers = this.change.reviewers[type] || [];
+      for (let i = 0; i < reviewers.length; i++) {
+        if (reviewers[i]._account_id == account._account_id) {
+          this.splice(`change.reviewers.${type}`, i, 1);
+          break;
+        }
+      }
+    });
+  }
+
+  _mapReviewer(reviewer) {
+    let reviewerId;
+    let confirmed;
+    if (reviewer.account) {
+      reviewerId = reviewer.account._account_id || reviewer.account.email;
+    } else if (reviewer.group) {
+      reviewerId = reviewer.group.id;
+      confirmed = reviewer.group.confirmed;
+    }
+    return {reviewer: reviewerId, confirmed};
+  }
+
+  send(includeComments, startReview) {
+    this.$.reporting.time(SEND_REPLY_TIMING_LABEL);
+    const labels = this.$.labelScores.getLabelValues();
+
+    const obj = {
+      drafts: includeComments ? 'PUBLISH_ALL_REVISIONS' : 'KEEP',
+      labels,
+    };
+
+    if (startReview) {
+      obj.ready = true;
+    }
+
+    if (this.draft != null) {
+      obj.message = this.draft;
+    }
+
+    const accountAdditions = {};
+    obj.reviewers = this.$.reviewers.additions().map(reviewer => {
+      if (reviewer.account) {
+        accountAdditions[reviewer.account._account_id] = true;
+      }
+      return this._mapReviewer(reviewer);
+    });
+    const ccsEl = this.$.ccs;
+    if (ccsEl) {
+      for (let reviewer of ccsEl.additions()) {
+        if (reviewer.account) {
+          accountAdditions[reviewer.account._account_id] = true;
+        }
+        reviewer = this._mapReviewer(reviewer);
+        reviewer.state = 'CC';
+        obj.reviewers.push(reviewer);
+      }
+    }
+
+    this.disabled = true;
+
+    const errFn = this._handle400Error.bind(this);
+    return this._saveReview(obj, errFn)
+        .then(response => {
+          if (!response) {
+            // Null or undefined response indicates that an error handler
+            // took responsibility, so just return.
+            return {};
+          }
+          if (!response.ok) {
+            this.fire('server-error', {response});
+            return {};
+          }
+
+          this.draft = '';
+          this._includeComments = true;
+          this.fire('send', null, {bubbles: false});
+          return accountAdditions;
+        })
+        .then(result => {
+          this.disabled = false;
+          return result;
+        })
+        .catch(err => {
+          this.disabled = false;
+          throw err;
+        });
+  }
+
+  _focusOn(section) {
+    // Safeguard- always want to focus on something.
+    if (!section || section === FocusTarget.ANY) {
+      section = this._chooseFocusTarget();
+    }
+    if (section === FocusTarget.BODY) {
+      const textarea = this.$.textarea;
+      textarea.async(textarea.getNativeTextarea()
+          .focus.bind(textarea.getNativeTextarea()));
+    } else if (section === FocusTarget.REVIEWERS) {
+      const reviewerEntry = this.$.reviewers.focusStart;
+      reviewerEntry.async(reviewerEntry.focus);
+    } else if (section === FocusTarget.CCS) {
+      const ccEntry = this.$.ccs.focusStart;
+      ccEntry.async(ccEntry.focus);
+    }
+  }
+
+  _chooseFocusTarget() {
+    // If we are the owner and the reviewers field is empty, focus on that.
+    if (this._account && this.change && this.change.owner &&
+        this._account._account_id === this.change.owner._account_id &&
+        (!this._reviewers || this._reviewers.length === 0)) {
+      return FocusTarget.REVIEWERS;
+    }
+
+    // Default to BODY.
+    return FocusTarget.BODY;
+  }
+
+  _handle400Error(response) {
+    // A call to _saveReview could fail with a server error if erroneous
+    // reviewers were requested. This is signalled with a 400 Bad Request
+    // status. The default gr-rest-api-interface error handling would
+    // result in a large JSON response body being displayed to the user in
+    // the gr-error-manager toast.
+    //
+    // We can modify the error handling behavior by passing this function
+    // through to restAPI as a custom error handling function. Since we're
+    // short-circuiting restAPI we can do our own response parsing and fire
+    // the server-error ourselves.
+    //
+    this.disabled = false;
+
+    // Using response.clone() here, because getResponseObject() and
+    // potentially the generic error handler will want to call text() on the
+    // response object, which can only be done once per object.
+    const jsonPromise = this.$.restAPI.getResponseObject(response.clone());
+    return jsonPromise.then(result => {
+      // Only perform custom error handling for 400s and a parseable
+      // ReviewResult response.
+      if (response.status === 400 && result) {
+        const errors = [];
+        for (const state of ['reviewers', 'ccs']) {
+          if (!result.hasOwnProperty(state)) { continue; }
+          for (const reviewer of Object.values(result[state])) {
+            if (reviewer.error) {
+              errors.push(reviewer.error);
+            }
+          }
+        }
+        response = {
+          ok: false,
+          status: response.status,
+          text() { return Promise.resolve(errors.join(', ')); },
+        };
+      }
+      this.fire('server-error', {response});
+      return null; // Means that the error has been handled.
+    });
+  }
+
+  _computeHideDraftList(draftCommentThreads) {
+    return draftCommentThreads.length === 0;
+  }
+
+  _computeDraftsTitle(draftCommentThreads) {
+    const total = draftCommentThreads.length;
+    if (total == 0) { return ''; }
+    if (total == 1) { return '1 Draft'; }
+    if (total > 1) { return total + ' Drafts'; }
+  }
+
+  _computeMessagePlaceholder(canBeStarted) {
+    return canBeStarted ?
+      'Add a note for your reviewers...' :
+      'Say something nice...';
+  }
+
+  _changeUpdated(changeRecord, owner) {
+    // Polymer 2: check for undefined
+    if ([changeRecord, owner].some(arg => arg === undefined)) {
+      return;
+    }
+
+    this._rebuildReviewerArrays(changeRecord.base, owner);
+  }
+
+  _rebuildReviewerArrays(change, owner) {
+    this._owner = owner;
+
+    const reviewers = [];
+    const ccs = [];
+
+    for (const key in change) {
+      if (change.hasOwnProperty(key)) {
+        if (key !== 'REVIEWER' && key !== 'CC') {
+          console.warn('unexpected reviewer state:', key);
+          continue;
+        }
+        for (const entry of change[key]) {
+          if (entry._account_id === owner._account_id) {
+            continue;
+          }
+          switch (key) {
+            case 'REVIEWER':
+              reviewers.push(entry);
+              break;
+            case 'CC':
+              ccs.push(entry);
+              break;
+          }
+        }
+      }
+    }
+
+    this._ccs = ccs;
+    this._reviewers = reviewers;
+  }
+
+  _accountOrGroupKey(entry) {
+    return entry.id || entry._account_id;
+  }
+
+  /**
+   * Generates a function to filter out reviewer/CC entries. When isCCs is
+   * truthy, the function filters out entries that already exist in this._ccs.
+   * When falsy, the function filters entries that exist in this._reviewers.
+   *
+   * @param {boolean} isCCs
+   * @return {!Function}
+   */
+  _filterReviewerSuggestionGenerator(isCCs) {
+    return suggestion => {
+      let entry;
+      if (suggestion.account) {
+        entry = suggestion.account;
+      } else if (suggestion.group) {
+        entry = suggestion.group;
+      } else {
+        console.warn(
+            'received suggestion that was neither account nor group:',
+            suggestion);
+      }
+      if (entry._account_id === this._owner._account_id) {
+        return false;
+      }
+
+      const key = this._accountOrGroupKey(entry);
+      const finder = entry => this._accountOrGroupKey(entry) === key;
+      if (isCCs) {
+        return this._ccs.find(finder) === undefined;
+      }
+      return this._reviewers.find(finder) === undefined;
+    };
+  }
+
+  _getAccount() {
+    return this.$.restAPI.getAccount();
+  }
+
+  _cancelTapHandler(e) {
+    e.preventDefault();
+    this.cancel();
+  }
+
+  cancel() {
+    this.fire('cancel', null, {bubbles: false});
+    this.$.textarea.closeDropdown();
+    this._purgeReviewersPendingRemove(true);
+    this._rebuildReviewerArrays(this.change.reviewers, this._owner);
+  }
+
+  _saveClickHandler(e) {
+    e.preventDefault();
+    if (!this.$.ccs.submitEntryText()) {
+      // Do not proceed with the save if there is an invalid email entry in
+      // the text field of the CC entry.
+      return;
+    }
+    this.send(this._includeComments, false).then(keepReviewers => {
+      this._purgeReviewersPendingRemove(false, keepReviewers);
+    });
+  }
+
+  _sendTapHandler(e) {
+    e.preventDefault();
+    this._submit();
+  }
+
+  _submit() {
+    if (!this.$.ccs.submitEntryText()) {
+      // Do not proceed with the send if there is an invalid email entry in
+      // the text field of the CC entry.
+      return;
+    }
+    if (this._sendDisabled) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        bubbles: true,
+        composed: true,
+        detail: {message: EMPTY_REPLY_MESSAGE},
+      }));
+      return;
+    }
+    return this.send(this._includeComments, this.canBeStarted)
+        .then(keepReviewers => {
+          this._purgeReviewersPendingRemove(false, keepReviewers);
+        })
+        .catch(err => {
+          this.dispatchEvent(new CustomEvent('show-error', {
+            bubbles: true,
+            composed: true,
+            detail: {message: `Error submitting review ${err}`},
+          }));
+        });
+  }
+
+  _saveReview(review, opt_errFn) {
+    return this.$.restAPI.saveChangeReview(this.change._number, this.patchNum,
+        review, opt_errFn);
+  }
+
+  _reviewerPendingConfirmationUpdated(reviewer) {
+    if (reviewer === null) {
+      this.$.reviewerConfirmationOverlay.close();
+    } else {
+      this._pendingConfirmationDetails =
+          this._ccPendingConfirmation || this._reviewerPendingConfirmation;
+      this.$.reviewerConfirmationOverlay.open();
+    }
+  }
+
+  _confirmPendingReviewer() {
+    if (this._ccPendingConfirmation) {
+      this.$.ccs.confirmGroup(this._ccPendingConfirmation.group);
+      this._focusOn(FocusTarget.CCS);
+    } else {
+      this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
+      this._focusOn(FocusTarget.REVIEWERS);
+    }
+  }
+
+  _cancelPendingReviewer() {
+    this._ccPendingConfirmation = null;
+    this._reviewerPendingConfirmation = null;
+
+    const target =
+        this._ccPendingConfirmation ? FocusTarget.CCS : FocusTarget.REVIEWERS;
+    this._focusOn(target);
+  }
+
+  _getStorageLocation() {
+    // Tests trigger this method without setting change.
+    if (!this.change) { return {}; }
+    return {
+      changeNum: this.change._number,
+      patchNum: '@change',
+      path: '@change',
+    };
+  }
+
+  _loadStoredDraft() {
+    const draft = this.$.storage.getDraftComment(this._getStorageLocation());
+    return draft ? draft.message : '';
+  }
+
+  _handleAccountTextEntry() {
+    // When either of the account entries has input added to the autocomplete,
+    // it should trigger the save button to enable/
+    //
+    // Note: if the text is removed, the save button will not get disabled.
+    this._reviewersMutated = true;
+  }
+
+  _draftChanged(newDraft, oldDraft) {
+    this.debounce('store', () => {
+      if (!newDraft.length && oldDraft) {
+        // If the draft has been modified to be empty, then erase the storage
+        // entry.
+        this.$.storage.eraseDraftComment(this._getStorageLocation());
+      } else if (newDraft.length) {
+        this.$.storage.setDraftComment(this._getStorageLocation(),
+            this.draft);
+      }
+    }, STORAGE_DEBOUNCE_INTERVAL_MS);
+  }
+
+  _handleHeightChanged(e) {
+    this.fire('autogrow');
+  }
+
+  _handleLabelsChanged() {
+    this._labelsChanged = Object.keys(
+        this.$.labelScores.getLabelValues()).length !== 0;
+  }
+
+  _isState(knownLatestState, value) {
+    return knownLatestState === value;
+  }
+
+  _reload() {
+    // Load the current change without any patch range.
+    Gerrit.Nav.navigateToChange(this.change);
+    this.cancel();
+  }
+
+  _computeSendButtonLabel(canBeStarted) {
+    return canBeStarted ? ButtonLabels.START_REVIEW : ButtonLabels.SEND;
+  }
+
+  _computeSendButtonTooltip(canBeStarted) {
+    return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
+  }
+
+  _computeSavingLabelClass(savingComments) {
+    return savingComments ? 'saving' : '';
+  }
+
+  _computeSendButtonDisabled(
+      buttonLabel, draftCommentThreads, text, reviewersMutated,
+      labelsChanged, includeComments, disabled) {
+    // Polymer 2: check for undefined
+    if ([
+      buttonLabel,
+      draftCommentThreads,
+      text,
+      reviewersMutated,
+      labelsChanged,
+      includeComments,
+      disabled,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    if (disabled) { return true; }
+    if (buttonLabel === ButtonLabels.START_REVIEW) { return false; }
+    const hasDrafts = includeComments && draftCommentThreads.length;
+    return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
+  }
+
+  _computePatchSetWarning(patchNum, labelsChanged) {
+    let str = `Patch ${patchNum} is not latest.`;
+    if (labelsChanged) {
+      str += ' Voting on a non-latest patch will have no effect.';
+    }
+    return str;
+  }
+
+  setPluginMessage(message) {
+    this._pluginMessage = message;
+  }
+
+  _sendDisabledChanged(sendDisabled) {
+    this.dispatchEvent(new CustomEvent('send-disabled-changed'));
+  }
+
+  _getReviewerSuggestionsProvider(change) {
+    const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
+        change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
+    provider.init();
+    return provider;
+  }
+
+  _getCcSuggestionsProvider(change) {
+    const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
+        change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.CC);
+    provider.init();
+    return provider;
+  }
+
+  _onThreadListModified() {
+    // TODO(taoalpha): this won't propogate the changes to the files
+    // should consider replacing this with either top level events
+    // or gerrit level events
+
+    // emit the event so change-view can also get updated with latest changes
+    this.fire('comment-refresh');
+  }
+}
+
+customElements.define(GrReplyDialog.is, GrReplyDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js
index 8424b5d..0c98834 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js
@@ -1,47 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../../shared/gr-textarea/gr-textarea.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../../shared/gr-account-list/gr-account-list.html">
-<link rel="import" href="../gr-label-scores/gr-label-scores.html">
-<link rel="import" href="../gr-thread-list/gr-thread-list.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../change/gr-comment-list/gr-comment-list.html">
-<script src="../../../scripts/gr-display-name-utils/gr-display-name-utils.js"></script>
-<script src="../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js"></script>
-
-<dom-module id="gr-reply-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         background-color: var(--dialog-background-color);
@@ -167,33 +142,15 @@
       <section class="peopleContainer">
         <div class="peopleList">
           <div class="peopleListLabel">Reviewers</div>
-          <gr-account-list
-              id="reviewers"
-              accounts="{{_reviewers}}"
-              removable-values="[[change.removable_reviewers]]"
-              filter="[[filterReviewerSuggestion]]"
-              pending-confirmation="{{_reviewerPendingConfirmation}}"
-              placeholder="Add reviewer..."
-              on-account-text-changed="_handleAccountTextEntry"
-              suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]">
+          <gr-account-list id="reviewers" accounts="{{_reviewers}}" removable-values="[[change.removable_reviewers]]" filter="[[filterReviewerSuggestion]]" pending-confirmation="{{_reviewerPendingConfirmation}}" placeholder="Add reviewer..." on-account-text-changed="_handleAccountTextEntry" suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]">
           </gr-account-list>
         </div>
         <div class="peopleList">
           <div class="peopleListLabel">CC</div>
-          <gr-account-list
-              id="ccs"
-              accounts="{{_ccs}}"
-              filter="[[filterCCSuggestion]]"
-              pending-confirmation="{{_ccPendingConfirmation}}"
-              allow-any-input
-              placeholder="Add CC..."
-              on-account-text-changed="_handleAccountTextEntry"
-              suggestions-provider="[[_getCcSuggestionsProvider(change)]]">
+          <gr-account-list id="ccs" accounts="{{_ccs}}" filter="[[filterCCSuggestion]]" pending-confirmation="{{_ccPendingConfirmation}}" allow-any-input="" placeholder="Add CC..." on-account-text-changed="_handleAccountTextEntry" suggestions-provider="[[_getCcSuggestionsProvider(change)]]">
           </gr-account-list>
         </div>
-        <gr-overlay
-            id="reviewerConfirmationOverlay"
-            on-iron-overlay-canceled="_cancelPendingReviewer">
+        <gr-overlay id="reviewerConfirmationOverlay" on-iron-overlay-canceled="_cancelPendingReviewer">
           <div class="reviewerConfirmation">
             Group
             <span class="groupName">
@@ -215,18 +172,7 @@
       </section>
       <section class="textareaContainer">
         <gr-endpoint-decorator name="reply-text">
-          <gr-textarea
-              id="textarea"
-              class="message"
-              autocomplete="on"
-              placeholder=[[_messagePlaceholder]]
-              fixed-position-dropdown
-              hide-border="true"
-              monospace="true"
-              disabled="{{disabled}}"
-              rows="4"
-              text="{{draft}}"
-              on-bind-value-changed="_handleHeightChanged">
+          <gr-textarea id="textarea" class="message" autocomplete="on" placeholder="[[_messagePlaceholder]]" fixed-position-dropdown="" hide-border="true" monospace="true" disabled="{{disabled}}" rows="4" text="{{draft}}" on-bind-value-changed="_handleHeightChanged">
           </gr-textarea>
         </gr-endpoint-decorator>
       </section>
@@ -235,84 +181,44 @@
           <input type="checkbox" checked="{{_previewFormatting::change}}">
           Preview formatting
         </label>
-        <gr-formatted-text
-            content="[[draft]]"
-            hidden$="[[!_previewFormatting]]"
-            config="[[projectConfig.commentlinks]]"></gr-formatted-text>
+        <gr-formatted-text content="[[draft]]" hidden\$="[[!_previewFormatting]]" config="[[projectConfig.commentlinks]]"></gr-formatted-text>
       </section>
       <section class="labelsContainer">
         <gr-endpoint-decorator name="reply-label-scores">
-          <gr-label-scores
-              id="labelScores"
-              account="[[_account]]"
-              change="[[change]]"
-              on-labels-changed="_handleLabelsChanged"
-              permitted-labels=[[permittedLabels]]></gr-label-scores>
+          <gr-label-scores id="labelScores" account="[[_account]]" change="[[change]]" on-labels-changed="_handleLabelsChanged" permitted-labels="[[permittedLabels]]"></gr-label-scores>
         </gr-endpoint-decorator>
         <div id="pluginMessage">[[_pluginMessage]]</div>
       </section>
-      <section class="draftsContainer" hidden$="[[_computeHideDraftList(draftCommentThreads)]]">
+      <section class="draftsContainer" hidden\$="[[_computeHideDraftList(draftCommentThreads)]]">
         <div class="includeComments">
-          <input type="checkbox" id="includeComments"
-              checked="{{_includeComments::change}}">
+          <input type="checkbox" id="includeComments" checked="{{_includeComments::change}}">
           <label for="includeComments">Publish [[_computeDraftsTitle(draftCommentThreads)]]</label>
         </div>
-        <gr-thread-list
-          id="commentList"
-          hidden$="[[!_includeComments]]"
-          threads="[[draftCommentThreads]]"
-          change="[[change]]"
-          change-num="[[change._number]]"
-          logged-in="true"
-          hide-toggle-buttons
-          on-thread-list-modified="_onThreadListModified">
+        <gr-thread-list id="commentList" hidden\$="[[!_includeComments]]" threads="[[draftCommentThreads]]" change="[[change]]" change-num="[[change._number]]" logged-in="true" hide-toggle-buttons="" on-thread-list-modified="_onThreadListModified">
         </gr-thread-list>
-        <span
-            id="savingLabel"
-            class$="[[_computeSavingLabelClass(_savingComments)]]">
+        <span id="savingLabel" class\$="[[_computeSavingLabelClass(_savingComments)]]">
           Saving comments...
         </span>
       </section>
       <section class="actions">
         <div class="left">
-          <span
-              id="checkingStatusLabel"
-              hidden$="[[!_isState(knownLatestState, 'checking')]]">
+          <span id="checkingStatusLabel" hidden\$="[[!_isState(knownLatestState, 'checking')]]">
             Checking whether patch [[patchNum]] is latest...
           </span>
-          <span
-              id="notLatestLabel"
-              hidden$="[[!_isState(knownLatestState, 'not-latest')]]">
+          <span id="notLatestLabel" hidden\$="[[!_isState(knownLatestState, 'not-latest')]]">
             [[_computePatchSetWarning(patchNum, _labelsChanged)]]
-            <gr-button link on-click="_reload">Reload</gr-button>
+            <gr-button link="" on-click="_reload">Reload</gr-button>
           </span>
         </div>
         <div class="right">
-          <gr-button
-              link
-              id="cancelButton"
-              class="action cancel"
-              on-click="_cancelTapHandler">Cancel</gr-button>
+          <gr-button link="" id="cancelButton" class="action cancel" on-click="_cancelTapHandler">Cancel</gr-button>
           <template is="dom-if" if="[[canBeStarted]]">
             <!-- Use 'Send' here as the change may only about reviewers / ccs
               and when this button is visible, the next button will always
               be 'Start review' -->
-            <gr-button
-                link
-                disabled="[[_isState(knownLatestState, 'not-latest')]]"
-                class="action save"
-                has-tooltip
-                title="[[_saveTooltip]]"
-                on-click="_saveClickHandler">Save</gr-button>
+            <gr-button link="" disabled="[[_isState(knownLatestState, 'not-latest')]]" class="action save" has-tooltip="" title="[[_saveTooltip]]" on-click="_saveClickHandler">Save</gr-button>
           </template>
-          <gr-button
-              id="sendButton"
-              primary
-              disabled="[[_sendDisabled]]"
-              class="action send"
-              has-tooltip
-              title$="[[_computeSendButtonTooltip(canBeStarted)]]"
-              on-click="_sendTapHandler">[[_sendButtonLabel]]</gr-button>
+          <gr-button id="sendButton" primary="" disabled="[[_sendDisabled]]" class="action send" has-tooltip="" title\$="[[_computeSendButtonTooltip(canBeStarted)]]" on-click="_sendTapHandler">[[_sendButtonLabel]]</gr-button>
         </div>
       </section>
     </div>
@@ -320,6 +226,4 @@
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
     <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-reply-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index b7c2330..5257ef46 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -19,16 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reply-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="/bower_components/iron-overlay-behavior/iron-overlay-manager.html">
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-reply-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-reply-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-reply-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,1196 +40,1199 @@
   </template>
 </test-fixture>
 
-<script>
-  function cloneableResponse(status, text) {
-    return {
-      ok: false,
-      status,
-      text() {
-        return Promise.resolve(text);
-      },
-      clone() {
-        return {
-          ok: false,
-          status,
-          text() {
-            return Promise.resolve(text);
-          },
-        };
-      },
-    };
-  }
-
-  suite('gr-reply-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let changeNum;
-    let patchNum;
-
-    let sandbox;
-    let getDraftCommentStub;
-    let setDraftCommentStub;
-    let eraseDraftCommentStub;
-
-    let lastId = 0;
-    const makeAccount = function() { return {_account_id: lastId++}; };
-    const makeGroup = function() { return {id: lastId++}; };
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-
-      changeNum = 42;
-      patchNum = 1;
-
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getAccount() { return Promise.resolve({}); },
-        getChange() { return Promise.resolve({}); },
-        getChangeSuggestedReviewers() { return Promise.resolve([]); },
-      });
-
-      element = fixture('basic');
-      element.change = {
-        _number: changeNum,
-        labels: {
-          'Verified': {
-            values: {
-              '-1': 'Fails',
-              ' 0': 'No score',
-              '+1': 'Verified',
-            },
-            default_value: 0,
-          },
-          'Code-Review': {
-            values: {
-              '-2': 'Do not submit',
-              '-1': 'I would prefer that you didn\'t submit this',
-              ' 0': 'No score',
-              '+1': 'Looks good to me, but someone else must approve',
-              '+2': 'Looks good to me, approved',
-            },
-            default_value: 0,
-          },
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import {IronOverlayManager} from '@polymer/iron-overlay-behavior/iron-overlay-manager.js';
+import '../../../test/common-test-setup.js';
+import './gr-reply-dialog.js';
+function cloneableResponse(status, text) {
+  return {
+    ok: false,
+    status,
+    text() {
+      return Promise.resolve(text);
+    },
+    clone() {
+      return {
+        ok: false,
+        status,
+        text() {
+          return Promise.resolve(text);
         },
       };
-      element.patchNum = patchNum;
-      element.permittedLabels = {
-        'Code-Review': [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-        'Verified': [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
+    },
+  };
+}
 
-      getDraftCommentStub = sandbox.stub(element.$.storage, 'getDraftComment');
-      setDraftCommentStub = sandbox.stub(element.$.storage, 'setDraftComment');
-      eraseDraftCommentStub = sandbox.stub(element.$.storage,
-          'eraseDraftComment');
+suite('gr-reply-dialog tests', () => {
+  let element;
+  let changeNum;
+  let patchNum;
 
-      sandbox.stub(element, 'fetchChangeUpdates')
-          .returns(Promise.resolve({isLatest: true}));
+  let sandbox;
+  let getDraftCommentStub;
+  let setDraftCommentStub;
+  let eraseDraftCommentStub;
 
-      // Allow the elements created by dom-repeat to be stamped.
-      flushAsynchronousOperations();
+  let lastId = 0;
+  const makeAccount = function() { return {_account_id: lastId++}; };
+  const makeGroup = function() { return {id: lastId++}; };
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+
+    changeNum = 42;
+    patchNum = 1;
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve({}); },
+      getChange() { return Promise.resolve({}); },
+      getChangeSuggestedReviewers() { return Promise.resolve([]); },
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    function stubSaveReview(jsonResponseProducer) {
-      return sandbox.stub(
-          element,
-          '_saveReview',
-          review => new Promise((resolve, reject) => {
-            try {
-              const result = jsonResponseProducer(review) || {};
-              const resultStr =
-              element.$.restAPI.JSON_PREFIX + JSON.stringify(result);
-              resolve({
-                ok: true,
-                text() {
-                  return Promise.resolve(resultStr);
-                },
-              });
-            } catch (err) {
-              reject(err);
-            }
-          }));
-    }
-
-    test('default to publishing draft comments with reply', done => {
-      // Async tick is needed because iron-selector content is distributed and
-      // distributed content requires an observer to be set up.
-      // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-      flush(() => {
-        flush(() => {
-          element.draft = 'I wholeheartedly disapprove';
-
-          stubSaveReview(review => {
-            assert.deepEqual(review, {
-              drafts: 'PUBLISH_ALL_REVISIONS',
-              labels: {
-                'Code-Review': 0,
-                'Verified': 0,
-              },
-              message: 'I wholeheartedly disapprove',
-              reviewers: [],
-            });
-            assert.isFalse(element.$.commentList.hidden);
-            done();
-          });
-
-          // This is needed on non-Blink engines most likely due to the ways in
-          // which the dom-repeat elements are stamped.
-          flush(() => {
-            MockInteractions.tap(element.shadowRoot
-                .querySelector('.send'));
-          });
-        });
-      });
-    });
-
-    test('keep draft comments with reply', done => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
-      assert.equal(element._includeComments, false);
-
-      // Async tick is needed because iron-selector content is distributed and
-      // distributed content requires an observer to be set up.
-      // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-      flush(() => {
-        flush(() => {
-          element.draft = 'I wholeheartedly disapprove';
-
-          stubSaveReview(review => {
-            assert.deepEqual(review, {
-              drafts: 'KEEP',
-              labels: {
-                'Code-Review': 0,
-                'Verified': 0,
-              },
-              message: 'I wholeheartedly disapprove',
-              reviewers: [],
-            });
-            assert.isTrue(element.$.commentList.hidden);
-            done();
-          });
-
-          // This is needed on non-Blink engines most likely due to the ways in
-          // which the dom-repeat elements are stamped.
-          flush(() => {
-            MockInteractions.tap(element.shadowRoot
-                .querySelector('.send'));
-          });
-        });
-      });
-    });
-
-    test('label picker', done => {
-      element.draft = 'I wholeheartedly disapprove';
-      stubSaveReview(review => {
-        assert.deepEqual(review, {
-          drafts: 'PUBLISH_ALL_REVISIONS',
-          labels: {
-            'Code-Review': -1,
-            'Verified': -1,
+    element = fixture('basic');
+    element.change = {
+      _number: changeNum,
+      labels: {
+        'Verified': {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified',
           },
-          message: 'I wholeheartedly disapprove',
-          reviewers: [],
-        });
-      });
+          default_value: 0,
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didn\'t submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      },
+    };
+    element.patchNum = patchNum;
+    element.permittedLabels = {
+      'Code-Review': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
 
-      sandbox.stub(element.$.labelScores, 'getLabelValues', () => {
-        return {
-          'Code-Review': -1,
-          'Verified': -1,
-        };
-      });
+    getDraftCommentStub = sandbox.stub(element.$.storage, 'getDraftComment');
+    setDraftCommentStub = sandbox.stub(element.$.storage, 'setDraftComment');
+    eraseDraftCommentStub = sandbox.stub(element.$.storage,
+        'eraseDraftComment');
 
-      element.addEventListener('send', () => {
-        // Flush to ensure properties are updated.
-        flush(() => {
-          assert.isFalse(element.disabled,
-              'Element should be enabled when done sending reply.');
-          assert.equal(element.draft.length, 0);
+    sandbox.stub(element, 'fetchChangeUpdates')
+        .returns(Promise.resolve({isLatest: true}));
+
+    // Allow the elements created by dom-repeat to be stamped.
+    flushAsynchronousOperations();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  function stubSaveReview(jsonResponseProducer) {
+    return sandbox.stub(
+        element,
+        '_saveReview',
+        review => new Promise((resolve, reject) => {
+          try {
+            const result = jsonResponseProducer(review) || {};
+            const resultStr =
+            element.$.restAPI.JSON_PREFIX + JSON.stringify(result);
+            resolve({
+              ok: true,
+              text() {
+                return Promise.resolve(resultStr);
+              },
+            });
+          } catch (err) {
+            reject(err);
+          }
+        }));
+  }
+
+  test('default to publishing draft comments with reply', done => {
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+    flush(() => {
+      flush(() => {
+        element.draft = 'I wholeheartedly disapprove';
+
+        stubSaveReview(review => {
+          assert.deepEqual(review, {
+            drafts: 'PUBLISH_ALL_REVISIONS',
+            labels: {
+              'Code-Review': 0,
+              'Verified': 0,
+            },
+            message: 'I wholeheartedly disapprove',
+            reviewers: [],
+          });
+          assert.isFalse(element.$.commentList.hidden);
           done();
         });
-      });
 
-      // This is needed on non-Blink engines most likely due to the ways in
-      // which the dom-repeat elements are stamped.
-      flush(() => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.send'));
-        assert.isTrue(element.disabled);
-      });
-    });
-
-    test('getlabelValue returns value', done => {
-      flush(() => {
-        element.shadowRoot
-            .querySelector('gr-label-scores')
-            .shadowRoot
-            .querySelector(`gr-label-score-row[name="Verified"]`)
-            .setSelectedValue(-1);
-        assert.equal('-1', element.getLabelValue('Verified'));
-        done();
-      });
-    });
-
-    test('getlabelValue when no score is selected', done => {
-      flush(() => {
-        element.shadowRoot
-            .querySelector('gr-label-scores')
-            .shadowRoot
-            .querySelector(`gr-label-score-row[name="Code-Review"]`)
-            .setSelectedValue(-1);
-        assert.strictEqual(element.getLabelValue('Verified'), ' 0');
-        done();
-      });
-    });
-
-    test('setlabelValue', done => {
-      element._account = {_account_id: 1};
-      flush(() => {
-        const label = 'Verified';
-        const value = '+1';
-        element.setLabelValue(label, value);
-
-        const labels = element.$.labelScores.getLabelValues();
-        assert.deepEqual(labels, {
-          'Code-Review': 0,
-          'Verified': 1,
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
+        flush(() => {
+          MockInteractions.tap(element.shadowRoot
+              .querySelector('.send'));
         });
+      });
+    });
+  });
+
+  test('keep draft comments with reply', done => {
+    MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
+    assert.equal(element._includeComments, false);
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+    flush(() => {
+      flush(() => {
+        element.draft = 'I wholeheartedly disapprove';
+
+        stubSaveReview(review => {
+          assert.deepEqual(review, {
+            drafts: 'KEEP',
+            labels: {
+              'Code-Review': 0,
+              'Verified': 0,
+            },
+            message: 'I wholeheartedly disapprove',
+            reviewers: [],
+          });
+          assert.isTrue(element.$.commentList.hidden);
+          done();
+        });
+
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
+        flush(() => {
+          MockInteractions.tap(element.shadowRoot
+              .querySelector('.send'));
+        });
+      });
+    });
+  });
+
+  test('label picker', done => {
+    element.draft = 'I wholeheartedly disapprove';
+    stubSaveReview(review => {
+      assert.deepEqual(review, {
+        drafts: 'PUBLISH_ALL_REVISIONS',
+        labels: {
+          'Code-Review': -1,
+          'Verified': -1,
+        },
+        message: 'I wholeheartedly disapprove',
+        reviewers: [],
+      });
+    });
+
+    sandbox.stub(element.$.labelScores, 'getLabelValues', () => {
+      return {
+        'Code-Review': -1,
+        'Verified': -1,
+      };
+    });
+
+    element.addEventListener('send', () => {
+      // Flush to ensure properties are updated.
+      flush(() => {
+        assert.isFalse(element.disabled,
+            'Element should be enabled when done sending reply.');
+        assert.equal(element.draft.length, 0);
         done();
       });
     });
 
-    function getActiveElement() {
-      return Polymer.IronOverlayManager.deepActiveElement;
-    }
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    flush(() => {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.send'));
+      assert.isTrue(element.disabled);
+    });
+  });
 
-    function isVisible(el) {
-      assert.ok(el);
-      return getComputedStyle(el).getPropertyValue('display') != 'none';
-    }
+  test('getlabelValue returns value', done => {
+    flush(() => {
+      element.shadowRoot
+          .querySelector('gr-label-scores')
+          .shadowRoot
+          .querySelector(`gr-label-score-row[name="Verified"]`)
+          .setSelectedValue(-1);
+      assert.equal('-1', element.getLabelValue('Verified'));
+      done();
+    });
+  });
 
-    function overlayObserver(mode) {
-      return new Promise(resolve => {
-        function listener() {
-          element.removeEventListener('iron-overlay-' + mode, listener);
-          resolve();
-        }
-        element.addEventListener('iron-overlay-' + mode, listener);
+  test('getlabelValue when no score is selected', done => {
+    flush(() => {
+      element.shadowRoot
+          .querySelector('gr-label-scores')
+          .shadowRoot
+          .querySelector(`gr-label-score-row[name="Code-Review"]`)
+          .setSelectedValue(-1);
+      assert.strictEqual(element.getLabelValue('Verified'), ' 0');
+      done();
+    });
+  });
+
+  test('setlabelValue', done => {
+    element._account = {_account_id: 1};
+    flush(() => {
+      const label = 'Verified';
+      const value = '+1';
+      element.setLabelValue(label, value);
+
+      const labels = element.$.labelScores.getLabelValues();
+      assert.deepEqual(labels, {
+        'Code-Review': 0,
+        'Verified': 1,
       });
-    }
+      done();
+    });
+  });
 
-    function isFocusInsideElement(element) {
-      // In Polymer 2 focused element either <paper-input> or nested
-      // native input <input> element depending on the current focus
-      // in browser window.
-      // For example, the focus is changed if the developer console
-      // get a focus.
-      let activeElement = getActiveElement();
-      while (activeElement) {
-        if (activeElement === element) {
-          return true;
-        }
-        if (activeElement.parentElement) {
-          activeElement = activeElement.parentElement;
-        } else {
-          activeElement = activeElement.getRootNode().host;
-        }
+  function getActiveElement() {
+    return IronOverlayManager.deepActiveElement;
+  }
+
+  function isVisible(el) {
+    assert.ok(el);
+    return getComputedStyle(el).getPropertyValue('display') != 'none';
+  }
+
+  function overlayObserver(mode) {
+    return new Promise(resolve => {
+      function listener() {
+        element.removeEventListener('iron-overlay-' + mode, listener);
+        resolve();
       }
-      return false;
+      element.addEventListener('iron-overlay-' + mode, listener);
+    });
+  }
+
+  function isFocusInsideElement(element) {
+    // In Polymer 2 focused element either <paper-input> or nested
+    // native input <input> element depending on the current focus
+    // in browser window.
+    // For example, the focus is changed if the developer console
+    // get a focus.
+    let activeElement = getActiveElement();
+    while (activeElement) {
+      if (activeElement === element) {
+        return true;
+      }
+      if (activeElement.parentElement) {
+        activeElement = activeElement.parentElement;
+      } else {
+        activeElement = activeElement.getRootNode().host;
+      }
     }
+    return false;
+  }
 
-    function testConfirmationDialog(done, cc) {
-      const yesButton = element
-          .shadowRoot
-          .querySelector('.reviewerConfirmationButtons gr-button:first-child');
-      const noButton = element
-          .shadowRoot
-          .querySelector('.reviewerConfirmationButtons gr-button:last-child');
+  function testConfirmationDialog(done, cc) {
+    const yesButton = element
+        .shadowRoot
+        .querySelector('.reviewerConfirmationButtons gr-button:first-child');
+    const noButton = element
+        .shadowRoot
+        .querySelector('.reviewerConfirmationButtons gr-button:last-child');
 
-      element._ccPendingConfirmation = null;
-      element._reviewerPendingConfirmation = null;
-      flushAsynchronousOperations();
-      assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+    element._ccPendingConfirmation = null;
+    element._reviewerPendingConfirmation = null;
+    flushAsynchronousOperations();
+    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
 
-      // Cause the confirmation dialog to display.
-      let observer = overlayObserver('opened');
-      const group = {
-        id: 'id',
-        name: 'name',
+    // Cause the confirmation dialog to display.
+    let observer = overlayObserver('opened');
+    const group = {
+      id: 'id',
+      name: 'name',
+    };
+    if (cc) {
+      element._ccPendingConfirmation = {
+        group,
+        count: 10,
       };
-      if (cc) {
-        element._ccPendingConfirmation = {
-          group,
-          count: 10,
-        };
-      } else {
-        element._reviewerPendingConfirmation = {
-          group,
-          count: 10,
-        };
-      }
-      flushAsynchronousOperations();
+    } else {
+      element._reviewerPendingConfirmation = {
+        group,
+        count: 10,
+      };
+    }
+    flushAsynchronousOperations();
 
-      if (cc) {
-        assert.deepEqual(
-            element._ccPendingConfirmation,
-            element._pendingConfirmationDetails);
-      } else {
-        assert.deepEqual(
-            element._reviewerPendingConfirmation,
-            element._pendingConfirmationDetails);
-      }
+    if (cc) {
+      assert.deepEqual(
+          element._ccPendingConfirmation,
+          element._pendingConfirmationDetails);
+    } else {
+      assert.deepEqual(
+          element._reviewerPendingConfirmation,
+          element._pendingConfirmationDetails);
+    }
 
-      observer
-          .then(() => {
-            assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
-            observer = overlayObserver('closed');
-            const expected = 'Group name has 10 members';
-            assert.notEqual(
-                element.$.reviewerConfirmationOverlay.innerText
-                    .indexOf(expected),
-                -1);
-            MockInteractions.tap(noButton); // close the overlay
-            return observer;
-          }).then(() => {
-            assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+    observer
+        .then(() => {
+          assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
+          observer = overlayObserver('closed');
+          const expected = 'Group name has 10 members';
+          assert.notEqual(
+              element.$.reviewerConfirmationOverlay.innerText
+                  .indexOf(expected),
+              -1);
+          MockInteractions.tap(noButton); // close the overlay
+          return observer;
+        }).then(() => {
+          assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
 
-            // We should be focused on account entry input.
+          // We should be focused on account entry input.
+          assert.isTrue(
+              isFocusInsideElement(
+                  element.$.reviewers.$.entry.$.input.$.input
+              )
+          );
+
+          // No reviewer/CC should have been added.
+          assert.equal(element.$.ccs.additions().length, 0);
+          assert.equal(element.$.reviewers.additions().length, 0);
+
+          // Reopen confirmation dialog.
+          observer = overlayObserver('opened');
+          if (cc) {
+            element._ccPendingConfirmation = {
+              group,
+              count: 10,
+            };
+          } else {
+            element._reviewerPendingConfirmation = {
+              group,
+              count: 10,
+            };
+          }
+          return observer;
+        })
+        .then(() => {
+          assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
+          observer = overlayObserver('closed');
+          MockInteractions.tap(yesButton); // Confirm the group.
+          return observer;
+        })
+        .then(() => {
+          assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+          const additions = cc ?
+            element.$.ccs.additions() :
+            element.$.reviewers.additions();
+          assert.deepEqual(
+              additions,
+              [
+                {
+                  group: {
+                    id: 'id',
+                    name: 'name',
+                    confirmed: true,
+                    _group: true,
+                    _pendingAdd: true,
+                  },
+                },
+              ]);
+
+          // We should be focused on account entry input.
+          if (cc) {
+            assert.isTrue(
+                isFocusInsideElement(
+                    element.$.ccs.$.entry.$.input.$.input
+                )
+            );
+          } else {
             assert.isTrue(
                 isFocusInsideElement(
                     element.$.reviewers.$.entry.$.input.$.input
                 )
             );
+          }
+        })
+        .then(done);
+  }
 
-            // No reviewer/CC should have been added.
-            assert.equal(element.$.ccs.additions().length, 0);
-            assert.equal(element.$.reviewers.additions().length, 0);
+  test('cc confirmation', done => {
+    testConfirmationDialog(done, true);
+  });
 
-            // Reopen confirmation dialog.
-            observer = overlayObserver('opened');
-            if (cc) {
-              element._ccPendingConfirmation = {
-                group,
-                count: 10,
-              };
-            } else {
-              element._reviewerPendingConfirmation = {
-                group,
-                count: 10,
-              };
-            }
-            return observer;
-          })
-          .then(() => {
-            assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
-            observer = overlayObserver('closed');
-            MockInteractions.tap(yesButton); // Confirm the group.
-            return observer;
-          })
-          .then(() => {
-            assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-            const additions = cc ?
-              element.$.ccs.additions() :
-              element.$.reviewers.additions();
-            assert.deepEqual(
-                additions,
-                [
-                  {
-                    group: {
-                      id: 'id',
-                      name: 'name',
-                      confirmed: true,
-                      _group: true,
-                      _pendingAdd: true,
-                    },
-                  },
-                ]);
+  test('reviewer confirmation', done => {
+    testConfirmationDialog(done, false);
+  });
 
-            // We should be focused on account entry input.
-            if (cc) {
-              assert.isTrue(
-                  isFocusInsideElement(
-                      element.$.ccs.$.entry.$.input.$.input
-                  )
-              );
-            } else {
-              assert.isTrue(
-                  isFocusInsideElement(
-                      element.$.reviewers.$.entry.$.input.$.input
-                  )
-              );
-            }
-          })
-          .then(done);
-    }
+  test('_getStorageLocation', () => {
+    const actual = element._getStorageLocation();
+    assert.equal(actual.changeNum, changeNum);
+    assert.equal(actual.patchNum, '@change');
+    assert.equal(actual.path, '@change');
+  });
 
-    test('cc confirmation', done => {
-      testConfirmationDialog(done, true);
+  test('_reviewersMutated when account-text-change is fired from ccs', () => {
+    flushAsynchronousOperations();
+    assert.isFalse(element._reviewersMutated);
+    assert.isTrue(element.$.ccs.allowAnyInput);
+    assert.isFalse(element.shadowRoot
+        .querySelector('#reviewers').allowAnyInput);
+    element.$.ccs.dispatchEvent(new CustomEvent('account-text-changed',
+        {bubbles: true, composed: true}));
+    assert.isTrue(element._reviewersMutated);
+  });
+
+  test('gets draft from storage on open', () => {
+    const storedDraft = 'hello world';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, storedDraft);
+  });
+
+  test('gets draft from storage even when text is already present', () => {
+    const storedDraft = 'hello world';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.draft = 'foo bar';
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, storedDraft);
+  });
+
+  test('blank if no stored draft', () => {
+    getDraftCommentStub.returns(null);
+    element.draft = 'foo bar';
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, '');
+  });
+
+  test('does not check stored draft when quote is present', () => {
+    const storedDraft = 'hello world';
+    const quote = '> foo bar';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.quote = quote;
+    element.open();
+    assert.isFalse(getDraftCommentStub.called);
+    assert.equal(element.draft, quote);
+    assert.isNotOk(element.quote);
+  });
+
+  test('updates stored draft on edits', () => {
+    const firstEdit = 'hello';
+    const location = element._getStorageLocation();
+
+    element.draft = firstEdit;
+    element.flushDebouncer('store');
+
+    assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
+
+    element.draft = '';
+    element.flushDebouncer('store');
+
+    assert.isTrue(eraseDraftCommentStub.calledWith(location));
+  });
+
+  test('400 converts to human-readable server-error', done => {
+    sandbox.stub(window, 'fetch', () => {
+      const text = '....{"reviewers":{"id1":{"error":"first error"}},' +
+        '"ccs":{"id2":{"error":"second error"}}}';
+      return Promise.resolve(cloneableResponse(400, text));
     });
 
-    test('reviewer confirmation', done => {
-      testConfirmationDialog(done, false);
-    });
-
-    test('_getStorageLocation', () => {
-      const actual = element._getStorageLocation();
-      assert.equal(actual.changeNum, changeNum);
-      assert.equal(actual.patchNum, '@change');
-      assert.equal(actual.path, '@change');
-    });
-
-    test('_reviewersMutated when account-text-change is fired from ccs', () => {
-      flushAsynchronousOperations();
-      assert.isFalse(element._reviewersMutated);
-      assert.isTrue(element.$.ccs.allowAnyInput);
-      assert.isFalse(element.shadowRoot
-          .querySelector('#reviewers').allowAnyInput);
-      element.$.ccs.dispatchEvent(new CustomEvent('account-text-changed',
-          {bubbles: true, composed: true}));
-      assert.isTrue(element._reviewersMutated);
-    });
-
-    test('gets draft from storage on open', () => {
-      const storedDraft = 'hello world';
-      getDraftCommentStub.returns({message: storedDraft});
-      element.open();
-      assert.isTrue(getDraftCommentStub.called);
-      assert.equal(element.draft, storedDraft);
-    });
-
-    test('gets draft from storage even when text is already present', () => {
-      const storedDraft = 'hello world';
-      getDraftCommentStub.returns({message: storedDraft});
-      element.draft = 'foo bar';
-      element.open();
-      assert.isTrue(getDraftCommentStub.called);
-      assert.equal(element.draft, storedDraft);
-    });
-
-    test('blank if no stored draft', () => {
-      getDraftCommentStub.returns(null);
-      element.draft = 'foo bar';
-      element.open();
-      assert.isTrue(getDraftCommentStub.called);
-      assert.equal(element.draft, '');
-    });
-
-    test('does not check stored draft when quote is present', () => {
-      const storedDraft = 'hello world';
-      const quote = '> foo bar';
-      getDraftCommentStub.returns({message: storedDraft});
-      element.quote = quote;
-      element.open();
-      assert.isFalse(getDraftCommentStub.called);
-      assert.equal(element.draft, quote);
-      assert.isNotOk(element.quote);
-    });
-
-    test('updates stored draft on edits', () => {
-      const firstEdit = 'hello';
-      const location = element._getStorageLocation();
-
-      element.draft = firstEdit;
-      element.flushDebouncer('store');
-
-      assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
-
-      element.draft = '';
-      element.flushDebouncer('store');
-
-      assert.isTrue(eraseDraftCommentStub.calledWith(location));
-    });
-
-    test('400 converts to human-readable server-error', done => {
-      sandbox.stub(window, 'fetch', () => {
-        const text = '....{"reviewers":{"id1":{"error":"first error"}},' +
-          '"ccs":{"id2":{"error":"second error"}}}';
-        return Promise.resolve(cloneableResponse(400, text));
-      });
-
-      element.addEventListener('server-error', event => {
-        if (event.target !== element) {
-          return;
-        }
-        event.detail.response.text().then(body => {
-          assert.equal(body, 'first error, second error');
-          done();
-        });
-      });
-
-      // Async tick is needed because iron-selector content is distributed and
-      // distributed content requires an observer to be set up.
-      flush(() => { element.send(); });
-    });
-
-    test('non-json 400 is treated as a normal server-error', done => {
-      sandbox.stub(window, 'fetch', () => {
-        const text = 'Comment validation error!';
-        return Promise.resolve(cloneableResponse(400, text));
-      });
-
-      element.addEventListener('server-error', event => {
-        if (event.target !== element) {
-          return;
-        }
-        event.detail.response.text().then(body => {
-          assert.equal(body, 'Comment validation error!');
-          done();
-        });
-      });
-
-      // Async tick is needed because iron-selector content is distributed and
-      // distributed content requires an observer to be set up.
-      flush(() => { element.send(); });
-    });
-
-    test('filterReviewerSuggestion', () => {
-      const owner = makeAccount();
-      const reviewer1 = makeAccount();
-      const reviewer2 = makeGroup();
-      const cc1 = makeAccount();
-      const cc2 = makeGroup();
-      let filter = element._filterReviewerSuggestionGenerator(false);
-
-      element._owner = owner;
-      element._reviewers = [reviewer1, reviewer2];
-      element._ccs = [cc1, cc2];
-
-      assert.isTrue(filter({account: makeAccount()}));
-      assert.isTrue(filter({group: makeGroup()}));
-
-      // Owner should be excluded.
-      assert.isFalse(filter({account: owner}));
-
-      // Existing and pending reviewers should be excluded when isCC = false.
-      assert.isFalse(filter({account: reviewer1}));
-      assert.isFalse(filter({group: reviewer2}));
-
-      filter = element._filterReviewerSuggestionGenerator(true);
-
-      // Existing and pending CCs should be excluded when isCC = true;.
-      assert.isFalse(filter({account: cc1}));
-      assert.isFalse(filter({group: cc2}));
-    });
-
-    test('_focusOn', () => {
-      sandbox.spy(element, '_chooseFocusTarget');
-      flushAsynchronousOperations();
-      const textareaStub = sandbox.stub(element.$.textarea, 'async');
-      const reviewerEntryStub = sandbox.stub(element.$.reviewers.focusStart,
-          'async');
-      const ccStub = sandbox.stub(element.$.ccs.focusStart, 'async');
-      element._focusOn();
-      assert.equal(element._chooseFocusTarget.callCount, 1);
-      assert.deepEqual(textareaStub.callCount, 1);
-      assert.deepEqual(reviewerEntryStub.callCount, 0);
-      assert.deepEqual(ccStub.callCount, 0);
-
-      element._focusOn(element.FocusTarget.ANY);
-      assert.equal(element._chooseFocusTarget.callCount, 2);
-      assert.deepEqual(textareaStub.callCount, 2);
-      assert.deepEqual(reviewerEntryStub.callCount, 0);
-      assert.deepEqual(ccStub.callCount, 0);
-
-      element._focusOn(element.FocusTarget.BODY);
-      assert.equal(element._chooseFocusTarget.callCount, 2);
-      assert.deepEqual(textareaStub.callCount, 3);
-      assert.deepEqual(reviewerEntryStub.callCount, 0);
-      assert.deepEqual(ccStub.callCount, 0);
-
-      element._focusOn(element.FocusTarget.REVIEWERS);
-      assert.equal(element._chooseFocusTarget.callCount, 2);
-      assert.deepEqual(textareaStub.callCount, 3);
-      assert.deepEqual(reviewerEntryStub.callCount, 1);
-      assert.deepEqual(ccStub.callCount, 0);
-
-      element._focusOn(element.FocusTarget.CCS);
-      assert.equal(element._chooseFocusTarget.callCount, 2);
-      assert.deepEqual(textareaStub.callCount, 3);
-      assert.deepEqual(reviewerEntryStub.callCount, 1);
-      assert.deepEqual(ccStub.callCount, 1);
-    });
-
-    test('_chooseFocusTarget', () => {
-      element._account = null;
-      assert.strictEqual(
-          element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-      element._account = {_account_id: 1};
-      assert.strictEqual(
-          element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-      element.change.owner = {_account_id: 2};
-      assert.strictEqual(
-          element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-      element.change.owner._account_id = 1;
-      element.change._reviewers = null;
-      assert.strictEqual(
-          element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
-
-      element._reviewers = [];
-      assert.strictEqual(
-          element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
-
-      element._reviewers.push({});
-      assert.strictEqual(
-          element._chooseFocusTarget(), element.FocusTarget.BODY);
-    });
-
-    test('only send labels that have changed', done => {
-      flush(() => {
-        stubSaveReview(review => {
-          assert.deepEqual(review.labels, {
-            'Code-Review': 0,
-            'Verified': -1,
-          });
-        });
-
-        element.addEventListener('send', () => {
-          done();
-        });
-        // Without wrapping this test in flush(), the below two calls to
-        // MockInteractions.tap() cause a race in some situations in shadow DOM.
-        // The send button can be tapped before the others, causing the test to
-        // fail.
-
-        element.shadowRoot
-            .querySelector('gr-label-scores').shadowRoot
-            .querySelector(
-                'gr-label-score-row[name="Verified"]')
-            .setSelectedValue(-1);
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.send'));
-      });
-    });
-
-    test('_processReviewerChange', () => {
-      const mockIndexSplices = function(toRemove) {
-        return [{
-          removed: [toRemove],
-        }];
-      };
-
-      element._processReviewerChange(
-          mockIndexSplices(makeAccount()), 'REVIEWER');
-      assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
-    });
-
-    test('_purgeReviewersPendingRemove', () => {
-      const removeStub = sandbox.stub(element, '_removeAccount');
-      const mock = function() {
-        element._reviewersPendingRemove = {
-          test: [makeAccount()],
-          test2: [makeAccount(), makeAccount()],
-        };
-      };
-      const checkObjEmpty = function(obj) {
-        for (const prop in obj) {
-          if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
-        }
-        return true;
-      };
-      mock();
-      element._purgeReviewersPendingRemove(true); // Cancel
-      assert.isFalse(removeStub.called);
-      assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
-
-      mock();
-      element._purgeReviewersPendingRemove(false); // Submit
-      assert.isTrue(removeStub.called);
-      assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
-    });
-
-    test('_removeAccount', done => {
-      sandbox.stub(element.$.restAPI, 'removeChangeReviewer')
-          .returns(Promise.resolve({ok: true}));
-      const arr = [makeAccount(), makeAccount()];
-      element.change.reviewers = {
-        REVIEWER: arr.slice(),
-      };
-
-      element._removeAccount(arr[1], 'REVIEWER').then(() => {
-        assert.equal(element.change.reviewers.REVIEWER.length, 1);
-        assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
+    element.addEventListener('server-error', event => {
+      if (event.target !== element) {
+        return;
+      }
+      event.detail.response.text().then(body => {
+        assert.equal(body, 'first error, second error');
         done();
       });
     });
 
-    test('moving from cc to reviewer', () => {
-      element._reviewersPendingRemove = {
-        CC: [],
-        REVIEWER: [],
-      };
-      flushAsynchronousOperations();
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    flush(() => { element.send(); });
+  });
 
-      const reviewer1 = makeAccount();
-      const reviewer2 = makeAccount();
-      const reviewer3 = makeAccount();
-      const cc1 = makeAccount();
-      const cc2 = makeAccount();
-      const cc3 = makeAccount();
-      const cc4 = makeAccount();
-      element._reviewers = [reviewer1, reviewer2, reviewer3];
-      element._ccs = [cc1, cc2, cc3, cc4];
-      element.push('_reviewers', cc1);
-      flushAsynchronousOperations();
-
-      assert.deepEqual(element._reviewers,
-          [reviewer1, reviewer2, reviewer3, cc1]);
-      assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
-      assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
-
-      element.push('_reviewers', cc4, cc3);
-      flushAsynchronousOperations();
-
-      assert.deepEqual(element._reviewers,
-          [reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
-      assert.deepEqual(element._ccs, [cc2]);
-      assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
+  test('non-json 400 is treated as a normal server-error', done => {
+    sandbox.stub(window, 'fetch', () => {
+      const text = 'Comment validation error!';
+      return Promise.resolve(cloneableResponse(400, text));
     });
 
-    test('moving from reviewer to cc', () => {
-      element._reviewersPendingRemove = {
-        CC: [],
-        REVIEWER: [],
-      };
-      flushAsynchronousOperations();
-
-      const reviewer1 = makeAccount();
-      const reviewer2 = makeAccount();
-      const reviewer3 = makeAccount();
-      const cc1 = makeAccount();
-      const cc2 = makeAccount();
-      const cc3 = makeAccount();
-      const cc4 = makeAccount();
-      element._reviewers = [reviewer1, reviewer2, reviewer3];
-      element._ccs = [cc1, cc2, cc3, cc4];
-      element.push('_ccs', reviewer1);
-      flushAsynchronousOperations();
-
-      assert.deepEqual(element._reviewers,
-          [reviewer2, reviewer3]);
-      assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
-      assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
-
-      element.push('_ccs', reviewer3, reviewer2);
-      flushAsynchronousOperations();
-
-      assert.deepEqual(element._reviewers, []);
-      assert.deepEqual(element._ccs,
-          [cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
-      assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
-          [reviewer1, reviewer3, reviewer2]);
-    });
-
-    test('migrate reviewers between states', done => {
-      element._reviewersPendingRemove = {
-        CC: [],
-        REVIEWER: [],
-      };
-      flushAsynchronousOperations();
-      const reviewers = element.$.reviewers;
-      const ccs = element.$.ccs;
-      const reviewer1 = makeAccount();
-      const reviewer2 = makeAccount();
-      const cc1 = makeAccount();
-      const cc2 = makeAccount();
-      const cc3 = makeAccount();
-      element._reviewers = [reviewer1, reviewer2];
-      element._ccs = [cc1, cc2, cc3];
-
-      const mutations = [];
-
-      stubSaveReview(review => mutations.push(...review.reviewers));
-
-      sandbox.stub(element, '_removeAccount', (account, type) => {
-        mutations.push({state: 'REMOVED', account});
-        return Promise.resolve();
-      });
-
-      // Remove and add to other field.
-      reviewers.fire('remove', {account: reviewer1});
-      ccs.$.entry.fire('add', {value: {account: reviewer1}});
-      ccs.fire('remove', {account: cc1});
-      ccs.fire('remove', {account: cc3});
-      reviewers.$.entry.fire('add', {value: {account: cc1}});
-
-      // Add to other field without removing from former field.
-      // (Currently not possible in UI, but this is a good consistency check).
-      reviewers.$.entry.fire('add', {value: {account: cc2}});
-      ccs.$.entry.fire('add', {value: {account: reviewer2}});
-      const mapReviewer = function(reviewer, opt_state) {
-        const result = {reviewer: reviewer._account_id, confirmed: undefined};
-        if (opt_state) {
-          result.state = opt_state;
-        }
-        return result;
-      };
-
-      // Send and purge and verify moves, delete cc3.
-      element.send()
-          .then(keepReviewers =>
-            element._purgeReviewersPendingRemove(false, keepReviewers))
-          .then(() => {
-            assert.deepEqual(
-                mutations, [
-                  mapReviewer(cc1),
-                  mapReviewer(cc2),
-                  mapReviewer(reviewer1, 'CC'),
-                  mapReviewer(reviewer2, 'CC'),
-                  {account: cc3, state: 'REMOVED'},
-                ]);
-            done();
-          });
-    });
-
-    test('emits cancel on esc key', () => {
-      const cancelHandler = sandbox.spy();
-      element.addEventListener('cancel', cancelHandler);
-      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
-      flushAsynchronousOperations();
-
-      assert.isTrue(cancelHandler.called);
-    });
-
-    test('should not send on enter key', () => {
-      stubSaveReview(() => undefined);
-      element.addEventListener('send', () => assert.fail('wrongly called'));
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-      flushAsynchronousOperations();
-    });
-
-    test('emit send on ctrl+enter key', done => {
-      stubSaveReview(() => undefined);
-      element.addEventListener('send', () => done());
-      MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
-      flushAsynchronousOperations();
-    });
-
-    test('_computeMessagePlaceholder', () => {
-      assert.equal(
-          element._computeMessagePlaceholder(false),
-          'Say something nice...');
-      assert.equal(
-          element._computeMessagePlaceholder(true),
-          'Add a note for your reviewers...');
-    });
-
-    test('_computeSendButtonLabel', () => {
-      assert.equal(
-          element._computeSendButtonLabel(false),
-          'Send');
-      assert.equal(
-          element._computeSendButtonLabel(true),
-          'Start review');
-    });
-
-    test('_handle400Error reviewrs and CCs', done => {
-      const error1 = 'error 1';
-      const error2 = 'error 2';
-      const error3 = 'error 3';
-      const text = ')]}\'' + JSON.stringify({
-        reviewers: {
-          username1: {
-            input: 'user 1',
-            error: error1,
-          },
-          username2: {
-            input: 'user 2',
-            error: error2,
-          },
-        },
-        ccs: {
-          username3: {
-            input: 'user 3',
-            error: error3,
-          },
-        },
-      });
-      element.addEventListener('server-error', e => {
-        e.detail.response.text().then(text => {
-          assert.equal(text, [error1, error2, error3].join(', '));
-          done();
-        });
-      });
-      element._handle400Error(cloneableResponse(400, text));
-    });
-
-    test('_handle400Error CCs only', done => {
-      const error1 = 'error 1';
-      const text = ')]}\'' + JSON.stringify({
-        ccs: {
-          username1: {
-            input: 'user 1',
-            error: error1,
-          },
-        },
-      });
-      element.addEventListener('server-error', e => {
-        e.detail.response.text().then(text => {
-          assert.equal(text, error1);
-          done();
-        });
-      });
-      element._handle400Error(cloneableResponse(400, text));
-    });
-
-    test('fires height change when the drafts comments load', done => {
-      // Flush DOM operations before binding to the autogrow event so we don't
-      // catch the events fired from the initial layout.
-      flush(() => {
-        const autoGrowHandler = sinon.stub();
-        element.addEventListener('autogrow', autoGrowHandler);
-        element.draftCommentThreads = [];
-        flush(() => {
-          assert.isTrue(autoGrowHandler.called);
-          done();
-        });
+    element.addEventListener('server-error', event => {
+      if (event.target !== element) {
+        return;
+      }
+      event.detail.response.text().then(body => {
+        assert.equal(body, 'Comment validation error!');
+        done();
       });
     });
 
-    suite('post review API', () => {
-      let startReviewStub;
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    flush(() => { element.send(); });
+  });
 
-      setup(() => {
-        startReviewStub = sandbox.stub(
-            element.$.restAPI,
-            'startReview',
-            () => Promise.resolve());
-      });
+  test('filterReviewerSuggestion', () => {
+    const owner = makeAccount();
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeGroup();
+    const cc1 = makeAccount();
+    const cc2 = makeGroup();
+    let filter = element._filterReviewerSuggestionGenerator(false);
 
-      test('ready property in review input on start review', () => {
-        stubSaveReview(review => {
-          assert.isTrue(review.ready);
-          return {ready: true};
-        });
-        return element.send(true, true).then(() => {
-          assert.isFalse(startReviewStub.called);
-        });
-      });
+    element._owner = owner;
+    element._reviewers = [reviewer1, reviewer2];
+    element._ccs = [cc1, cc2];
 
-      test('no ready property in review input on save review', () => {
-        stubSaveReview(review => {
-          assert.isUndefined(review.ready);
-        });
-        return element.send(true, false).then(() => {
-          assert.isFalse(startReviewStub.called);
-        });
-      });
-    });
+    assert.isTrue(filter({account: makeAccount()}));
+    assert.isTrue(filter({group: makeGroup()}));
 
-    suite('start review and save buttons', () => {
-      let sendStub;
+    // Owner should be excluded.
+    assert.isFalse(filter({account: owner}));
 
-      setup(() => {
-        sendStub = sandbox.stub(element, 'send', () => Promise.resolve());
-        element.canBeStarted = true;
-        // Flush to make both Start/Save buttons appear in DOM.
-        flushAsynchronousOperations();
-      });
+    // Existing and pending reviewers should be excluded when isCC = false.
+    assert.isFalse(filter({account: reviewer1}));
+    assert.isFalse(filter({group: reviewer2}));
 
-      test('start review sets ready', () => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.send'));
-        flushAsynchronousOperations();
-        assert.isTrue(sendStub.calledWith(true, true));
-      });
+    filter = element._filterReviewerSuggestionGenerator(true);
 
-      test('save review doesn\'t set ready', () => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.save'));
-        flushAsynchronousOperations();
-        assert.isTrue(sendStub.calledWith(true, false));
-      });
-    });
+    // Existing and pending CCs should be excluded when isCC = true;.
+    assert.isFalse(filter({account: cc1}));
+    assert.isFalse(filter({group: cc2}));
+  });
 
-    test('buttons disabled until all API calls are resolved', () => {
+  test('_focusOn', () => {
+    sandbox.spy(element, '_chooseFocusTarget');
+    flushAsynchronousOperations();
+    const textareaStub = sandbox.stub(element.$.textarea, 'async');
+    const reviewerEntryStub = sandbox.stub(element.$.reviewers.focusStart,
+        'async');
+    const ccStub = sandbox.stub(element.$.ccs.focusStart, 'async');
+    element._focusOn();
+    assert.equal(element._chooseFocusTarget.callCount, 1);
+    assert.deepEqual(textareaStub.callCount, 1);
+    assert.deepEqual(reviewerEntryStub.callCount, 0);
+    assert.deepEqual(ccStub.callCount, 0);
+
+    element._focusOn(element.FocusTarget.ANY);
+    assert.equal(element._chooseFocusTarget.callCount, 2);
+    assert.deepEqual(textareaStub.callCount, 2);
+    assert.deepEqual(reviewerEntryStub.callCount, 0);
+    assert.deepEqual(ccStub.callCount, 0);
+
+    element._focusOn(element.FocusTarget.BODY);
+    assert.equal(element._chooseFocusTarget.callCount, 2);
+    assert.deepEqual(textareaStub.callCount, 3);
+    assert.deepEqual(reviewerEntryStub.callCount, 0);
+    assert.deepEqual(ccStub.callCount, 0);
+
+    element._focusOn(element.FocusTarget.REVIEWERS);
+    assert.equal(element._chooseFocusTarget.callCount, 2);
+    assert.deepEqual(textareaStub.callCount, 3);
+    assert.deepEqual(reviewerEntryStub.callCount, 1);
+    assert.deepEqual(ccStub.callCount, 0);
+
+    element._focusOn(element.FocusTarget.CCS);
+    assert.equal(element._chooseFocusTarget.callCount, 2);
+    assert.deepEqual(textareaStub.callCount, 3);
+    assert.deepEqual(reviewerEntryStub.callCount, 1);
+    assert.deepEqual(ccStub.callCount, 1);
+  });
+
+  test('_chooseFocusTarget', () => {
+    element._account = null;
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element._account = {_account_id: 1};
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element.change.owner = {_account_id: 2};
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element.change.owner._account_id = 1;
+    element.change._reviewers = null;
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
+
+    element._reviewers = [];
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
+
+    element._reviewers.push({});
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.BODY);
+  });
+
+  test('only send labels that have changed', done => {
+    flush(() => {
       stubSaveReview(review => {
+        assert.deepEqual(review.labels, {
+          'Code-Review': 0,
+          'Verified': -1,
+        });
+      });
+
+      element.addEventListener('send', () => {
+        done();
+      });
+      // Without wrapping this test in flush(), the below two calls to
+      // MockInteractions.tap() cause a race in some situations in shadow DOM.
+      // The send button can be tapped before the others, causing the test to
+      // fail.
+
+      element.shadowRoot
+          .querySelector('gr-label-scores').shadowRoot
+          .querySelector(
+              'gr-label-score-row[name="Verified"]')
+          .setSelectedValue(-1);
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.send'));
+    });
+  });
+
+  test('_processReviewerChange', () => {
+    const mockIndexSplices = function(toRemove) {
+      return [{
+        removed: [toRemove],
+      }];
+    };
+
+    element._processReviewerChange(
+        mockIndexSplices(makeAccount()), 'REVIEWER');
+    assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
+  });
+
+  test('_purgeReviewersPendingRemove', () => {
+    const removeStub = sandbox.stub(element, '_removeAccount');
+    const mock = function() {
+      element._reviewersPendingRemove = {
+        test: [makeAccount()],
+        test2: [makeAccount(), makeAccount()],
+      };
+    };
+    const checkObjEmpty = function(obj) {
+      for (const prop in obj) {
+        if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
+      }
+      return true;
+    };
+    mock();
+    element._purgeReviewersPendingRemove(true); // Cancel
+    assert.isFalse(removeStub.called);
+    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
+
+    mock();
+    element._purgeReviewersPendingRemove(false); // Submit
+    assert.isTrue(removeStub.called);
+    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
+  });
+
+  test('_removeAccount', done => {
+    sandbox.stub(element.$.restAPI, 'removeChangeReviewer')
+        .returns(Promise.resolve({ok: true}));
+    const arr = [makeAccount(), makeAccount()];
+    element.change.reviewers = {
+      REVIEWER: arr.slice(),
+    };
+
+    element._removeAccount(arr[1], 'REVIEWER').then(() => {
+      assert.equal(element.change.reviewers.REVIEWER.length, 1);
+      assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
+      done();
+    });
+  });
+
+  test('moving from cc to reviewer', () => {
+    element._reviewersPendingRemove = {
+      CC: [],
+      REVIEWER: [],
+    };
+    flushAsynchronousOperations();
+
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const reviewer3 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    const cc4 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2, reviewer3];
+    element._ccs = [cc1, cc2, cc3, cc4];
+    element.push('_reviewers', cc1);
+    flushAsynchronousOperations();
+
+    assert.deepEqual(element._reviewers,
+        [reviewer1, reviewer2, reviewer3, cc1]);
+    assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
+    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
+
+    element.push('_reviewers', cc4, cc3);
+    flushAsynchronousOperations();
+
+    assert.deepEqual(element._reviewers,
+        [reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
+    assert.deepEqual(element._ccs, [cc2]);
+    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
+  });
+
+  test('moving from reviewer to cc', () => {
+    element._reviewersPendingRemove = {
+      CC: [],
+      REVIEWER: [],
+    };
+    flushAsynchronousOperations();
+
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const reviewer3 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    const cc4 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2, reviewer3];
+    element._ccs = [cc1, cc2, cc3, cc4];
+    element.push('_ccs', reviewer1);
+    flushAsynchronousOperations();
+
+    assert.deepEqual(element._reviewers,
+        [reviewer2, reviewer3]);
+    assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
+    assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
+
+    element.push('_ccs', reviewer3, reviewer2);
+    flushAsynchronousOperations();
+
+    assert.deepEqual(element._reviewers, []);
+    assert.deepEqual(element._ccs,
+        [cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
+    assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
+        [reviewer1, reviewer3, reviewer2]);
+  });
+
+  test('migrate reviewers between states', done => {
+    element._reviewersPendingRemove = {
+      CC: [],
+      REVIEWER: [],
+    };
+    flushAsynchronousOperations();
+    const reviewers = element.$.reviewers;
+    const ccs = element.$.ccs;
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2];
+    element._ccs = [cc1, cc2, cc3];
+
+    const mutations = [];
+
+    stubSaveReview(review => mutations.push(...review.reviewers));
+
+    sandbox.stub(element, '_removeAccount', (account, type) => {
+      mutations.push({state: 'REMOVED', account});
+      return Promise.resolve();
+    });
+
+    // Remove and add to other field.
+    reviewers.fire('remove', {account: reviewer1});
+    ccs.$.entry.fire('add', {value: {account: reviewer1}});
+    ccs.fire('remove', {account: cc1});
+    ccs.fire('remove', {account: cc3});
+    reviewers.$.entry.fire('add', {value: {account: cc1}});
+
+    // Add to other field without removing from former field.
+    // (Currently not possible in UI, but this is a good consistency check).
+    reviewers.$.entry.fire('add', {value: {account: cc2}});
+    ccs.$.entry.fire('add', {value: {account: reviewer2}});
+    const mapReviewer = function(reviewer, opt_state) {
+      const result = {reviewer: reviewer._account_id, confirmed: undefined};
+      if (opt_state) {
+        result.state = opt_state;
+      }
+      return result;
+    };
+
+    // Send and purge and verify moves, delete cc3.
+    element.send()
+        .then(keepReviewers =>
+          element._purgeReviewersPendingRemove(false, keepReviewers))
+        .then(() => {
+          assert.deepEqual(
+              mutations, [
+                mapReviewer(cc1),
+                mapReviewer(cc2),
+                mapReviewer(reviewer1, 'CC'),
+                mapReviewer(reviewer2, 'CC'),
+                {account: cc3, state: 'REMOVED'},
+              ]);
+          done();
+        });
+  });
+
+  test('emits cancel on esc key', () => {
+    const cancelHandler = sandbox.spy();
+    element.addEventListener('cancel', cancelHandler);
+    MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+    flushAsynchronousOperations();
+
+    assert.isTrue(cancelHandler.called);
+  });
+
+  test('should not send on enter key', () => {
+    stubSaveReview(() => undefined);
+    element.addEventListener('send', () => assert.fail('wrongly called'));
+    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+    flushAsynchronousOperations();
+  });
+
+  test('emit send on ctrl+enter key', done => {
+    stubSaveReview(() => undefined);
+    element.addEventListener('send', () => done());
+    MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
+    flushAsynchronousOperations();
+  });
+
+  test('_computeMessagePlaceholder', () => {
+    assert.equal(
+        element._computeMessagePlaceholder(false),
+        'Say something nice...');
+    assert.equal(
+        element._computeMessagePlaceholder(true),
+        'Add a note for your reviewers...');
+  });
+
+  test('_computeSendButtonLabel', () => {
+    assert.equal(
+        element._computeSendButtonLabel(false),
+        'Send');
+    assert.equal(
+        element._computeSendButtonLabel(true),
+        'Start review');
+  });
+
+  test('_handle400Error reviewrs and CCs', done => {
+    const error1 = 'error 1';
+    const error2 = 'error 2';
+    const error3 = 'error 3';
+    const text = ')]}\'' + JSON.stringify({
+      reviewers: {
+        username1: {
+          input: 'user 1',
+          error: error1,
+        },
+        username2: {
+          input: 'user 2',
+          error: error2,
+        },
+      },
+      ccs: {
+        username3: {
+          input: 'user 3',
+          error: error3,
+        },
+      },
+    });
+    element.addEventListener('server-error', e => {
+      e.detail.response.text().then(text => {
+        assert.equal(text, [error1, error2, error3].join(', '));
+        done();
+      });
+    });
+    element._handle400Error(cloneableResponse(400, text));
+  });
+
+  test('_handle400Error CCs only', done => {
+    const error1 = 'error 1';
+    const text = ')]}\'' + JSON.stringify({
+      ccs: {
+        username1: {
+          input: 'user 1',
+          error: error1,
+        },
+      },
+    });
+    element.addEventListener('server-error', e => {
+      e.detail.response.text().then(text => {
+        assert.equal(text, error1);
+        done();
+      });
+    });
+    element._handle400Error(cloneableResponse(400, text));
+  });
+
+  test('fires height change when the drafts comments load', done => {
+    // Flush DOM operations before binding to the autogrow event so we don't
+    // catch the events fired from the initial layout.
+    flush(() => {
+      const autoGrowHandler = sinon.stub();
+      element.addEventListener('autogrow', autoGrowHandler);
+      element.draftCommentThreads = [];
+      flush(() => {
+        assert.isTrue(autoGrowHandler.called);
+        done();
+      });
+    });
+  });
+
+  suite('post review API', () => {
+    let startReviewStub;
+
+    setup(() => {
+      startReviewStub = sandbox.stub(
+          element.$.restAPI,
+          'startReview',
+          () => Promise.resolve());
+    });
+
+    test('ready property in review input on start review', () => {
+      stubSaveReview(review => {
+        assert.isTrue(review.ready);
         return {ready: true};
       });
       return element.send(true, true).then(() => {
-        assert.isFalse(element.disabled);
+        assert.isFalse(startReviewStub.called);
       });
     });
 
-    suite('error handling', () => {
-      const expectedDraft = 'draft';
-      const expectedError = new Error('test');
-
-      setup(() => {
-        element.draft = expectedDraft;
+    test('no ready property in review input on save review', () => {
+      stubSaveReview(review => {
+        assert.isUndefined(review.ready);
       });
-
-      function assertDialogOpenAndEnabled() {
-        assert.strictEqual(expectedDraft, element.draft);
-        assert.isFalse(element.disabled);
-      }
-
-      test('error occurs in _saveReview', () => {
-        stubSaveReview(review => {
-          throw expectedError;
-        });
-        return element.send(true, true).catch(err => {
-          assert.strictEqual(expectedError, err);
-          assertDialogOpenAndEnabled();
-        });
+      return element.send(true, false).then(() => {
+        assert.isFalse(startReviewStub.called);
       });
-
-      suite('pending diff drafts?', () => {
-        test('yes', () => {
-          const promise = mockPromise();
-          const refreshHandler = sandbox.stub();
-
-          element.addEventListener('comment-refresh', refreshHandler);
-          sandbox.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(true);
-          element.$.restAPI._pendingRequests.sendDiffDraft = [promise];
-          element.open();
-
-          assert.isFalse(refreshHandler.called);
-          assert.isTrue(element._savingComments);
-
-          promise.resolve();
-
-          return element.$.restAPI.awaitPendingDiffDrafts().then(() => {
-            assert.isTrue(refreshHandler.called);
-            assert.isFalse(element._savingComments);
-          });
-        });
-
-        test('no', () => {
-          sandbox.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(false);
-          element.open();
-          assert.notOk(element._savingComments);
-        });
-      });
-    });
-
-    test('_computeSendButtonDisabled', () => {
-      const fn = element._computeSendButtonDisabled.bind(element);
-      assert.isFalse(fn(
-          /* buttonLabel= */ 'Start review',
-          /* draftCommentThreads= */ [],
-          /* text= */ '',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ false,
-          /* includeComments= */ false,
-          /* disabled= */ false
-      ));
-      assert.isTrue(fn(
-          /* buttonLabel= */ 'Send',
-          /* draftCommentThreads= */ [],
-          /* text= */ '',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ false,
-          /* includeComments= */ false,
-          /* disabled= */ false
-      ));
-      // Mock nonempty comment draft array, with seding comments.
-      assert.isFalse(fn(
-          /* buttonLabel= */ 'Send',
-          /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
-          /* text= */ '',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ false,
-          /* includeComments= */ true,
-          /* disabled= */ false
-      ));
-      // Mock nonempty comment draft array, without seding comments.
-      assert.isTrue(fn(
-          /* buttonLabel= */ 'Send',
-          /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
-          /* text= */ '',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ false,
-          /* includeComments= */ false,
-          /* disabled= */ false
-      ));
-      // Mock nonempty change message.
-      assert.isFalse(fn(
-          /* buttonLabel= */ 'Send',
-          /* draftCommentThreads= */ {},
-          /* text= */ 'test',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ false,
-          /* includeComments= */ false,
-          /* disabled= */ false
-      ));
-      // Mock reviewers mutated.
-      assert.isFalse(fn(
-          /* buttonLabel= */ 'Send',
-          /* draftCommentThreads= */ {},
-          /* text= */ '',
-          /* reviewersMutated= */ true,
-          /* labelsChanged= */ false,
-          /* includeComments= */ false,
-          /* disabled= */ false
-      ));
-      // Mock labels changed.
-      assert.isFalse(fn(
-          /* buttonLabel= */ 'Send',
-          /* draftCommentThreads= */ {},
-          /* text= */ '',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ true,
-          /* includeComments= */ false,
-          /* disabled= */ false
-      ));
-      // Whole dialog is disabled.
-      assert.isTrue(fn(
-          /* buttonLabel= */ 'Send',
-          /* draftCommentThreads= */ {},
-          /* text= */ '',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ true,
-          /* includeComments= */ false,
-          /* disabled= */ true
-      ));
-    });
-
-    test('_submit blocked when no mutations exist', () => {
-      const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
-      // Stub the below function to avoid side effects from the send promise
-      // resolving.
-      sandbox.stub(element, '_purgeReviewersPendingRemove');
-      element.draftCommentThreads = [];
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('gr-button.send'));
-      assert.isFalse(sendStub.called);
-
-      element.draftCommentThreads = [{comments: [{__draft: true}]}];
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('gr-button.send'));
-      assert.isTrue(sendStub.called);
-    });
-
-    test('getFocusStops', () => {
-      // Setting draftCommentThreads to an empty object causes _sendDisabled to be
-      // computed to false.
-      element.draftCommentThreads = [];
-      assert.equal(element.getFocusStops().end, element.$.cancelButton);
-      element.draftCommentThreads = [{comments: [{__draft: true}]}];
-      assert.equal(element.getFocusStops().end, element.$.sendButton);
-    });
-
-    test('setPluginMessage', () => {
-      element.setPluginMessage('foo');
-      assert.equal(element.$.pluginMessage.textContent, 'foo');
     });
   });
+
+  suite('start review and save buttons', () => {
+    let sendStub;
+
+    setup(() => {
+      sendStub = sandbox.stub(element, 'send', () => Promise.resolve());
+      element.canBeStarted = true;
+      // Flush to make both Start/Save buttons appear in DOM.
+      flushAsynchronousOperations();
+    });
+
+    test('start review sets ready', () => {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.send'));
+      flushAsynchronousOperations();
+      assert.isTrue(sendStub.calledWith(true, true));
+    });
+
+    test('save review doesn\'t set ready', () => {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+      flushAsynchronousOperations();
+      assert.isTrue(sendStub.calledWith(true, false));
+    });
+  });
+
+  test('buttons disabled until all API calls are resolved', () => {
+    stubSaveReview(review => {
+      return {ready: true};
+    });
+    return element.send(true, true).then(() => {
+      assert.isFalse(element.disabled);
+    });
+  });
+
+  suite('error handling', () => {
+    const expectedDraft = 'draft';
+    const expectedError = new Error('test');
+
+    setup(() => {
+      element.draft = expectedDraft;
+    });
+
+    function assertDialogOpenAndEnabled() {
+      assert.strictEqual(expectedDraft, element.draft);
+      assert.isFalse(element.disabled);
+    }
+
+    test('error occurs in _saveReview', () => {
+      stubSaveReview(review => {
+        throw expectedError;
+      });
+      return element.send(true, true).catch(err => {
+        assert.strictEqual(expectedError, err);
+        assertDialogOpenAndEnabled();
+      });
+    });
+
+    suite('pending diff drafts?', () => {
+      test('yes', () => {
+        const promise = mockPromise();
+        const refreshHandler = sandbox.stub();
+
+        element.addEventListener('comment-refresh', refreshHandler);
+        sandbox.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(true);
+        element.$.restAPI._pendingRequests.sendDiffDraft = [promise];
+        element.open();
+
+        assert.isFalse(refreshHandler.called);
+        assert.isTrue(element._savingComments);
+
+        promise.resolve();
+
+        return element.$.restAPI.awaitPendingDiffDrafts().then(() => {
+          assert.isTrue(refreshHandler.called);
+          assert.isFalse(element._savingComments);
+        });
+      });
+
+      test('no', () => {
+        sandbox.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(false);
+        element.open();
+        assert.notOk(element._savingComments);
+      });
+    });
+  });
+
+  test('_computeSendButtonDisabled', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    assert.isFalse(fn(
+        /* buttonLabel= */ 'Start review',
+        /* draftCommentThreads= */ [],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false
+    ));
+    assert.isTrue(fn(
+        /* buttonLabel= */ 'Send',
+        /* draftCommentThreads= */ [],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false
+    ));
+    // Mock nonempty comment draft array, with seding comments.
+    assert.isFalse(fn(
+        /* buttonLabel= */ 'Send',
+        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ true,
+        /* disabled= */ false
+    ));
+    // Mock nonempty comment draft array, without seding comments.
+    assert.isTrue(fn(
+        /* buttonLabel= */ 'Send',
+        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false
+    ));
+    // Mock nonempty change message.
+    assert.isFalse(fn(
+        /* buttonLabel= */ 'Send',
+        /* draftCommentThreads= */ {},
+        /* text= */ 'test',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false
+    ));
+    // Mock reviewers mutated.
+    assert.isFalse(fn(
+        /* buttonLabel= */ 'Send',
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ true,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false
+    ));
+    // Mock labels changed.
+    assert.isFalse(fn(
+        /* buttonLabel= */ 'Send',
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ true,
+        /* includeComments= */ false,
+        /* disabled= */ false
+    ));
+    // Whole dialog is disabled.
+    assert.isTrue(fn(
+        /* buttonLabel= */ 'Send',
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ true,
+        /* includeComments= */ false,
+        /* disabled= */ true
+    ));
+  });
+
+  test('_submit blocked when no mutations exist', () => {
+    const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
+    // Stub the below function to avoid side effects from the send promise
+    // resolving.
+    sandbox.stub(element, '_purgeReviewersPendingRemove');
+    element.draftCommentThreads = [];
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isFalse(sendStub.called);
+
+    element.draftCommentThreads = [{comments: [{__draft: true}]}];
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isTrue(sendStub.called);
+  });
+
+  test('getFocusStops', () => {
+    // Setting draftCommentThreads to an empty object causes _sendDisabled to be
+    // computed to false.
+    element.draftCommentThreads = [];
+    assert.equal(element.getFocusStops().end, element.$.cancelButton);
+    element.draftCommentThreads = [{comments: [{__draft: true}]}];
+    assert.equal(element.getFocusStops().end, element.$.sendButton);
+  });
+
+  test('setPluginMessage', () => {
+    element.setPluginMessage('foo');
+    assert.equal(element.$.pluginMessage.textContent, 'foo');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index ddc6275..a74ec5f 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,276 +14,288 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../shared/gr-account-chip/gr-account-chip.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-reviewer-list_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrReviewerList extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-reviewer-list'; }
+  /**
+   * Fired when the "Add reviewer..." button is tapped.
+   *
+   * @event show-reply-dialog
+   */
+
+  static get properties() {
+    return {
+      change: Object,
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      mutable: {
+        type: Boolean,
+        value: false,
+      },
+      reviewersOnly: {
+        type: Boolean,
+        value: false,
+      },
+      ccsOnly: {
+        type: Boolean,
+        value: false,
+      },
+      maxReviewersDisplayed: Number,
+
+      _displayedReviewers: {
+        type: Array,
+        value() { return []; },
+      },
+      _reviewers: {
+        type: Array,
+        value() { return []; },
+      },
+      _showInput: {
+        type: Boolean,
+        value: false,
+      },
+      _addLabel: {
+        type: String,
+        computed: '_computeAddLabel(ccsOnly)',
+      },
+      _hiddenReviewerCount: {
+        type: Number,
+        computed: '_computeHiddenCount(_reviewers, _displayedReviewers)',
+      },
+
+      // Used for testing.
+      _lastAutocompleteRequest: Object,
+      _xhrPromise: Object,
+    };
+  }
+
+  static get observers() {
+    return [
+      '_reviewersChanged(change.reviewers.*, change.owner)',
+    ];
+  }
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Converts change.permitted_labels to an array of hashes of label keys to
+   * numeric scores.
+   * Example:
+   * [{
+   *   'Code-Review': ['-1', ' 0', '+1']
+   * }]
+   * will be converted to
+   * [{
+   *   label: 'Code-Review',
+   *   scores: [-1, 0, 1]
+   * }]
    */
-  class GrReviewerList extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-reviewer-list'; }
-    /**
-     * Fired when the "Add reviewer..." button is tapped.
-     *
-     * @event show-reply-dialog
-     */
-
-    static get properties() {
+  _permittedLabelsToNumericScores(labels) {
+    if (!labels) return [];
+    return Object.keys(labels).map(label => {
       return {
-        change: Object,
-        disabled: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-        mutable: {
-          type: Boolean,
-          value: false,
-        },
-        reviewersOnly: {
-          type: Boolean,
-          value: false,
-        },
-        ccsOnly: {
-          type: Boolean,
-          value: false,
-        },
-        maxReviewersDisplayed: Number,
-
-        _displayedReviewers: {
-          type: Array,
-          value() { return []; },
-        },
-        _reviewers: {
-          type: Array,
-          value() { return []; },
-        },
-        _showInput: {
-          type: Boolean,
-          value: false,
-        },
-        _addLabel: {
-          type: String,
-          computed: '_computeAddLabel(ccsOnly)',
-        },
-        _hiddenReviewerCount: {
-          type: Number,
-          computed: '_computeHiddenCount(_reviewers, _displayedReviewers)',
-        },
-
-        // Used for testing.
-        _lastAutocompleteRequest: Object,
-        _xhrPromise: Object,
+        label,
+        scores: labels[label].map(v => parseInt(v, 10)),
       };
-    }
+    });
+  }
 
-    static get observers() {
-      return [
-        '_reviewersChanged(change.reviewers.*, change.owner)',
-      ];
-    }
+  /**
+   * Returns hash of labels to max permitted score.
+   *
+   * @param {!Object} change
+   * @returns {!Object} labels to max permitted scores hash
+   */
+  _getMaxPermittedScores(change) {
+    return this._permittedLabelsToNumericScores(change.permitted_labels)
+        .map(({label, scores}) => {
+          return {
+            [label]: scores
+                .map(v => parseInt(v, 10))
+                .reduce((a, b) => Math.max(a, b))};
+        })
+        .reduce((acc, i) => Object.assign(acc, i), {});
+  }
 
-    /**
-     * Converts change.permitted_labels to an array of hashes of label keys to
-     * numeric scores.
-     * Example:
-     * [{
-     *   'Code-Review': ['-1', ' 0', '+1']
-     * }]
-     * will be converted to
-     * [{
-     *   label: 'Code-Review',
-     *   scores: [-1, 0, 1]
-     * }]
-     */
-    _permittedLabelsToNumericScores(labels) {
-      if (!labels) return [];
-      return Object.keys(labels).map(label => {
-        return {
-          label,
-          scores: labels[label].map(v => parseInt(v, 10)),
-        };
-      });
-    }
-
-    /**
-     * Returns hash of labels to max permitted score.
-     *
-     * @param {!Object} change
-     * @returns {!Object} labels to max permitted scores hash
-     */
-    _getMaxPermittedScores(change) {
-      return this._permittedLabelsToNumericScores(change.permitted_labels)
-          .map(({label, scores}) => {
-            return {
-              [label]: scores
-                  .map(v => parseInt(v, 10))
-                  .reduce((a, b) => Math.max(a, b))};
-          })
-          .reduce((acc, i) => Object.assign(acc, i), {});
-    }
-
-    /**
-     * Returns max permitted score for reviewer.
-     *
-     * @param {!Object} reviewer
-     * @param {!Object} change
-     * @param {string} label
-     * @return {number}
-     */
-    _getReviewerPermittedScore(reviewer, change, label) {
-      // Note (issue 7874): sometimes the "all" list is not included in change
-      // detail responses, even when DETAILED_LABELS is included in options.
-      if (!change.labels[label].all) { return NaN; }
-      const detailed = change.labels[label].all.filter(
-          ({_account_id}) => reviewer._account_id === _account_id).pop();
-      if (!detailed) {
-        return NaN;
-      }
-      if (detailed.hasOwnProperty('permitted_voting_range')) {
-        return detailed.permitted_voting_range.max;
-      } else if (detailed.hasOwnProperty('value')) {
-        // If preset, user can vote on the label.
-        return 0;
-      }
+  /**
+   * Returns max permitted score for reviewer.
+   *
+   * @param {!Object} reviewer
+   * @param {!Object} change
+   * @param {string} label
+   * @return {number}
+   */
+  _getReviewerPermittedScore(reviewer, change, label) {
+    // Note (issue 7874): sometimes the "all" list is not included in change
+    // detail responses, even when DETAILED_LABELS is included in options.
+    if (!change.labels[label].all) { return NaN; }
+    const detailed = change.labels[label].all.filter(
+        ({_account_id}) => reviewer._account_id === _account_id).pop();
+    if (!detailed) {
       return NaN;
     }
+    if (detailed.hasOwnProperty('permitted_voting_range')) {
+      return detailed.permitted_voting_range.max;
+    } else if (detailed.hasOwnProperty('value')) {
+      // If preset, user can vote on the label.
+      return 0;
+    }
+    return NaN;
+  }
 
-    _computeReviewerTooltip(reviewer, change) {
-      if (!change || !change.labels) { return ''; }
-      const maxScores = [];
-      const maxPermitted = this._getMaxPermittedScores(change);
-      for (const label of Object.keys(change.labels)) {
-        const maxScore =
-              this._getReviewerPermittedScore(reviewer, change, label);
-        if (isNaN(maxScore) || maxScore < 0) { continue; }
-        if (maxScore > 0 && maxScore === maxPermitted[label]) {
-          maxScores.push(`${label}: +${maxScore}`);
-        } else {
-          maxScores.push(`${label}`);
-        }
-      }
-      if (maxScores.length) {
-        return 'Votable: ' + maxScores.join(', ');
+  _computeReviewerTooltip(reviewer, change) {
+    if (!change || !change.labels) { return ''; }
+    const maxScores = [];
+    const maxPermitted = this._getMaxPermittedScores(change);
+    for (const label of Object.keys(change.labels)) {
+      const maxScore =
+            this._getReviewerPermittedScore(reviewer, change, label);
+      if (isNaN(maxScore) || maxScore < 0) { continue; }
+      if (maxScore > 0 && maxScore === maxPermitted[label]) {
+        maxScores.push(`${label}: +${maxScore}`);
       } else {
-        return '';
+        maxScores.push(`${label}`);
       }
     }
-
-    _reviewersChanged(changeRecord, owner) {
-      // Polymer 2: check for undefined
-      if ([changeRecord, owner].some(arg => arg === undefined)) {
-        return;
-      }
-
-      let result = [];
-      const reviewers = changeRecord.base;
-      for (const key in reviewers) {
-        if (this.reviewersOnly && key !== 'REVIEWER') {
-          continue;
-        }
-        if (this.ccsOnly && key !== 'CC') {
-          continue;
-        }
-        if (key === 'REVIEWER' || key === 'CC') {
-          result = result.concat(reviewers[key]);
-        }
-      }
-      this._reviewers = result
-          .filter(reviewer => reviewer._account_id != owner._account_id);
-
-      // If there is one or two more than the max reviewers, don't show the
-      // 'show more' button, because it takes up just as much space.
-      if (this.maxReviewersDisplayed &&
-          this._reviewers.length > this.maxReviewersDisplayed + 2) {
-        this._displayedReviewers =
-          this._reviewers.slice(0, this.maxReviewersDisplayed);
-      } else {
-        this._displayedReviewers = this._reviewers;
-      }
-    }
-
-    _computeHiddenCount(reviewers, displayedReviewers) {
-      // Polymer 2: check for undefined
-      if ([reviewers, displayedReviewers].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      return reviewers.length - displayedReviewers.length;
-    }
-
-    _computeCanRemoveReviewer(reviewer, mutable) {
-      if (!mutable) { return false; }
-
-      let current;
-      for (let i = 0; i < this.change.removable_reviewers.length; i++) {
-        current = this.change.removable_reviewers[i];
-        if (current._account_id === reviewer._account_id ||
-            (!reviewer._account_id && current.email === reviewer.email)) {
-          return true;
-        }
-      }
-      return false;
-    }
-
-    _handleRemove(e) {
-      e.preventDefault();
-      const target = Polymer.dom(e).rootTarget;
-      if (!target.account) { return; }
-      const accountID = target.account._account_id || target.account.email;
-      this.disabled = true;
-      this._xhrPromise = this._removeReviewer(accountID).then(response => {
-        this.disabled = false;
-        if (!response.ok) { return response; }
-
-        const reviewers = this.change.reviewers;
-
-        for (const type of ['REVIEWER', 'CC']) {
-          reviewers[type] = reviewers[type] || [];
-          for (let i = 0; i < reviewers[type].length; i++) {
-            if (reviewers[type][i]._account_id == accountID ||
-            reviewers[type][i].email == accountID) {
-              this.splice('change.reviewers.' + type, i, 1);
-              break;
-            }
-          }
-        }
-      })
-          .catch(err => {
-            this.disabled = false;
-            throw err;
-          });
-    }
-
-    _handleAddTap(e) {
-      e.preventDefault();
-      const value = {};
-      if (this.reviewersOnly) {
-        value.reviewersOnly = true;
-      }
-      if (this.ccsOnly) {
-        value.ccsOnly = true;
-      }
-      this.fire('show-reply-dialog', {value});
-    }
-
-    _handleViewAll(e) {
-      this._displayedReviewers = this._reviewers;
-    }
-
-    _removeReviewer(id) {
-      return this.$.restAPI.removeChangeReviewer(this.change._number, id);
-    }
-
-    _computeAddLabel(ccsOnly) {
-      return ccsOnly ? 'Add CC' : 'Add reviewer';
+    if (maxScores.length) {
+      return 'Votable: ' + maxScores.join(', ');
+    } else {
+      return '';
     }
   }
 
-  customElements.define(GrReviewerList.is, GrReviewerList);
-})();
+  _reviewersChanged(changeRecord, owner) {
+    // Polymer 2: check for undefined
+    if ([changeRecord, owner].some(arg => arg === undefined)) {
+      return;
+    }
+
+    let result = [];
+    const reviewers = changeRecord.base;
+    for (const key in reviewers) {
+      if (this.reviewersOnly && key !== 'REVIEWER') {
+        continue;
+      }
+      if (this.ccsOnly && key !== 'CC') {
+        continue;
+      }
+      if (key === 'REVIEWER' || key === 'CC') {
+        result = result.concat(reviewers[key]);
+      }
+    }
+    this._reviewers = result
+        .filter(reviewer => reviewer._account_id != owner._account_id);
+
+    // If there is one or two more than the max reviewers, don't show the
+    // 'show more' button, because it takes up just as much space.
+    if (this.maxReviewersDisplayed &&
+        this._reviewers.length > this.maxReviewersDisplayed + 2) {
+      this._displayedReviewers =
+        this._reviewers.slice(0, this.maxReviewersDisplayed);
+    } else {
+      this._displayedReviewers = this._reviewers;
+    }
+  }
+
+  _computeHiddenCount(reviewers, displayedReviewers) {
+    // Polymer 2: check for undefined
+    if ([reviewers, displayedReviewers].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    return reviewers.length - displayedReviewers.length;
+  }
+
+  _computeCanRemoveReviewer(reviewer, mutable) {
+    if (!mutable) { return false; }
+
+    let current;
+    for (let i = 0; i < this.change.removable_reviewers.length; i++) {
+      current = this.change.removable_reviewers[i];
+      if (current._account_id === reviewer._account_id ||
+          (!reviewer._account_id && current.email === reviewer.email)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  _handleRemove(e) {
+    e.preventDefault();
+    const target = dom(e).rootTarget;
+    if (!target.account) { return; }
+    const accountID = target.account._account_id || target.account.email;
+    this.disabled = true;
+    this._xhrPromise = this._removeReviewer(accountID).then(response => {
+      this.disabled = false;
+      if (!response.ok) { return response; }
+
+      const reviewers = this.change.reviewers;
+
+      for (const type of ['REVIEWER', 'CC']) {
+        reviewers[type] = reviewers[type] || [];
+        for (let i = 0; i < reviewers[type].length; i++) {
+          if (reviewers[type][i]._account_id == accountID ||
+          reviewers[type][i].email == accountID) {
+            this.splice('change.reviewers.' + type, i, 1);
+            break;
+          }
+        }
+      }
+    })
+        .catch(err => {
+          this.disabled = false;
+          throw err;
+        });
+  }
+
+  _handleAddTap(e) {
+    e.preventDefault();
+    const value = {};
+    if (this.reviewersOnly) {
+      value.reviewersOnly = true;
+    }
+    if (this.ccsOnly) {
+      value.ccsOnly = true;
+    }
+    this.fire('show-reply-dialog', {value});
+  }
+
+  _handleViewAll(e) {
+    this._displayedReviewers = this._reviewers;
+  }
+
+  _removeReviewer(id) {
+    return this.$.restAPI.removeChangeReviewer(this.change._number, id);
+  }
+
+  _computeAddLabel(ccsOnly) {
+    return ccsOnly ? 'Add CC' : 'Add reviewer';
+  }
+}
+
+customElements.define(GrReviewerList.is, GrReviewerList);
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js
index 132ce11..bf7db12 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-reviewer-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -51,26 +44,13 @@
     </style>
     <div class="container">
       <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
-        <gr-account-chip class="reviewer" account="[[reviewer]]"
-            on-remove="_handleRemove"
-            additional-text="[[_computeReviewerTooltip(reviewer, change)]]"
-            removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]">
+        <gr-account-chip class="reviewer" account="[[reviewer]]" on-remove="_handleRemove" additional-text="[[_computeReviewerTooltip(reviewer, change)]]" removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]">
         </gr-account-chip>
       </template>
-      <gr-button
-          class="hiddenReviewers"
-          link
-          hidden$="[[!_hiddenReviewerCount]]"
-          on-click="_handleViewAll">and [[_hiddenReviewerCount]] more</gr-button>
-      <div class="controlsContainer" hidden$="[[!mutable]]">
-        <gr-button
-            link
-            id="addReviewer"
-            class="addReviewer"
-            on-click="_handleAddTap">[[_addLabel]]</gr-button>
+      <gr-button class="hiddenReviewers" link="" hidden\$="[[!_hiddenReviewerCount]]" on-click="_handleViewAll">and [[_hiddenReviewerCount]] more</gr-button>
+      <div class="controlsContainer" hidden\$="[[!mutable]]">
+        <gr-button link="" id="addReviewer" class="addReviewer" on-click="_handleAddTap">[[_addLabel]]</gr-button>
       </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-reviewer-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index be65a86..627fa10 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-reviewer-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-reviewer-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-reviewer-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,78 +40,66 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-reviewer-list tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-reviewer-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-reviewer-list tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        removeChangeReviewer() {
-          return Promise.resolve({ok: true});
-        },
-      });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      removeChangeReviewer() {
+        return Promise.resolve({ok: true});
+      },
     });
+  });
 
-    teardown(() => {
-      sandbox.restore();
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('controls hidden on immutable element', () => {
+    element.mutable = false;
+    assert.isTrue(element.shadowRoot
+        .querySelector('.controlsContainer').hasAttribute('hidden'));
+    element.mutable = true;
+    assert.isFalse(element.shadowRoot
+        .querySelector('.controlsContainer').hasAttribute('hidden'));
+  });
+
+  test('add reviewer button opens reply dialog', done => {
+    element.addEventListener('show-reply-dialog', () => {
+      done();
     });
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.addReviewer'));
+  });
 
-    test('controls hidden on immutable element', () => {
-      element.mutable = false;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.controlsContainer').hasAttribute('hidden'));
-      element.mutable = true;
-      assert.isFalse(element.shadowRoot
-          .querySelector('.controlsContainer').hasAttribute('hidden'));
-    });
-
-    test('add reviewer button opens reply dialog', done => {
-      element.addEventListener('show-reply-dialog', () => {
-        done();
-      });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.addReviewer'));
-    });
-
-    test('only show remove for removable reviewers', () => {
-      element.mutable = true;
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          REVIEWER: [
-            {
-              _account_id: 2,
-              name: 'Bojack Horseman',
-              email: 'SecretariatRulez96@hotmail.com',
-            },
-            {
-              _account_id: 3,
-              name: 'Pinky Penguin',
-            },
-          ],
-          CC: [
-            {
-              _account_id: 4,
-              name: 'Diane Nguyen',
-              email: 'macarthurfellow2B@juno.com',
-            },
-            {
-              email: 'test@e.mail',
-            },
-          ],
-        },
-        removable_reviewers: [
+  test('only show remove for removable reviewers', () => {
+    element.mutable = true;
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        REVIEWER: [
+          {
+            _account_id: 2,
+            name: 'Bojack Horseman',
+            email: 'SecretariatRulez96@hotmail.com',
+          },
           {
             _account_id: 3,
             name: 'Pinky Penguin',
           },
+        ],
+        CC: [
           {
             _account_id: 4,
             name: 'Diane Nguyen',
@@ -116,230 +109,245 @@
             email: 'test@e.mail',
           },
         ],
-      };
-      flushAsynchronousOperations();
-      const chips =
-          Polymer.dom(element.root).querySelectorAll('gr-account-chip');
-      assert.equal(chips.length, 4);
+      },
+      removable_reviewers: [
+        {
+          _account_id: 3,
+          name: 'Pinky Penguin',
+        },
+        {
+          _account_id: 4,
+          name: 'Diane Nguyen',
+          email: 'macarthurfellow2B@juno.com',
+        },
+        {
+          email: 'test@e.mail',
+        },
+      ],
+    };
+    flushAsynchronousOperations();
+    const chips =
+        dom(element.root).querySelectorAll('gr-account-chip');
+    assert.equal(chips.length, 4);
 
-      for (const el of Array.from(chips)) {
-        const accountID = el.account._account_id || el.account.email;
-        assert.ok(accountID);
+    for (const el of Array.from(chips)) {
+      const accountID = el.account._account_id || el.account.email;
+      assert.ok(accountID);
 
-        const buttonEl = el.shadowRoot
-            .querySelector('gr-button');
-        assert.isNotNull(buttonEl);
-        if (accountID == 2) {
-          assert.isTrue(buttonEl.hasAttribute('hidden'));
-        } else {
-          assert.isFalse(buttonEl.hasAttribute('hidden'));
-        }
+      const buttonEl = el.shadowRoot
+          .querySelector('gr-button');
+      assert.isNotNull(buttonEl);
+      if (accountID == 2) {
+        assert.isTrue(buttonEl.hasAttribute('hidden'));
+      } else {
+        assert.isFalse(buttonEl.hasAttribute('hidden'));
       }
-    });
-
-    test('tracking reviewers and ccs', () => {
-      let counter = 0;
-      function makeAccount() {
-        return {_account_id: counter++};
-      }
-
-      const owner = makeAccount();
-      const reviewer = makeAccount();
-      const cc = makeAccount();
-      const reviewers = {
-        REMOVED: [makeAccount()],
-        REVIEWER: [owner, reviewer],
-        CC: [owner, cc],
-      };
-
-      element.ccsOnly = false;
-      element.reviewersOnly = false;
-      element.change = {
-        owner,
-        reviewers,
-      };
-      assert.deepEqual(element._reviewers, [reviewer, cc]);
-
-      element.reviewersOnly = true;
-      element.change = {
-        owner,
-        reviewers,
-      };
-      assert.deepEqual(element._reviewers, [reviewer]);
-
-      element.ccsOnly = true;
-      element.reviewersOnly = false;
-      element.change = {
-        owner,
-        reviewers,
-      };
-      assert.deepEqual(element._reviewers, [cc]);
-    });
-
-    test('_handleAddTap passes mode with event', () => {
-      const fireStub = sandbox.stub(element, 'fire');
-      const e = {preventDefault() {}};
-
-      element.ccsOnly = false;
-      element.reviewersOnly = false;
-      element._handleAddTap(e);
-      assert.isTrue(fireStub.calledWith('show-reply-dialog', {value: {}}));
-
-      element.reviewersOnly = true;
-      element._handleAddTap(e);
-      assert.isTrue(fireStub.lastCall.calledWith('show-reply-dialog',
-          {value: {reviewersOnly: true}}));
-
-      element.ccsOnly = true;
-      element.reviewersOnly = false;
-      element._handleAddTap(e);
-      assert.isTrue(fireStub.lastCall.calledWith('show-reply-dialog',
-          {value: {ccsOnly: true}}));
-    });
-
-    test('no show all reviewers button with 6 reviewers', () => {
-      const reviewers = [];
-      element.maxReviewersDisplayed = 5;
-      for (let i = 0; i < 6; i++) {
-        reviewers.push(
-            {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-      }
-      element.ccsOnly = true;
-
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          CC: reviewers,
-        },
-      };
-      assert.equal(element._hiddenReviewerCount, 0);
-      assert.equal(element._displayedReviewers.length, 6);
-      assert.equal(element._reviewers.length, 6);
-      assert.isTrue(element.shadowRoot
-          .querySelector('.hiddenReviewers').hidden);
-    });
-
-    test('show all reviewers button with 8 reviewers', () => {
-      const reviewers = [];
-      element.maxReviewersDisplayed = 5;
-      for (let i = 0; i < 8; i++) {
-        reviewers.push(
-            {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-      }
-      element.ccsOnly = true;
-
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          CC: reviewers,
-        },
-      };
-      assert.equal(element._hiddenReviewerCount, 3);
-      assert.equal(element._displayedReviewers.length, 5);
-      assert.equal(element._reviewers.length, 8);
-      assert.isFalse(element.shadowRoot
-          .querySelector('.hiddenReviewers').hidden);
-    });
-
-    test('no maxReviewersDisplayed', () => {
-      const reviewers = [];
-      for (let i = 0; i < 7; i++) {
-        reviewers.push(
-            {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-      }
-      element.ccsOnly = true;
-
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          CC: reviewers,
-        },
-      };
-      assert.equal(element._hiddenReviewerCount, 0);
-      assert.equal(element._displayedReviewers.length, 7);
-      assert.equal(element._reviewers.length, 7);
-      assert.isTrue(element.shadowRoot
-          .querySelector('.hiddenReviewers').hidden);
-    });
-
-    test('show all reviewers button', () => {
-      const reviewers = [];
-      element.maxReviewersDisplayed = 5;
-      for (let i = 0; i < 100; i++) {
-        reviewers.push(
-            {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-      }
-      element.ccsOnly = true;
-
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          CC: reviewers,
-        },
-      };
-      assert.equal(element._hiddenReviewerCount, 95);
-      assert.equal(element._displayedReviewers.length, 5);
-      assert.equal(element._reviewers.length, 100);
-      assert.isFalse(element.shadowRoot
-          .querySelector('.hiddenReviewers').hidden);
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.hiddenReviewers'));
-
-      assert.equal(element._hiddenReviewerCount, 0);
-      assert.equal(element._displayedReviewers.length, 100);
-      assert.equal(element._reviewers.length, 100);
-      assert.isTrue(element.shadowRoot
-          .querySelector('.hiddenReviewers').hidden);
-    });
-
-    test('votable labels', () => {
-      const change = {
-        labels: {
-          Foo: {
-            all: [{_account_id: 7, permitted_voting_range: {max: 2}}],
-          },
-          Bar: {
-            all: [{_account_id: 1, permitted_voting_range: {max: 1}},
-              {_account_id: 7, permitted_voting_range: {max: 1}}],
-          },
-          FooBar: {
-            all: [{_account_id: 7, value: 0}],
-          },
-        },
-        permitted_labels: {
-          Foo: ['-1', ' 0', '+1', '+2'],
-          FooBar: ['-1', ' 0'],
-        },
-      };
-      assert.strictEqual(
-          element._computeReviewerTooltip({_account_id: 1}, change),
-          'Votable: Bar');
-      assert.strictEqual(
-          element._computeReviewerTooltip({_account_id: 7}, change),
-          'Votable: Foo: +2, Bar, FooBar');
-      assert.strictEqual(
-          element._computeReviewerTooltip({_account_id: 2}, change),
-          '');
-    });
-
-    test('fails gracefully when all is not included', () => {
-      const change = {
-        labels: {Foo: {}},
-        permitted_labels: {
-          Foo: ['-1', ' 0', '+1', '+2'],
-        },
-      };
-      assert.strictEqual(
-          element._computeReviewerTooltip({_account_id: 1}, change), '');
-    });
+    }
   });
+
+  test('tracking reviewers and ccs', () => {
+    let counter = 0;
+    function makeAccount() {
+      return {_account_id: counter++};
+    }
+
+    const owner = makeAccount();
+    const reviewer = makeAccount();
+    const cc = makeAccount();
+    const reviewers = {
+      REMOVED: [makeAccount()],
+      REVIEWER: [owner, reviewer],
+      CC: [owner, cc],
+    };
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element.change = {
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer, cc]);
+
+    element.reviewersOnly = true;
+    element.change = {
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer]);
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element.change = {
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [cc]);
+  });
+
+  test('_handleAddTap passes mode with event', () => {
+    const fireStub = sandbox.stub(element, 'fire');
+    const e = {preventDefault() {}};
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.isTrue(fireStub.calledWith('show-reply-dialog', {value: {}}));
+
+    element.reviewersOnly = true;
+    element._handleAddTap(e);
+    assert.isTrue(fireStub.lastCall.calledWith('show-reply-dialog',
+        {value: {reviewersOnly: true}}));
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.isTrue(fireStub.lastCall.calledWith('show-reply-dialog',
+        {value: {ccsOnly: true}}));
+  });
+
+  test('no show all reviewers button with 6 reviewers', () => {
+    const reviewers = [];
+    element.maxReviewersDisplayed = 5;
+    for (let i = 0; i < 6; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 6);
+    assert.equal(element._reviewers.length, 6);
+    assert.isTrue(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('show all reviewers button with 8 reviewers', () => {
+    const reviewers = [];
+    element.maxReviewersDisplayed = 5;
+    for (let i = 0; i < 8; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 3);
+    assert.equal(element._displayedReviewers.length, 5);
+    assert.equal(element._reviewers.length, 8);
+    assert.isFalse(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('no maxReviewersDisplayed', () => {
+    const reviewers = [];
+    for (let i = 0; i < 7; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 7);
+    assert.equal(element._reviewers.length, 7);
+    assert.isTrue(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('show all reviewers button', () => {
+    const reviewers = [];
+    element.maxReviewersDisplayed = 5;
+    for (let i = 0; i < 100; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 95);
+    assert.equal(element._displayedReviewers.length, 5);
+    assert.equal(element._reviewers.length, 100);
+    assert.isFalse(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.hiddenReviewers'));
+
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 100);
+    assert.equal(element._reviewers.length, 100);
+    assert.isTrue(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('votable labels', () => {
+    const change = {
+      labels: {
+        Foo: {
+          all: [{_account_id: 7, permitted_voting_range: {max: 2}}],
+        },
+        Bar: {
+          all: [{_account_id: 1, permitted_voting_range: {max: 1}},
+            {_account_id: 7, permitted_voting_range: {max: 1}}],
+        },
+        FooBar: {
+          all: [{_account_id: 7, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+    assert.strictEqual(
+        element._computeReviewerTooltip({_account_id: 1}, change),
+        'Votable: Bar');
+    assert.strictEqual(
+        element._computeReviewerTooltip({_account_id: 7}, change),
+        'Votable: Foo: +2, Bar, FooBar');
+    assert.strictEqual(
+        element._computeReviewerTooltip({_account_id: 2}, change),
+        '');
+  });
+
+  test('fails gracefully when all is not included', () => {
+    const change = {
+      labels: {Foo: {}},
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+      },
+    };
+    assert.strictEqual(
+        element._computeReviewerTooltip({_account_id: 1}, change), '');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
index e99367e..850bfb4 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
@@ -14,200 +14,209 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-comment-thread/gr-comment-thread.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-thread-list_html.js';
+
+/**
+ * Fired when a comment is saved or deleted
+ *
+ * @event thread-list-modified
+ * @extends Polymer.Element
+ */
+const NO_THREADS_MESSAGE = 'There are no inline comment threads on any diff '
+  + 'for this change.';
+const NO_ROBOT_COMMENTS_THREADS_MESSAGE = 'There are no findings for this ' +
+  'patchset.';
+const FINDINGS_TAB_NAME = '__gerrit_internal_findings';
+
+class GrThreadList extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-thread-list'; }
+
+  static get properties() {
+    return {
+    /** @type {?} */
+      change: Object,
+      threads: Array,
+      changeNum: String,
+      loggedIn: Boolean,
+      _sortedThreads: {
+        type: Array,
+      },
+      _filteredThreads: {
+        type: Array,
+        computed: '_computeFilteredThreads(_sortedThreads, ' +
+          '_unresolvedOnly, _draftsOnly,' +
+          'onlyShowRobotCommentsWithHumanReply)',
+      },
+      _unresolvedOnly: {
+        type: Boolean,
+        value: false,
+      },
+      _draftsOnly: {
+        type: Boolean,
+        value: false,
+      },
+      /* Boolean properties used must default to false if passed as attribute
+      by the parent */
+      onlyShowRobotCommentsWithHumanReply: {
+        type: Boolean,
+        value: false,
+      },
+      hideToggleButtons: {
+        type: Boolean,
+        value: false,
+      },
+      tab: {
+        type: String,
+        value: '',
+      },
+    };
+  }
+
+  static get observers() { return ['_computeSortedThreads(threads.*)']; }
+
+  _computeShowDraftToggle(loggedIn) {
+    return loggedIn ? 'show' : '';
+  }
+
+  _computeNoThreadsMessage(tab) {
+    if (tab === FINDINGS_TAB_NAME) {
+      return NO_ROBOT_COMMENTS_THREADS_MESSAGE;
+    }
+    return NO_THREADS_MESSAGE;
+  }
 
   /**
-   * Fired when a comment is saved or deleted
+   * Order as follows:
+   *  - Unresolved threads with drafts (reverse chronological)
+   *  - Unresolved threads without drafts (reverse chronological)
+   *  - Resolved threads with drafts (reverse chronological)
+   *  - Resolved threads without drafts (reverse chronological)
    *
-   * @event thread-list-modified
-   * @extends Polymer.Element
+   * @param {!Object} changeRecord
    */
-  const NO_THREADS_MESSAGE = 'There are no inline comment threads on any diff '
-    + 'for this change.';
-  const NO_ROBOT_COMMENTS_THREADS_MESSAGE = 'There are no findings for this ' +
-    'patchset.';
-  const FINDINGS_TAB_NAME = '__gerrit_internal_findings';
+  _computeSortedThreads(changeRecord) {
+    const threads = changeRecord.base;
+    if (!threads) { return []; }
+    this._updateSortedThreads(threads);
+  }
 
-  class GrThreadList extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-thread-list'; }
-
-    static get properties() {
-      return {
-      /** @type {?} */
-        change: Object,
-        threads: Array,
-        changeNum: String,
-        loggedIn: Boolean,
-        _sortedThreads: {
-          type: Array,
-        },
-        _filteredThreads: {
-          type: Array,
-          computed: '_computeFilteredThreads(_sortedThreads, ' +
-            '_unresolvedOnly, _draftsOnly,' +
-            'onlyShowRobotCommentsWithHumanReply)',
-        },
-        _unresolvedOnly: {
-          type: Boolean,
-          value: false,
-        },
-        _draftsOnly: {
-          type: Boolean,
-          value: false,
-        },
-        /* Boolean properties used must default to false if passed as attribute
-        by the parent */
-        onlyShowRobotCommentsWithHumanReply: {
-          type: Boolean,
-          value: false,
-        },
-        hideToggleButtons: {
-          type: Boolean,
-          value: false,
-        },
-        tab: {
-          type: String,
-          value: '',
-        },
-      };
-    }
-
-    static get observers() { return ['_computeSortedThreads(threads.*)']; }
-
-    _computeShowDraftToggle(loggedIn) {
-      return loggedIn ? 'show' : '';
-    }
-
-    _computeNoThreadsMessage(tab) {
-      if (tab === FINDINGS_TAB_NAME) {
-        return NO_ROBOT_COMMENTS_THREADS_MESSAGE;
-      }
-      return NO_THREADS_MESSAGE;
-    }
-
-    /**
-     * Order as follows:
-     *  - Unresolved threads with drafts (reverse chronological)
-     *  - Unresolved threads without drafts (reverse chronological)
-     *  - Resolved threads with drafts (reverse chronological)
-     *  - Resolved threads without drafts (reverse chronological)
-     *
-     * @param {!Object} changeRecord
-     */
-    _computeSortedThreads(changeRecord) {
-      const threads = changeRecord.base;
-      if (!threads) { return []; }
-      this._updateSortedThreads(threads);
-    }
-
-    _updateSortedThreads(threads) {
-      this._sortedThreads =
-          threads.map(this._getThreadWithSortInfo).sort((c1, c2) => {
-            const c1Date = c1.__date || util.parseDate(c1.updated);
-            const c2Date = c2.__date || util.parseDate(c2.updated);
-            const dateCompare = c2Date - c1Date;
-            if (c2.unresolved || c1.unresolved) {
-              if (!c1.unresolved) { return 1; }
-              if (!c2.unresolved) { return -1; }
-            }
-            if (c2.hasDraft || c1.hasDraft) {
-              if (!c1.hasDraft) { return 1; }
-              if (!c2.hasDraft) { return -1; }
-            }
-
-            if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) {
-              return 0;
-            }
-            return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
-          });
-    }
-
-    _computeFilteredThreads(sortedThreads, unresolvedOnly, draftsOnly,
-        onlyShowRobotCommentsWithHumanReply) {
-      // Polymer 2: check for undefined
-      if ([
-        sortedThreads,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      return sortedThreads.filter(c => {
-        if (draftsOnly) {
-          return c.hasDraft;
-        } else if (unresolvedOnly) {
-          return c.unresolved;
-        } else {
-          const comments = c && c.thread && c.thread.comments;
-          let robotComment = false;
-          let humanReplyToRobotComment = false;
-          comments.forEach(comment => {
-            if (comment.robot_id) {
-              robotComment = true;
-            } else if (robotComment) {
-              // Robot comment exists and human comment exists after it
-              humanReplyToRobotComment = true;
-            }
-          });
-          if (robotComment && onlyShowRobotCommentsWithHumanReply) {
-            return humanReplyToRobotComment;
+  _updateSortedThreads(threads) {
+    this._sortedThreads =
+        threads.map(this._getThreadWithSortInfo).sort((c1, c2) => {
+          const c1Date = c1.__date || util.parseDate(c1.updated);
+          const c2Date = c2.__date || util.parseDate(c2.updated);
+          const dateCompare = c2Date - c1Date;
+          if (c2.unresolved || c1.unresolved) {
+            if (!c1.unresolved) { return 1; }
+            if (!c2.unresolved) { return -1; }
           }
-          return c;
-        }
-      }).map(threadInfo => threadInfo.thread);
+          if (c2.hasDraft || c1.hasDraft) {
+            if (!c1.hasDraft) { return 1; }
+            if (!c2.hasDraft) { return -1; }
+          }
+
+          if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) {
+            return 0;
+          }
+          return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
+        });
+  }
+
+  _computeFilteredThreads(sortedThreads, unresolvedOnly, draftsOnly,
+      onlyShowRobotCommentsWithHumanReply) {
+    // Polymer 2: check for undefined
+    if ([
+      sortedThreads,
+      unresolvedOnly,
+      draftsOnly,
+      onlyShowRobotCommentsWithHumanReply,
+    ].some(arg => arg === undefined)) {
+      return undefined;
     }
 
-    _getThreadWithSortInfo(thread) {
-      const lastComment = thread.comments[thread.comments.length - 1] || {};
-
-      const lastNonDraftComment =
-          (lastComment.__draft && thread.comments.length > 1) ?
-            thread.comments[thread.comments.length - 2] :
-            lastComment;
-
-      return {
-        thread,
-        // Use the unresolved bit for the last non draft comment. This is what
-        // anybody other than the current user would see.
-        unresolved: !!lastNonDraftComment.unresolved,
-        hasDraft: !!lastComment.__draft,
-        updated: lastComment.updated,
-      };
-    }
-
-    removeThread(rootId) {
-      for (let i = 0; i < this.threads.length; i++) {
-        if (this.threads[i].rootId === rootId) {
-          this.splice('threads', i, 1);
-          // Needed to ensure threads get re-rendered in the correct order.
-          Polymer.dom.flush();
-          return;
+    return sortedThreads.filter(c => {
+      if (draftsOnly) {
+        return c.hasDraft;
+      } else if (unresolvedOnly) {
+        return c.unresolved;
+      } else {
+        const comments = c && c.thread && c.thread.comments;
+        let robotComment = false;
+        let humanReplyToRobotComment = false;
+        comments.forEach(comment => {
+          if (comment.robot_id) {
+            robotComment = true;
+          } else if (robotComment) {
+            // Robot comment exists and human comment exists after it
+            humanReplyToRobotComment = true;
+          }
+        });
+        if (robotComment && onlyShowRobotCommentsWithHumanReply) {
+          return humanReplyToRobotComment;
         }
+        return c;
       }
-    }
+    }).map(threadInfo => threadInfo.thread);
+  }
 
-    _handleThreadDiscard(e) {
-      this.removeThread(e.detail.rootId);
-    }
+  _getThreadWithSortInfo(thread) {
+    const lastComment = thread.comments[thread.comments.length - 1] || {};
 
-    _handleCommentsChanged(e) {
-      // Reset threads so thread computations occur on deep array changes to
-      // threads comments that are not observed naturally.
-      this._updateSortedThreads(this.threads);
+    const lastNonDraftComment =
+        (lastComment.__draft && thread.comments.length > 1) ?
+          thread.comments[thread.comments.length - 2] :
+          lastComment;
 
-      this.dispatchEvent(new CustomEvent('thread-list-modified',
-          {detail: {rootId: e.detail.rootId, path: e.detail.path}}));
-    }
+    return {
+      thread,
+      // Use the unresolved bit for the last non draft comment. This is what
+      // anybody other than the current user would see.
+      unresolved: !!lastNonDraftComment.unresolved,
+      hasDraft: !!lastComment.__draft,
+      updated: lastComment.updated,
+    };
+  }
 
-    _isOnParent(side) {
-      return !!side;
+  removeThread(rootId) {
+    for (let i = 0; i < this.threads.length; i++) {
+      if (this.threads[i].rootId === rootId) {
+        this.splice('threads', i, 1);
+        // Needed to ensure threads get re-rendered in the correct order.
+        flush();
+        return;
+      }
     }
   }
 
-  customElements.define(GrThreadList.is, GrThreadList);
-})();
+  _handleThreadDiscard(e) {
+    this.removeThread(e.detail.rootId);
+  }
+
+  _handleCommentsChanged(e) {
+    // Reset threads so thread computations occur on deep array changes to
+    // threads comments that are not observed naturally.
+    this._updateSortedThreads(this.threads);
+
+    this.dispatchEvent(new CustomEvent('thread-list-modified',
+        {detail: {rootId: e.detail.rootId, path: e.detail.path}}));
+  }
+
+  _isOnParent(side) {
+    return !!side;
+  }
+}
+
+customElements.define(GrThreadList.is, GrThreadList);
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
index f04a39a..b9f0b01 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
@@ -1,27 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-comment-thread/gr-comment-thread.html">
-
-<dom-module id="gr-thread-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       #threads {
         display: block;
@@ -62,14 +57,10 @@
     <template is="dom-if" if="[[!hideToggleButtons]]">
       <div class="header">
         <div class="toggleItem">
-          <paper-toggle-button
-              id="unresolvedToggle"
-              checked="{{_unresolvedOnly}}"></paper-toggle-button>
+          <paper-toggle-button id="unresolvedToggle" checked="{{_unresolvedOnly}}"></paper-toggle-button>
             Only unresolved threads</div>
-        <div class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]">
-          <paper-toggle-button
-              id="draftToggle"
-              checked="{{_draftsOnly}}"></paper-toggle-button>
+        <div class\$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]">
+          <paper-toggle-button id="draftToggle" checked="{{_draftsOnly}}"></paper-toggle-button>
             Only threads with drafts</div>
       </div>
     </template>
@@ -77,27 +68,8 @@
       <template is="dom-if" if="[[!threads.length]]">
         [[_computeNoThreadsMessage(tab)]]
       </template>
-      <template
-          is="dom-repeat"
-          items="[[_filteredThreads]]"
-          as="thread"
-          initial-count="5"
-          target-framerate="60">
-        <gr-comment-thread
-            show-file-path
-            change-num="[[changeNum]]"
-            comments="[[thread.comments]]"
-            comment-side="[[thread.commentSide]]"
-            project-name="[[change.project]]"
-            is-on-parent="[[_isOnParent(thread.commentSide)]]"
-            line-num="[[thread.line]]"
-            patch-num="[[thread.patchNum]]"
-            path="[[thread.path]]"
-            root-id="{{thread.rootId}}"
-            on-thread-changed="_handleCommentsChanged"
-            on-thread-discard="_handleThreadDiscard"></gr-comment-thread>
+      <template is="dom-repeat" items="[[_filteredThreads]]" as="thread" initial-count="5" target-framerate="60">
+        <gr-comment-thread show-file-path="" change-num="[[changeNum]]" comments="[[thread.comments]]" comment-side="[[thread.commentSide]]" project-name="[[change.project]]" is-on-parent="[[_isOnParent(thread.commentSide)]]" line-num="[[thread.line]]" patch-num="[[thread.patchNum]]" path="[[thread.path]]" root-id="{{thread.rootId}}" on-thread-changed="_handleCommentsChanged" on-thread-discard="_handleThreadDiscard"></gr-comment-thread>
       </template>
     </div>
-  </template>
-  <script src="gr-thread-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
index 30c598a..a2c3620 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-thread-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-thread-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-thread-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-thread-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,338 +40,341 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-thread-list tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let threadElements;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-thread-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-thread-list tests', () => {
+  let element;
+  let sandbox;
+  let threadElements;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.onlyShowRobotCommentsWithHumanReply = true;
-      element.threads = [
-        {
-          comments: [
-            {
-              __path: '/COMMIT_MSG',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 4,
-              id: 'ecf0b9fa_fe1a5f62',
-              line: 5,
-              updated: '2018-02-08 18:49:18.000000000',
-              message: 'test',
-              unresolved: true,
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.onlyShowRobotCommentsWithHumanReply = true;
+    element.threads = [
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-            {
-              id: '503008e2_0ab203ee',
-              path: '/COMMIT_MSG',
-              line: 5,
-              in_reply_to: 'ecf0b9fa_fe1a5f62',
-              updated: '2018-02-13 22:48:48.018000000',
-              message: 'draft',
-              unresolved: false,
-              __draft: true,
-              __draftID: '0.m683trwff68',
-              __editing: false,
-              patch_set: '2',
+            patch_set: 4,
+            id: 'ecf0b9fa_fe1a5f62',
+            line: 5,
+            updated: '2018-02-08 18:49:18.000000000',
+            message: 'test',
+            unresolved: true,
+          },
+          {
+            id: '503008e2_0ab203ee',
+            path: '/COMMIT_MSG',
+            line: 5,
+            in_reply_to: 'ecf0b9fa_fe1a5f62',
+            updated: '2018-02-13 22:48:48.018000000',
+            message: 'draft',
+            unresolved: false,
+            __draft: true,
+            __draftID: '0.m683trwff68',
+            __editing: false,
+            patch_set: '2',
+          },
+        ],
+        patchNum: 4,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'ecf0b9fa_fe1a5f62',
+        start_datetime: '2018-02-08 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            __path: 'test.txt',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-          ],
-          patchNum: 4,
-          path: '/COMMIT_MSG',
-          line: 5,
-          rootId: 'ecf0b9fa_fe1a5f62',
-          start_datetime: '2018-02-08 18:49:18.000000000',
-        },
-        {
-          comments: [
-            {
-              __path: 'test.txt',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 3,
-              id: '09a9fb0a_1484e6cf',
-              side: 'PARENT',
-              updated: '2018-02-13 22:47:19.000000000',
-              message: 'Some comment on another patchset.',
-              unresolved: false,
+            patch_set: 3,
+            id: '09a9fb0a_1484e6cf',
+            side: 'PARENT',
+            updated: '2018-02-13 22:47:19.000000000',
+            message: 'Some comment on another patchset.',
+            unresolved: false,
+          },
+        ],
+        patchNum: 3,
+        path: 'test.txt',
+        rootId: '09a9fb0a_1484e6cf',
+        start_datetime: '2018-02-13 22:47:19.000000000',
+        commentSide: 'PARENT',
+      },
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-          ],
-          patchNum: 3,
-          path: 'test.txt',
-          rootId: '09a9fb0a_1484e6cf',
-          start_datetime: '2018-02-13 22:47:19.000000000',
-          commentSide: 'PARENT',
-        },
-        {
-          comments: [
-            {
-              __path: '/COMMIT_MSG',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 2,
-              id: '8caddf38_44770ec1',
-              line: 4,
-              updated: '2018-02-13 22:48:40.000000000',
-              message: 'Another unresolved comment',
-              unresolved: true,
+            patch_set: 2,
+            id: '8caddf38_44770ec1',
+            line: 4,
+            updated: '2018-02-13 22:48:40.000000000',
+            message: 'Another unresolved comment',
+            unresolved: true,
+          },
+        ],
+        patchNum: 2,
+        path: '/COMMIT_MSG',
+        line: 4,
+        rootId: '8caddf38_44770ec1',
+        start_datetime: '2018-02-13 22:48:40.000000000',
+      },
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-          ],
-          patchNum: 2,
-          path: '/COMMIT_MSG',
-          line: 4,
-          rootId: '8caddf38_44770ec1',
-          start_datetime: '2018-02-13 22:48:40.000000000',
-        },
-        {
-          comments: [
-            {
-              __path: '/COMMIT_MSG',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 2,
-              id: 'scaddf38_44770ec1',
-              line: 4,
-              updated: '2018-02-14 22:48:40.000000000',
-              message: 'Yet another unresolved comment',
-              unresolved: true,
+            patch_set: 2,
+            id: 'scaddf38_44770ec1',
+            line: 4,
+            updated: '2018-02-14 22:48:40.000000000',
+            message: 'Yet another unresolved comment',
+            unresolved: true,
+          },
+        ],
+        patchNum: 2,
+        path: '/COMMIT_MSG',
+        line: 4,
+        rootId: 'scaddf38_44770ec1',
+        start_datetime: '2018-02-14 22:48:40.000000000',
+      },
+      {
+        comments: [
+          {
+            id: 'zcf0b9fa_fe1a5f62',
+            path: '/COMMIT_MSG',
+            line: 6,
+            updated: '2018-02-15 22:48:48.018000000',
+            message: 'resolved draft',
+            unresolved: false,
+            __draft: true,
+            __draftID: '0.m683trwff68',
+            __editing: false,
+            patch_set: '2',
+          },
+        ],
+        patchNum: 4,
+        path: '/COMMIT_MSG',
+        line: 6,
+        rootId: 'zcf0b9fa_fe1a5f62',
+        start_datetime: '2018-02-09 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-          ],
-          patchNum: 2,
-          path: '/COMMIT_MSG',
-          line: 4,
-          rootId: 'scaddf38_44770ec1',
-          start_datetime: '2018-02-14 22:48:40.000000000',
-        },
-        {
-          comments: [
-            {
-              id: 'zcf0b9fa_fe1a5f62',
-              path: '/COMMIT_MSG',
-              line: 6,
-              updated: '2018-02-15 22:48:48.018000000',
-              message: 'resolved draft',
-              unresolved: false,
-              __draft: true,
-              __draftID: '0.m683trwff68',
-              __editing: false,
-              patch_set: '2',
+            patch_set: 4,
+            id: 'rc1',
+            line: 5,
+            updated: '2019-02-08 18:49:18.000000000',
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc1',
+          },
+        ],
+        patchNum: 4,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'rc1',
+        start_datetime: '2019-02-08 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-          ],
-          patchNum: 4,
-          path: '/COMMIT_MSG',
-          line: 6,
-          rootId: 'zcf0b9fa_fe1a5f62',
-          start_datetime: '2018-02-09 18:49:18.000000000',
-        },
-        {
-          comments: [
-            {
-              __path: '/COMMIT_MSG',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 4,
-              id: 'rc1',
-              line: 5,
-              updated: '2019-02-08 18:49:18.000000000',
-              message: 'test',
-              unresolved: true,
-              robot_id: 'rc1',
+            patch_set: 4,
+            id: 'rc2',
+            line: 5,
+            updated: '2019-03-08 18:49:18.000000000',
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc2',
+          },
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-          ],
-          patchNum: 4,
-          path: '/COMMIT_MSG',
-          line: 5,
-          rootId: 'rc1',
-          start_datetime: '2019-02-08 18:49:18.000000000',
-        },
-        {
-          comments: [
-            {
-              __path: '/COMMIT_MSG',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 4,
-              id: 'rc2',
-              line: 5,
-              updated: '2019-03-08 18:49:18.000000000',
-              message: 'test',
-              unresolved: true,
-              robot_id: 'rc2',
-            },
-            {
-              __path: '/COMMIT_MSG',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 4,
-              id: 'c2_1',
-              line: 5,
-              updated: '2019-03-08 18:49:18.000000000',
-              message: 'test',
-              unresolved: true,
-            },
-          ],
-          patchNum: 4,
-          path: '/COMMIT_MSG',
-          line: 5,
-          rootId: 'rc2',
-          start_datetime: '2019-03-08 18:49:18.000000000',
-        },
-      ];
-      flushAsynchronousOperations();
-      threadElements = Polymer.dom(element.root)
-          .querySelectorAll('gr-comment-thread');
-    });
+            patch_set: 4,
+            id: 'c2_1',
+            line: 5,
+            updated: '2019-03-08 18:49:18.000000000',
+            message: 'test',
+            unresolved: true,
+          },
+        ],
+        patchNum: 4,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'rc2',
+        start_datetime: '2019-03-08 18:49:18.000000000',
+      },
+    ];
+    flushAsynchronousOperations();
+    threadElements = dom(element.root)
+        .querySelectorAll('gr-comment-thread');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('draft toggle only appears when logged in', () => {
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('.draftToggle')).display,
-      'none');
-      element.loggedIn = true;
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.draftToggle')).display,
-      'none');
-    });
+  test('draft toggle only appears when logged in', () => {
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.draftToggle')).display,
+    'none');
+    element.loggedIn = true;
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.draftToggle')).display,
+    'none');
+  });
 
-    test('there are five threads by default', () => {
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-comment-thread').length, 5);
-    });
+  test('there are five threads by default', () => {
+    assert.equal(dom(element.root)
+        .querySelectorAll('gr-comment-thread').length, 5);
+  });
 
-    test('_computeSortedThreads', () => {
-      assert.equal(element._sortedThreads.length, 7);
-      // Draft and unresolved
-      assert.equal(element._sortedThreads[0].thread.rootId,
-          'ecf0b9fa_fe1a5f62');
-      // Unresolved robot comment
-      assert.equal(element._sortedThreads[1].thread.rootId,
-          'rc2');
-      // Unresolved robot comment
-      assert.equal(element._sortedThreads[2].thread.rootId,
-          'rc1');
-      // unresolved
-      assert.equal(element._sortedThreads[3].thread.rootId,
-          'scaddf38_44770ec1');
-      // unresolved
-      assert.equal(element._sortedThreads[4].thread.rootId,
-          '8caddf38_44770ec1');
-      // resolved and draft
-      assert.equal(element._sortedThreads[5].thread.rootId,
-          'zcf0b9fa_fe1a5f62');
-      // resolved
-      assert.equal(element._sortedThreads[6].thread.rootId,
-          '09a9fb0a_1484e6cf');
-    });
+  test('_computeSortedThreads', () => {
+    assert.equal(element._sortedThreads.length, 7);
+    // Draft and unresolved
+    assert.equal(element._sortedThreads[0].thread.rootId,
+        'ecf0b9fa_fe1a5f62');
+    // Unresolved robot comment
+    assert.equal(element._sortedThreads[1].thread.rootId,
+        'rc2');
+    // Unresolved robot comment
+    assert.equal(element._sortedThreads[2].thread.rootId,
+        'rc1');
+    // unresolved
+    assert.equal(element._sortedThreads[3].thread.rootId,
+        'scaddf38_44770ec1');
+    // unresolved
+    assert.equal(element._sortedThreads[4].thread.rootId,
+        '8caddf38_44770ec1');
+    // resolved and draft
+    assert.equal(element._sortedThreads[5].thread.rootId,
+        'zcf0b9fa_fe1a5f62');
+    // resolved
+    assert.equal(element._sortedThreads[6].thread.rootId,
+        '09a9fb0a_1484e6cf');
+  });
 
-    test('filtered threads do not contain robot comments without reply', () => {
-      const thread = element.threads.find(thread => thread.rootId === 'rc1');
-      assert.equal(element._filteredThreads.includes(thread), false);
-    });
+  test('filtered threads do not contain robot comments without reply', () => {
+    const thread = element.threads.find(thread => thread.rootId === 'rc1');
+    assert.equal(element._filteredThreads.includes(thread), false);
+  });
 
-    test('filtered threads contains robot comments with reply', () => {
-      const thread = element.threads.find(thread => thread.rootId === 'rc2');
-      assert.equal(element._filteredThreads.includes(thread), true);
-    });
+  test('filtered threads contains robot comments with reply', () => {
+    const thread = element.threads.find(thread => thread.rootId === 'rc2');
+    assert.equal(element._filteredThreads.includes(thread), true);
+  });
 
-    test('thread removal', () => {
-      threadElements[1].fire('thread-discard', {rootId: 'rc2'});
-      flushAsynchronousOperations();
-      assert.equal(element._sortedThreads.length, 6);
-      assert.equal(element._sortedThreads[0].thread.rootId,
-          'ecf0b9fa_fe1a5f62');
-      // Unresolved robot comment
-      assert.equal(element._sortedThreads[1].thread.rootId,
-          'rc1');
-      // unresolved
-      assert.equal(element._sortedThreads[2].thread.rootId,
-          'scaddf38_44770ec1');
-      // unresolved
-      assert.equal(element._sortedThreads[3].thread.rootId,
-          '8caddf38_44770ec1');
-      // resolved and draft
-      assert.equal(element._sortedThreads[4].thread.rootId,
-          'zcf0b9fa_fe1a5f62');
-      // resolved
-      assert.equal(element._sortedThreads[5].thread.rootId,
-          '09a9fb0a_1484e6cf');
-    });
+  test('thread removal', () => {
+    threadElements[1].fire('thread-discard', {rootId: 'rc2'});
+    flushAsynchronousOperations();
+    assert.equal(element._sortedThreads.length, 6);
+    assert.equal(element._sortedThreads[0].thread.rootId,
+        'ecf0b9fa_fe1a5f62');
+    // Unresolved robot comment
+    assert.equal(element._sortedThreads[1].thread.rootId,
+        'rc1');
+    // unresolved
+    assert.equal(element._sortedThreads[2].thread.rootId,
+        'scaddf38_44770ec1');
+    // unresolved
+    assert.equal(element._sortedThreads[3].thread.rootId,
+        '8caddf38_44770ec1');
+    // resolved and draft
+    assert.equal(element._sortedThreads[4].thread.rootId,
+        'zcf0b9fa_fe1a5f62');
+    // resolved
+    assert.equal(element._sortedThreads[5].thread.rootId,
+        '09a9fb0a_1484e6cf');
+  });
 
-    test('toggle unresolved only shows unresolved comments', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector(
-          '#unresolvedToggle'));
-      flushAsynchronousOperations();
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-comment-thread').length, 5);
-    });
+  test('toggle unresolved only shows unresolved comments', () => {
+    MockInteractions.tap(element.shadowRoot.querySelector(
+        '#unresolvedToggle'));
+    flushAsynchronousOperations();
+    assert.equal(dom(element.root)
+        .querySelectorAll('gr-comment-thread').length, 5);
+  });
 
-    test('toggle drafts only shows threads with draft comments', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
-      flushAsynchronousOperations();
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-comment-thread').length, 2);
-    });
+  test('toggle drafts only shows threads with draft comments', () => {
+    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
+    flushAsynchronousOperations();
+    assert.equal(dom(element.root)
+        .querySelectorAll('gr-comment-thread').length, 2);
+  });
 
-    test('toggle drafts and unresolved only shows threads with drafts and ' +
-        'publicly unresolved ', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
-      MockInteractions.tap(element.shadowRoot.querySelector(
-          '#unresolvedToggle'));
-      flushAsynchronousOperations();
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-comment-thread').length, 2);
-    });
+  test('toggle drafts and unresolved only shows threads with drafts and ' +
+      'publicly unresolved ', () => {
+    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
+    MockInteractions.tap(element.shadowRoot.querySelector(
+        '#unresolvedToggle'));
+    flushAsynchronousOperations();
+    assert.equal(dom(element.root)
+        .querySelectorAll('gr-comment-thread').length, 2);
+  });
 
-    test('modification events are consumed and displatched', () => {
-      sandbox.spy(element, '_handleCommentsChanged');
-      const dispatchSpy = sandbox.stub();
-      element.addEventListener('thread-list-modified', dispatchSpy);
-      threadElements[0].fire('thread-changed', {
-        rootId: 'ecf0b9fa_fe1a5f62', path: '/COMMIT_MSG'});
-      assert.isTrue(element._handleCommentsChanged.called);
-      assert.isTrue(dispatchSpy.called);
-      assert.equal(dispatchSpy.lastCall.args[0].detail.rootId,
-          'ecf0b9fa_fe1a5f62');
-      assert.equal(dispatchSpy.lastCall.args[0].detail.path, '/COMMIT_MSG');
-    });
+  test('modification events are consumed and displatched', () => {
+    sandbox.spy(element, '_handleCommentsChanged');
+    const dispatchSpy = sandbox.stub();
+    element.addEventListener('thread-list-modified', dispatchSpy);
+    threadElements[0].fire('thread-changed', {
+      rootId: 'ecf0b9fa_fe1a5f62', path: '/COMMIT_MSG'});
+    assert.isTrue(element._handleCommentsChanged.called);
+    assert.isTrue(dispatchSpy.called);
+    assert.equal(dispatchSpy.lastCall.args[0].detail.rootId,
+        'ecf0b9fa_fe1a5f62');
+    assert.equal(dispatchSpy.lastCall.args[0].detail.path, '/COMMIT_MSG');
+  });
 
-    suite('findings tab', () => {
-      setup(done => {
-        element.hideToggleButtons = true;
-        flush(() => {
-          done();
-        });
+  suite('findings tab', () => {
+    setup(done => {
+      element.hideToggleButtons = true;
+      flush(() => {
+        done();
       });
-      test('toggle buttons are hidden', () => {
-        assert.equal(element.shadowRoot.querySelector('.header').style.display,
-            'none');
-      });
+    });
+    test('toggle buttons are hidden', () => {
+      assert.equal(element.shadowRoot.querySelector('.header').style.display,
+          'none');
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
index 60cbd42..1ab3926 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
@@ -14,133 +14,144 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
-  const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-shell-command/gr-shell-command.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-upload-help-dialog_html.js';
 
-  // Command names correspond to download plugin definitions.
-  const PREFERRED_FETCH_COMMAND_ORDER = [
-    'checkout',
-    'cherry pick',
-    'pull',
-  ];
+const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
+const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
 
+// Command names correspond to download plugin definitions.
+const PREFERRED_FETCH_COMMAND_ORDER = [
+  'checkout',
+  'cherry pick',
+  'pull',
+];
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrUploadHelpDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-upload-help-dialog'; }
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the user presses the close button.
+   *
+   * @event close
    */
-  class GrUploadHelpDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-upload-help-dialog'; }
-    /**
-     * Fired when the user presses the close button.
-     *
-     * @event close
-     */
 
-    static get properties() {
-      return {
-        revision: Object,
-        targetBranch: String,
-        _commitCommand: {
-          type: String,
-          value: COMMIT_COMMAND,
-          readOnly: true,
-        },
-        _fetchCommand: {
-          type: String,
-          computed: '_computeFetchCommand(revision, ' +
-            '_preferredDownloadCommand, _preferredDownloadScheme)',
-        },
-        _preferredDownloadCommand: String,
-        _preferredDownloadScheme: String,
-        _pushCommand: {
-          type: String,
-          computed: '_computePushCommand(targetBranch)',
-        },
-      };
-    }
+  static get properties() {
+    return {
+      revision: Object,
+      targetBranch: String,
+      _commitCommand: {
+        type: String,
+        value: COMMIT_COMMAND,
+        readOnly: true,
+      },
+      _fetchCommand: {
+        type: String,
+        computed: '_computeFetchCommand(revision, ' +
+          '_preferredDownloadCommand, _preferredDownloadScheme)',
+      },
+      _preferredDownloadCommand: String,
+      _preferredDownloadScheme: String,
+      _pushCommand: {
+        type: String,
+        computed: '_computePushCommand(targetBranch)',
+      },
+    };
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this.$.restAPI.getLoggedIn()
-          .then(loggedIn => {
-            if (loggedIn) {
-              return this.$.restAPI.getPreferences();
-            }
-          })
-          .then(prefs => {
-            if (prefs) {
-              this._preferredDownloadCommand = prefs.download_command;
-              this._preferredDownloadScheme = prefs.download_scheme;
-            }
-          });
-    }
+  /** @override */
+  attached() {
+    super.attached();
+    this.$.restAPI.getLoggedIn()
+        .then(loggedIn => {
+          if (loggedIn) {
+            return this.$.restAPI.getPreferences();
+          }
+        })
+        .then(prefs => {
+          if (prefs) {
+            this._preferredDownloadCommand = prefs.download_command;
+            this._preferredDownloadScheme = prefs.download_scheme;
+          }
+        });
+  }
 
-    _handleCloseTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('close', null, {bubbles: false});
-    }
+  _handleCloseTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('close', null, {bubbles: false});
+  }
 
-    _computeFetchCommand(revision, preferredDownloadCommand,
-        preferredDownloadScheme) {
-      // Polymer 2: check for undefined
-      if ([
-        revision,
-        preferredDownloadCommand,
-        preferredDownloadScheme,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      if (!revision) { return; }
-      if (!revision || !revision.fetch) { return; }
-
-      let scheme = preferredDownloadScheme;
-      if (!scheme) {
-        const keys = Object.keys(revision.fetch).sort();
-        if (keys.length === 0) {
-          return;
-        }
-        scheme = keys[0];
-      }
-
-      if (!revision.fetch[scheme] || !revision.fetch[scheme].commands) {
-        return;
-      }
-
-      const cmds = {};
-      Object.entries(revision.fetch[scheme].commands).forEach(([key, cmd]) => {
-        cmds[key.toLowerCase()] = cmd;
-      });
-
-      if (preferredDownloadCommand &&
-          cmds[preferredDownloadCommand.toLowerCase()]) {
-        return cmds[preferredDownloadCommand.toLowerCase()];
-      }
-
-      // If no supported command preference is given, look for known commands
-      // from the downloads plugin in order of preference.
-      for (let i = 0; i < PREFERRED_FETCH_COMMAND_ORDER.length; i++) {
-        if (cmds[PREFERRED_FETCH_COMMAND_ORDER[i]]) {
-          return cmds[PREFERRED_FETCH_COMMAND_ORDER[i]];
-        }
-      }
-
+  _computeFetchCommand(revision, preferredDownloadCommand,
+      preferredDownloadScheme) {
+    // Polymer 2: check for undefined
+    if ([
+      revision,
+      preferredDownloadCommand,
+      preferredDownloadScheme,
+    ].some(arg => arg === undefined)) {
       return undefined;
     }
 
-    _computePushCommand(targetBranch) {
-      return PUSH_COMMAND_PREFIX + targetBranch;
+    if (!revision) { return; }
+    if (!revision || !revision.fetch) { return; }
+
+    let scheme = preferredDownloadScheme;
+    if (!scheme) {
+      const keys = Object.keys(revision.fetch).sort();
+      if (keys.length === 0) {
+        return;
+      }
+      scheme = keys[0];
     }
+
+    if (!revision.fetch[scheme] || !revision.fetch[scheme].commands) {
+      return;
+    }
+
+    const cmds = {};
+    Object.entries(revision.fetch[scheme].commands).forEach(([key, cmd]) => {
+      cmds[key.toLowerCase()] = cmd;
+    });
+
+    if (preferredDownloadCommand &&
+        cmds[preferredDownloadCommand.toLowerCase()]) {
+      return cmds[preferredDownloadCommand.toLowerCase()];
+    }
+
+    // If no supported command preference is given, look for known commands
+    // from the downloads plugin in order of preference.
+    for (let i = 0; i < PREFERRED_FETCH_COMMAND_ORDER.length; i++) {
+      if (cmds[PREFERRED_FETCH_COMMAND_ORDER[i]]) {
+        return cmds[PREFERRED_FETCH_COMMAND_ORDER[i]];
+      }
+    }
+
+    return undefined;
   }
 
-  customElements.define(GrUploadHelpDialog.is, GrUploadHelpDialog);
-})();
+  _computePushCommand(targetBranch) {
+    return PUSH_COMMAND_PREFIX + targetBranch;
+  }
+}
+
+customElements.define(GrUploadHelpDialog.is, GrUploadHelpDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js
index e3cee56..ccf22e6 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-upload-help-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         background-color: var(--dialog-background-color);
@@ -40,10 +33,7 @@
         margin-bottom: var(--spacing-m);
       }
     </style>
-    <gr-dialog
-        confirm-label="Done"
-        cancel-label=""
-        on-confirm="_handleCloseTap">
+    <gr-dialog confirm-label="Done" cancel-label="" on-confirm="_handleCloseTap">
       <div class="header" slot="header">How to update this change:</div>
       <div class="main" slot="main">
         <ol>
@@ -77,6 +67,4 @@
       </div>
     </gr-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-upload-help-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
index 3af5449..2aa71c6 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-upload-help-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-upload-help-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-upload-help-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-upload-help-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,93 +40,95 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-upload-help-dialog tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-upload-help-dialog.js';
+suite('gr-upload-help-dialog tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
+  setup(() => {
+    element = fixture('basic');
+  });
 
-    test('constructs push command from branch', () => {
-      element.targetBranch = 'foo';
-      assert.equal(element._pushCommand, 'git push origin HEAD:refs/for/foo');
+  test('constructs push command from branch', () => {
+    element.targetBranch = 'foo';
+    assert.equal(element._pushCommand, 'git push origin HEAD:refs/for/foo');
 
-      element.targetBranch = 'master';
-      assert.equal(element._pushCommand,
-          'git push origin HEAD:refs/for/master');
-    });
+    element.targetBranch = 'master';
+    assert.equal(element._pushCommand,
+        'git push origin HEAD:refs/for/master');
+  });
 
-    suite('fetch command', () => {
-      const testRev = {
-        fetch: {
-          http: {
-            commands: {
-              Checkout: 'http checkout',
-              Pull: 'http pull',
-            },
-          },
-          ssh: {
-            commands: {
-              Pull: 'ssh pull',
-            },
+  suite('fetch command', () => {
+    const testRev = {
+      fetch: {
+        http: {
+          commands: {
+            Checkout: 'http checkout',
+            Pull: 'http pull',
           },
         },
-      };
+        ssh: {
+          commands: {
+            Pull: 'ssh pull',
+          },
+        },
+      },
+    };
 
-      test('null cases', () => {
-        assert.isUndefined(element._computeFetchCommand());
-        assert.isUndefined(element._computeFetchCommand({}));
-        assert.isUndefined(element._computeFetchCommand({fetch: null}));
-        assert.isUndefined(element._computeFetchCommand({fetch: {}}));
-      });
+    test('null cases', () => {
+      assert.isUndefined(element._computeFetchCommand());
+      assert.isUndefined(element._computeFetchCommand({}));
+      assert.isUndefined(element._computeFetchCommand({fetch: null}));
+      assert.isUndefined(element._computeFetchCommand({fetch: {}}));
+    });
 
-      test('not all defined', () => {
-        assert.isUndefined(
-            element._computeFetchCommand(testRev, undefined, ''));
-        assert.isUndefined(
-            element._computeFetchCommand(testRev, '', undefined));
-        assert.isUndefined(
-            element._computeFetchCommand(undefined, '', ''));
-      });
+    test('not all defined', () => {
+      assert.isUndefined(
+          element._computeFetchCommand(testRev, undefined, ''));
+      assert.isUndefined(
+          element._computeFetchCommand(testRev, '', undefined));
+      assert.isUndefined(
+          element._computeFetchCommand(undefined, '', ''));
+    });
 
-      test('insufficiently defined scheme', () => {
-        assert.isUndefined(
-            element._computeFetchCommand(testRev, '', 'badscheme'));
+    test('insufficiently defined scheme', () => {
+      assert.isUndefined(
+          element._computeFetchCommand(testRev, '', 'badscheme'));
 
-        const rev = Object.assign({}, testRev);
-        rev.fetch = Object.assign({}, testRev.fetch, {nocmds: {commands: {}}});
-        assert.isUndefined(
-            element._computeFetchCommand(rev, '', 'nocmds'));
+      const rev = Object.assign({}, testRev);
+      rev.fetch = Object.assign({}, testRev.fetch, {nocmds: {commands: {}}});
+      assert.isUndefined(
+          element._computeFetchCommand(rev, '', 'nocmds'));
 
-        rev.fetch.nocmds.commands.unsupported = 'unsupported';
-        assert.isUndefined(
-            element._computeFetchCommand(rev, '', 'nocmds'));
-      });
+      rev.fetch.nocmds.commands.unsupported = 'unsupported';
+      assert.isUndefined(
+          element._computeFetchCommand(rev, '', 'nocmds'));
+    });
 
-      test('default scheme and command', () => {
-        const cmd = element._computeFetchCommand(testRev, '', '');
-        assert.isTrue(cmd === 'http checkout' || cmd === 'ssh pull');
-      });
+    test('default scheme and command', () => {
+      const cmd = element._computeFetchCommand(testRev, '', '');
+      assert.isTrue(cmd === 'http checkout' || cmd === 'ssh pull');
+    });
 
-      test('default command', () => {
-        assert.strictEqual(
-            element._computeFetchCommand(testRev, '', 'http'),
-            'http checkout');
-        assert.strictEqual(
-            element._computeFetchCommand(testRev, '', 'ssh'),
-            'ssh pull');
-      });
+    test('default command', () => {
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, '', 'http'),
+          'http checkout');
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, '', 'ssh'),
+          'ssh pull');
+    });
 
-      test('user preferred scheme and command', () => {
-        assert.strictEqual(
-            element._computeFetchCommand(testRev, 'PULL', 'http'),
-            'http pull');
-        assert.strictEqual(
-            element._computeFetchCommand(testRev, 'badcmd', 'http'),
-            'http checkout');
-      });
+    test('user preferred scheme and command', () => {
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, 'PULL', 'http'),
+          'http pull');
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, 'badcmd', 'http'),
+          'http checkout');
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index 66c00f9..6d9f9d7 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,106 +14,118 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.js';
 
-  const INTERPOLATE_URL_PATTERN = /\$\{([\w]+)\}/g;
+import '../../../scripts/bundled-polymer.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-dropdown/gr-dropdown.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-avatar/gr-avatar.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-dropdown_html.js';
 
-  /**
-   * @appliesMixin Gerrit.DisplayNameMixin
-   * @extends Polymer.Element
-   */
-  class GrAccountDropdown extends Polymer.mixinBehaviors( [
-    Gerrit.DisplayNameBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-account-dropdown'; }
+const INTERPOLATE_URL_PATTERN = /\$\{([\w]+)\}/g;
 
-    static get properties() {
-      return {
-        account: Object,
-        config: Object,
-        links: {
-          type: Array,
-          computed: '_getLinks(_switchAccountUrl, _path)',
-        },
-        topContent: {
-          type: Array,
-          computed: '_getTopContent(account)',
-        },
-        _path: {
-          type: String,
-          value: '/',
-        },
-        _hasAvatars: Boolean,
-        _switchAccountUrl: String,
-      };
-    }
+/**
+ * @appliesMixin Gerrit.DisplayNameMixin
+ * @extends Polymer.Element
+ */
+class GrAccountDropdown extends mixinBehaviors( [
+  Gerrit.DisplayNameBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this._handleLocationChange();
-      this.listen(window, 'location-change', '_handleLocationChange');
-      this.$.restAPI.getConfig().then(cfg => {
-        this.config = cfg;
+  static get is() { return 'gr-account-dropdown'; }
 
-        if (cfg && cfg.auth && cfg.auth.switch_account_url) {
-          this._switchAccountUrl = cfg.auth.switch_account_url;
-        } else {
-          this._switchAccountUrl = '';
-        }
-        this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-      });
-    }
-
-    /** @override */
-    detached() {
-      super.detached();
-      this.unlisten(window, 'location-change', '_handleLocationChange');
-    }
-
-    _getLinks(switchAccountUrl, path) {
-      // Polymer 2: check for undefined
-      if ([switchAccountUrl, path].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      const links = [{name: 'Settings', url: '/settings/'}];
-      if (switchAccountUrl) {
-        const replacements = {path};
-        const url = this._interpolateUrl(switchAccountUrl, replacements);
-        links.push({name: 'Switch account', url, external: true});
-      }
-      links.push({name: 'Sign out', url: '/logout'});
-      return links;
-    }
-
-    _getTopContent(account) {
-      return [
-        {text: this._accountName(account), bold: true},
-        {text: account.email ? account.email : ''},
-      ];
-    }
-
-    _handleLocationChange() {
-      this._path =
-          window.location.pathname +
-          window.location.search +
-          window.location.hash;
-    }
-
-    _interpolateUrl(url, replacements) {
-      return url.replace(
-          INTERPOLATE_URL_PATTERN,
-          (match, p1) => replacements[p1] || '');
-    }
-
-    _accountName(account) {
-      return this.getUserName(this.config, account, true);
-    }
+  static get properties() {
+    return {
+      account: Object,
+      config: Object,
+      links: {
+        type: Array,
+        computed: '_getLinks(_switchAccountUrl, _path)',
+      },
+      topContent: {
+        type: Array,
+        computed: '_getTopContent(account)',
+      },
+      _path: {
+        type: String,
+        value: '/',
+      },
+      _hasAvatars: Boolean,
+      _switchAccountUrl: String,
+    };
   }
 
-  customElements.define(GrAccountDropdown.is, GrAccountDropdown);
-})();
+  /** @override */
+  attached() {
+    super.attached();
+    this._handleLocationChange();
+    this.listen(window, 'location-change', '_handleLocationChange');
+    this.$.restAPI.getConfig().then(cfg => {
+      this.config = cfg;
+
+      if (cfg && cfg.auth && cfg.auth.switch_account_url) {
+        this._switchAccountUrl = cfg.auth.switch_account_url;
+      } else {
+        this._switchAccountUrl = '';
+      }
+      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'location-change', '_handleLocationChange');
+  }
+
+  _getLinks(switchAccountUrl, path) {
+    // Polymer 2: check for undefined
+    if ([switchAccountUrl, path].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const links = [{name: 'Settings', url: '/settings/'}];
+    if (switchAccountUrl) {
+      const replacements = {path};
+      const url = this._interpolateUrl(switchAccountUrl, replacements);
+      links.push({name: 'Switch account', url, external: true});
+    }
+    links.push({name: 'Sign out', url: '/logout'});
+    return links;
+  }
+
+  _getTopContent(account) {
+    return [
+      {text: this._accountName(account), bold: true},
+      {text: account.email ? account.email : ''},
+    ];
+  }
+
+  _handleLocationChange() {
+    this._path =
+        window.location.pathname +
+        window.location.search +
+        window.location.hash;
+  }
+
+  _interpolateUrl(url, replacements) {
+    return url.replace(
+        INTERPOLATE_URL_PATTERN,
+        (match, p1) => replacements[p1] || '');
+  }
+
+  _accountName(account) {
+    return this.getUserName(this.config, account, true);
+  }
+}
+
+customElements.define(GrAccountDropdown.is, GrAccountDropdown);
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
index 5152ef9..e22db65 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
-
-<dom-module id="gr-account-dropdown">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       gr-dropdown {
         padding: 0 var(--spacing-m);
@@ -41,16 +33,9 @@
         vertical-align: middle;
       }
     </style>
-    <gr-dropdown
-        link
-        items=[[links]]
-        top-content=[[topContent]]
-        horizontal-align="right">
-        <span hidden$="[[_hasAvatars]]" hidden>[[_accountName(account)]]</span>
-        <gr-avatar account="[[account]]" hidden$="[[!_hasAvatars]]" hidden
-            image-size="56" aria-label="Account avatar"></gr-avatar>
+    <gr-dropdown link="" items="[[links]]" top-content="[[topContent]]" horizontal-align="right">
+        <span hidden\$="[[_hasAvatars]]" hidden="">[[_accountName(account)]]</span>
+        <gr-avatar account="[[account]]" hidden\$="[[!_hasAvatars]]" hidden="" image-size="56" aria-label="Account avatar"></gr-avatar>
     </gr-dropdown>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-account-dropdown.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index 0a11df1..fa0c7a7 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-dropdown</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-account-dropdown.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-account-dropdown.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-account-dropdown.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,93 +40,95 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-account-dropdown tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-account-dropdown.js';
+suite('gr-account-dropdown tests', () => {
+  let element;
 
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = fixture('basic');
+  });
+
+  test('account information', () => {
+    element.account = {name: 'John Doe', email: 'john@doe.com'};
+    assert.deepEqual(element.topContent,
+        [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
+  });
+
+  test('test for account without a name', () => {
+    element.account = {id: '0001'};
+    assert.deepEqual(element.topContent,
+        [{text: 'Anonymous', bold: true}, {text: ''}]);
+  });
+
+  test('test for account without a name but using config', () => {
+    element.config = {
+      user: {
+        anonymous_coward_name: 'WikiGerrit',
+      },
+    };
+    element.account = {id: '0001'};
+    assert.deepEqual(element.topContent,
+        [{text: 'WikiGerrit', bold: true}, {text: ''}]);
+  });
+
+  test('test for account name as an email', () => {
+    element.config = {
+      user: {
+        anonymous_coward_name: 'WikiGerrit',
+      },
+    };
+    element.account = {email: 'john@doe.com'};
+    assert.deepEqual(element.topContent,
+        [{text: 'john@doe.com', bold: true}, {text: 'john@doe.com'}]);
+  });
+
+  test('switch account', () => {
+    // Missing params.
+    assert.isUndefined(element._getLinks());
+    assert.isUndefined(element._getLinks(null));
+
+    // No switch account link.
+    assert.equal(element._getLinks(null, '').length, 2);
+
+    // Unparameterized switch account link.
+    let links = element._getLinks('/switch-account', '');
+    assert.equal(links.length, 3);
+    assert.deepEqual(links[1], {
+      name: 'Switch account',
+      url: '/switch-account',
+      external: true,
     });
 
-    test('account information', () => {
-      element.account = {name: 'John Doe', email: 'john@doe.com'};
-      assert.deepEqual(element.topContent,
-          [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
-    });
-
-    test('test for account without a name', () => {
-      element.account = {id: '0001'};
-      assert.deepEqual(element.topContent,
-          [{text: 'Anonymous', bold: true}, {text: ''}]);
-    });
-
-    test('test for account without a name but using config', () => {
-      element.config = {
-        user: {
-          anonymous_coward_name: 'WikiGerrit',
-        },
-      };
-      element.account = {id: '0001'};
-      assert.deepEqual(element.topContent,
-          [{text: 'WikiGerrit', bold: true}, {text: ''}]);
-    });
-
-    test('test for account name as an email', () => {
-      element.config = {
-        user: {
-          anonymous_coward_name: 'WikiGerrit',
-        },
-      };
-      element.account = {email: 'john@doe.com'};
-      assert.deepEqual(element.topContent,
-          [{text: 'john@doe.com', bold: true}, {text: 'john@doe.com'}]);
-    });
-
-    test('switch account', () => {
-      // Missing params.
-      assert.isUndefined(element._getLinks());
-      assert.isUndefined(element._getLinks(null));
-
-      // No switch account link.
-      assert.equal(element._getLinks(null, '').length, 2);
-
-      // Unparameterized switch account link.
-      let links = element._getLinks('/switch-account', '');
-      assert.equal(links.length, 3);
-      assert.deepEqual(links[1], {
-        name: 'Switch account',
-        url: '/switch-account',
-        external: true,
-      });
-
-      // Parameterized switch account link.
-      links = element._getLinks('/switch-account${path}', '/c/123');
-      assert.equal(links.length, 3);
-      assert.deepEqual(links[1], {
-        name: 'Switch account',
-        url: '/switch-account/c/123',
-        external: true,
-      });
-    });
-
-    test('_interpolateUrl', () => {
-      const replacements = {
-        foo: 'bar',
-        test: 'TEST',
-      };
-      const interpolate = function(url) {
-        return element._interpolateUrl(url, replacements);
-      };
-
-      assert.equal(interpolate('test'), 'test');
-      assert.equal(interpolate('${test}'), 'TEST');
-      assert.equal(
-          interpolate('${}, ${test}, ${TEST}, ${foo}'),
-          '${}, TEST, , bar');
+    // Parameterized switch account link.
+    links = element._getLinks('/switch-account${path}', '/c/123');
+    assert.equal(links.length, 3);
+    assert.deepEqual(links[1], {
+      name: 'Switch account',
+      url: '/switch-account/c/123',
+      external: true,
     });
   });
+
+  test('_interpolateUrl', () => {
+    const replacements = {
+      foo: 'bar',
+      test: 'TEST',
+    };
+    const interpolate = function(url) {
+      return element._interpolateUrl(url, replacements);
+    };
+
+    assert.equal(interpolate('test'), 'test');
+    assert.equal(interpolate('${test}'), 'TEST');
+    assert.equal(
+        interpolate('${}, ${test}, ${TEST}, ${foo}'),
+        '${}, TEST, , bar');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
index 63339c9..6814d89 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
@@ -14,44 +14,51 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrErrorDialog extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-error-dialog'; }
-    /**
-     * Fired when the dismiss button is pressed.
-     *
-     * @event dismiss
-     */
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-error-dialog_html.js';
 
-    static get properties() {
-      return {
-        text: String,
-        /**
-         * loginUrl to open on "sign in" button click
-         */
-        loginUrl: {
-          type: String,
-          value: '/login',
-        },
-        /**
-         * Show/hide "Sign In" button in dialog
-         */
-        showSignInButton: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrErrorDialog extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _handleConfirm() {
-      this.dispatchEvent(new CustomEvent('dismiss'));
-    }
+  static get is() { return 'gr-error-dialog'; }
+  /**
+   * Fired when the dismiss button is pressed.
+   *
+   * @event dismiss
+   */
+
+  static get properties() {
+    return {
+      text: String,
+      /**
+       * loginUrl to open on "sign in" button click
+       */
+      loginUrl: {
+        type: String,
+        value: '/login',
+      },
+      /**
+       * Show/hide "Sign In" button in dialog
+       */
+      showSignInButton: {
+        type: Boolean,
+        value: false,
+      },
+    };
   }
 
-  customElements.define(GrErrorDialog.is, GrErrorDialog);
-})();
+  _handleConfirm() {
+    this.dispatchEvent(new CustomEvent('dismiss'));
+  }
+}
+
+customElements.define(GrErrorDialog.is, GrErrorDialog);
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.js
index ffd7f896..e18d1bd 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.js
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.js
@@ -1,26 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-error-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .main {
         max-height: 40em;
@@ -38,23 +34,11 @@
         text-decoration: none;
       }
     </style>
-    <gr-dialog
-        id="dialog"
-        cancel-label=""
-        on-confirm="_handleConfirm"
-        confirm-label="Dismiss"
-        confirm-on-enter>
+    <gr-dialog id="dialog" cancel-label="" on-confirm="_handleConfirm" confirm-label="Dismiss" confirm-on-enter="">
       <div class="header" slot="header">An error occurred</div>
       <div class="main" slot="main">[[text]]</div>
-      <gr-button
-          id="signIn"
-          class$="signInLink"
-          hidden$="[[!showSignInButton]]"
-          link
-          slot="footer">
-        <a href$="[[loginUrl]]" class="signInLink">Sign in</a>
+      <gr-button id="signIn" class\$="signInLink" hidden\$="[[!showSignInButton]]" link="" slot="footer">
+        <a href\$="[[loginUrl]]" class="signInLink">Sign in</a>
       </gr-button>
     </gr-dialog>
-  </template>
-  <script src="gr-error-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
index c87f8bb..296c6f0 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-error-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-error-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-error-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-error-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,18 +40,20 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-error-dialog tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-error-dialog.js';
+suite('gr-error-dialog tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('dismiss tap fires event', done => {
-      element.addEventListener('dismiss', () => { done(); });
-      MockInteractions.tap(element.$.dialog.$.confirm);
-    });
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('dismiss tap fires event', done => {
+    element.addEventListener('dismiss', () => { done(); });
+    MockInteractions.tap(element.$.dialog.$.confirm);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index b828774..e2284a9 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -14,384 +14,405 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+/* Import to get Gerrit interface */
+/* TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface */
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  const HIDE_ALERT_TIMEOUT_MS = 5000;
-  const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
-  const STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000;
-  const SIGN_IN_WIDTH_PX = 690;
-  const SIGN_IN_HEIGHT_PX = 500;
-  const TOO_MANY_FILES = 'too many files to find conflicts';
-  const AUTHENTICATION_REQUIRED = 'Authentication required\n';
+import '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../gr-error-dialog/gr-error-dialog.js';
+import '../gr-reporting/gr-reporting.js';
+import '../../shared/gr-alert/gr-alert.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-error-manager_html.js';
+
+const HIDE_ALERT_TIMEOUT_MS = 5000;
+const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
+const STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000;
+const SIGN_IN_WIDTH_PX = 690;
+const SIGN_IN_HEIGHT_PX = 500;
+const TOO_MANY_FILES = 'too many files to find conflicts';
+const AUTHENTICATION_REQUIRED = 'Authentication required\n';
+
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrErrorManager extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-error-manager'; }
+
+  static get properties() {
+    return {
+    /**
+     * The ID of the account that was logged in when the app was launched. If
+     * not set, then there was no account at launch.
+     */
+      knownAccountId: Number,
+
+      /** @type {?Object} */
+      _alertElement: Object,
+      /** @type {?number} */
+      _hideAlertHandle: Number,
+      _refreshingCredentials: {
+        type: Boolean,
+        value: false,
+      },
+
+      /**
+       * The time (in milliseconds) since the most recent credential check.
+       */
+      _lastCredentialCheck: {
+        type: Number,
+        value() { return Date.now(); },
+      },
+
+      loginUrl: {
+        type: String,
+        value: '/login',
+      },
+    };
+  }
+
+  constructor() {
+    super();
+
+    /** @type {!Gerrit.Auth} */
+    this._authService = Gerrit.Auth;
+
+    /** @type {?Function} */
+    this._authErrorHandlerDeregistrationHook;
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.listen(document, 'server-error', '_handleServerError');
+    this.listen(document, 'network-error', '_handleNetworkError');
+    this.listen(document, 'show-alert', '_handleShowAlert');
+    this.listen(document, 'show-error', '_handleShowErrorDialog');
+    this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+    this.listen(document, 'show-auth-required', '_handleAuthRequired');
+
+    this._authErrorHandlerDeregistrationHook = Gerrit.on('auth-error',
+        event => {
+          this._handleAuthError(event.message, event.action);
+        });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this._clearHideAlertHandle();
+    this.unlisten(document, 'server-error', '_handleServerError');
+    this.unlisten(document, 'network-error', '_handleNetworkError');
+    this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
+    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+    this.unlisten(document, 'show-error', '_handleShowErrorDialog');
+
+    this._authErrorHandlerDeregistrationHook();
+  }
+
+  _shouldSuppressError(msg) {
+    return msg.includes(TOO_MANY_FILES);
+  }
+
+  _handleAuthRequired() {
+    this._showAuthErrorAlert(
+        'Log in is required to perform that action.', 'Log in.');
+  }
+
+  _handleAuthError(msg, action) {
+    this.$.noInteractionOverlay.open().then(() => {
+      this._showAuthErrorAlert(msg, action);
+    });
+  }
+
+  _handleServerError(e) {
+    const {request, response} = e.detail;
+    response.text().then(errorText => {
+      const url = request && (request.anonymizedUrl || request.url);
+      const {status, statusText} = response;
+      if (response.status === 403
+              && !this._authService.isAuthed
+              && errorText === AUTHENTICATION_REQUIRED) {
+        // if not authed previously, this is trying to access auth required APIs
+        // show auth required alert
+        this._handleAuthRequired();
+      } else if (response.status === 403
+              && this._authService.isAuthed
+              && errorText === AUTHENTICATION_REQUIRED) {
+        // The app was logged at one point and is now getting auth errors.
+        // This indicates the auth token may no longer valid.
+        // Re-check on auth
+        this._authService.clearCache();
+        this.$.restAPI.getLoggedIn();
+      } else if (!this._shouldSuppressError(errorText)) {
+        const trace =
+            response.headers && response.headers.get('X-Gerrit-Trace');
+        if (response.status === 404) {
+          this._showNotFoundMessageWithTip({
+            status,
+            statusText,
+            errorText,
+            url,
+            trace,
+          });
+        } else {
+          this._showErrorDialog(this._constructServerErrorMsg({
+            status,
+            statusText,
+            errorText,
+            url,
+            trace,
+          }));
+        }
+      }
+      console.log(`server error: ${errorText}`);
+    });
+  }
+
+  _showNotFoundMessageWithTip({status, statusText, errorText, url, trace}) {
+    this.$.restAPI.getLoggedIn().then(isLoggedIn => {
+      const tip = isLoggedIn ?
+        'You might have not enough privileges.' :
+        'You might have not enough privileges. Sign in and try again.';
+      this._showErrorDialog(this._constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        url,
+        trace,
+        tip,
+      }), {
+        showSignInButton: !isLoggedIn,
+      });
+    });
+    return;
+  }
+
+  _constructServerErrorMsg({errorText, status, statusText, url, trace, tip}) {
+    let err = '';
+    if (tip) {
+      err += `${tip}\n\n`;
+    }
+    err += `Error ${status}`;
+    if (statusText) { err += ` (${statusText})`; }
+    if (errorText || url) { err += ': '; }
+    if (errorText) { err += errorText; }
+    if (url) { err += `\nEndpoint: ${url}`; }
+    if (trace) { err += `\nTrace Id: ${trace}`; }
+    return err;
+  }
+
+  _handleShowAlert(e) {
+    this._showAlert(e.detail.message, e.detail.action, e.detail.callback,
+        e.detail.dismissOnNavigation);
+  }
+
+  _handleNetworkError(e) {
+    this._showAlert('Server unavailable');
+    console.error(e.detail.error.message);
+  }
 
   /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * @param {string} text
+   * @param {?string=} opt_actionText
+   * @param {?Function=} opt_actionCallback
+   * @param {?boolean=} opt_dismissOnNavigation
    */
-  class GrErrorManager extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-error-manager'; }
-
-    static get properties() {
-      return {
-      /**
-       * The ID of the account that was logged in when the app was launched. If
-       * not set, then there was no account at launch.
-       */
-        knownAccountId: Number,
-
-        /** @type {?Object} */
-        _alertElement: Object,
-        /** @type {?number} */
-        _hideAlertHandle: Number,
-        _refreshingCredentials: {
-          type: Boolean,
-          value: false,
-        },
-
-        /**
-         * The time (in milliseconds) since the most recent credential check.
-         */
-        _lastCredentialCheck: {
-          type: Number,
-          value() { return Date.now(); },
-        },
-
-        loginUrl: {
-          type: String,
-          value: '/login',
-        },
-      };
-    }
-
-    constructor() {
-      super();
-
-      /** @type {!Gerrit.Auth} */
-      this._authService = Gerrit.Auth;
-
-      /** @type {?Function} */
-      this._authErrorHandlerDeregistrationHook;
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this.listen(document, 'server-error', '_handleServerError');
-      this.listen(document, 'network-error', '_handleNetworkError');
-      this.listen(document, 'show-alert', '_handleShowAlert');
-      this.listen(document, 'show-error', '_handleShowErrorDialog');
-      this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-      this.listen(document, 'show-auth-required', '_handleAuthRequired');
-
-      this._authErrorHandlerDeregistrationHook = Gerrit.on('auth-error',
-          event => {
-            this._handleAuthError(event.message, event.action);
-          });
-    }
-
-    /** @override */
-    detached() {
-      super.detached();
-      this._clearHideAlertHandle();
-      this.unlisten(document, 'server-error', '_handleServerError');
-      this.unlisten(document, 'network-error', '_handleNetworkError');
-      this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
-      this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
-      this.unlisten(document, 'show-error', '_handleShowErrorDialog');
-
-      this._authErrorHandlerDeregistrationHook();
-    }
-
-    _shouldSuppressError(msg) {
-      return msg.includes(TOO_MANY_FILES);
-    }
-
-    _handleAuthRequired() {
-      this._showAuthErrorAlert(
-          'Log in is required to perform that action.', 'Log in.');
-    }
-
-    _handleAuthError(msg, action) {
-      this.$.noInteractionOverlay.open().then(() => {
-        this._showAuthErrorAlert(msg, action);
-      });
-    }
-
-    _handleServerError(e) {
-      const {request, response} = e.detail;
-      response.text().then(errorText => {
-        const url = request && (request.anonymizedUrl || request.url);
-        const {status, statusText} = response;
-        if (response.status === 403
-                && !this._authService.isAuthed
-                && errorText === AUTHENTICATION_REQUIRED) {
-          // if not authed previously, this is trying to access auth required APIs
-          // show auth required alert
-          this._handleAuthRequired();
-        } else if (response.status === 403
-                && this._authService.isAuthed
-                && errorText === AUTHENTICATION_REQUIRED) {
-          // The app was logged at one point and is now getting auth errors.
-          // This indicates the auth token may no longer valid.
-          // Re-check on auth
-          this._authService.clearCache();
-          this.$.restAPI.getLoggedIn();
-        } else if (!this._shouldSuppressError(errorText)) {
-          const trace =
-              response.headers && response.headers.get('X-Gerrit-Trace');
-          if (response.status === 404) {
-            this._showNotFoundMessageWithTip({
-              status,
-              statusText,
-              errorText,
-              url,
-              trace,
-            });
-          } else {
-            this._showErrorDialog(this._constructServerErrorMsg({
-              status,
-              statusText,
-              errorText,
-              url,
-              trace,
-            }));
-          }
-        }
-        console.log(`server error: ${errorText}`);
-      });
-    }
-
-    _showNotFoundMessageWithTip({status, statusText, errorText, url, trace}) {
-      this.$.restAPI.getLoggedIn().then(isLoggedIn => {
-        const tip = isLoggedIn ?
-          'You might have not enough privileges.' :
-          'You might have not enough privileges. Sign in and try again.';
-        this._showErrorDialog(this._constructServerErrorMsg({
-          status,
-          statusText,
-          errorText,
-          url,
-          trace,
-          tip,
-        }), {
-          showSignInButton: !isLoggedIn,
-        });
-      });
-      return;
-    }
-
-    _constructServerErrorMsg({errorText, status, statusText, url, trace, tip}) {
-      let err = '';
-      if (tip) {
-        err += `${tip}\n\n`;
-      }
-      err += `Error ${status}`;
-      if (statusText) { err += ` (${statusText})`; }
-      if (errorText || url) { err += ': '; }
-      if (errorText) { err += errorText; }
-      if (url) { err += `\nEndpoint: ${url}`; }
-      if (trace) { err += `\nTrace Id: ${trace}`; }
-      return err;
-    }
-
-    _handleShowAlert(e) {
-      this._showAlert(e.detail.message, e.detail.action, e.detail.callback,
-          e.detail.dismissOnNavigation);
-    }
-
-    _handleNetworkError(e) {
-      this._showAlert('Server unavailable');
-      console.error(e.detail.error.message);
-    }
-
-    /**
-     * @param {string} text
-     * @param {?string=} opt_actionText
-     * @param {?Function=} opt_actionCallback
-     * @param {?boolean=} opt_dismissOnNavigation
-     */
-    _showAlert(text, opt_actionText, opt_actionCallback,
-        opt_dismissOnNavigation) {
-      if (this._alertElement) {
-        // do not override auth alerts
-        if (this._alertElement.type === 'AUTH') return;
-        this._hideAlert();
-      }
-
-      this._clearHideAlertHandle();
-      if (opt_dismissOnNavigation) {
-        // Persist alert until navigation.
-        this.listen(document, 'location-change', '_hideAlert');
-      } else {
-        this._hideAlertHandle =
-          this.async(this._hideAlert, HIDE_ALERT_TIMEOUT_MS);
-      }
-      const el = this._createToastAlert();
-      el.show(text, opt_actionText, opt_actionCallback);
-      this._alertElement = el;
-    }
-
-    _hideAlert() {
-      if (!this._alertElement) { return; }
-
-      this._alertElement.hide();
-      this._alertElement = null;
-
-      // Remove listener for page navigation, if it exists.
-      this.unlisten(document, 'location-change', '_hideAlert');
-    }
-
-    _clearHideAlertHandle() {
-      if (this._hideAlertHandle != null) {
-        this.cancelAsync(this._hideAlertHandle);
-        this._hideAlertHandle = null;
-      }
-    }
-
-    _showAuthErrorAlert(errorText, actionText) {
-      // hide any existing alert like `reload`
-      // as auth error should have the highest priority
-      if (this._alertElement) {
-        this._alertElement.hide();
-      }
-
-      this._alertElement = this._createToastAlert();
-      this._alertElement.type = 'AUTH';
-      this._alertElement.show(errorText, actionText,
-          this._createLoginPopup.bind(this));
-
-      this._refreshingCredentials = true;
-      this._requestCheckLoggedIn();
-      if (!document.hidden) {
-        this._handleVisibilityChange();
-      }
-    }
-
-    _createToastAlert() {
-      const el = document.createElement('gr-alert');
-      el.toast = true;
-      return el;
-    }
-
-    _handleVisibilityChange() {
-      // Ignore when the page is transitioning to hidden (or hidden is
-      // undefined).
-      if (document.hidden !== false) { return; }
-
-      // If not currently refreshing credentials and the credentials are old,
-      // request them to confirm their validity or (display an auth toast if it
-      // fails).
-      const timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
-      if (!this._refreshingCredentials &&
-          this.knownAccountId !== undefined &&
-          timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) {
-        this._lastCredentialCheck = Date.now();
-
-        // check auth status in case:
-        // - user signed out
-        // - user switched account
-        this._checkSignedIn();
-      }
-    }
-
-    _requestCheckLoggedIn() {
-      this.debounce(
-          'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
-    }
-
-    _checkSignedIn() {
-      this._lastCredentialCheck = Date.now();
-
-      // force to refetch account info
-      this.$.restAPI.invalidateAccountsCache();
-      this._authService.clearCache();
-
-      this.$.restAPI.getLoggedIn().then(isLoggedIn => {
-        // do nothing if its refreshing
-        if (!this._refreshingCredentials) return;
-
-        if (!isLoggedIn) {
-          // check later
-          // 1. guest mode
-          // 2. or signed out
-          // in case #2, auth-error is taken care of separately
-          this._requestCheckLoggedIn();
-        } else {
-          // check account
-          this.$.restAPI.getAccount().then(account => {
-            if (this._refreshingCredentials) {
-              // If the credentials were refreshed but the account is different
-              // then reload the page completely.
-              if (account._account_id !== this.knownAccountId) {
-                this._reloadPage();
-                return;
-              }
-
-              this._handleCredentialRefreshed();
-            }
-          });
-        }
-      });
-    }
-
-    _reloadPage() {
-      window.location.reload();
-    }
-
-    _createLoginPopup() {
-      const left = window.screenLeft +
-          (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
-      const top = window.screenTop +
-          (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
-      const options = [
-        'width=' + SIGN_IN_WIDTH_PX,
-        'height=' + SIGN_IN_HEIGHT_PX,
-        'left=' + left,
-        'top=' + top,
-      ];
-      window.open(this.getBaseUrl() +
-          '/login/%3FcloseAfterLogin', '_blank', options.join(','));
-      this.listen(window, 'focus', '_handleWindowFocus');
-    }
-
-    _handleCredentialRefreshed() {
-      this.unlisten(window, 'focus', '_handleWindowFocus');
-      this._refreshingCredentials = false;
+  _showAlert(text, opt_actionText, opt_actionCallback,
+      opt_dismissOnNavigation) {
+    if (this._alertElement) {
+      // do not override auth alerts
+      if (this._alertElement.type === 'AUTH') return;
       this._hideAlert();
-      this._showAlert('Credentials refreshed.');
-      this.$.noInteractionOverlay.close();
-
-      // Clear the cache for auth
-      this._authService.clearCache();
     }
 
-    _handleWindowFocus() {
-      this.flushDebouncer('checkLoggedIn');
+    this._clearHideAlertHandle();
+    if (opt_dismissOnNavigation) {
+      // Persist alert until navigation.
+      this.listen(document, 'location-change', '_hideAlert');
+    } else {
+      this._hideAlertHandle =
+        this.async(this._hideAlert, HIDE_ALERT_TIMEOUT_MS);
     }
+    const el = this._createToastAlert();
+    el.show(text, opt_actionText, opt_actionCallback);
+    this._alertElement = el;
+  }
 
-    _handleShowErrorDialog(e) {
-      this._showErrorDialog(e.detail.message);
-    }
+  _hideAlert() {
+    if (!this._alertElement) { return; }
 
-    _handleDismissErrorDialog() {
-      this.$.errorOverlay.close();
-    }
+    this._alertElement.hide();
+    this._alertElement = null;
 
-    _showErrorDialog(message, opt_options) {
-      this.$.reporting.reportErrorDialog(message);
-      this.$.errorDialog.text = message;
-      this.$.errorDialog.showSignInButton =
-          opt_options && opt_options.showSignInButton;
-      this.$.errorOverlay.open();
+    // Remove listener for page navigation, if it exists.
+    this.unlisten(document, 'location-change', '_hideAlert');
+  }
+
+  _clearHideAlertHandle() {
+    if (this._hideAlertHandle != null) {
+      this.cancelAsync(this._hideAlertHandle);
+      this._hideAlertHandle = null;
     }
   }
 
-  customElements.define(GrErrorManager.is, GrErrorManager);
-})();
+  _showAuthErrorAlert(errorText, actionText) {
+    // hide any existing alert like `reload`
+    // as auth error should have the highest priority
+    if (this._alertElement) {
+      this._alertElement.hide();
+    }
+
+    this._alertElement = this._createToastAlert();
+    this._alertElement.type = 'AUTH';
+    this._alertElement.show(errorText, actionText,
+        this._createLoginPopup.bind(this));
+
+    this._refreshingCredentials = true;
+    this._requestCheckLoggedIn();
+    if (!document.hidden) {
+      this._handleVisibilityChange();
+    }
+  }
+
+  _createToastAlert() {
+    const el = document.createElement('gr-alert');
+    el.toast = true;
+    return el;
+  }
+
+  _handleVisibilityChange() {
+    // Ignore when the page is transitioning to hidden (or hidden is
+    // undefined).
+    if (document.hidden !== false) { return; }
+
+    // If not currently refreshing credentials and the credentials are old,
+    // request them to confirm their validity or (display an auth toast if it
+    // fails).
+    const timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
+    if (!this._refreshingCredentials &&
+        this.knownAccountId !== undefined &&
+        timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) {
+      this._lastCredentialCheck = Date.now();
+
+      // check auth status in case:
+      // - user signed out
+      // - user switched account
+      this._checkSignedIn();
+    }
+  }
+
+  _requestCheckLoggedIn() {
+    this.debounce(
+        'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
+  }
+
+  _checkSignedIn() {
+    this._lastCredentialCheck = Date.now();
+
+    // force to refetch account info
+    this.$.restAPI.invalidateAccountsCache();
+    this._authService.clearCache();
+
+    this.$.restAPI.getLoggedIn().then(isLoggedIn => {
+      // do nothing if its refreshing
+      if (!this._refreshingCredentials) return;
+
+      if (!isLoggedIn) {
+        // check later
+        // 1. guest mode
+        // 2. or signed out
+        // in case #2, auth-error is taken care of separately
+        this._requestCheckLoggedIn();
+      } else {
+        // check account
+        this.$.restAPI.getAccount().then(account => {
+          if (this._refreshingCredentials) {
+            // If the credentials were refreshed but the account is different
+            // then reload the page completely.
+            if (account._account_id !== this.knownAccountId) {
+              this._reloadPage();
+              return;
+            }
+
+            this._handleCredentialRefreshed();
+          }
+        });
+      }
+    });
+  }
+
+  _reloadPage() {
+    window.location.reload();
+  }
+
+  _createLoginPopup() {
+    const left = window.screenLeft +
+        (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
+    const top = window.screenTop +
+        (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
+    const options = [
+      'width=' + SIGN_IN_WIDTH_PX,
+      'height=' + SIGN_IN_HEIGHT_PX,
+      'left=' + left,
+      'top=' + top,
+    ];
+    window.open(this.getBaseUrl() +
+        '/login/%3FcloseAfterLogin', '_blank', options.join(','));
+    this.listen(window, 'focus', '_handleWindowFocus');
+  }
+
+  _handleCredentialRefreshed() {
+    this.unlisten(window, 'focus', '_handleWindowFocus');
+    this._refreshingCredentials = false;
+    this._hideAlert();
+    this._showAlert('Credentials refreshed.');
+    this.$.noInteractionOverlay.close();
+
+    // Clear the cache for auth
+    this._authService.clearCache();
+  }
+
+  _handleWindowFocus() {
+    this.flushDebouncer('checkLoggedIn');
+  }
+
+  _handleShowErrorDialog(e) {
+    this._showErrorDialog(e.detail.message);
+  }
+
+  _handleDismissErrorDialog() {
+    this.$.errorOverlay.close();
+  }
+
+  _showErrorDialog(message, opt_options) {
+    this.$.reporting.reportErrorDialog(message);
+    this.$.errorDialog.text = message;
+    this.$.errorDialog.showSignInButton =
+        opt_options && opt_options.showSignInButton;
+    this.$.errorOverlay.open();
+  }
+}
+
+customElements.define(GrErrorManager.is, GrErrorManager);
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.js
index 104d5b0..5661d1e 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.js
@@ -1,52 +1,27 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../core/gr-error-dialog/gr-error-dialog.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-alert/gr-alert.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<!-- Import to get Gerrit interface -->
-<!-- TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface -->
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-
-<dom-module id="gr-error-manager">
-  <template>
-    <gr-overlay with-backdrop id="errorOverlay">
-      <gr-error-dialog
-          id="errorDialog"
-          on-dismiss="_handleDismissErrorDialog"
-          confirm-label="Dismiss"
-          confirm-on-enter
-          login-url="[[loginUrl]]"
-      ></gr-error-dialog>
+export const htmlTemplate = html`
+    <gr-overlay with-backdrop="" id="errorOverlay">
+      <gr-error-dialog id="errorDialog" on-dismiss="_handleDismissErrorDialog" confirm-label="Dismiss" confirm-on-enter="" login-url="[[loginUrl]]"></gr-error-dialog>
     </gr-overlay>
-    <gr-overlay
-      id="noInteractionOverlay"
-      with-backdrop
-      always-on-top
-      no-cancel-on-esc-key
-      no-cancel-on-outside-click>
+    <gr-overlay id="noInteractionOverlay" with-backdrop="" always-on-top="" no-cancel-on-esc-key="" no-cancel-on-outside-click="">
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-error-manager.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index e984577..a4a9a7d 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -19,14 +19,18 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-error-manager</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html" />
-<link rel="import" href="gr-error-manager.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-error-manager.js"></script>
 
-<script>void (0);</script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-error-manager.js';
+void (0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -34,465 +38,467 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-error-manager tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-error-manager.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-error-manager tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('when authed', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
+      sandbox.stub(window, 'fetch')
+          .returns(Promise.resolve({ok: true, status: 204}));
+      element = fixture('basic');
+      element._authService.clearCache();
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('does not show auth error on 403 by default', done => {
+      const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
+      const responseText = Promise.resolve('server says no.');
+      element.fire('server-error',
+          {response: {status: 403, text() { return responseText; }}}
+      );
+      flush(() => {
+        assert.isFalse(showAuthErrorStub.calledOnce);
+        done();
+      });
     });
 
-    suite('when authed', () => {
-      setup(() => {
-        sandbox.stub(window, 'fetch')
-            .returns(Promise.resolve({ok: true, status: 204}));
-        element = fixture('basic');
-        element._authService.clearCache();
-      });
-
-      test('does not show auth error on 403 by default', done => {
-        const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
-        const responseText = Promise.resolve('server says no.');
-        element.fire('server-error',
-            {response: {status: 403, text() { return responseText; }}}
-        );
-        flush(() => {
-          assert.isFalse(showAuthErrorStub.calledOnce);
-          done();
-        });
-      });
-
-      test('show auth required for 403 with auth error and not authed before',
-          done => {
-            const showAuthErrorStub = sandbox.stub(
-                element, '_showAuthErrorAlert'
-            );
-            const responseText = Promise.resolve('Authentication required\n');
-            sinon.stub(element.$.restAPI, 'getLoggedIn')
-                .returns(Promise.resolve(true));
-            element.fire('server-error',
-                {response: {status: 403, text() { return responseText; }}}
-            );
-            flush(() => {
-              assert.isTrue(showAuthErrorStub.calledOnce);
-              done();
-            });
-          });
-
-      test('recheck auth for 403 with auth error if authed before', done => {
-        // starts with authed state
-        element.$.restAPI.getLoggedIn();
-        const responseText = Promise.resolve('Authentication required\n');
-        sinon.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(true));
-        element.fire('server-error',
-            {response: {status: 403, text() { return responseText; }}}
-        );
-        flush(() => {
-          assert.isTrue(element.$.restAPI.getLoggedIn.calledOnce);
-          done();
-        });
-      });
-
-      test('show logged in error', () => {
-        sandbox.stub(element, '_showAuthErrorAlert');
-        element.fire('show-auth-required');
-        assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
-            'Log in is required to perform that action.', 'Log in.'));
-      });
-
-      test('show normal Error', done => {
-        const showErrorStub = sandbox.stub(element, '_showErrorDialog');
-        const textSpy = sandbox.spy(() => Promise.resolve('ZOMG'));
-        element.fire('server-error', {response: {status: 500, text: textSpy}});
-
-        assert.isTrue(textSpy.called);
-        flush(() => {
-          assert.isTrue(showErrorStub.calledOnce);
-          assert.isTrue(showErrorStub.lastCall.calledWithExactly(
-              'Error 500: ZOMG'));
-          done();
-        });
-      });
-
-      test('_constructServerErrorMsg', () => {
-        const errorText = 'change conflicts';
-        const status = 409;
-        const statusText = 'Conflict';
-        const url = '/my/test/url';
-
-        assert.equal(element._constructServerErrorMsg({status}),
-            'Error 409');
-        assert.equal(element._constructServerErrorMsg({status, url}),
-            'Error 409: \nEndpoint: /my/test/url');
-        assert.equal(element.
-            _constructServerErrorMsg({status, statusText, url}),
-        'Error 409 (Conflict): \nEndpoint: /my/test/url');
-        assert.equal(element._constructServerErrorMsg({
-          status,
-          statusText,
-          errorText,
-          url,
-        }), 'Error 409 (Conflict): change conflicts' +
-        '\nEndpoint: /my/test/url');
-        assert.equal(element._constructServerErrorMsg({
-          status,
-          statusText,
-          errorText,
-          url,
-          trace: 'xxxxx',
-        }), 'Error 409 (Conflict): change conflicts' +
-        '\nEndpoint: /my/test/url\nTrace Id: xxxxx');
-      });
-
-      test('extract trace id from headers if exists', done => {
-        const textSpy = sandbox.spy(
-            () => Promise.resolve('500')
-        );
-        const headers = new Headers();
-        headers.set('X-Gerrit-Trace', 'xxxx');
-        element.fire('server-error', {
-          response: {
-            headers,
-            status: 500,
-            text: textSpy,
-          },
-        });
-        flush(() => {
-          assert.equal(
-              element.$.errorDialog.text,
-              'Error 500: 500\nTrace Id: xxxx'
+    test('show auth required for 403 with auth error and not authed before',
+        done => {
+          const showAuthErrorStub = sandbox.stub(
+              element, '_showAuthErrorAlert'
           );
-          done();
-        });
-      });
-
-      test('suppress TOO_MANY_FILES error', done => {
-        const showAlertStub = sandbox.stub(element, '_showAlert');
-        const textSpy = sandbox.spy(
-            () => Promise.resolve('too many files to find conflicts')
-        );
-        element.fire('server-error', {response: {status: 500, text: textSpy}});
-
-        assert.isTrue(textSpy.called);
-        flush(() => {
-          assert.isFalse(showAlertStub.called);
-          done();
-        });
-      });
-
-      test('show network error', done => {
-        const consoleErrorStub = sandbox.stub(console, 'error');
-        const showAlertStub = sandbox.stub(element, '_showAlert');
-        element.fire('network-error', {error: new Error('ZOMG')});
-        flush(() => {
-          assert.isTrue(showAlertStub.calledOnce);
-          assert.isTrue(showAlertStub.lastCall.calledWithExactly(
-              'Server unavailable'));
-          assert.isTrue(consoleErrorStub.calledOnce);
-          assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
-          done();
-        });
-      });
-
-      test('show auth refresh toast', done => {
-        // starts with authed state
-        element.$.restAPI.getLoggedIn();
-        const refreshStub = sandbox.stub(element.$.restAPI, 'getAccount',
-            () => Promise.resolve({}));
-        const toastSpy = sandbox.spy(element, '_createToastAlert');
-        const windowOpen = sandbox.stub(window, 'open');
-        const responseText = Promise.resolve('Authentication required\n');
-        // fake failed auth
-        window.fetch.returns(Promise.resolve({status: 403}));
-        element.fire('server-error',
-            {response: {status: 403, text() { return responseText; }}}
-        );
-        assert.equal(window.fetch.callCount, 1);
-        flush(() => {
-          // here needs two flush as there are two chanined
-          // promises on server-error handler and flush only flushes one
-          assert.equal(window.fetch.callCount, 2);
+          const responseText = Promise.resolve('Authentication required\n');
+          sinon.stub(element.$.restAPI, 'getLoggedIn')
+              .returns(Promise.resolve(true));
+          element.fire('server-error',
+              {response: {status: 403, text() { return responseText; }}}
+          );
           flush(() => {
-            // auth-error fired
-            assert.isTrue(toastSpy.called);
+            assert.isTrue(showAuthErrorStub.calledOnce);
+            done();
+          });
+        });
 
-            // toast
-            let toast = toastSpy.lastCall.returnValue;
+    test('recheck auth for 403 with auth error if authed before', done => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+      const responseText = Promise.resolve('Authentication required\n');
+      sinon.stub(element.$.restAPI, 'getLoggedIn')
+          .returns(Promise.resolve(true));
+      element.fire('server-error',
+          {response: {status: 403, text() { return responseText; }}}
+      );
+      flush(() => {
+        assert.isTrue(element.$.restAPI.getLoggedIn.calledOnce);
+        done();
+      });
+    });
+
+    test('show logged in error', () => {
+      sandbox.stub(element, '_showAuthErrorAlert');
+      element.fire('show-auth-required');
+      assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
+          'Log in is required to perform that action.', 'Log in.'));
+    });
+
+    test('show normal Error', done => {
+      const showErrorStub = sandbox.stub(element, '_showErrorDialog');
+      const textSpy = sandbox.spy(() => Promise.resolve('ZOMG'));
+      element.fire('server-error', {response: {status: 500, text: textSpy}});
+
+      assert.isTrue(textSpy.called);
+      flush(() => {
+        assert.isTrue(showErrorStub.calledOnce);
+        assert.isTrue(showErrorStub.lastCall.calledWithExactly(
+            'Error 500: ZOMG'));
+        done();
+      });
+    });
+
+    test('_constructServerErrorMsg', () => {
+      const errorText = 'change conflicts';
+      const status = 409;
+      const statusText = 'Conflict';
+      const url = '/my/test/url';
+
+      assert.equal(element._constructServerErrorMsg({status}),
+          'Error 409');
+      assert.equal(element._constructServerErrorMsg({status, url}),
+          'Error 409: \nEndpoint: /my/test/url');
+      assert.equal(element.
+          _constructServerErrorMsg({status, statusText, url}),
+      'Error 409 (Conflict): \nEndpoint: /my/test/url');
+      assert.equal(element._constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        url,
+      }), 'Error 409 (Conflict): change conflicts' +
+      '\nEndpoint: /my/test/url');
+      assert.equal(element._constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        url,
+        trace: 'xxxxx',
+      }), 'Error 409 (Conflict): change conflicts' +
+      '\nEndpoint: /my/test/url\nTrace Id: xxxxx');
+    });
+
+    test('extract trace id from headers if exists', done => {
+      const textSpy = sandbox.spy(
+          () => Promise.resolve('500')
+      );
+      const headers = new Headers();
+      headers.set('X-Gerrit-Trace', 'xxxx');
+      element.fire('server-error', {
+        response: {
+          headers,
+          status: 500,
+          text: textSpy,
+        },
+      });
+      flush(() => {
+        assert.equal(
+            element.$.errorDialog.text,
+            'Error 500: 500\nTrace Id: xxxx'
+        );
+        done();
+      });
+    });
+
+    test('suppress TOO_MANY_FILES error', done => {
+      const showAlertStub = sandbox.stub(element, '_showAlert');
+      const textSpy = sandbox.spy(
+          () => Promise.resolve('too many files to find conflicts')
+      );
+      element.fire('server-error', {response: {status: 500, text: textSpy}});
+
+      assert.isTrue(textSpy.called);
+      flush(() => {
+        assert.isFalse(showAlertStub.called);
+        done();
+      });
+    });
+
+    test('show network error', done => {
+      const consoleErrorStub = sandbox.stub(console, 'error');
+      const showAlertStub = sandbox.stub(element, '_showAlert');
+      element.fire('network-error', {error: new Error('ZOMG')});
+      flush(() => {
+        assert.isTrue(showAlertStub.calledOnce);
+        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
+            'Server unavailable'));
+        assert.isTrue(consoleErrorStub.calledOnce);
+        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
+        done();
+      });
+    });
+
+    test('show auth refresh toast', done => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+      const refreshStub = sandbox.stub(element.$.restAPI, 'getAccount',
+          () => Promise.resolve({}));
+      const toastSpy = sandbox.spy(element, '_createToastAlert');
+      const windowOpen = sandbox.stub(window, 'open');
+      const responseText = Promise.resolve('Authentication required\n');
+      // fake failed auth
+      window.fetch.returns(Promise.resolve({status: 403}));
+      element.fire('server-error',
+          {response: {status: 403, text() { return responseText; }}}
+      );
+      assert.equal(window.fetch.callCount, 1);
+      flush(() => {
+        // here needs two flush as there are two chanined
+        // promises on server-error handler and flush only flushes one
+        assert.equal(window.fetch.callCount, 2);
+        flush(() => {
+          // auth-error fired
+          assert.isTrue(toastSpy.called);
+
+          // toast
+          let toast = toastSpy.lastCall.returnValue;
+          assert.isOk(toast);
+          assert.include(
+              dom(toast.root).textContent, 'Credentials expired.');
+          assert.include(
+              dom(toast.root).textContent, 'Refresh credentials');
+
+          // noInteractionOverlay
+          const noInteractionOverlay = element.$.noInteractionOverlay;
+          assert.isOk(noInteractionOverlay);
+          sinon.spy(noInteractionOverlay, 'close');
+          assert.equal(
+              noInteractionOverlay.backdropElement.getAttribute('opened'),
+              '');
+          assert.isFalse(windowOpen.called);
+          MockInteractions.tap(toast.shadowRoot
+              .querySelector('gr-button.action'));
+          assert.isTrue(windowOpen.called);
+
+          // @see Issue 5822: noopener breaks closeAfterLogin
+          assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
+              -1);
+
+          const hideToastSpy = sandbox.spy(toast, 'hide');
+
+          // now fake authed
+          window.fetch.returns(Promise.resolve({status: 204}));
+          element._handleWindowFocus();
+          element.flushDebouncer('checkLoggedIn');
+          flush(() => {
+            assert.isTrue(refreshStub.called);
+            assert.isTrue(hideToastSpy.called);
+
+            // toast update
+            assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
+            toast = toastSpy.lastCall.returnValue;
             assert.isOk(toast);
             assert.include(
-                Polymer.dom(toast.root).textContent, 'Credentials expired.');
-            assert.include(
-                Polymer.dom(toast.root).textContent, 'Refresh credentials');
+                dom(toast.root).textContent, 'Credentials refreshed');
 
-            // noInteractionOverlay
-            const noInteractionOverlay = element.$.noInteractionOverlay;
-            assert.isOk(noInteractionOverlay);
-            sinon.spy(noInteractionOverlay, 'close');
-            assert.equal(
-                noInteractionOverlay.backdropElement.getAttribute('opened'),
-                '');
-            assert.isFalse(windowOpen.called);
-            MockInteractions.tap(toast.shadowRoot
-                .querySelector('gr-button.action'));
-            assert.isTrue(windowOpen.called);
-
-            // @see Issue 5822: noopener breaks closeAfterLogin
-            assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
-                -1);
-
-            const hideToastSpy = sandbox.spy(toast, 'hide');
-
-            // now fake authed
-            window.fetch.returns(Promise.resolve({status: 204}));
-            element._handleWindowFocus();
-            element.flushDebouncer('checkLoggedIn');
-            flush(() => {
-              assert.isTrue(refreshStub.called);
-              assert.isTrue(hideToastSpy.called);
-
-              // toast update
-              assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
-              toast = toastSpy.lastCall.returnValue;
-              assert.isOk(toast);
-              assert.include(
-                  Polymer.dom(toast.root).textContent, 'Credentials refreshed');
-
-              // close overlay
-              assert.isTrue(noInteractionOverlay.close.called);
-              done();
-            });
-          });
-        });
-      });
-
-      test('auth toast should dismiss existing toast', done => {
-        // starts with authed state
-        element.$.restAPI.getLoggedIn();
-        const toastSpy = sandbox.spy(element, '_createToastAlert');
-        const responseText = Promise.resolve('Authentication required\n');
-
-        // fake an alert
-        element.fire('show-alert', {message: 'test reload', action: 'reload'});
-        const toast = toastSpy.lastCall.returnValue;
-        assert.isOk(toast);
-        assert.include(
-            Polymer.dom(toast.root).textContent, 'test reload');
-
-        // fake auth
-        window.fetch.returns(Promise.resolve({status: 403}));
-        element.fire('server-error',
-            {response: {status: 403, text() { return responseText; }}}
-        );
-        assert.equal(window.fetch.callCount, 1);
-        flush(() => {
-          // here needs two flush as there are two chanined
-          // promises on server-error handler and flush only flushes one
-          assert.equal(window.fetch.callCount, 2);
-          flush(() => {
-            // toast
-            const toast = toastSpy.lastCall.returnValue;
-            assert.include(
-                Polymer.dom(toast.root).textContent, 'Credentials expired.');
-            assert.include(
-                Polymer.dom(toast.root).textContent, 'Refresh credentials');
+            // close overlay
+            assert.isTrue(noInteractionOverlay.close.called);
             done();
           });
         });
       });
+    });
 
-      test('regular toast should dismiss regular toast', () => {
-        // starts with authed state
-        element.$.restAPI.getLoggedIn();
-        const toastSpy = sandbox.spy(element, '_createToastAlert');
+    test('auth toast should dismiss existing toast', done => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+      const toastSpy = sandbox.spy(element, '_createToastAlert');
+      const responseText = Promise.resolve('Authentication required\n');
 
-        // fake an alert
-        element.fire('show-alert', {message: 'test reload', action: 'reload'});
-        let toast = toastSpy.lastCall.returnValue;
-        assert.isOk(toast);
-        assert.include(
-            Polymer.dom(toast.root).textContent, 'test reload');
+      // fake an alert
+      element.fire('show-alert', {message: 'test reload', action: 'reload'});
+      const toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(
+          dom(toast.root).textContent, 'test reload');
 
-        // new alert
-        element.fire('show-alert', {message: 'second-test', action: 'reload'});
-
-        toast = toastSpy.lastCall.returnValue;
-        assert.include(Polymer.dom(toast.root).textContent, 'second-test');
-      });
-
-      test('regular toast should not dismiss auth toast', done => {
-        // starts with authed state
-        element.$.restAPI.getLoggedIn();
-        const toastSpy = sandbox.spy(element, '_createToastAlert');
-        const responseText = Promise.resolve('Authentication required\n');
-
-        // fake auth
-        window.fetch.returns(Promise.resolve({status: 403}));
-        element.fire('server-error',
-            {response: {status: 403, text() { return responseText; }}}
-        );
-        assert.equal(window.fetch.callCount, 1);
+      // fake auth
+      window.fetch.returns(Promise.resolve({status: 403}));
+      element.fire('server-error',
+          {response: {status: 403, text() { return responseText; }}}
+      );
+      assert.equal(window.fetch.callCount, 1);
+      flush(() => {
+        // here needs two flush as there are two chanined
+        // promises on server-error handler and flush only flushes one
+        assert.equal(window.fetch.callCount, 2);
         flush(() => {
-          // here needs two flush as there are two chanined
-          // promises on server-error handler and flush only flushes one
-          assert.equal(window.fetch.callCount, 2);
-          flush(() => {
-            let toast = toastSpy.lastCall.returnValue;
-            assert.include(
-                Polymer.dom(toast.root).textContent, 'Credentials expired.');
-            assert.include(
-                Polymer.dom(toast.root).textContent, 'Refresh credentials');
+          // toast
+          const toast = toastSpy.lastCall.returnValue;
+          assert.include(
+              dom(toast.root).textContent, 'Credentials expired.');
+          assert.include(
+              dom(toast.root).textContent, 'Refresh credentials');
+          done();
+        });
+      });
+    });
 
-            // fake an alert
-            element.fire('show-alert', {
-              message: 'test-alert', action: 'reload',
-            });
-            flush(() => {
-              toast = toastSpy.lastCall.returnValue;
-              assert.isOk(toast);
-              assert.include(
-                  Polymer.dom(toast.root).textContent, 'Credentials expired.');
-              done();
-            });
+    test('regular toast should dismiss regular toast', () => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+      const toastSpy = sandbox.spy(element, '_createToastAlert');
+
+      // fake an alert
+      element.fire('show-alert', {message: 'test reload', action: 'reload'});
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(
+          dom(toast.root).textContent, 'test reload');
+
+      // new alert
+      element.fire('show-alert', {message: 'second-test', action: 'reload'});
+
+      toast = toastSpy.lastCall.returnValue;
+      assert.include(dom(toast.root).textContent, 'second-test');
+    });
+
+    test('regular toast should not dismiss auth toast', done => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+      const toastSpy = sandbox.spy(element, '_createToastAlert');
+      const responseText = Promise.resolve('Authentication required\n');
+
+      // fake auth
+      window.fetch.returns(Promise.resolve({status: 403}));
+      element.fire('server-error',
+          {response: {status: 403, text() { return responseText; }}}
+      );
+      assert.equal(window.fetch.callCount, 1);
+      flush(() => {
+        // here needs two flush as there are two chanined
+        // promises on server-error handler and flush only flushes one
+        assert.equal(window.fetch.callCount, 2);
+        flush(() => {
+          let toast = toastSpy.lastCall.returnValue;
+          assert.include(
+              dom(toast.root).textContent, 'Credentials expired.');
+          assert.include(
+              dom(toast.root).textContent, 'Refresh credentials');
+
+          // fake an alert
+          element.fire('show-alert', {
+            message: 'test-alert', action: 'reload',
+          });
+          flush(() => {
+            toast = toastSpy.lastCall.returnValue;
+            assert.isOk(toast);
+            assert.include(
+                dom(toast.root).textContent, 'Credentials expired.');
+            done();
           });
         });
       });
+    });
 
-      test('show alert', () => {
-        const alertObj = {message: 'foo'};
-        sandbox.stub(element, '_showAlert');
-        element.fire('show-alert', alertObj);
-        assert.isTrue(element._showAlert.calledOnce);
-        assert.equal(element._showAlert.lastCall.args[0], 'foo');
-        assert.isNotOk(element._showAlert.lastCall.args[1]);
-        assert.isNotOk(element._showAlert.lastCall.args[2]);
-      });
+    test('show alert', () => {
+      const alertObj = {message: 'foo'};
+      sandbox.stub(element, '_showAlert');
+      element.fire('show-alert', alertObj);
+      assert.isTrue(element._showAlert.calledOnce);
+      assert.equal(element._showAlert.lastCall.args[0], 'foo');
+      assert.isNotOk(element._showAlert.lastCall.args[1]);
+      assert.isNotOk(element._showAlert.lastCall.args[2]);
+    });
 
-      test('checks stale credentials on visibility change', () => {
-        const refreshStub = sandbox.stub(element,
-            '_checkSignedIn');
-        sandbox.stub(Date, 'now').returns(999999);
-        element._lastCredentialCheck = 0;
-        element._handleVisibilityChange();
+    test('checks stale credentials on visibility change', () => {
+      const refreshStub = sandbox.stub(element,
+          '_checkSignedIn');
+      sandbox.stub(Date, 'now').returns(999999);
+      element._lastCredentialCheck = 0;
+      element._handleVisibilityChange();
 
-        // Since there is no known account, it should not test credentials.
-        assert.isFalse(refreshStub.called);
-        assert.equal(element._lastCredentialCheck, 0);
+      // Since there is no known account, it should not test credentials.
+      assert.isFalse(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 0);
 
-        element.knownAccountId = 123;
-        element._handleVisibilityChange();
+      element.knownAccountId = 123;
+      element._handleVisibilityChange();
 
-        // Should test credentials, since there is a known account.
-        assert.isTrue(refreshStub.called);
-        assert.equal(element._lastCredentialCheck, 999999);
-      });
+      // Should test credentials, since there is a known account.
+      assert.isTrue(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 999999);
+    });
 
-      test('refreshes with same credentials', done => {
-        const accountPromise = Promise.resolve({_account_id: 1234});
-        sandbox.stub(element.$.restAPI, 'getAccount')
-            .returns(accountPromise);
-        const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
-        const handleRefreshStub = sandbox.stub(element,
-            '_handleCredentialRefreshed');
-        const reloadStub = sandbox.stub(element, '_reloadPage');
+    test('refreshes with same credentials', done => {
+      const accountPromise = Promise.resolve({_account_id: 1234});
+      sandbox.stub(element.$.restAPI, 'getAccount')
+          .returns(accountPromise);
+      const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sandbox.stub(element,
+          '_handleCredentialRefreshed');
+      const reloadStub = sandbox.stub(element, '_reloadPage');
 
-        element.knownAccountId = 1234;
-        element._refreshingCredentials = true;
-        element._checkSignedIn();
+      element.knownAccountId = 1234;
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
 
-        flush(() => {
-          assert.isFalse(requestCheckStub.called);
-          assert.isTrue(handleRefreshStub.called);
-          assert.isFalse(reloadStub.called);
-          done();
-        });
-      });
-
-      test('_showAlert hides existing alerts', () => {
-        element._alertElement = element._createToastAlert();
-        const hideStub = sandbox.stub(element, '_hideAlert');
-        element._showAlert();
-        assert.isTrue(hideStub.calledOnce);
-      });
-
-      test('show-error', () => {
-        const openStub = sandbox.stub(element.$.errorOverlay, 'open');
-        const closeStub = sandbox.stub(element.$.errorOverlay, 'close');
-        const reportStub = sandbox.stub(
-            element.$.reporting,
-            'reportErrorDialog'
-        );
-
-        const message = 'test message';
-        element.fire('show-error', {message});
-        flushAsynchronousOperations();
-
-        assert.isTrue(openStub.called);
-        assert.isTrue(reportStub.called);
-        assert.equal(element.$.errorDialog.text, message);
-
-        element.$.errorDialog.fire('dismiss');
-        flushAsynchronousOperations();
-
-        assert.isTrue(closeStub.called);
-      });
-
-      test('reloads when refreshed credentials differ', done => {
-        const accountPromise = Promise.resolve({_account_id: 1234});
-        sandbox.stub(element.$.restAPI, 'getAccount')
-            .returns(accountPromise);
-        const requestCheckStub = sandbox.stub(
-            element,
-            '_requestCheckLoggedIn');
-        const handleRefreshStub = sandbox.stub(element,
-            '_handleCredentialRefreshed');
-        const reloadStub = sandbox.stub(element, '_reloadPage');
-
-        element.knownAccountId = 4321; // Different from 1234
-        element._refreshingCredentials = true;
-        element._checkSignedIn();
-
-        flush(() => {
-          assert.isFalse(requestCheckStub.called);
-          assert.isFalse(handleRefreshStub.called);
-          assert.isTrue(reloadStub.called);
-          done();
-        });
+      flush(() => {
+        assert.isFalse(requestCheckStub.called);
+        assert.isTrue(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
       });
     });
 
-    suite('when not authed', () => {
-      setup(() => {
-        stub('gr-rest-api-interface', {
-          getLoggedIn() { return Promise.resolve(false); },
-        });
-        element = fixture('basic');
-      });
+    test('_showAlert hides existing alerts', () => {
+      element._alertElement = element._createToastAlert();
+      const hideStub = sandbox.stub(element, '_hideAlert');
+      element._showAlert();
+      assert.isTrue(hideStub.calledOnce);
+    });
 
-      test('refresh loop continues on credential fail', done => {
-        const requestCheckStub = sandbox.stub(
-            element,
-            '_requestCheckLoggedIn');
-        const handleRefreshStub = sandbox.stub(element,
-            '_handleCredentialRefreshed');
-        const reloadStub = sandbox.stub(element, '_reloadPage');
+    test('show-error', () => {
+      const openStub = sandbox.stub(element.$.errorOverlay, 'open');
+      const closeStub = sandbox.stub(element.$.errorOverlay, 'close');
+      const reportStub = sandbox.stub(
+          element.$.reporting,
+          'reportErrorDialog'
+      );
 
-        element._refreshingCredentials = true;
-        element._checkSignedIn();
+      const message = 'test message';
+      element.fire('show-error', {message});
+      flushAsynchronousOperations();
 
-        flush(() => {
-          assert.isTrue(requestCheckStub.called);
-          assert.isFalse(handleRefreshStub.called);
-          assert.isFalse(reloadStub.called);
-          done();
-        });
+      assert.isTrue(openStub.called);
+      assert.isTrue(reportStub.called);
+      assert.equal(element.$.errorDialog.text, message);
+
+      element.$.errorDialog.fire('dismiss');
+      flushAsynchronousOperations();
+
+      assert.isTrue(closeStub.called);
+    });
+
+    test('reloads when refreshed credentials differ', done => {
+      const accountPromise = Promise.resolve({_account_id: 1234});
+      sandbox.stub(element.$.restAPI, 'getAccount')
+          .returns(accountPromise);
+      const requestCheckStub = sandbox.stub(
+          element,
+          '_requestCheckLoggedIn');
+      const handleRefreshStub = sandbox.stub(element,
+          '_handleCredentialRefreshed');
+      const reloadStub = sandbox.stub(element, '_reloadPage');
+
+      element.knownAccountId = 4321; // Different from 1234
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      flush(() => {
+        assert.isFalse(requestCheckStub.called);
+        assert.isFalse(handleRefreshStub.called);
+        assert.isTrue(reloadStub.called);
+        done();
       });
     });
   });
+
+  suite('when not authed', () => {
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+      });
+      element = fixture('basic');
+    });
+
+    test('refresh loop continues on credential fail', done => {
+      const requestCheckStub = sandbox.stub(
+          element,
+          '_requestCheckLoggedIn');
+      const handleRefreshStub = sandbox.stub(element,
+          '_handleCredentialRefreshed');
+      const reloadStub = sandbox.stub(element, '_reloadPage');
+
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      flush(() => {
+        assert.isTrue(requestCheckStub.called);
+        assert.isFalse(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
index 3d424bc..5d7ec27 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
@@ -14,30 +14,36 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrKeyBindingDisplay extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-key-binding-display'; }
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-key-binding-display_html.js';
 
-    static get properties() {
-      return {
-      /** @type {Array<string>} */
-        binding: Array,
-      };
-    }
+/** @extends Polymer.Element */
+class GrKeyBindingDisplay extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _computeModifiers(binding) {
-      return binding.slice(0, binding.length - 1);
-    }
+  static get is() { return 'gr-key-binding-display'; }
 
-    _computeKey(binding) {
-      return binding[binding.length - 1];
-    }
+  static get properties() {
+    return {
+    /** @type {Array<string>} */
+      binding: Array,
+    };
   }
 
-  customElements.define(GrKeyBindingDisplay.is, GrKeyBindingDisplay);
-})();
+  _computeModifiers(binding) {
+    return binding.slice(0, binding.length - 1);
+  }
+
+  _computeKey(binding) {
+    return binding[binding.length - 1];
+  }
+}
+
+customElements.define(GrKeyBindingDisplay.is, GrKeyBindingDisplay);
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.js
index a863276..f98be3a 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.js
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.js
@@ -1,25 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-key-binding-display">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .key {
         background-color: var(--chip-background-color);
@@ -35,14 +32,9 @@
       <template is="dom-if" if="[[index]]">
         or
       </template>
-      <template
-          is="dom-repeat"
-          items="[[_computeModifiers(item)]]"
-          as="modifier">
+      <template is="dom-repeat" items="[[_computeModifiers(item)]]" as="modifier">
         <span class="key modifier">[[modifier]]</span>
       </template>
       <span class="key">[[_computeKey(item)]]</span>
     </template>
-  </template>
-  <script src="gr-key-binding-display.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
index f682f0a..bb449c3 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
@@ -18,15 +18,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-key-binding-display</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-key-binding-display.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-key-binding-display.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-key-binding-display.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -34,37 +39,39 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-key-binding-display tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-key-binding-display.js';
+suite('gr-key-binding-display tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
+  setup(() => {
+    element = fixture('basic');
+  });
+
+  suite('_computeKey', () => {
+    test('unmodified key', () => {
+      assert.strictEqual(element._computeKey(['x']), 'x');
     });
 
-    suite('_computeKey', () => {
-      test('unmodified key', () => {
-        assert.strictEqual(element._computeKey(['x']), 'x');
-      });
-
-      test('key with modifiers', () => {
-        assert.strictEqual(element._computeKey(['Ctrl', 'x']), 'x');
-        assert.strictEqual(element._computeKey(['Shift', 'Meta', 'x']), 'x');
-      });
-    });
-
-    suite('_computeModifiers', () => {
-      test('single unmodified key', () => {
-        assert.deepEqual(element._computeModifiers(['x']), []);
-      });
-
-      test('key with modifiers', () => {
-        assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
-        assert.deepEqual(
-            element._computeModifiers(['Shift', 'Meta', 'x']),
-            ['Shift', 'Meta']);
-      });
+    test('key with modifiers', () => {
+      assert.strictEqual(element._computeKey(['Ctrl', 'x']), 'x');
+      assert.strictEqual(element._computeKey(['Shift', 'Meta', 'x']), 'x');
     });
   });
+
+  suite('_computeModifiers', () => {
+    test('single unmodified key', () => {
+      assert.deepEqual(element._computeModifiers(['x']), []);
+    });
+
+    test('key with modifiers', () => {
+      assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
+      assert.deepEqual(
+          element._computeModifiers(['Shift', 'Meta', 'x']),
+          ['Shift', 'Meta']);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
index 4630ca7..371ba02 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
@@ -14,129 +14,140 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const {ShortcutSection} = window.Gerrit.KeyboardShortcutBinder;
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../../shared/gr-button/gr-button.js';
+import '../gr-key-binding-display/gr-key-binding-display.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-keyboard-shortcuts-dialog_html.js';
 
+const {ShortcutSection} = window.Gerrit.KeyboardShortcutBinder;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @extends Polymer.Element
+ */
+class GrKeyboardShortcutsDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-keyboard-shortcuts-dialog'; }
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @extends Polymer.Element
+   * Fired when the user presses the close button.
+   *
+   * @event close
    */
-  class GrKeyboardShortcutsDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-keyboard-shortcuts-dialog'; }
-    /**
-     * Fired when the user presses the close button.
-     *
-     * @event close
-     */
 
-    static get properties() {
-      return {
-        _left: Array,
-        _right: Array,
+  static get properties() {
+    return {
+      _left: Array,
+      _right: Array,
 
-        _propertyBySection: {
-          type: Object,
-          value() {
-            return {
-              [ShortcutSection.EVERYWHERE]: '_everywhere',
-              [ShortcutSection.NAVIGATION]: '_navigation',
-              [ShortcutSection.DASHBOARD]: '_dashboard',
-              [ShortcutSection.CHANGE_LIST]: '_changeList',
-              [ShortcutSection.ACTIONS]: '_actions',
-              [ShortcutSection.REPLY_DIALOG]: '_replyDialog',
-              [ShortcutSection.FILE_LIST]: '_fileList',
-              [ShortcutSection.DIFFS]: '_diffs',
-            };
-          },
+      _propertyBySection: {
+        type: Object,
+        value() {
+          return {
+            [ShortcutSection.EVERYWHERE]: '_everywhere',
+            [ShortcutSection.NAVIGATION]: '_navigation',
+            [ShortcutSection.DASHBOARD]: '_dashboard',
+            [ShortcutSection.CHANGE_LIST]: '_changeList',
+            [ShortcutSection.ACTIONS]: '_actions',
+            [ShortcutSection.REPLY_DIALOG]: '_replyDialog',
+            [ShortcutSection.FILE_LIST]: '_fileList',
+            [ShortcutSection.DIFFS]: '_diffs',
+          };
         },
-      };
-    }
-
-    /** @override */
-    ready() {
-      super.ready();
-      this._ensureAttribute('role', 'dialog');
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this.addKeyboardShortcutDirectoryListener(
-          this._onDirectoryUpdated.bind(this));
-    }
-
-    /** @override */
-    detached() {
-      super.detached();
-      this.removeKeyboardShortcutDirectoryListener(
-          this._onDirectoryUpdated.bind(this));
-    }
-
-    _handleCloseTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('close', null, {bubbles: false});
-    }
-
-    _onDirectoryUpdated(directory) {
-      const left = [];
-      const right = [];
-
-      if (directory.has(ShortcutSection.EVERYWHERE)) {
-        left.push({
-          section: ShortcutSection.EVERYWHERE,
-          shortcuts: directory.get(ShortcutSection.EVERYWHERE),
-        });
-      }
-
-      if (directory.has(ShortcutSection.NAVIGATION)) {
-        left.push({
-          section: ShortcutSection.NAVIGATION,
-          shortcuts: directory.get(ShortcutSection.NAVIGATION),
-        });
-      }
-
-      if (directory.has(ShortcutSection.ACTIONS)) {
-        right.push({
-          section: ShortcutSection.ACTIONS,
-          shortcuts: directory.get(ShortcutSection.ACTIONS),
-        });
-      }
-
-      if (directory.has(ShortcutSection.REPLY_DIALOG)) {
-        right.push({
-          section: ShortcutSection.REPLY_DIALOG,
-          shortcuts: directory.get(ShortcutSection.REPLY_DIALOG),
-        });
-      }
-
-      if (directory.has(ShortcutSection.FILE_LIST)) {
-        right.push({
-          section: ShortcutSection.FILE_LIST,
-          shortcuts: directory.get(ShortcutSection.FILE_LIST),
-        });
-      }
-
-      if (directory.has(ShortcutSection.DIFFS)) {
-        right.push({
-          section: ShortcutSection.DIFFS,
-          shortcuts: directory.get(ShortcutSection.DIFFS),
-        });
-      }
-
-      this.set('_left', left);
-      this.set('_right', right);
-    }
+      },
+    };
   }
 
-  customElements.define(GrKeyboardShortcutsDialog.is,
-      GrKeyboardShortcutsDialog);
-})();
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'dialog');
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.addKeyboardShortcutDirectoryListener(
+        this._onDirectoryUpdated.bind(this));
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.removeKeyboardShortcutDirectoryListener(
+        this._onDirectoryUpdated.bind(this));
+  }
+
+  _handleCloseTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('close', null, {bubbles: false});
+  }
+
+  _onDirectoryUpdated(directory) {
+    const left = [];
+    const right = [];
+
+    if (directory.has(ShortcutSection.EVERYWHERE)) {
+      left.push({
+        section: ShortcutSection.EVERYWHERE,
+        shortcuts: directory.get(ShortcutSection.EVERYWHERE),
+      });
+    }
+
+    if (directory.has(ShortcutSection.NAVIGATION)) {
+      left.push({
+        section: ShortcutSection.NAVIGATION,
+        shortcuts: directory.get(ShortcutSection.NAVIGATION),
+      });
+    }
+
+    if (directory.has(ShortcutSection.ACTIONS)) {
+      right.push({
+        section: ShortcutSection.ACTIONS,
+        shortcuts: directory.get(ShortcutSection.ACTIONS),
+      });
+    }
+
+    if (directory.has(ShortcutSection.REPLY_DIALOG)) {
+      right.push({
+        section: ShortcutSection.REPLY_DIALOG,
+        shortcuts: directory.get(ShortcutSection.REPLY_DIALOG),
+      });
+    }
+
+    if (directory.has(ShortcutSection.FILE_LIST)) {
+      right.push({
+        section: ShortcutSection.FILE_LIST,
+        shortcuts: directory.get(ShortcutSection.FILE_LIST),
+      });
+    }
+
+    if (directory.has(ShortcutSection.DIFFS)) {
+      right.push({
+        section: ShortcutSection.DIFFS,
+        shortcuts: directory.get(ShortcutSection.DIFFS),
+      });
+    }
+
+    this.set('_left', left);
+    this.set('_right', right);
+  }
+}
+
+customElements.define(GrKeyboardShortcutsDialog.is,
+    GrKeyboardShortcutsDialog);
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js
index a4424a2..380228f 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../gr-key-binding-display/gr-key-binding-display.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-keyboard-shortcuts-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -63,7 +56,7 @@
     </style>
     <header>
       <h3>Keyboard shortcuts</h3>
-      <gr-button link on-click="_handleCloseTap">Close</gr-button>
+      <gr-button link="" on-click="_handleCloseTap">Close</gr-button>
     </header>
     <main>
       <table>
@@ -106,6 +99,4 @@
       </template>
     </main>
     <footer></footer>
-  </template>
-  <script src="gr-keyboard-shortcuts-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
index cc53db17..eedd166 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
@@ -18,15 +18,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-key-binding-display</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-keyboard-shortcuts-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-keyboard-shortcuts-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-keyboard-shortcuts-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -34,150 +39,152 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-keyboard-shortcuts-dialog tests', async () => {
-    await readyToTest();
-    const kb = window.Gerrit.KeyboardShortcutBinder;
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-keyboard-shortcuts-dialog.js';
+suite('gr-keyboard-shortcuts-dialog tests', () => {
+  const kb = window.Gerrit.KeyboardShortcutBinder;
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
+  setup(() => {
+    element = fixture('basic');
+  });
+
+  function update(directory) {
+    element._onDirectoryUpdated(directory);
+    flushAsynchronousOperations();
+  }
+
+  suite('_left and _right contents', () => {
+    test('empty dialog', () => {
+      assert.strictEqual(element._left.length, 0);
+      assert.strictEqual(element._right.length, 0);
     });
 
-    function update(directory) {
-      element._onDirectoryUpdated(directory);
-      flushAsynchronousOperations();
-    }
+    test('everywhere goes on left', () => {
+      update(new Map([
+        [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._left,
+          [
+            {
+              section: kb.ShortcutSection.EVERYWHERE,
+              shortcuts: ['everywhere shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._right.length, 0);
+    });
 
-    suite('_left and _right contents', () => {
-      test('empty dialog', () => {
-        assert.strictEqual(element._left.length, 0);
-        assert.strictEqual(element._right.length, 0);
-      });
+    test('navigation goes on left', () => {
+      update(new Map([
+        [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._left,
+          [
+            {
+              section: kb.ShortcutSection.NAVIGATION,
+              shortcuts: ['navigation shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._right.length, 0);
+    });
 
-      test('everywhere goes on left', () => {
-        update(new Map([
-          [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._left,
-            [
-              {
-                section: kb.ShortcutSection.EVERYWHERE,
-                shortcuts: ['everywhere shortcuts'],
-              },
-            ]);
-        assert.strictEqual(element._right.length, 0);
-      });
+    test('actions go on right', () => {
+      update(new Map([
+        [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: kb.ShortcutSection.ACTIONS,
+              shortcuts: ['actions shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
 
-      test('navigation goes on left', () => {
-        update(new Map([
-          [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._left,
-            [
-              {
-                section: kb.ShortcutSection.NAVIGATION,
-                shortcuts: ['navigation shortcuts'],
-              },
-            ]);
-        assert.strictEqual(element._right.length, 0);
-      });
+    test('reply dialog goes on right', () => {
+      update(new Map([
+        [kb.ShortcutSection.REPLY_DIALOG, ['reply dialog shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: kb.ShortcutSection.REPLY_DIALOG,
+              shortcuts: ['reply dialog shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
 
-      test('actions go on right', () => {
-        update(new Map([
-          [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._right,
-            [
-              {
-                section: kb.ShortcutSection.ACTIONS,
-                shortcuts: ['actions shortcuts'],
-              },
-            ]);
-        assert.strictEqual(element._left.length, 0);
-      });
+    test('file list goes on right', () => {
+      update(new Map([
+        [kb.ShortcutSection.FILE_LIST, ['file list shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: kb.ShortcutSection.FILE_LIST,
+              shortcuts: ['file list shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
 
-      test('reply dialog goes on right', () => {
-        update(new Map([
-          [kb.ShortcutSection.REPLY_DIALOG, ['reply dialog shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._right,
-            [
-              {
-                section: kb.ShortcutSection.REPLY_DIALOG,
-                shortcuts: ['reply dialog shortcuts'],
-              },
-            ]);
-        assert.strictEqual(element._left.length, 0);
-      });
+    test('diffs go on right', () => {
+      update(new Map([
+        [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: kb.ShortcutSection.DIFFS,
+              shortcuts: ['diffs shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
 
-      test('file list goes on right', () => {
-        update(new Map([
-          [kb.ShortcutSection.FILE_LIST, ['file list shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._right,
-            [
-              {
-                section: kb.ShortcutSection.FILE_LIST,
-                shortcuts: ['file list shortcuts'],
-              },
-            ]);
-        assert.strictEqual(element._left.length, 0);
-      });
-
-      test('diffs go on right', () => {
-        update(new Map([
-          [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._right,
-            [
-              {
-                section: kb.ShortcutSection.DIFFS,
-                shortcuts: ['diffs shortcuts'],
-              },
-            ]);
-        assert.strictEqual(element._left.length, 0);
-      });
-
-      test('multiple sections on each side', () => {
-        update(new Map([
-          [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
-          [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
-          [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
-          [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._left,
-            [
-              {
-                section: kb.ShortcutSection.EVERYWHERE,
-                shortcuts: ['everywhere shortcuts'],
-              },
-              {
-                section: kb.ShortcutSection.NAVIGATION,
-                shortcuts: ['navigation shortcuts'],
-              },
-            ]);
-        assert.deepEqual(
-            element._right,
-            [
-              {
-                section: kb.ShortcutSection.ACTIONS,
-                shortcuts: ['actions shortcuts'],
-              },
-              {
-                section: kb.ShortcutSection.DIFFS,
-                shortcuts: ['diffs shortcuts'],
-              },
-            ]);
-      });
+    test('multiple sections on each side', () => {
+      update(new Map([
+        [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
+        [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
+        [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
+        [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._left,
+          [
+            {
+              section: kb.ShortcutSection.EVERYWHERE,
+              shortcuts: ['everywhere shortcuts'],
+            },
+            {
+              section: kb.ShortcutSection.NAVIGATION,
+              shortcuts: ['navigation shortcuts'],
+            },
+          ]);
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: kb.ShortcutSection.ACTIONS,
+              shortcuts: ['actions shortcuts'],
+            },
+            {
+              section: kb.ShortcutSection.DIFFS,
+              shortcuts: ['diffs shortcuts'],
+            },
+          ]);
     });
   });
+});
 </script>
 
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 05765fb..c8ed50c 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -14,332 +14,349 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const DEFAULT_LINKS = [{
-    title: 'Changes',
-    links: [
-      {
-        url: '/q/status:open',
-        name: 'Open',
+import '../../../behaviors/docs-url-behavior/docs-url-behavior.js';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../shared/gr-dropdown/gr-dropdown.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-account-dropdown/gr-account-dropdown.js';
+import '../gr-smart-search/gr-smart-search.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-main-header_html.js';
+
+const DEFAULT_LINKS = [{
+  title: 'Changes',
+  links: [
+    {
+      url: '/q/status:open',
+      name: 'Open',
+    },
+    {
+      url: '/q/status:merged',
+      name: 'Merged',
+    },
+    {
+      url: '/q/status:abandoned',
+      name: 'Abandoned',
+    },
+  ],
+}];
+
+const DOCUMENTATION_LINKS = [
+  {
+    url: '/index.html',
+    name: 'Table of Contents',
+  },
+  {
+    url: '/user-search.html',
+    name: 'Searching',
+  },
+  {
+    url: '/user-upload.html',
+    name: 'Uploading',
+  },
+  {
+    url: '/access-control.html',
+    name: 'Access Control',
+  },
+  {
+    url: '/rest-api.html',
+    name: 'REST API',
+  },
+  {
+    url: '/intro-project-owner.html',
+    name: 'Project Owner Guide',
+  },
+];
+
+// Set of authentication methods that can provide custom registration page.
+const AUTH_TYPES_WITH_REGISTER_URL = new Set([
+  'LDAP',
+  'LDAP_BIND',
+  'CUSTOM_EXTENSION',
+]);
+
+/**
+ * @appliesMixin Gerrit.AdminNavMixin
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.DocsUrlMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrMainHeader extends mixinBehaviors( [
+  Gerrit.AdminNavBehavior,
+  Gerrit.BaseUrlBehavior,
+  Gerrit.DocsUrlBehavior,
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-main-header'; }
+
+  static get properties() {
+    return {
+      searchQuery: {
+        type: String,
+        notify: true,
       },
-      {
-        url: '/q/status:merged',
-        name: 'Merged',
+      loggedIn: {
+        type: Boolean,
+        reflectToAttribute: true,
       },
-      {
-        url: '/q/status:abandoned',
-        name: 'Abandoned',
+      loading: {
+        type: Boolean,
+        reflectToAttribute: true,
       },
-    ],
-  }];
 
-  const DOCUMENTATION_LINKS = [
-    {
-      url: '/index.html',
-      name: 'Table of Contents',
-    },
-    {
-      url: '/user-search.html',
-      name: 'Searching',
-    },
-    {
-      url: '/user-upload.html',
-      name: 'Uploading',
-    },
-    {
-      url: '/access-control.html',
-      name: 'Access Control',
-    },
-    {
-      url: '/rest-api.html',
-      name: 'REST API',
-    },
-    {
-      url: '/intro-project-owner.html',
-      name: 'Project Owner Guide',
-    },
-  ];
+      /** @type {?Object} */
+      _account: Object,
+      _adminLinks: {
+        type: Array,
+        value() { return []; },
+      },
+      _defaultLinks: {
+        type: Array,
+        value() {
+          return DEFAULT_LINKS;
+        },
+      },
+      _docBaseUrl: {
+        type: String,
+        value: null,
+      },
+      _links: {
+        type: Array,
+        computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
+          '_topMenus, _docBaseUrl)',
+      },
+      loginUrl: {
+        type: String,
+        value: '/login',
+      },
+      _userLinks: {
+        type: Array,
+        value() { return []; },
+      },
+      _topMenus: {
+        type: Array,
+        value() { return []; },
+      },
+      _registerText: {
+        type: String,
+        value: 'Sign up',
+      },
+      _registerURL: {
+        type: String,
+        value: null,
+      },
+    };
+  }
 
-  // Set of authentication methods that can provide custom registration page.
-  const AUTH_TYPES_WITH_REGISTER_URL = new Set([
-    'LDAP',
-    'LDAP_BIND',
-    'CUSTOM_EXTENSION',
-  ]);
+  static get observers() {
+    return [
+      '_accountLoaded(_account)',
+    ];
+  }
 
-  /**
-   * @appliesMixin Gerrit.AdminNavMixin
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.DocsUrlMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
-   */
-  class GrMainHeader extends Polymer.mixinBehaviors( [
-    Gerrit.AdminNavBehavior,
-    Gerrit.BaseUrlBehavior,
-    Gerrit.DocsUrlBehavior,
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-main-header'; }
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'banner');
+  }
 
-    static get properties() {
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadAccount();
+    this._loadConfig();
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+  }
+
+  reload() {
+    this._loadAccount();
+  }
+
+  _computeRelativeURL(path) {
+    return '//' + window.location.host + this.getBaseUrl() + path;
+  }
+
+  _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
+    // Polymer 2: check for undefined
+    if ([
+      defaultLinks,
+      userLinks,
+      adminLinks,
+      topMenus,
+      docBaseUrl,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const links = defaultLinks.map(menu => {
       return {
-        searchQuery: {
-          type: String,
-          notify: true,
-        },
-        loggedIn: {
-          type: Boolean,
-          reflectToAttribute: true,
-        },
-        loading: {
-          type: Boolean,
-          reflectToAttribute: true,
-        },
-
-        /** @type {?Object} */
-        _account: Object,
-        _adminLinks: {
-          type: Array,
-          value() { return []; },
-        },
-        _defaultLinks: {
-          type: Array,
-          value() {
-            return DEFAULT_LINKS;
-          },
-        },
-        _docBaseUrl: {
-          type: String,
-          value: null,
-        },
-        _links: {
-          type: Array,
-          computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
-            '_topMenus, _docBaseUrl)',
-        },
-        loginUrl: {
-          type: String,
-          value: '/login',
-        },
-        _userLinks: {
-          type: Array,
-          value() { return []; },
-        },
-        _topMenus: {
-          type: Array,
-          value() { return []; },
-        },
-        _registerText: {
-          type: String,
-          value: 'Sign up',
-        },
-        _registerURL: {
-          type: String,
-          value: null,
-        },
+        title: menu.title,
+        links: menu.links.slice(),
       };
-    }
-
-    static get observers() {
-      return [
-        '_accountLoaded(_account)',
-      ];
-    }
-
-    /** @override */
-    ready() {
-      super.ready();
-      this._ensureAttribute('role', 'banner');
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this._loadAccount();
-      this._loadConfig();
-    }
-
-    /** @override */
-    detached() {
-      super.detached();
-    }
-
-    reload() {
-      this._loadAccount();
-    }
-
-    _computeRelativeURL(path) {
-      return '//' + window.location.host + this.getBaseUrl() + path;
-    }
-
-    _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
-      // Polymer 2: check for undefined
-      if ([
-        defaultLinks,
-        userLinks,
-        adminLinks,
-        topMenus,
-        docBaseUrl,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      const links = defaultLinks.map(menu => {
-        return {
-          title: menu.title,
-          links: menu.links.slice(),
-        };
-      });
-      if (userLinks && userLinks.length > 0) {
-        links.push({
-          title: 'Your',
-          links: userLinks.slice(),
-        });
-      }
-      const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
-      if (docLinks.length) {
-        links.push({
-          title: 'Documentation',
-          links: docLinks,
-          class: 'hideOnMobile',
-        });
-      }
+    });
+    if (userLinks && userLinks.length > 0) {
       links.push({
-        title: 'Browse',
-        links: adminLinks.slice(),
+        title: 'Your',
+        links: userLinks.slice(),
       });
-      const topMenuLinks = [];
-      links.forEach(link => { topMenuLinks[link.title] = link.links; });
-      for (const m of topMenus) {
-        const items = m.items.map(this._fixCustomMenuItem).filter(link =>
-          // Ignore GWT project links
-          !link.url.includes('${projectName}')
-        );
-        if (m.name in topMenuLinks) {
-          items.forEach(link => { topMenuLinks[m.name].push(link); });
-        } else {
-          links.push({
-            title: m.name,
-            links: topMenuLinks[m.name] = items,
+    }
+    const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+    if (docLinks.length) {
+      links.push({
+        title: 'Documentation',
+        links: docLinks,
+        class: 'hideOnMobile',
+      });
+    }
+    links.push({
+      title: 'Browse',
+      links: adminLinks.slice(),
+    });
+    const topMenuLinks = [];
+    links.forEach(link => { topMenuLinks[link.title] = link.links; });
+    for (const m of topMenus) {
+      const items = m.items.map(this._fixCustomMenuItem).filter(link =>
+        // Ignore GWT project links
+        !link.url.includes('${projectName}')
+      );
+      if (m.name in topMenuLinks) {
+        items.forEach(link => { topMenuLinks[m.name].push(link); });
+      } else {
+        links.push({
+          title: m.name,
+          links: topMenuLinks[m.name] = items,
+        });
+      }
+    }
+    return links;
+  }
+
+  _getDocLinks(docBaseUrl, docLinks) {
+    if (!docBaseUrl || !docLinks) {
+      return [];
+    }
+    return docLinks.map(link => {
+      let url = docBaseUrl;
+      if (url && url[url.length - 1] === '/') {
+        url = url.substring(0, url.length - 1);
+      }
+      return {
+        url: url + link.url,
+        name: link.name,
+        target: '_blank',
+      };
+    });
+  }
+
+  _loadAccount() {
+    this.loading = true;
+    const promises = [
+      this.$.restAPI.getAccount(),
+      this.$.restAPI.getTopMenus(),
+      Gerrit.awaitPluginsLoaded(),
+    ];
+
+    return Promise.all(promises).then(result => {
+      const account = result[0];
+      this._account = account;
+      this.loggedIn = !!account;
+      this.loading = false;
+      this._topMenus = result[1];
+
+      return this.getAdminLinks(account,
+          this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
+          this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI))
+          .then(res => {
+            this._adminLinks = res.links;
           });
-        }
+    });
+  }
+
+  _loadConfig() {
+    this.$.restAPI.getConfig()
+        .then(config => {
+          this._retrieveRegisterURL(config);
+          return this.getDocsBaseUrl(config, this.$.restAPI);
+        })
+        .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
+  }
+
+  _accountLoaded(account) {
+    if (!account) { return; }
+
+    this.$.restAPI.getPreferences().then(prefs => {
+      this._userLinks = prefs && prefs.my ?
+        prefs.my.map(this._fixCustomMenuItem) : [];
+    });
+  }
+
+  _retrieveRegisterURL(config) {
+    if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
+      this._registerURL = config.auth.register_url;
+      if (config.auth.register_text) {
+        this._registerText = config.auth.register_text;
       }
-      return links;
-    }
-
-    _getDocLinks(docBaseUrl, docLinks) {
-      if (!docBaseUrl || !docLinks) {
-        return [];
-      }
-      return docLinks.map(link => {
-        let url = docBaseUrl;
-        if (url && url[url.length - 1] === '/') {
-          url = url.substring(0, url.length - 1);
-        }
-        return {
-          url: url + link.url,
-          name: link.name,
-          target: '_blank',
-        };
-      });
-    }
-
-    _loadAccount() {
-      this.loading = true;
-      const promises = [
-        this.$.restAPI.getAccount(),
-        this.$.restAPI.getTopMenus(),
-        Gerrit.awaitPluginsLoaded(),
-      ];
-
-      return Promise.all(promises).then(result => {
-        const account = result[0];
-        this._account = account;
-        this.loggedIn = !!account;
-        this.loading = false;
-        this._topMenus = result[1];
-
-        return this.getAdminLinks(account,
-            this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
-            this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI))
-            .then(res => {
-              this._adminLinks = res.links;
-            });
-      });
-    }
-
-    _loadConfig() {
-      this.$.restAPI.getConfig()
-          .then(config => {
-            this._retrieveRegisterURL(config);
-            return this.getDocsBaseUrl(config, this.$.restAPI);
-          })
-          .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
-    }
-
-    _accountLoaded(account) {
-      if (!account) { return; }
-
-      this.$.restAPI.getPreferences().then(prefs => {
-        this._userLinks = prefs && prefs.my ?
-          prefs.my.map(this._fixCustomMenuItem) : [];
-      });
-    }
-
-    _retrieveRegisterURL(config) {
-      if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
-        this._registerURL = config.auth.register_url;
-        if (config.auth.register_text) {
-          this._registerText = config.auth.register_text;
-        }
-      }
-    }
-
-    _computeIsInvisible(registerURL) {
-      return registerURL ? '' : 'invisible';
-    }
-
-    _fixCustomMenuItem(linkObj) {
-      // Normalize all urls to PolyGerrit style.
-      if (linkObj.url.startsWith('#')) {
-        linkObj.url = linkObj.url.slice(1);
-      }
-
-      // Delete target property due to complications of
-      // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
-      //
-      // The server tries to guess whether URL is a view within the UI.
-      // If not, it sets target='_blank' on the menu item. The server
-      // makes assumptions that work for the GWT UI, but not PolyGerrit,
-      // so we'll just disable it altogether for now.
-      delete linkObj.target;
-
-      return linkObj;
-    }
-
-    _generateSettingsLink() {
-      return this.getBaseUrl() + '/settings/';
-    }
-
-    _onMobileSearchTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('mobile-search', null, {bubbles: false});
-    }
-
-    _computeLinkGroupClass(linkGroup) {
-      if (linkGroup && linkGroup.class) {
-        return linkGroup.class;
-      }
-
-      return '';
     }
   }
 
-  customElements.define(GrMainHeader.is, GrMainHeader);
-})();
+  _computeIsInvisible(registerURL) {
+    return registerURL ? '' : 'invisible';
+  }
+
+  _fixCustomMenuItem(linkObj) {
+    // Normalize all urls to PolyGerrit style.
+    if (linkObj.url.startsWith('#')) {
+      linkObj.url = linkObj.url.slice(1);
+    }
+
+    // Delete target property due to complications of
+    // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
+    //
+    // The server tries to guess whether URL is a view within the UI.
+    // If not, it sets target='_blank' on the menu item. The server
+    // makes assumptions that work for the GWT UI, but not PolyGerrit,
+    // so we'll just disable it altogether for now.
+    delete linkObj.target;
+
+    return linkObj;
+  }
+
+  _generateSettingsLink() {
+    return this.getBaseUrl() + '/settings/';
+  }
+
+  _onMobileSearchTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('mobile-search', null, {bubbles: false});
+  }
+
+  _computeLinkGroupClass(linkGroup) {
+    if (linkGroup && linkGroup.class) {
+      return linkGroup.class;
+    }
+
+    return '';
+  }
+}
+
+customElements.define(GrMainHeader.is, GrMainHeader);
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js
index 51717a8..307a081 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js
@@ -1,35 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/docs-url-behavior/docs-url-behavior.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-account-dropdown/gr-account-dropdown.html">
-<link rel="import" href="../gr-smart-search/gr-smart-search.html">
-
-<dom-module id="gr-main-header">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -183,19 +170,15 @@
       }
     </style>
     <nav>
-      <a href$="[[_computeRelativeURL('/')]]" class="bigTitle">
+      <a href\$="[[_computeRelativeURL('/')]]" class="bigTitle">
         <gr-endpoint-decorator name="header-title">
           <span class="titleText"></span>
         </gr-endpoint-decorator>
       </a>
       <ul class="links">
         <template is="dom-repeat" items="[[_links]]" as="linkGroup">
-          <li class$="[[_computeLinkGroupClass(linkGroup)]]">
-            <gr-dropdown
-                link
-                down-arrow
-                items = [[linkGroup.links]]
-                horizontal-align="left">
+          <li class\$="[[_computeLinkGroupClass(linkGroup)]]">
+            <gr-dropdown link="" down-arrow="" items="[[linkGroup.links]]" horizontal-align="left">
               <span class="linksTitle" id="[[linkGroup.title]]">
                 [[linkGroup.title]]
               </span>
@@ -204,29 +187,18 @@
         </template>
       </ul>
       <div class="rightItems">
-        <gr-endpoint-decorator
-            class="hideOnMobile"
-            name="header-small-banner"></gr-endpoint-decorator>
-        <gr-smart-search
-            id="search"
-            search-query="{{searchQuery}}"></gr-smart-search>
-        <gr-endpoint-decorator
-            class="hideOnMobile"
-            name="header-browse-source"></gr-endpoint-decorator>
+        <gr-endpoint-decorator class="hideOnMobile" name="header-small-banner"></gr-endpoint-decorator>
+        <gr-smart-search id="search" search-query="{{searchQuery}}"></gr-smart-search>
+        <gr-endpoint-decorator class="hideOnMobile" name="header-browse-source"></gr-endpoint-decorator>
         <div class="accountContainer" id="accountContainer">
-          <iron-icon id="mobileSearch" icon="gr-icons:search" on-tap='_onMobileSearchTap'></iron-icon>
-          <div class$="[[_computeIsInvisible(_registerURL)]]">
-            <a
-                class="registerButton"
-                href$="[[_registerURL]]">
+          <iron-icon id="mobileSearch" icon="gr-icons:search" on-tap="_onMobileSearchTap"></iron-icon>
+          <div class\$="[[_computeIsInvisible(_registerURL)]]">
+            <a class="registerButton" href\$="[[_registerURL]]">
               [[_registerText]]
             </a>
           </div>
-          <a class="loginButton" href$="[[loginUrl]]">Sign in</a>
-          <a
-              class="settingsButton"
-              href$="[[_generateSettingsLink()]]"
-              title="Settings">
+          <a class="loginButton" href\$="[[loginUrl]]">Sign in</a>
+          <a class="settingsButton" href\$="[[_generateSettingsLink()]]" title="Settings">
             <iron-icon icon="gr-icons:settings"></iron-icon>
           </a>
           <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
@@ -235,6 +207,4 @@
     </nav>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-main-header.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
index 8fe3fca..817643b 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-main-header</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-main-header.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-main-header.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-main-header.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,379 +40,381 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-main-header tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-main-header.js';
+suite('gr-main-header tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        probePath(path) { return Promise.resolve(false); },
-      });
-      stub('gr-main-header', {
-        _loadAccount() {},
-      });
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      probePath(path) { return Promise.resolve(false); },
     });
-
-    teardown(() => {
-      sandbox.restore();
+    stub('gr-main-header', {
+      _loadAccount() {},
     });
-
-    test('link visibility', () => {
-      element.loading = true;
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('.accountContainer')).display,
-      'none');
-      element.loading = false;
-      element.loggedIn = false;
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.accountContainer')).display,
-      'none');
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.loginButton')).display,
-      'none');
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.registerButton')).display,
-      'none');
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('gr-account-dropdown')).display,
-      'none');
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('.settingsButton')).display,
-      'none');
-      element.loggedIn = true;
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('.loginButton')).display,
-      'none');
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('.registerButton')).display,
-      'none');
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('gr-account-dropdown'))
-          .display,
-      'none');
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.settingsButton')).display,
-      'none');
-    });
-
-    test('fix my menu item', () => {
-      assert.deepEqual([
-        {url: 'https://awesometown.com/#hashyhash'},
-        {url: 'url', target: '_blank'},
-      ].map(element._fixCustomMenuItem), [
-        {url: 'https://awesometown.com/#hashyhash'},
-        {url: 'url'},
-      ]);
-    });
-
-    test('user links', () => {
-      const defaultLinks = [{
-        title: 'Faves',
-        links: [{
-          name: 'Pinterest',
-          url: 'https://pinterest.com',
-        }],
-      }];
-      const userLinks = [{
-        name: 'Facebook',
-        url: 'https://facebook.com',
-      }];
-      const adminLinks = [{
-        name: 'Repos',
-        url: '/repos',
-      }];
-
-      // When no admin links are passed, it should use the default.
-      assert.deepEqual(element._computeLinks(
-          defaultLinks,
-          /* userLinks= */[],
-          adminLinks,
-          /* topMenus= */[],
-          /* docBaseUrl= */ ''
-      ),
-      defaultLinks.concat({
-        title: 'Browse',
-        links: adminLinks,
-      }));
-      assert.deepEqual(element._computeLinks(
-          defaultLinks,
-          userLinks,
-          adminLinks,
-          /* topMenus= */[],
-          /* docBaseUrl= */ ''
-      ),
-      defaultLinks.concat([
-        {
-          title: 'Your',
-          links: userLinks,
-        },
-        {
-          title: 'Browse',
-          links: adminLinks,
-        }])
-      );
-    });
-
-    test('documentation links', () => {
-      const docLinks = [
-        {
-          name: 'Table of Contents',
-          url: '/index.html',
-        },
-      ];
-
-      assert.deepEqual(element._getDocLinks(null, docLinks), []);
-      assert.deepEqual(element._getDocLinks('', docLinks), []);
-      assert.deepEqual(element._getDocLinks('base', null), []);
-      assert.deepEqual(element._getDocLinks('base', []), []);
-
-      assert.deepEqual(element._getDocLinks('base', docLinks), [{
-        name: 'Table of Contents',
-        target: '_blank',
-        url: 'base/index.html',
-      }]);
-
-      assert.deepEqual(element._getDocLinks('base/', docLinks), [{
-        name: 'Table of Contents',
-        target: '_blank',
-        url: 'base/index.html',
-      }]);
-    });
-
-    test('top menus', () => {
-      const adminLinks = [{
-        name: 'Repos',
-        url: '/repos',
-      }];
-      const topMenus = [{
-        name: 'Plugins',
-        items: [{
-          name: 'Manage',
-          target: '_blank',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }],
-      }];
-      assert.deepEqual(element._computeLinks(
-          /* defaultLinks= */ [],
-          /* userLinks= */ [],
-          adminLinks,
-          topMenus,
-          /* baseDocUrl= */ ''
-      ), [{
-        title: 'Browse',
-        links: adminLinks,
-      },
-      {
-        title: 'Plugins',
-        links: [{
-          name: 'Manage',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }],
-      }]);
-    });
-
-    test('ignore top project menus', () => {
-      const adminLinks = [{
-        name: 'Repos',
-        url: '/repos',
-      }];
-      const topMenus = [{
-        name: 'Projects',
-        items: [{
-          name: 'Project Settings',
-          target: '_blank',
-          url: '/plugins/myplugin/${projectName}',
-        }, {
-          name: 'Project List',
-          target: '_blank',
-          url: '/plugins/myplugin/index.html',
-        }],
-      }];
-      assert.deepEqual(element._computeLinks(
-          /* defaultLinks= */ [],
-          /* userLinks= */ [],
-          adminLinks,
-          topMenus,
-          /* baseDocUrl= */ ''
-      ), [{
-        title: 'Browse',
-        links: adminLinks,
-      },
-      {
-        title: 'Projects',
-        links: [{
-          name: 'Project List',
-          url: '/plugins/myplugin/index.html',
-        }],
-      }]);
-    });
-
-    test('merge top menus', () => {
-      const adminLinks = [{
-        name: 'Repos',
-        url: '/repos',
-      }];
-      const topMenus = [{
-        name: 'Plugins',
-        items: [{
-          name: 'Manage',
-          target: '_blank',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }],
-      }, {
-        name: 'Plugins',
-        items: [{
-          name: 'Create',
-          target: '_blank',
-          url: 'https://gerrit/plugins/plugin-manager/static/create.html',
-        }],
-      }];
-      assert.deepEqual(element._computeLinks(
-          /* defaultLinks= */ [],
-          /* userLinks= */ [],
-          adminLinks,
-          topMenus,
-          /* baseDocUrl= */ ''
-      ), [{
-        title: 'Browse',
-        links: adminLinks,
-      }, {
-        title: 'Plugins',
-        links: [{
-          name: 'Manage',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }, {
-          name: 'Create',
-          url: 'https://gerrit/plugins/plugin-manager/static/create.html',
-        }],
-      }]);
-    });
-
-    test('merge top menus in default links', () => {
-      const defaultLinks = [{
-        title: 'Faves',
-        links: [{
-          name: 'Pinterest',
-          url: 'https://pinterest.com',
-        }],
-      }];
-      const topMenus = [{
-        name: 'Faves',
-        items: [{
-          name: 'Manage',
-          target: '_blank',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }],
-      }];
-      assert.deepEqual(element._computeLinks(
-          defaultLinks,
-          /* userLinks= */ [],
-          /* adminLinks= */ [],
-          topMenus,
-          /* baseDocUrl= */ ''
-      ), [{
-        title: 'Faves',
-        links: defaultLinks[0].links.concat([{
-          name: 'Manage',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }]),
-      }, {
-        title: 'Browse',
-        links: [],
-      }]);
-    });
-
-    test('merge top menus in user links', () => {
-      const userLinks = [{
-        name: 'Facebook',
-        url: 'https://facebook.com',
-      }];
-      const topMenus = [{
-        name: 'Your',
-        items: [{
-          name: 'Manage',
-          target: '_blank',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }],
-      }];
-      assert.deepEqual(element._computeLinks(
-          /* defaultLinks= */ [],
-          userLinks,
-          /* adminLinks= */ [],
-          topMenus,
-          /* baseDocUrl= */ ''
-      ), [{
-        title: 'Your',
-        links: userLinks.concat([{
-          name: 'Manage',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }]),
-      }, {
-        title: 'Browse',
-        links: [],
-      }]);
-    });
-
-    test('merge top menus in admin links', () => {
-      const adminLinks = [{
-        name: 'Repos',
-        url: '/repos',
-      }];
-      const topMenus = [{
-        name: 'Browse',
-        items: [{
-          name: 'Manage',
-          target: '_blank',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }],
-      }];
-      assert.deepEqual(element._computeLinks(
-          /* defaultLinks= */ [],
-          /* userLinks= */ [],
-          adminLinks,
-          topMenus,
-          /* baseDocUrl= */ ''
-      ), [{
-        title: 'Browse',
-        links: adminLinks.concat([{
-          name: 'Manage',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }]),
-      }]);
-    });
-
-    test('register URL', () => {
-      const config = {
-        auth: {
-          auth_type: 'LDAP',
-          register_url: 'https//gerrit.example.com/register',
-        },
-      };
-      element._retrieveRegisterURL(config);
-      assert.equal(element._registerURL, config.auth.register_url);
-      assert.equal(element._registerText, 'Sign up');
-
-      config.auth.register_text = 'Create account';
-      element._retrieveRegisterURL(config);
-      assert.equal(element._registerURL, config.auth.register_url);
-      assert.equal(element._registerText, config.auth.register_text);
-    });
-
-    test('register URL ignored for wrong auth type', () => {
-      const config = {
-        auth: {
-          auth_type: 'OPENID',
-          register_url: 'https//gerrit.example.com/register',
-        },
-      };
-      element._retrieveRegisterURL(config);
-      assert.equal(element._registerURL, null);
-      assert.equal(element._registerText, 'Sign up');
-    });
+    element = fixture('basic');
   });
-      </script>
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('link visibility', () => {
+    element.loading = true;
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.accountContainer')).display,
+    'none');
+    element.loading = false;
+    element.loggedIn = false;
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.accountContainer')).display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.loginButton')).display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.registerButton')).display,
+    'none');
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('gr-account-dropdown')).display,
+    'none');
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.settingsButton')).display,
+    'none');
+    element.loggedIn = true;
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.loginButton')).display,
+    'none');
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.registerButton')).display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('gr-account-dropdown'))
+        .display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.settingsButton')).display,
+    'none');
+  });
+
+  test('fix my menu item', () => {
+    assert.deepEqual([
+      {url: 'https://awesometown.com/#hashyhash'},
+      {url: 'url', target: '_blank'},
+    ].map(element._fixCustomMenuItem), [
+      {url: 'https://awesometown.com/#hashyhash'},
+      {url: 'url'},
+    ]);
+  });
+
+  test('user links', () => {
+    const defaultLinks = [{
+      title: 'Faves',
+      links: [{
+        name: 'Pinterest',
+        url: 'https://pinterest.com',
+      }],
+    }];
+    const userLinks = [{
+      name: 'Facebook',
+      url: 'https://facebook.com',
+    }];
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+
+    // When no admin links are passed, it should use the default.
+    assert.deepEqual(element._computeLinks(
+        defaultLinks,
+        /* userLinks= */[],
+        adminLinks,
+        /* topMenus= */[],
+        /* docBaseUrl= */ ''
+    ),
+    defaultLinks.concat({
+      title: 'Browse',
+      links: adminLinks,
+    }));
+    assert.deepEqual(element._computeLinks(
+        defaultLinks,
+        userLinks,
+        adminLinks,
+        /* topMenus= */[],
+        /* docBaseUrl= */ ''
+    ),
+    defaultLinks.concat([
+      {
+        title: 'Your',
+        links: userLinks,
+      },
+      {
+        title: 'Browse',
+        links: adminLinks,
+      }])
+    );
+  });
+
+  test('documentation links', () => {
+    const docLinks = [
+      {
+        name: 'Table of Contents',
+        url: '/index.html',
+      },
+    ];
+
+    assert.deepEqual(element._getDocLinks(null, docLinks), []);
+    assert.deepEqual(element._getDocLinks('', docLinks), []);
+    assert.deepEqual(element._getDocLinks('base', null), []);
+    assert.deepEqual(element._getDocLinks('base', []), []);
+
+    assert.deepEqual(element._getDocLinks('base', docLinks), [{
+      name: 'Table of Contents',
+      target: '_blank',
+      url: 'base/index.html',
+    }]);
+
+    assert.deepEqual(element._getDocLinks('base/', docLinks), [{
+      name: 'Table of Contents',
+      target: '_blank',
+      url: 'base/index.html',
+    }]);
+  });
+
+  test('top menus', () => {
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+    const topMenus = [{
+      name: 'Plugins',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Browse',
+      links: adminLinks,
+    },
+    {
+      title: 'Plugins',
+      links: [{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }]);
+  });
+
+  test('ignore top project menus', () => {
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+    const topMenus = [{
+      name: 'Projects',
+      items: [{
+        name: 'Project Settings',
+        target: '_blank',
+        url: '/plugins/myplugin/${projectName}',
+      }, {
+        name: 'Project List',
+        target: '_blank',
+        url: '/plugins/myplugin/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Browse',
+      links: adminLinks,
+    },
+    {
+      title: 'Projects',
+      links: [{
+        name: 'Project List',
+        url: '/plugins/myplugin/index.html',
+      }],
+    }]);
+  });
+
+  test('merge top menus', () => {
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+    const topMenus = [{
+      name: 'Plugins',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }, {
+      name: 'Plugins',
+      items: [{
+        name: 'Create',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Browse',
+      links: adminLinks,
+    }, {
+      title: 'Plugins',
+      links: [{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }, {
+        name: 'Create',
+        url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+      }],
+    }]);
+  });
+
+  test('merge top menus in default links', () => {
+    const defaultLinks = [{
+      title: 'Faves',
+      links: [{
+        name: 'Pinterest',
+        url: 'https://pinterest.com',
+      }],
+    }];
+    const topMenus = [{
+      name: 'Faves',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        defaultLinks,
+        /* userLinks= */ [],
+        /* adminLinks= */ [],
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Faves',
+      links: defaultLinks[0].links.concat([{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }]),
+    }, {
+      title: 'Browse',
+      links: [],
+    }]);
+  });
+
+  test('merge top menus in user links', () => {
+    const userLinks = [{
+      name: 'Facebook',
+      url: 'https://facebook.com',
+    }];
+    const topMenus = [{
+      name: 'Your',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        userLinks,
+        /* adminLinks= */ [],
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Your',
+      links: userLinks.concat([{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }]),
+    }, {
+      title: 'Browse',
+      links: [],
+    }]);
+  });
+
+  test('merge top menus in admin links', () => {
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+    const topMenus = [{
+      name: 'Browse',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Browse',
+      links: adminLinks.concat([{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }]),
+    }]);
+  });
+
+  test('register URL', () => {
+    const config = {
+      auth: {
+        auth_type: 'LDAP',
+        register_url: 'https//gerrit.example.com/register',
+      },
+    };
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, config.auth.register_url);
+    assert.equal(element._registerText, 'Sign up');
+
+    config.auth.register_text = 'Create account';
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, config.auth.register_url);
+    assert.equal(element._registerText, config.auth.register_text);
+  });
+
+  test('register URL ignored for wrong auth type', () => {
+    const config = {
+      auth: {
+        auth_type: 'OPENID',
+        register_url: 'https//gerrit.example.com/register',
+      },
+    };
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, null);
+    assert.equal(element._registerText, 'Sign up');
+  });
+});
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
index e79277a..c8724c3 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
@@ -1,750 +1,748 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.
+ */
+(function(window) {
+  'use strict';
 
-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
+  // Navigation parameters object format:
+  //
+  // Each object has a `view` property with a value from Gerrit.Nav.View. The
+  // remaining properties depend on the value used for view.
+  //
+  //  - Gerrit.Nav.View.CHANGE:
+  //    - `changeNum`, required, String: the numeric ID of the change.
+  //    - `project`, optional, String: the project name.
+  //    - `patchNum`, optional, Number: the patch for the right-hand-side of
+  //        the diff.
+  //    - `basePatchNum`, optional, Number: the patch for the left-hand-side
+  //        of the diff. If `basePatchNum` is provided, then `patchNum` must
+  //        also be provided.
+  //    - `edit`, optional, Boolean: whether or not to load the file list with
+  //        edit controls.
+  //    - `messageHash`, optional, String: the hash of the change message to
+  //        scroll to.
+  //
+  // - Gerrit.Nav.View.SEARCH:
+  //    - `query`, optional, String: the literal search query. If provided,
+  //        the string will be used as the query, and all other params will be
+  //        ignored.
+  //    - `owner`, optional, String: the owner name.
+  //    - `project`, optional, String: the project name.
+  //    - `branch`, optional, String: the branch name.
+  //    - `topic`, optional, String: the topic name.
+  //    - `hashtag`, optional, String: the hashtag name.
+  //    - `statuses`, optional, Array<String>: the list of change statuses to
+  //        search for. If more than one is provided, the search will OR them
+  //        together.
+  //    - `offset`, optional, Number: the offset for the query.
+  //
+  //  - Gerrit.Nav.View.DIFF:
+  //    - `changeNum`, required, String: the numeric ID of the change.
+  //    - `path`, required, String: the filepath of the diff.
+  //    - `patchNum`, required, Number: the patch for the right-hand-side of
+  //        the diff.
+  //    - `basePatchNum`, optional, Number: the patch for the left-hand-side
+  //        of the diff. If `basePatchNum` is provided, then `patchNum` must
+  //        also be provided.
+  //    - `lineNum`, optional, Number: the line number to be selected on load.
+  //    - `leftSide`, optional, Boolean: if a `lineNum` is provided, a value
+  //        of true selects the line from base of the patch range. False by
+  //        default.
+  //
+  //  - Gerrit.Nav.View.GROUP:
+  //    - `groupId`, required, String: the ID of the group.
+  //    - `detail`, optional, String: the name of the group detail view.
+  //      Takes any value from Gerrit.Nav.GroupDetailView.
+  //
+  //  - Gerrit.Nav.View.REPO:
+  //    - `repoName`, required, String: the name of the repo
+  //    - `detail`, optional, String: the name of the repo detail view.
+  //      Takes any value from Gerrit.Nav.RepoDetailView.
+  //
+  //  - Gerrit.Nav.View.DASHBOARD
+  //    - `repo`, optional, String.
+  //    - `sections`, optional, Array of objects with `title` and `query`
+  //      strings.
+  //    - `user`, optional, String.
+  //
+  //  - Gerrit.Nav.View.ROOT:
+  //    - no possible parameters.
 
-http://www.apache.org/licenses/LICENSE-2.0
+  window.Gerrit = window.Gerrit || {};
 
-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.
--->
-<script>
-  (function(window) {
-    'use strict';
+  // Prevent redefinition.
+  if (window.Gerrit.hasOwnProperty('Nav')) { return; }
 
-    // Navigation parameters object format:
-    //
-    // Each object has a `view` property with a value from Gerrit.Nav.View. The
-    // remaining properties depend on the value used for view.
-    //
-    //  - Gerrit.Nav.View.CHANGE:
-    //    - `changeNum`, required, String: the numeric ID of the change.
-    //    - `project`, optional, String: the project name.
-    //    - `patchNum`, optional, Number: the patch for the right-hand-side of
-    //        the diff.
-    //    - `basePatchNum`, optional, Number: the patch for the left-hand-side
-    //        of the diff. If `basePatchNum` is provided, then `patchNum` must
-    //        also be provided.
-    //    - `edit`, optional, Boolean: whether or not to load the file list with
-    //        edit controls.
-    //    - `messageHash`, optional, String: the hash of the change message to
-    //        scroll to.
-    //
-    // - Gerrit.Nav.View.SEARCH:
-    //    - `query`, optional, String: the literal search query. If provided,
-    //        the string will be used as the query, and all other params will be
-    //        ignored.
-    //    - `owner`, optional, String: the owner name.
-    //    - `project`, optional, String: the project name.
-    //    - `branch`, optional, String: the branch name.
-    //    - `topic`, optional, String: the topic name.
-    //    - `hashtag`, optional, String: the hashtag name.
-    //    - `statuses`, optional, Array<String>: the list of change statuses to
-    //        search for. If more than one is provided, the search will OR them
-    //        together.
-    //    - `offset`, optional, Number: the offset for the query.
-    //
-    //  - Gerrit.Nav.View.DIFF:
-    //    - `changeNum`, required, String: the numeric ID of the change.
-    //    - `path`, required, String: the filepath of the diff.
-    //    - `patchNum`, required, Number: the patch for the right-hand-side of
-    //        the diff.
-    //    - `basePatchNum`, optional, Number: the patch for the left-hand-side
-    //        of the diff. If `basePatchNum` is provided, then `patchNum` must
-    //        also be provided.
-    //    - `lineNum`, optional, Number: the line number to be selected on load.
-    //    - `leftSide`, optional, Boolean: if a `lineNum` is provided, a value
-    //        of true selects the line from base of the patch range. False by
-    //        default.
-    //
-    //  - Gerrit.Nav.View.GROUP:
-    //    - `groupId`, required, String: the ID of the group.
-    //    - `detail`, optional, String: the name of the group detail view.
-    //      Takes any value from Gerrit.Nav.GroupDetailView.
-    //
-    //  - Gerrit.Nav.View.REPO:
-    //    - `repoName`, required, String: the name of the repo
-    //    - `detail`, optional, String: the name of the repo detail view.
-    //      Takes any value from Gerrit.Nav.RepoDetailView.
-    //
-    //  - Gerrit.Nav.View.DASHBOARD
-    //    - `repo`, optional, String.
-    //    - `sections`, optional, Array of objects with `title` and `query`
-    //      strings.
-    //    - `user`, optional, String.
-    //
-    //  - Gerrit.Nav.View.ROOT:
-    //    - no possible parameters.
+  const uninitialized = () => {
+    console.warn('Use of uninitialized routing');
+  };
 
-    window.Gerrit = window.Gerrit || {};
+  const EDIT_PATCHNUM = 'edit';
+  const PARENT_PATCHNUM = 'PARENT';
 
-    // Prevent redefinition.
-    if (window.Gerrit.hasOwnProperty('Nav')) { return; }
+  const USER_PLACEHOLDER_PATTERN = /\$\{user\}/g;
 
-    const uninitialized = () => {
-      console.warn('Use of uninitialized routing');
-    };
+  // NOTE: These queries are tested in Java. Any changes made to definitions
+  // here require corresponding changes to:
+  // javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+  const DEFAULT_SECTIONS = [
+    {
+      // Changes with unpublished draft comments. This section is omitted when
+      // viewing other users, so we don't need to filter anything out.
+      name: 'Has draft comments',
+      query: 'has:draft',
+      selfOnly: true,
+      hideIfEmpty: true,
+      suffixForDashboard: 'limit:10',
+    },
+    {
+      // Changes that are assigned to the viewed user.
+      name: 'Assigned reviews',
+      query: 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
+          'is:open -is:ignored',
+      hideIfEmpty: true,
+      suffixForDashboard: 'limit:25',
+    },
+    {
+      // WIP open changes owned by viewing user. This section is omitted when
+      // viewing other users, so we don't need to filter anything out.
+      name: 'Work in progress',
+      query: 'is:open owner:${user} is:wip',
+      selfOnly: true,
+      hideIfEmpty: true,
+      suffixForDashboard: 'limit:25',
+    },
+    {
+      // Non-WIP open changes owned by viewed user. Filter out changes ignored
+      // by the viewing user.
+      name: 'Outgoing reviews',
+      query: 'is:open owner:${user} -is:wip -is:ignored',
+      isOutgoing: true,
+      suffixForDashboard: 'limit:25',
+    },
+    {
+      // Non-WIP open changes not owned by the viewed user, that the viewed user
+      // is associated with (as either a reviewer or the assignee). Changes
+      // ignored by the viewing user are filtered out.
+      name: 'Incoming reviews',
+      query: 'is:open -owner:${user} -is:wip -is:ignored ' +
+          '(reviewer:${user} OR assignee:${user})',
+      suffixForDashboard: 'limit:25',
+    },
+    {
+      // Open changes the viewed user is CCed on. Changes ignored by the viewing
+      // user are filtered out.
+      name: 'CCed on',
+      query: 'is:open -is:ignored cc:${user}',
+      suffixForDashboard: 'limit:10',
+    },
+    {
+      name: 'Recently closed',
+      // Closed changes where viewed user is owner, reviewer, or assignee.
+      // Changes ignored by the viewing user are filtered out, and so are WIP
+      // changes not owned by the viewing user (the one instance of
+      // 'owner:self' is intentional and implements this logic).
+      query: 'is:closed -is:ignored (-is:wip OR owner:self) ' +
+          '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
+          'OR cc:${user})',
+      suffixForDashboard: '-age:4w limit:10',
+    },
+  ];
 
-    const EDIT_PATCHNUM = 'edit';
-    const PARENT_PATCHNUM = 'PARENT';
+  window.Gerrit.Nav = {
 
-    const USER_PLACEHOLDER_PATTERN = /\$\{user\}/g;
+    View: {
+      ADMIN: 'admin',
+      AGREEMENTS: 'agreements',
+      CHANGE: 'change',
+      DASHBOARD: 'dashboard',
+      DIFF: 'diff',
+      DOCUMENTATION_SEARCH: 'documentation-search',
+      EDIT: 'edit',
+      GROUP: 'group',
+      PLUGIN_SCREEN: 'plugin-screen',
+      REPO: 'repo',
+      ROOT: 'root',
+      SEARCH: 'search',
+      SETTINGS: 'settings',
+    },
 
-    // NOTE: These queries are tested in Java. Any changes made to definitions
-    // here require corresponding changes to:
-    // javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
-    const DEFAULT_SECTIONS = [
-      {
-        // Changes with unpublished draft comments. This section is omitted when
-        // viewing other users, so we don't need to filter anything out.
-        name: 'Has draft comments',
-        query: 'has:draft',
-        selfOnly: true,
-        hideIfEmpty: true,
-        suffixForDashboard: 'limit:10',
-      },
-      {
-        // Changes that are assigned to the viewed user.
-        name: 'Assigned reviews',
-        query: 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
-            'is:open -is:ignored',
-        hideIfEmpty: true,
-        suffixForDashboard: 'limit:25',
-      },
-      {
-        // WIP open changes owned by viewing user. This section is omitted when
-        // viewing other users, so we don't need to filter anything out.
-        name: 'Work in progress',
-        query: 'is:open owner:${user} is:wip',
-        selfOnly: true,
-        hideIfEmpty: true,
-        suffixForDashboard: 'limit:25',
-      },
-      {
-        // Non-WIP open changes owned by viewed user. Filter out changes ignored
-        // by the viewing user.
-        name: 'Outgoing reviews',
-        query: 'is:open owner:${user} -is:wip -is:ignored',
-        isOutgoing: true,
-        suffixForDashboard: 'limit:25',
-      },
-      {
-        // Non-WIP open changes not owned by the viewed user, that the viewed user
-        // is associated with (as either a reviewer or the assignee). Changes
-        // ignored by the viewing user are filtered out.
-        name: 'Incoming reviews',
-        query: 'is:open -owner:${user} -is:wip -is:ignored ' +
-            '(reviewer:${user} OR assignee:${user})',
-        suffixForDashboard: 'limit:25',
-      },
-      {
-        // Open changes the viewed user is CCed on. Changes ignored by the viewing
-        // user are filtered out.
-        name: 'CCed on',
-        query: 'is:open -is:ignored cc:${user}',
-        suffixForDashboard: 'limit:10',
-      },
-      {
-        name: 'Recently closed',
-        // Closed changes where viewed user is owner, reviewer, or assignee.
-        // Changes ignored by the viewing user are filtered out, and so are WIP
-        // changes not owned by the viewing user (the one instance of
-        // 'owner:self' is intentional and implements this logic).
-        query: 'is:closed -is:ignored (-is:wip OR owner:self) ' +
-            '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
-            'OR cc:${user})',
-        suffixForDashboard: '-age:4w limit:10',
-      },
-    ];
+    GroupDetailView: {
+      MEMBERS: 'members',
+      LOG: 'log',
+    },
 
-    window.Gerrit.Nav = {
+    RepoDetailView: {
+      ACCESS: 'access',
+      BRANCHES: 'branches',
+      COMMANDS: 'commands',
+      DASHBOARDS: 'dashboards',
+      TAGS: 'tags',
+    },
 
-      View: {
-        ADMIN: 'admin',
-        AGREEMENTS: 'agreements',
-        CHANGE: 'change',
-        DASHBOARD: 'dashboard',
-        DIFF: 'diff',
-        DOCUMENTATION_SEARCH: 'documentation-search',
-        EDIT: 'edit',
-        GROUP: 'group',
-        PLUGIN_SCREEN: 'plugin-screen',
-        REPO: 'repo',
-        ROOT: 'root',
-        SEARCH: 'search',
-        SETTINGS: 'settings',
-      },
+    WeblinkType: {
+      CHANGE: 'change',
+      FILE: 'file',
+      PATCHSET: 'patchset',
+    },
 
-      GroupDetailView: {
-        MEMBERS: 'members',
-        LOG: 'log',
-      },
+    /** @type {Function} */
+    _navigate: uninitialized,
 
-      RepoDetailView: {
-        ACCESS: 'access',
-        BRANCHES: 'branches',
-        COMMANDS: 'commands',
-        DASHBOARDS: 'dashboards',
-        TAGS: 'tags',
-      },
+    /** @type {Function} */
+    _generateUrl: uninitialized,
 
-      WeblinkType: {
-        CHANGE: 'change',
-        FILE: 'file',
-        PATCHSET: 'patchset',
-      },
+    /** @type {Function} */
+    _generateWeblinks: uninitialized,
 
-      /** @type {Function} */
-      _navigate: uninitialized,
+    /** @type {Function} */
+    mapCommentlinks: uninitialized,
 
-      /** @type {Function} */
-      _generateUrl: uninitialized,
+    /**
+     * @param {number=} patchNum
+     * @param {number|string=} basePatchNum
+     */
+    _checkPatchRange(patchNum, basePatchNum) {
+      if (basePatchNum && !patchNum) {
+        throw new Error('Cannot use base patch number without patch number.');
+      }
+    },
 
-      /** @type {Function} */
-      _generateWeblinks: uninitialized,
+    /**
+     * Setup router implementation.
+     *
+     * @param {function(!string)} navigate the router-abstracted equivalent of
+     *     `window.location.href = ...`. Takes a string.
+     * @param {function(!Object): string} generateUrl generates a URL given
+     *     navigation parameters, detailed in the file header.
+     * @param {function(!Object): string} generateWeblinks weblinks generator
+     *     function takes single payload parameter with type property that
+     *  determines which
+     *     part of the UI is the consumer of the weblinks. type property can
+     *     be one of file, change, or patchset.
+     *     - For file type, payload will also contain string properties: repo,
+     *         commit, file.
+     *     - For patchset type, payload will also contain string properties:
+     *         repo, commit.
+     *     - For change type, payload will also contain string properties:
+     *         repo, commit. If server provides weblinks, those will be passed
+     *         as options.weblinks property on the main payload object.
+     * @param {function(!Object): Object} mapCommentlinks provides an escape
+     *     hatch to modify the commentlinks object, e.g. if it contains any
+     *     relative URLs.
+     */
+    setup(navigate, generateUrl, generateWeblinks, mapCommentlinks) {
+      this._navigate = navigate;
+      this._generateUrl = generateUrl;
+      this._generateWeblinks = generateWeblinks;
+      this.mapCommentlinks = mapCommentlinks;
+    },
 
-      /** @type {Function} */
-      mapCommentlinks: uninitialized,
+    destroy() {
+      this._navigate = uninitialized;
+      this._generateUrl = uninitialized;
+      this._generateWeblinks = uninitialized;
+      this.mapCommentlinks = uninitialized;
+    },
 
-      /**
-       * @param {number=} patchNum
-       * @param {number|string=} basePatchNum
-       */
-      _checkPatchRange(patchNum, basePatchNum) {
-        if (basePatchNum && !patchNum) {
-          throw new Error('Cannot use base patch number without patch number.');
-        }
-      },
+    /**
+     * Generate a URL for the given route parameters.
+     *
+     * @param {Object} params
+     * @return {string}
+     */
+    _getUrlFor(params) {
+      return this._generateUrl(params);
+    },
 
-      /**
-       * Setup router implementation.
-       *
-       * @param {function(!string)} navigate the router-abstracted equivalent of
-       *     `window.location.href = ...`. Takes a string.
-       * @param {function(!Object): string} generateUrl generates a URL given
-       *     navigation parameters, detailed in the file header.
-       * @param {function(!Object): string} generateWeblinks weblinks generator
-       *     function takes single payload parameter with type property that
-       *  determines which
-       *     part of the UI is the consumer of the weblinks. type property can
-       *     be one of file, change, or patchset.
-       *     - For file type, payload will also contain string properties: repo,
-       *         commit, file.
-       *     - For patchset type, payload will also contain string properties:
-       *         repo, commit.
-       *     - For change type, payload will also contain string properties:
-       *         repo, commit. If server provides weblinks, those will be passed
-       *         as options.weblinks property on the main payload object.
-       * @param {function(!Object): Object} mapCommentlinks provides an escape
-       *     hatch to modify the commentlinks object, e.g. if it contains any
-       *     relative URLs.
-       */
-      setup(navigate, generateUrl, generateWeblinks, mapCommentlinks) {
-        this._navigate = navigate;
-        this._generateUrl = generateUrl;
-        this._generateWeblinks = generateWeblinks;
-        this.mapCommentlinks = mapCommentlinks;
-      },
+    getUrlForSearchQuery(query, opt_offset) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.SEARCH,
+        query,
+        offset: opt_offset,
+      });
+    },
 
-      destroy() {
-        this._navigate = uninitialized;
-        this._generateUrl = uninitialized;
-        this._generateWeblinks = uninitialized;
-        this.mapCommentlinks = uninitialized;
-      },
+    /**
+     * @param {!string} project The name of the project.
+     * @param {boolean=} opt_openOnly When true, only search open changes in
+     *     the project.
+     * @param {string=} opt_host The host in which to search.
+     * @return {string}
+     */
+    getUrlForProjectChanges(project, opt_openOnly, opt_host) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.SEARCH,
+        project,
+        statuses: opt_openOnly ? ['open'] : [],
+        host: opt_host,
+      });
+    },
 
-      /**
-       * Generate a URL for the given route parameters.
-       *
-       * @param {Object} params
-       * @return {string}
-       */
-      _getUrlFor(params) {
-        return this._generateUrl(params);
-      },
+    /**
+     * @param {string} branch The name of the branch.
+     * @param {string} project The name of the project.
+     * @param {string=} opt_status The status to search.
+     * @param {string=} opt_host The host in which to search.
+     * @return {string}
+     */
+    getUrlForBranch(branch, project, opt_status, opt_host) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.SEARCH,
+        branch,
+        project,
+        statuses: opt_status ? [opt_status] : undefined,
+        host: opt_host,
+      });
+    },
 
-      getUrlForSearchQuery(query, opt_offset) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          query,
-          offset: opt_offset,
-        });
-      },
+    /**
+     * @param {string} topic The name of the topic.
+     * @param {string=} opt_host The host in which to search.
+     * @return {string}
+     */
+    getUrlForTopic(topic, opt_host) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.SEARCH,
+        topic,
+        statuses: ['open', 'merged'],
+        host: opt_host,
+      });
+    },
 
-      /**
-       * @param {!string} project The name of the project.
-       * @param {boolean=} opt_openOnly When true, only search open changes in
-       *     the project.
-       * @param {string=} opt_host The host in which to search.
-       * @return {string}
-       */
-      getUrlForProjectChanges(project, opt_openOnly, opt_host) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          project,
-          statuses: opt_openOnly ? ['open'] : [],
-          host: opt_host,
-        });
-      },
+    /**
+     * @param {string} hashtag The name of the hashtag.
+     * @return {string}
+     */
+    getUrlForHashtag(hashtag) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.SEARCH,
+        hashtag,
+        statuses: ['open', 'merged'],
+      });
+    },
 
-      /**
-       * @param {string} branch The name of the branch.
-       * @param {string} project The name of the project.
-       * @param {string=} opt_status The status to search.
-       * @param {string=} opt_host The host in which to search.
-       * @return {string}
-       */
-      getUrlForBranch(branch, project, opt_status, opt_host) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          branch,
-          project,
-          statuses: opt_status ? [opt_status] : undefined,
-          host: opt_host,
-        });
-      },
+    /**
+     * Navigate to a search for changes with the given status.
+     *
+     * @param {string} status
+     */
+    navigateToStatusSearch(status) {
+      this._navigate(this._getUrlFor({
+        view: Gerrit.Nav.View.SEARCH,
+        statuses: [status],
+      }));
+    },
 
-      /**
-       * @param {string} topic The name of the topic.
-       * @param {string=} opt_host The host in which to search.
-       * @return {string}
-       */
-      getUrlForTopic(topic, opt_host) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          topic,
-          statuses: ['open', 'merged'],
-          host: opt_host,
-        });
-      },
+    /**
+     * Navigate to a search query
+     *
+     * @param {string} query
+     * @param {number=} opt_offset
+     */
+    navigateToSearchQuery(query, opt_offset) {
+      return this._navigate(this.getUrlForSearchQuery(query, opt_offset));
+    },
 
-      /**
-       * @param {string} hashtag The name of the hashtag.
-       * @return {string}
-       */
-      getUrlForHashtag(hashtag) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          hashtag,
-          statuses: ['open', 'merged'],
-        });
-      },
+    /**
+     * Navigate to the user's dashboard
+     */
+    navigateToUserDashboard() {
+      return this._navigate(this.getUrlForUserDashboard('self'));
+    },
 
-      /**
-       * Navigate to a search for changes with the given status.
-       *
-       * @param {string} status
-       */
-      navigateToStatusSearch(status) {
-        this._navigate(this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          statuses: [status],
-        }));
-      },
+    /**
+     * @param {!Object} change The change object.
+     * @param {number=} opt_patchNum
+     * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+     *     used for none.
+     * @param {boolean=} opt_isEdit
+     * @param {string=} opt_messageHash
+     * @return {string}
+     */
+    getUrlForChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit,
+        opt_messageHash) {
+      if (opt_basePatchNum === PARENT_PATCHNUM) {
+        opt_basePatchNum = undefined;
+      }
 
-      /**
-       * Navigate to a search query
-       *
-       * @param {string} query
-       * @param {number=} opt_offset
-       */
-      navigateToSearchQuery(query, opt_offset) {
-        return this._navigate(this.getUrlForSearchQuery(query, opt_offset));
-      },
+      this._checkPatchRange(opt_patchNum, opt_basePatchNum);
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.CHANGE,
+        changeNum: change._number,
+        project: change.project,
+        patchNum: opt_patchNum,
+        basePatchNum: opt_basePatchNum,
+        edit: opt_isEdit,
+        host: change.internalHost || undefined,
+        messageHash: opt_messageHash,
+      });
+    },
 
-      /**
-       * Navigate to the user's dashboard
-       */
-      navigateToUserDashboard() {
-        return this._navigate(this.getUrlForUserDashboard('self'));
-      },
+    /**
+     * @param {number} changeNum
+     * @param {string} project The name of the project.
+     * @param {number=} opt_patchNum
+     * @return {string}
+     */
+    getUrlForChangeById(changeNum, project, opt_patchNum) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.CHANGE,
+        changeNum,
+        project,
+        patchNum: opt_patchNum,
+      });
+    },
 
-      /**
-       * @param {!Object} change The change object.
-       * @param {number=} opt_patchNum
-       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
-       *     used for none.
-       * @param {boolean=} opt_isEdit
-       * @param {string=} opt_messageHash
-       * @return {string}
-       */
-      getUrlForChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit,
-          opt_messageHash) {
-        if (opt_basePatchNum === PARENT_PATCHNUM) {
-          opt_basePatchNum = undefined;
-        }
+    /**
+     * @param {!Object} change The change object.
+     * @param {number=} opt_patchNum
+     * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+     *     used for none.
+     * @param {boolean=} opt_isEdit
+     */
+    navigateToChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit) {
+      this._navigate(this.getUrlForChange(change, opt_patchNum,
+          opt_basePatchNum, opt_isEdit));
+    },
 
-        this._checkPatchRange(opt_patchNum, opt_basePatchNum);
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.CHANGE,
-          changeNum: change._number,
-          project: change.project,
-          patchNum: opt_patchNum,
-          basePatchNum: opt_basePatchNum,
-          edit: opt_isEdit,
-          host: change.internalHost || undefined,
-          messageHash: opt_messageHash,
-        });
-      },
+    /**
+     * @param {{ _number: number, project: string }} change The change object.
+     * @param {string} path The file path.
+     * @param {number=} opt_patchNum
+     * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+     *     used for none.
+     * @param {number|string=} opt_lineNum
+     * @return {string}
+     */
+    getUrlForDiff(change, path, opt_patchNum, opt_basePatchNum, opt_lineNum) {
+      return this.getUrlForDiffById(change._number, change.project, path,
+          opt_patchNum, opt_basePatchNum, opt_lineNum);
+    },
 
-      /**
-       * @param {number} changeNum
-       * @param {string} project The name of the project.
-       * @param {number=} opt_patchNum
-       * @return {string}
-       */
-      getUrlForChangeById(changeNum, project, opt_patchNum) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.CHANGE,
-          changeNum,
-          project,
-          patchNum: opt_patchNum,
-        });
-      },
+    /**
+     * @param {number} changeNum
+     * @param {string} project The name of the project.
+     * @param {string} path The file path.
+     * @param {number=} opt_patchNum
+     * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+     *     used for none.
+     * @param {number=} opt_lineNum
+     * @param {boolean=} opt_leftSide
+     * @return {string}
+     */
+    getUrlForDiffById(changeNum, project, path, opt_patchNum,
+        opt_basePatchNum, opt_lineNum, opt_leftSide) {
+      if (opt_basePatchNum === PARENT_PATCHNUM) {
+        opt_basePatchNum = undefined;
+      }
 
-      /**
-       * @param {!Object} change The change object.
-       * @param {number=} opt_patchNum
-       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
-       *     used for none.
-       * @param {boolean=} opt_isEdit
-       */
-      navigateToChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit) {
-        this._navigate(this.getUrlForChange(change, opt_patchNum,
-            opt_basePatchNum, opt_isEdit));
-      },
+      this._checkPatchRange(opt_patchNum, opt_basePatchNum);
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.DIFF,
+        changeNum,
+        project,
+        path,
+        patchNum: opt_patchNum,
+        basePatchNum: opt_basePatchNum,
+        lineNum: opt_lineNum,
+        leftSide: opt_leftSide,
+      });
+    },
 
-      /**
-       * @param {{ _number: number, project: string }} change The change object.
-       * @param {string} path The file path.
-       * @param {number=} opt_patchNum
-       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
-       *     used for none.
-       * @param {number|string=} opt_lineNum
-       * @return {string}
-       */
-      getUrlForDiff(change, path, opt_patchNum, opt_basePatchNum, opt_lineNum) {
-        return this.getUrlForDiffById(change._number, change.project, path,
-            opt_patchNum, opt_basePatchNum, opt_lineNum);
-      },
+    /**
+     * @param {{ _number: number, project: string }} change The change object.
+     * @param {string} path The file path.
+     * @param {number=} opt_patchNum
+     * @return {string}
+     */
+    getEditUrlForDiff(change, path, opt_patchNum) {
+      return this.getEditUrlForDiffById(change._number, change.project, path,
+          opt_patchNum);
+    },
 
-      /**
-       * @param {number} changeNum
-       * @param {string} project The name of the project.
-       * @param {string} path The file path.
-       * @param {number=} opt_patchNum
-       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
-       *     used for none.
-       * @param {number=} opt_lineNum
-       * @param {boolean=} opt_leftSide
-       * @return {string}
-       */
-      getUrlForDiffById(changeNum, project, path, opt_patchNum,
-          opt_basePatchNum, opt_lineNum, opt_leftSide) {
-        if (opt_basePatchNum === PARENT_PATCHNUM) {
-          opt_basePatchNum = undefined;
-        }
+    /**
+     * @param {number} changeNum
+     * @param {string} project The name of the project.
+     * @param {string} path The file path.
+     * @param {number|string=} opt_patchNum The patchNum the file content
+     *    should be based on, or ${EDIT_PATCHNUM} if left undefined.
+     * @return {string}
+     */
+    getEditUrlForDiffById(changeNum, project, path, opt_patchNum) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.EDIT,
+        changeNum,
+        project,
+        path,
+        patchNum: opt_patchNum || EDIT_PATCHNUM,
+      });
+    },
 
-        this._checkPatchRange(opt_patchNum, opt_basePatchNum);
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.DIFF,
-          changeNum,
-          project,
-          path,
-          patchNum: opt_patchNum,
-          basePatchNum: opt_basePatchNum,
-          lineNum: opt_lineNum,
-          leftSide: opt_leftSide,
-        });
-      },
+    /**
+     * @param {!Object} change The change object.
+     * @param {string} path The file path.
+     * @param {number=} opt_patchNum
+     * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+     *     used for none.
+     */
+    navigateToDiff(change, path, opt_patchNum, opt_basePatchNum) {
+      this._navigate(this.getUrlForDiff(change, path, opt_patchNum,
+          opt_basePatchNum));
+    },
 
-      /**
-       * @param {{ _number: number, project: string }} change The change object.
-       * @param {string} path The file path.
-       * @param {number=} opt_patchNum
-       * @return {string}
-       */
-      getEditUrlForDiff(change, path, opt_patchNum) {
-        return this.getEditUrlForDiffById(change._number, change.project, path,
-            opt_patchNum);
-      },
+    /**
+     * @param {string} owner The name of the owner.
+     * @return {string}
+     */
+    getUrlForOwner(owner) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.SEARCH,
+        owner,
+      });
+    },
 
-      /**
-       * @param {number} changeNum
-       * @param {string} project The name of the project.
-       * @param {string} path The file path.
-       * @param {number|string=} opt_patchNum The patchNum the file content
-       *    should be based on, or ${EDIT_PATCHNUM} if left undefined.
-       * @return {string}
-       */
-      getEditUrlForDiffById(changeNum, project, path, opt_patchNum) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.EDIT,
-          changeNum,
-          project,
-          path,
-          patchNum: opt_patchNum || EDIT_PATCHNUM,
-        });
-      },
+    /**
+     * @param {string} user The name of the user.
+     * @return {string}
+     */
+    getUrlForUserDashboard(user) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.DASHBOARD,
+        user,
+      });
+    },
 
-      /**
-       * @param {!Object} change The change object.
-       * @param {string} path The file path.
-       * @param {number=} opt_patchNum
-       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
-       *     used for none.
-       */
-      navigateToDiff(change, path, opt_patchNum, opt_basePatchNum) {
-        this._navigate(this.getUrlForDiff(change, path, opt_patchNum,
-            opt_basePatchNum));
-      },
+    /**
+     * @return {string}
+     */
+    getUrlForRoot() {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.ROOT,
+      });
+    },
 
-      /**
-       * @param {string} owner The name of the owner.
-       * @return {string}
-       */
-      getUrlForOwner(owner) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          owner,
-        });
-      },
+    /**
+     * @param {string} repo The name of the repo.
+     * @param {string} dashboard The ID of the dashboard, in the form of
+     *     '<ref>:<path>'.
+     * @return {string}
+     */
+    getUrlForRepoDashboard(repo, dashboard) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.DASHBOARD,
+        repo,
+        dashboard,
+      });
+    },
 
-      /**
-       * @param {string} user The name of the user.
-       * @return {string}
-       */
-      getUrlForUserDashboard(user) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.DASHBOARD,
-          user,
-        });
-      },
+    /**
+     * Navigate to an arbitrary relative URL.
+     *
+     * @param {string} relativeUrl
+     */
+    navigateToRelativeUrl(relativeUrl) {
+      if (!relativeUrl.startsWith('/')) {
+        throw new Error('navigateToRelativeUrl with non-relative URL');
+      }
+      this._navigate(relativeUrl);
+    },
 
-      /**
-       * @return {string}
-       */
-      getUrlForRoot() {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.ROOT,
-        });
-      },
+    /**
+     * @param {string} repoName
+     * @return {string}
+     */
+    getUrlForRepo(repoName) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.REPO,
+        repoName,
+      });
+    },
 
-      /**
-       * @param {string} repo The name of the repo.
-       * @param {string} dashboard The ID of the dashboard, in the form of
-       *     '<ref>:<path>'.
-       * @return {string}
-       */
-      getUrlForRepoDashboard(repo, dashboard) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.DASHBOARD,
-          repo,
-          dashboard,
-        });
-      },
+    /**
+     * Navigate to a repo settings page.
+     *
+     * @param {string} repoName
+     */
+    navigateToRepo(repoName) {
+      this._navigate(this.getUrlForRepo(repoName));
+    },
 
-      /**
-       * Navigate to an arbitrary relative URL.
-       *
-       * @param {string} relativeUrl
-       */
-      navigateToRelativeUrl(relativeUrl) {
-        if (!relativeUrl.startsWith('/')) {
-          throw new Error('navigateToRelativeUrl with non-relative URL');
-        }
-        this._navigate(relativeUrl);
-      },
+    /**
+     * @param {string} repoName
+     * @return {string}
+     */
+    getUrlForRepoTags(repoName) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.REPO,
+        repoName,
+        detail: Gerrit.Nav.RepoDetailView.TAGS,
+      });
+    },
 
-      /**
-       * @param {string} repoName
-       * @return {string}
-       */
-      getUrlForRepo(repoName) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.REPO,
-          repoName,
-        });
-      },
+    /**
+     * @param {string} repoName
+     * @return {string}
+     */
+    getUrlForRepoBranches(repoName) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.REPO,
+        repoName,
+        detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+      });
+    },
 
-      /**
-       * Navigate to a repo settings page.
-       *
-       * @param {string} repoName
-       */
-      navigateToRepo(repoName) {
-        this._navigate(this.getUrlForRepo(repoName));
-      },
+    /**
+     * @param {string} repoName
+     * @return {string}
+     */
+    getUrlForRepoAccess(repoName) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.REPO,
+        repoName,
+        detail: Gerrit.Nav.RepoDetailView.ACCESS,
+      });
+    },
 
-      /**
-       * @param {string} repoName
-       * @return {string}
-       */
-      getUrlForRepoTags(repoName) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.REPO,
-          repoName,
-          detail: Gerrit.Nav.RepoDetailView.TAGS,
-        });
-      },
+    /**
+     * @param {string} repoName
+     * @return {string}
+     */
+    getUrlForRepoCommands(repoName) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.REPO,
+        repoName,
+        detail: Gerrit.Nav.RepoDetailView.COMMANDS,
+      });
+    },
 
-      /**
-       * @param {string} repoName
-       * @return {string}
-       */
-      getUrlForRepoBranches(repoName) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.REPO,
-          repoName,
-          detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-        });
-      },
+    /**
+     * @param {string} repoName
+     * @return {string}
+     */
+    getUrlForRepoDashboards(repoName) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.REPO,
+        repoName,
+        detail: Gerrit.Nav.RepoDetailView.DASHBOARDS,
+      });
+    },
 
-      /**
-       * @param {string} repoName
-       * @return {string}
-       */
-      getUrlForRepoAccess(repoName) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.REPO,
-          repoName,
-          detail: Gerrit.Nav.RepoDetailView.ACCESS,
-        });
-      },
+    /**
+     * @param {string} groupId
+     * @return {string}
+     */
+    getUrlForGroup(groupId) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.GROUP,
+        groupId,
+      });
+    },
 
-      /**
-       * @param {string} repoName
-       * @return {string}
-       */
-      getUrlForRepoCommands(repoName) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.REPO,
-          repoName,
-          detail: Gerrit.Nav.RepoDetailView.COMMANDS,
-        });
-      },
+    /**
+     * @param {string} groupId
+     * @return {string}
+     */
+    getUrlForGroupLog(groupId) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.GROUP,
+        groupId,
+        detail: Gerrit.Nav.GroupDetailView.LOG,
+      });
+    },
 
-      /**
-       * @param {string} repoName
-       * @return {string}
-       */
-      getUrlForRepoDashboards(repoName) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.REPO,
-          repoName,
-          detail: Gerrit.Nav.RepoDetailView.DASHBOARDS,
-        });
-      },
+    /**
+     * @param {string} groupId
+     * @return {string}
+     */
+    getUrlForGroupMembers(groupId) {
+      return this._getUrlFor({
+        view: Gerrit.Nav.View.GROUP,
+        groupId,
+        detail: Gerrit.Nav.GroupDetailView.MEMBERS,
+      });
+    },
 
-      /**
-       * @param {string} groupId
-       * @return {string}
-       */
-      getUrlForGroup(groupId) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.GROUP,
-          groupId,
-        });
-      },
+    getUrlForSettings() {
+      return this._getUrlFor({view: Gerrit.Nav.View.SETTINGS});
+    },
 
-      /**
-       * @param {string} groupId
-       * @return {string}
-       */
-      getUrlForGroupLog(groupId) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.GROUP,
-          groupId,
-          detail: Gerrit.Nav.GroupDetailView.LOG,
-        });
-      },
+    /**
+     * @param {string} repo
+     * @param {string} commit
+     * @param {string} file
+     * @param {Object=} opt_options
+     * @return {
+     *   Array<{label: string, url: string}>|
+     *   {label: string, url: string}
+     *  }
+     */
+    getFileWebLinks(repo, commit, file, opt_options) {
+      const params = {type: Gerrit.Nav.WeblinkType.FILE, repo, commit, file};
+      if (opt_options) {
+        params.options = opt_options;
+      }
+      return [].concat(this._generateWeblinks(params));
+    },
 
-      /**
-       * @param {string} groupId
-       * @return {string}
-       */
-      getUrlForGroupMembers(groupId) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.GROUP,
-          groupId,
-          detail: Gerrit.Nav.GroupDetailView.MEMBERS,
-        });
-      },
+    /**
+     * @param {string} repo
+     * @param {string} commit
+     * @param {Object=} opt_options
+     * @return {{label: string, url: string}}
+     */
+    getPatchSetWeblink(repo, commit, opt_options) {
+      const params = {type: Gerrit.Nav.WeblinkType.PATCHSET, repo, commit};
+      if (opt_options) {
+        params.options = opt_options;
+      }
+      const result = this._generateWeblinks(params);
+      if (Array.isArray(result)) {
+        return result.pop();
+      } else {
+        return result;
+      }
+    },
 
-      getUrlForSettings() {
-        return this._getUrlFor({view: Gerrit.Nav.View.SETTINGS});
-      },
+    /**
+     * @param {string} repo
+     * @param {string} commit
+     * @param {Object=} opt_options
+     * @return {
+     *   Array<{label: string, url: string}>|
+     *   {label: string, url: string}
+     *  }
+     */
+    getChangeWeblinks(repo, commit, opt_options) {
+      const params = {type: Gerrit.Nav.WeblinkType.CHANGE, repo, commit};
+      if (opt_options) {
+        params.options = opt_options;
+      }
+      return [].concat(this._generateWeblinks(params));
+    },
 
-      /**
-       * @param {string} repo
-       * @param {string} commit
-       * @param {string} file
-       * @param {Object=} opt_options
-       * @return {
-       *   Array<{label: string, url: string}>|
-       *   {label: string, url: string}
-       *  }
-       */
-      getFileWebLinks(repo, commit, file, opt_options) {
-        const params = {type: Gerrit.Nav.WeblinkType.FILE, repo, commit, file};
-        if (opt_options) {
-          params.options = opt_options;
-        }
-        return [].concat(this._generateWeblinks(params));
-      },
-
-      /**
-       * @param {string} repo
-       * @param {string} commit
-       * @param {Object=} opt_options
-       * @return {{label: string, url: string}}
-       */
-      getPatchSetWeblink(repo, commit, opt_options) {
-        const params = {type: Gerrit.Nav.WeblinkType.PATCHSET, repo, commit};
-        if (opt_options) {
-          params.options = opt_options;
-        }
-        const result = this._generateWeblinks(params);
-        if (Array.isArray(result)) {
-          return result.pop();
-        } else {
-          return result;
-        }
-      },
-
-      /**
-       * @param {string} repo
-       * @param {string} commit
-       * @param {Object=} opt_options
-       * @return {
-       *   Array<{label: string, url: string}>|
-       *   {label: string, url: string}
-       *  }
-       */
-      getChangeWeblinks(repo, commit, opt_options) {
-        const params = {type: Gerrit.Nav.WeblinkType.CHANGE, repo, commit};
-        if (opt_options) {
-          params.options = opt_options;
-        }
-        return [].concat(this._generateWeblinks(params));
-      },
-
-      getUserDashboard(user = 'self', sections = DEFAULT_SECTIONS,
-          title = '') {
-        sections = sections
-            .filter(section => (user === 'self' || !section.selfOnly))
-            .map(section => Object.assign({}, section, {
-              name: section.name,
-              query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
-            }));
-        return {title, sections};
-      },
-    };
-  })(window);
-</script>
+    getUserDashboard(user = 'self', sections = DEFAULT_SECTIONS,
+        title = '') {
+      sections = sections
+          .filter(section => (user === 'self' || !section.selfOnly))
+          .map(section => Object.assign({}, section, {
+            name: section.name,
+            query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+          }));
+      return {title, sections};
+    },
+  };
+})(window);
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
index f58780c..8f3c623 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
@@ -19,70 +19,71 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-navigation</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<script>
-  suite('gr-navigation tests', async () => {
-    await readyToTest();
-    test('invalid patch ranges throw exceptions', () => {
-      assert.throw(() => Gerrit.Nav.getUrlForChange('123', undefined, 12));
-      assert.throw(() => Gerrit.Nav.getUrlForDiff('123', 'x.c', undefined, 12));
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+suite('gr-navigation tests', () => {
+  test('invalid patch ranges throw exceptions', () => {
+    assert.throw(() => Gerrit.Nav.getUrlForChange('123', undefined, 12));
+    assert.throw(() => Gerrit.Nav.getUrlForDiff('123', 'x.c', undefined, 12));
+  });
+
+  suite('_getUserDashboard', () => {
+    const sections = [
+      {name: 'section 1', query: 'query 1'},
+      {name: 'section 2', query: 'query 2 for ${user}'},
+      {name: 'section 3', query: 'self only query', selfOnly: true},
+      {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
+    ];
+
+    test('dashboard for self', () => {
+      const dashboard =
+          Gerrit.Nav.getUserDashboard('self', sections, 'title');
+      assert.deepEqual(
+          dashboard,
+          {
+            title: 'title',
+            sections: [
+              {name: 'section 1', query: 'query 1'},
+              {name: 'section 2', query: 'query 2 for self'},
+              {
+                name: 'section 3',
+                query: 'self only query',
+                selfOnly: true,
+              }, {
+                name: 'section 4',
+                query: 'query 4',
+                suffixForDashboard: 'suffix',
+              },
+            ],
+          });
     });
 
-    suite('_getUserDashboard', () => {
-      const sections = [
-        {name: 'section 1', query: 'query 1'},
-        {name: 'section 2', query: 'query 2 for ${user}'},
-        {name: 'section 3', query: 'self only query', selfOnly: true},
-        {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
-      ];
-
-      test('dashboard for self', () => {
-        const dashboard =
-            Gerrit.Nav.getUserDashboard('self', sections, 'title');
-        assert.deepEqual(
-            dashboard,
-            {
-              title: 'title',
-              sections: [
-                {name: 'section 1', query: 'query 1'},
-                {name: 'section 2', query: 'query 2 for self'},
-                {
-                  name: 'section 3',
-                  query: 'self only query',
-                  selfOnly: true,
-                }, {
-                  name: 'section 4',
-                  query: 'query 4',
-                  suffixForDashboard: 'suffix',
-                },
-              ],
-            });
-      });
-
-      test('dashboard for other user', () => {
-        const dashboard =
-            Gerrit.Nav.getUserDashboard('user', sections, 'title');
-        assert.deepEqual(
-            dashboard,
-            {
-              title: 'title',
-              sections: [
-                {name: 'section 1', query: 'query 1'},
-                {name: 'section 2', query: 'query 2 for user'},
-                {
-                  name: 'section 4',
-                  query: 'query 4',
-                  suffixForDashboard: 'suffix',
-                },
-              ],
-            });
-      });
+    test('dashboard for other user', () => {
+      const dashboard =
+          Gerrit.Nav.getUserDashboard('user', sections, 'title');
+      assert.deepEqual(
+          dashboard,
+          {
+            title: 'title',
+            sections: [
+              {name: 'section 1', query: 'query 1'},
+              {name: 'section 2', query: 'query 2 for user'},
+              {
+                name: 'section 4',
+                query: 'query 4',
+                suffixForDashboard: 'suffix',
+              },
+            ],
+          });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
deleted file mode 100644
index 0ba8a22..0000000
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
+++ /dev/null
@@ -1,22 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-reporting">
-  <script src="gr-reporting.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index 106508c..55f8abd 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -14,566 +14,566 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Latency reporting constants.
-  const TIMING = {
-    TYPE: 'timing-report',
-    CATEGORY_UI_LATENCY: 'UI Latency',
-    CATEGORY_RPC: 'RPC Timing',
-    // Reported events - alphabetize below.
-    APP_STARTED: 'App Started',
-  };
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
 
-  // Plugin-related reporting constants.
-  const PLUGINS = {
-    TYPE: 'lifecycle',
-    // Reported events - alphabetize below.
-    INSTALLED: 'Plugins installed',
-  };
+// Latency reporting constants.
+const TIMING = {
+  TYPE: 'timing-report',
+  CATEGORY_UI_LATENCY: 'UI Latency',
+  CATEGORY_RPC: 'RPC Timing',
+  // Reported events - alphabetize below.
+  APP_STARTED: 'App Started',
+};
 
-  // Chrome extension-related reporting constants.
-  const EXTENSION = {
-    TYPE: 'lifecycle',
-    // Reported events - alphabetize below.
-    DETECTED: 'Extension detected',
-  };
+// Plugin-related reporting constants.
+const PLUGINS = {
+  TYPE: 'lifecycle',
+  // Reported events - alphabetize below.
+  INSTALLED: 'Plugins installed',
+};
 
-  // Navigation reporting constants.
-  const NAVIGATION = {
-    TYPE: 'nav-report',
-    CATEGORY: 'Location Changed',
-    PAGE: 'Page',
-  };
+// Chrome extension-related reporting constants.
+const EXTENSION = {
+  TYPE: 'lifecycle',
+  // Reported events - alphabetize below.
+  DETECTED: 'Extension detected',
+};
 
-  const ERROR = {
-    TYPE: 'error',
-    CATEGORY: 'exception',
-  };
+// Navigation reporting constants.
+const NAVIGATION = {
+  TYPE: 'nav-report',
+  CATEGORY: 'Location Changed',
+  PAGE: 'Page',
+};
 
-  const ERROR_DIALOG = {
-    TYPE: 'error',
-    CATEGORY: 'Error Dialog',
-  };
+const ERROR = {
+  TYPE: 'error',
+  CATEGORY: 'exception',
+};
 
-  const TIMER = {
-    CHANGE_DISPLAYED: 'ChangeDisplayed',
-    CHANGE_LOAD_FULL: 'ChangeFullyLoaded',
-    DASHBOARD_DISPLAYED: 'DashboardDisplayed',
-    DIFF_VIEW_CONTENT_DISPLAYED: 'DiffViewOnlyContent',
-    DIFF_VIEW_DISPLAYED: 'DiffViewDisplayed',
-    DIFF_VIEW_LOAD_FULL: 'DiffViewFullyLoaded',
-    FILE_LIST_DISPLAYED: 'FileListDisplayed',
-    PLUGINS_LOADED: 'PluginsLoaded',
-    STARTUP_CHANGE_DISPLAYED: 'StartupChangeDisplayed',
-    STARTUP_CHANGE_LOAD_FULL: 'StartupChangeFullyLoaded',
-    STARTUP_DASHBOARD_DISPLAYED: 'StartupDashboardDisplayed',
-    STARTUP_DIFF_VIEW_CONTENT_DISPLAYED: 'StartupDiffViewOnlyContent',
-    STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed',
-    STARTUP_DIFF_VIEW_LOAD_FULL: 'StartupDiffViewFullyLoaded',
-    STARTUP_FILE_LIST_DISPLAYED: 'StartupFileListDisplayed',
-    WEB_COMPONENTS_READY: 'WebComponentsReady',
-    METRICS_PLUGIN_LOADED: 'MetricsPluginLoaded',
-  };
+const ERROR_DIALOG = {
+  TYPE: 'error',
+  CATEGORY: 'Error Dialog',
+};
 
-  const STARTUP_TIMERS = {};
-  STARTUP_TIMERS[TIMER.PLUGINS_LOADED] = 0;
-  STARTUP_TIMERS[TIMER.METRICS_PLUGIN_LOADED] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_CHANGE_DISPLAYED] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_CHANGE_LOAD_FULL] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_DASHBOARD_DISPLAYED] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_DISPLAYED] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_LOAD_FULL] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_FILE_LIST_DISPLAYED] = 0;
-  STARTUP_TIMERS[TIMING.APP_STARTED] = 0;
-  // WebComponentsReady timer is triggered from gr-router.
-  STARTUP_TIMERS[TIMER.WEB_COMPONENTS_READY] = 0;
+const TIMER = {
+  CHANGE_DISPLAYED: 'ChangeDisplayed',
+  CHANGE_LOAD_FULL: 'ChangeFullyLoaded',
+  DASHBOARD_DISPLAYED: 'DashboardDisplayed',
+  DIFF_VIEW_CONTENT_DISPLAYED: 'DiffViewOnlyContent',
+  DIFF_VIEW_DISPLAYED: 'DiffViewDisplayed',
+  DIFF_VIEW_LOAD_FULL: 'DiffViewFullyLoaded',
+  FILE_LIST_DISPLAYED: 'FileListDisplayed',
+  PLUGINS_LOADED: 'PluginsLoaded',
+  STARTUP_CHANGE_DISPLAYED: 'StartupChangeDisplayed',
+  STARTUP_CHANGE_LOAD_FULL: 'StartupChangeFullyLoaded',
+  STARTUP_DASHBOARD_DISPLAYED: 'StartupDashboardDisplayed',
+  STARTUP_DIFF_VIEW_CONTENT_DISPLAYED: 'StartupDiffViewOnlyContent',
+  STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed',
+  STARTUP_DIFF_VIEW_LOAD_FULL: 'StartupDiffViewFullyLoaded',
+  STARTUP_FILE_LIST_DISPLAYED: 'StartupFileListDisplayed',
+  WEB_COMPONENTS_READY: 'WebComponentsReady',
+  METRICS_PLUGIN_LOADED: 'MetricsPluginLoaded',
+};
 
-  const INTERACTION_TYPE = 'interaction';
+const STARTUP_TIMERS = {};
+STARTUP_TIMERS[TIMER.PLUGINS_LOADED] = 0;
+STARTUP_TIMERS[TIMER.METRICS_PLUGIN_LOADED] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_CHANGE_DISPLAYED] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_CHANGE_LOAD_FULL] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_DASHBOARD_DISPLAYED] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_DISPLAYED] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_LOAD_FULL] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_FILE_LIST_DISPLAYED] = 0;
+STARTUP_TIMERS[TIMING.APP_STARTED] = 0;
+// WebComponentsReady timer is triggered from gr-router.
+STARTUP_TIMERS[TIMER.WEB_COMPONENTS_READY] = 0;
 
-  const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
-  const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
+const INTERACTION_TYPE = 'interaction';
 
-  let pending = [];
-  let slowRpcList = [];
-  const SLOW_RPC_THRESHOLD = 500;
+const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
+const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
 
-  // Variables that hold context info in global scope
-  let reportRepoName = undefined;
+let pending = [];
+let slowRpcList = [];
+const SLOW_RPC_THRESHOLD = 500;
 
-  const onError = function(oldOnError, msg, url, line, column, error) {
-    if (oldOnError) {
-      oldOnError(msg, url, line, column, error);
+// Variables that hold context info in global scope
+let reportRepoName = undefined;
+
+const onError = function(oldOnError, msg, url, line, column, error) {
+  if (oldOnError) {
+    oldOnError(msg, url, line, column, error);
+  }
+  if (error) {
+    line = line || error.lineNumber;
+    column = column || error.columnNumber;
+    let shortenedErrorStack = msg;
+    if (error.stack) {
+      const errorStackLines = error.stack.split('\n');
+      shortenedErrorStack = errorStackLines.slice(0,
+          Math.min(3, errorStackLines.length)).join('\n');
     }
-    if (error) {
-      line = line || error.lineNumber;
-      column = column || error.columnNumber;
-      let shortenedErrorStack = msg;
-      if (error.stack) {
-        const errorStackLines = error.stack.split('\n');
-        shortenedErrorStack = errorStackLines.slice(0,
-            Math.min(3, errorStackLines.length)).join('\n');
-      }
-      msg = shortenedErrorStack || error.toString();
-    }
+    msg = shortenedErrorStack || error.toString();
+  }
+  const payload = {
+    url,
+    line,
+    column,
+    error,
+  };
+  GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
+  return true;
+};
+
+const catchErrors = function(opt_context) {
+  const context = opt_context || window;
+  context.onerror = onError.bind(null, context.onerror);
+  context.addEventListener('unhandledrejection', e => {
+    const msg = e.reason.message;
     const payload = {
-      url,
-      line,
-      column,
-      error,
+      error: e.reason,
     };
     GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
-    return true;
-  };
+  });
+};
+catchErrors();
 
-  const catchErrors = function(opt_context) {
-    const context = opt_context || window;
-    context.onerror = onError.bind(null, context.onerror);
-    context.addEventListener('unhandledrejection', e => {
-      const msg = e.reason.message;
-      const payload = {
-        error: e.reason,
-      };
-      GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
+// PerformanceObserver interface is a browser API.
+if (window.PerformanceObserver) {
+  const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || [];
+  // Safari doesn't support longtask yet
+  if (supportedEntryTypes.includes('longtask')) {
+    const catchLongJsTasks = new PerformanceObserver(list => {
+      for (const task of list.getEntries()) {
+        // We are interested in longtask longer than 200 ms (default is 50 ms)
+        if (task.duration > 200) {
+          GrReporting.prototype.reporter(TIMING.TYPE,
+              TIMING.CATEGORY_UI_LATENCY, `Task ${task.name}`,
+              Math.round(task.duration), {}, false);
+        }
+      }
     });
-  };
-  catchErrors();
-
-  // PerformanceObserver interface is a browser API.
-  if (window.PerformanceObserver) {
-    const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || [];
-    // Safari doesn't support longtask yet
-    if (supportedEntryTypes.includes('longtask')) {
-      const catchLongJsTasks = new PerformanceObserver(list => {
-        for (const task of list.getEntries()) {
-          // We are interested in longtask longer than 200 ms (default is 50 ms)
-          if (task.duration > 200) {
-            GrReporting.prototype.reporter(TIMING.TYPE,
-                TIMING.CATEGORY_UI_LATENCY, `Task ${task.name}`,
-                Math.round(task.duration), {}, false);
-          }
-        }
-      });
-      catchLongJsTasks.observe({entryTypes: ['longtask']});
-    }
+    catchLongJsTasks.observe({entryTypes: ['longtask']});
   }
+}
 
-  document.addEventListener('visibilitychange', () => {
-    const eventName = `Visibility changed to ${document.visibilityState}`;
-    GrReporting.prototype.reporter(INTERACTION_TYPE, undefined, eventName,
-        undefined, {}, true);
-  });
+document.addEventListener('visibilitychange', () => {
+  const eventName = `Visibility changed to ${document.visibilityState}`;
+  GrReporting.prototype.reporter(INTERACTION_TYPE, undefined, eventName,
+      undefined, {}, true);
+});
 
-  // The Polymer pass of JSCompiler requires this to be reassignable
-  // eslint-disable-next-line prefer-const
-  let GrReporting = Polymer({
-    is: 'gr-reporting',
+// The Polymer pass of JSCompiler requires this to be reassignable
+// eslint-disable-next-line prefer-const
+let GrReporting = Polymer({
+  is: 'gr-reporting',
 
-    properties: {
-      category: String,
+  properties: {
+    category: String,
 
-      _baselines: {
-        type: Object,
-        value: STARTUP_TIMERS, // Shared across all instances.
+    _baselines: {
+      type: Object,
+      value: STARTUP_TIMERS, // Shared across all instances.
+    },
+
+    _timers: {
+      type: Object,
+      value: {timeBetweenDraftActions: null}, // Shared across all instances.
+    },
+  },
+
+  get performanceTiming() {
+    return window.performance.timing;
+  },
+
+  get slowRpcSnapshot() {
+    return slowRpcList.slice();
+  },
+
+  now() {
+    return Math.round(window.performance.now());
+  },
+
+  _arePluginsLoaded() {
+    return this._baselines &&
+      !this._baselines.hasOwnProperty(TIMER.PLUGINS_LOADED);
+  },
+
+  _isMetricsPluginLoaded() {
+    return this._arePluginsLoaded() || this._baselines &&
+      !this._baselines.hasOwnProperty(TIMER.METRICS_PLUGIN_LOADED);
+  },
+
+  /**
+   * Reporter reports events. Events will be queued if metrics plugin is not
+   * yet installed.
+   *
+   * @param {string} type
+   * @param {string} category
+   * @param {string} eventName
+   * @param {string|number} eventValue
+   * @param {Object} eventDetails
+   * @param {boolean|undefined} opt_noLog If true, the event will not be
+   *     logged to the JS console.
+   */
+  reporter(type, category, eventName, eventValue, eventDetails, opt_noLog) {
+    const eventInfo = this._createEventInfo(type, category,
+        eventName, eventValue, eventDetails);
+    if (type === ERROR.TYPE && category === ERROR.CATEGORY) {
+      console.error(eventValue && eventValue.error || eventName);
+    }
+
+    // We report events immediately when metrics plugin is loaded
+    if (this._isMetricsPluginLoaded() && !pending.length) {
+      this._reportEvent(eventInfo, opt_noLog);
+    } else {
+      // We cache until metrics plugin is loaded
+      pending.push([eventInfo, opt_noLog]);
+      if (this._isMetricsPluginLoaded()) {
+        pending.forEach(([eventInfo, opt_noLog]) => {
+          this._reportEvent(eventInfo, opt_noLog);
+        });
+        pending = [];
+      }
+    }
+  },
+
+  _reportEvent(eventInfo, opt_noLog) {
+    const {type, value, name} = eventInfo;
+    document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
+    if (opt_noLog) { return; }
+    if (type !== ERROR.TYPE) {
+      if (value !== undefined) {
+        console.log(`Reporting: ${name}: ${value}`);
+      } else {
+        console.log(`Reporting: ${name}`);
+      }
+    }
+  },
+
+  _createEventInfo(type, category, name, value, eventDetails) {
+    const eventInfo = {
+      type,
+      category,
+      name,
+      value,
+      eventStart: this.now(),
+    };
+
+    if (typeof(eventDetails) === 'object' &&
+      Object.entries(eventDetails).length !== 0) {
+      eventInfo.eventDetails = JSON.stringify(eventDetails);
+    }
+    if (reportRepoName) {
+      eventInfo.repoName = reportRepoName;
+    }
+    const isInBackgroundTab = document.visibilityState === 'hidden';
+    if (isInBackgroundTab !== undefined) {
+      eventInfo.inBackgroundTab = isInBackgroundTab;
+    }
+
+    return eventInfo;
+  },
+
+  /**
+   * User-perceived app start time, should be reported when the app is ready.
+   */
+  appStarted() {
+    this.timeEnd(TIMING.APP_STARTED);
+    this.pageLoaded();
+  },
+
+  /**
+   * Page load time and other metrics, should be reported at any time
+   * after navigation.
+   */
+  pageLoaded() {
+    if (this.performanceTiming.loadEventEnd === 0) {
+      console.error('pageLoaded should be called after window.onload');
+      this.async(this.pageLoaded, 100);
+    } else {
+      const perfEvents = Object.keys(this.performanceTiming.toJSON());
+      perfEvents.forEach(
+          eventName => this._reportPerformanceTiming(eventName)
+      );
+    }
+  },
+
+  _reportPerformanceTiming(eventName, eventDetails) {
+    const eventTiming = this.performanceTiming[eventName];
+    if (eventTiming > 0) {
+      const elapsedTime = eventTiming -
+          this.performanceTiming.navigationStart;
+      // NavResTime - Navigation and resource timings.
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
+          `NavResTime - ${eventName}`, elapsedTime, eventDetails, true);
+    }
+  },
+
+  beforeLocationChanged() {
+    for (const prop of Object.keys(this._baselines)) {
+      delete this._baselines[prop];
+    }
+    this.time(TIMER.CHANGE_DISPLAYED);
+    this.time(TIMER.CHANGE_LOAD_FULL);
+    this.time(TIMER.DASHBOARD_DISPLAYED);
+    this.time(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
+    this.time(TIMER.DIFF_VIEW_DISPLAYED);
+    this.time(TIMER.DIFF_VIEW_LOAD_FULL);
+    this.time(TIMER.FILE_LIST_DISPLAYED);
+    reportRepoName = undefined;
+    // reset slow rpc list since here start page loads which report these rpcs
+    slowRpcList = [];
+  },
+
+  locationChanged(page) {
+    this.reporter(
+        NAVIGATION.TYPE, NAVIGATION.CATEGORY, NAVIGATION.PAGE, page);
+  },
+
+  dashboardDisplayed() {
+    if (this._baselines.hasOwnProperty(TIMER.STARTUP_DASHBOARD_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED, {rpcList:
+        this.slowRpcSnapshot});
+    } else {
+      this.timeEnd(TIMER.DASHBOARD_DISPLAYED, {rpcList:
+        this.slowRpcSnapshot});
+    }
+  },
+
+  changeDisplayed() {
+    if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED, {rpcList:
+        this.slowRpcSnapshot});
+    } else {
+      this.timeEnd(TIMER.CHANGE_DISPLAYED, {rpcList:
+        this.slowRpcSnapshot});
+    }
+  },
+
+  changeFullyLoaded() {
+    if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_LOAD_FULL)) {
+      this.timeEnd(TIMER.STARTUP_CHANGE_LOAD_FULL);
+    } else {
+      this.timeEnd(TIMER.CHANGE_LOAD_FULL);
+    }
+  },
+
+  diffViewDisplayed() {
+    if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_DISPLAYED, {rpcList:
+        this.slowRpcSnapshot});
+    } else {
+      this.timeEnd(TIMER.DIFF_VIEW_DISPLAYED, {rpcList:
+        this.slowRpcSnapshot});
+    }
+  },
+
+  diffViewFullyLoaded() {
+    if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL)) {
+      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL);
+    } else {
+      this.timeEnd(TIMER.DIFF_VIEW_LOAD_FULL);
+    }
+  },
+
+  diffViewContentDisplayed() {
+    if (this._baselines.hasOwnProperty(
+        TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED);
+    } else {
+      this.timeEnd(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
+    }
+  },
+
+  fileListDisplayed() {
+    if (this._baselines.hasOwnProperty(TIMER.STARTUP_FILE_LIST_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_FILE_LIST_DISPLAYED);
+    } else {
+      this.timeEnd(TIMER.FILE_LIST_DISPLAYED);
+    }
+  },
+
+  reportExtension(name) {
+    this.reporter(EXTENSION.TYPE, EXTENSION.DETECTED, name);
+  },
+
+  pluginLoaded(name) {
+    if (name.startsWith('metrics-')) {
+      this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
+    }
+  },
+
+  pluginsLoaded(pluginsList) {
+    this.timeEnd(TIMER.PLUGINS_LOADED);
+    this.reporter(
+        PLUGINS.TYPE, PLUGINS.INSTALLED, PLUGINS.INSTALLED, undefined,
+        {pluginsList: pluginsList || []}, true);
+  },
+
+  /**
+   * Reset named timer.
+   */
+  time(name) {
+    this._baselines[name] = this.now();
+    window.performance.mark(`${name}-start`);
+  },
+
+  /**
+   * Finish named timer and report it to server.
+   */
+  timeEnd(name, eventDetails) {
+    if (!this._baselines.hasOwnProperty(name)) { return; }
+    const baseTime = this._baselines[name];
+    delete this._baselines[name];
+    this._reportTiming(name, this.now() - baseTime, eventDetails);
+
+    // Finalize the interval. Either from a registered start mark or
+    // the navigation start time (if baseTime is 0).
+    if (baseTime !== 0) {
+      window.performance.measure(name, `${name}-start`);
+    } else {
+      // Microsft Edge does not handle the 2nd param correctly
+      // (if undefined).
+      window.performance.measure(name);
+    }
+  },
+
+  /**
+   * Reports just line timeEnd, but additionally reports an average given a
+   * denominator and a separate reporiting name for the average.
+   *
+   * @param {string} name Timing name.
+   * @param {string} averageName Average timing name.
+   * @param {number} denominator Number by which to divide the total to
+   *     compute the average.
+   */
+  timeEndWithAverage(name, averageName, denominator) {
+    if (!this._baselines.hasOwnProperty(name)) { return; }
+    const baseTime = this._baselines[name];
+    this.timeEnd(name);
+
+    // Guard against division by zero.
+    if (!denominator) { return; }
+    const time = this.now() - baseTime;
+    this._reportTiming(averageName, time / denominator);
+  },
+
+  /**
+   * Send a timing report with an arbitrary time value.
+   *
+   * @param {string} name Timing name.
+   * @param {number} time The time to report as an integer of milliseconds.
+   * @param {Object} eventDetails non sensitive details
+   */
+  _reportTiming(name, time, eventDetails) {
+    this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, name, time,
+        eventDetails);
+  },
+
+  /**
+   * Get a timer object to for reporing a user timing. The start time will be
+   * the time that the object has been created, and the end time will be the
+   * time that the "end" method is called on the object.
+   *
+   * @param {string} name Timing name.
+   * @returns {!Object} The timer object.
+   */
+  getTimer(name) {
+    let called = false;
+    let start;
+    let max = null;
+
+    const timer = {
+
+      // Clear the timer and reset the start time.
+      reset: () => {
+        called = false;
+        start = this.now();
+        return timer;
       },
 
-      _timers: {
-        type: Object,
-        value: {timeBetweenDraftActions: null}, // Shared across all instances.
+      // Stop the timer and report the intervening time.
+      end: () => {
+        if (called) {
+          throw new Error(`Timer for "${name}" already ended.`);
+        }
+        called = true;
+        const time = this.now() - start;
+
+        // If a maximum is specified and the time exceeds it, do not report.
+        if (max && time > max) { return timer; }
+
+        this._reportTiming(name, time);
+        return timer;
       },
-    },
 
-    get performanceTiming() {
-      return window.performance.timing;
-    },
+      // Set a maximum reportable time. If a maximum is set and the timer is
+      // ended after the specified amount of time, the value is not reported.
+      withMaximum(maximum) {
+        max = maximum;
+        return timer;
+      },
+    };
 
-    get slowRpcSnapshot() {
-      return slowRpcList.slice();
-    },
+    // The timer is initialized to its creation time.
+    return timer.reset();
+  },
 
-    now() {
-      return Math.round(window.performance.now());
-    },
+  /**
+   * Log timing information for an RPC.
+   *
+   * @param {string} anonymizedUrl The URL of the RPC with tokens obfuscated.
+   * @param {number} elapsed The time elapsed of the RPC.
+   */
+  reportRpcTiming(anonymizedUrl, elapsed) {
+    this.reporter(TIMING.TYPE, TIMING.CATEGORY_RPC, 'RPC-' + anonymizedUrl,
+        elapsed, {}, true);
+    if (elapsed >= SLOW_RPC_THRESHOLD) {
+      slowRpcList.push({anonymizedUrl, elapsed});
+    }
+  },
 
-    _arePluginsLoaded() {
-      return this._baselines &&
-        !this._baselines.hasOwnProperty(TIMER.PLUGINS_LOADED);
-    },
+  reportInteraction(eventName, details) {
+    this.reporter(INTERACTION_TYPE, this.category, eventName, undefined,
+        details, true);
+  },
 
-    _isMetricsPluginLoaded() {
-      return this._arePluginsLoaded() || this._baselines &&
-        !this._baselines.hasOwnProperty(TIMER.METRICS_PLUGIN_LOADED);
-    },
+  /**
+   * A draft interaction was started. Update the time-betweeen-draft-actions
+   * timer.
+   */
+  recordDraftInteraction() {
+    // If there is no timer defined, then this is the first interaction.
+    // Set up the timer so that it's ready to record the intervening time when
+    // called again.
+    const timer = this._timers.timeBetweenDraftActions;
+    if (!timer) {
+      // Create a timer with a maximum length.
+      this._timers.timeBetweenDraftActions = this.getTimer(DRAFT_ACTION_TIMER)
+          .withMaximum(DRAFT_ACTION_TIMER_MAX);
+      return;
+    }
 
-    /**
-     * Reporter reports events. Events will be queued if metrics plugin is not
-     * yet installed.
-     *
-     * @param {string} type
-     * @param {string} category
-     * @param {string} eventName
-     * @param {string|number} eventValue
-     * @param {Object} eventDetails
-     * @param {boolean|undefined} opt_noLog If true, the event will not be
-     *     logged to the JS console.
-     */
-    reporter(type, category, eventName, eventValue, eventDetails, opt_noLog) {
-      const eventInfo = this._createEventInfo(type, category,
-          eventName, eventValue, eventDetails);
-      if (type === ERROR.TYPE && category === ERROR.CATEGORY) {
-        console.error(eventValue && eventValue.error || eventName);
-      }
+    // Mark the time and reinitialize the timer.
+    timer.end().reset();
+  },
 
-      // We report events immediately when metrics plugin is loaded
-      if (this._isMetricsPluginLoaded() && !pending.length) {
-        this._reportEvent(eventInfo, opt_noLog);
-      } else {
-        // We cache until metrics plugin is loaded
-        pending.push([eventInfo, opt_noLog]);
-        if (this._isMetricsPluginLoaded()) {
-          pending.forEach(([eventInfo, opt_noLog]) => {
-            this._reportEvent(eventInfo, opt_noLog);
-          });
-          pending = [];
-        }
-      }
-    },
+  reportErrorDialog(message) {
+    this.reporter(ERROR_DIALOG.TYPE, ERROR_DIALOG.CATEGORY,
+        'ErrorDialog: ' + message, {error: new Error(message)});
+  },
 
-    _reportEvent(eventInfo, opt_noLog) {
-      const {type, value, name} = eventInfo;
-      document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
-      if (opt_noLog) { return; }
-      if (type !== ERROR.TYPE) {
-        if (value !== undefined) {
-          console.log(`Reporting: ${name}: ${value}`);
-        } else {
-          console.log(`Reporting: ${name}`);
-        }
-      }
-    },
+  setRepoName(repoName) {
+    reportRepoName = repoName;
+  },
+});
 
-    _createEventInfo(type, category, name, value, eventDetails) {
-      const eventInfo = {
-        type,
-        category,
-        name,
-        value,
-        eventStart: this.now(),
-      };
-
-      if (typeof(eventDetails) === 'object' &&
-        Object.entries(eventDetails).length !== 0) {
-        eventInfo.eventDetails = JSON.stringify(eventDetails);
-      }
-      if (reportRepoName) {
-        eventInfo.repoName = reportRepoName;
-      }
-      const isInBackgroundTab = document.visibilityState === 'hidden';
-      if (isInBackgroundTab !== undefined) {
-        eventInfo.inBackgroundTab = isInBackgroundTab;
-      }
-
-      return eventInfo;
-    },
-
-    /**
-     * User-perceived app start time, should be reported when the app is ready.
-     */
-    appStarted() {
-      this.timeEnd(TIMING.APP_STARTED);
-      this.pageLoaded();
-    },
-
-    /**
-     * Page load time and other metrics, should be reported at any time
-     * after navigation.
-     */
-    pageLoaded() {
-      if (this.performanceTiming.loadEventEnd === 0) {
-        console.error('pageLoaded should be called after window.onload');
-        this.async(this.pageLoaded, 100);
-      } else {
-        const perfEvents = Object.keys(this.performanceTiming.toJSON());
-        perfEvents.forEach(
-            eventName => this._reportPerformanceTiming(eventName)
-        );
-      }
-    },
-
-    _reportPerformanceTiming(eventName, eventDetails) {
-      const eventTiming = this.performanceTiming[eventName];
-      if (eventTiming > 0) {
-        const elapsedTime = eventTiming -
-            this.performanceTiming.navigationStart;
-        // NavResTime - Navigation and resource timings.
-        this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
-            `NavResTime - ${eventName}`, elapsedTime, eventDetails, true);
-      }
-    },
-
-    beforeLocationChanged() {
-      for (const prop of Object.keys(this._baselines)) {
-        delete this._baselines[prop];
-      }
-      this.time(TIMER.CHANGE_DISPLAYED);
-      this.time(TIMER.CHANGE_LOAD_FULL);
-      this.time(TIMER.DASHBOARD_DISPLAYED);
-      this.time(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
-      this.time(TIMER.DIFF_VIEW_DISPLAYED);
-      this.time(TIMER.DIFF_VIEW_LOAD_FULL);
-      this.time(TIMER.FILE_LIST_DISPLAYED);
-      reportRepoName = undefined;
-      // reset slow rpc list since here start page loads which report these rpcs
-      slowRpcList = [];
-    },
-
-    locationChanged(page) {
-      this.reporter(
-          NAVIGATION.TYPE, NAVIGATION.CATEGORY, NAVIGATION.PAGE, page);
-    },
-
-    dashboardDisplayed() {
-      if (this._baselines.hasOwnProperty(TIMER.STARTUP_DASHBOARD_DISPLAYED)) {
-        this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED, {rpcList:
-          this.slowRpcSnapshot});
-      } else {
-        this.timeEnd(TIMER.DASHBOARD_DISPLAYED, {rpcList:
-          this.slowRpcSnapshot});
-      }
-    },
-
-    changeDisplayed() {
-      if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_DISPLAYED)) {
-        this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED, {rpcList:
-          this.slowRpcSnapshot});
-      } else {
-        this.timeEnd(TIMER.CHANGE_DISPLAYED, {rpcList:
-          this.slowRpcSnapshot});
-      }
-    },
-
-    changeFullyLoaded() {
-      if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_LOAD_FULL)) {
-        this.timeEnd(TIMER.STARTUP_CHANGE_LOAD_FULL);
-      } else {
-        this.timeEnd(TIMER.CHANGE_LOAD_FULL);
-      }
-    },
-
-    diffViewDisplayed() {
-      if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_DISPLAYED)) {
-        this.timeEnd(TIMER.STARTUP_DIFF_VIEW_DISPLAYED, {rpcList:
-          this.slowRpcSnapshot});
-      } else {
-        this.timeEnd(TIMER.DIFF_VIEW_DISPLAYED, {rpcList:
-          this.slowRpcSnapshot});
-      }
-    },
-
-    diffViewFullyLoaded() {
-      if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL)) {
-        this.timeEnd(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL);
-      } else {
-        this.timeEnd(TIMER.DIFF_VIEW_LOAD_FULL);
-      }
-    },
-
-    diffViewContentDisplayed() {
-      if (this._baselines.hasOwnProperty(
-          TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED)) {
-        this.timeEnd(TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED);
-      } else {
-        this.timeEnd(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
-      }
-    },
-
-    fileListDisplayed() {
-      if (this._baselines.hasOwnProperty(TIMER.STARTUP_FILE_LIST_DISPLAYED)) {
-        this.timeEnd(TIMER.STARTUP_FILE_LIST_DISPLAYED);
-      } else {
-        this.timeEnd(TIMER.FILE_LIST_DISPLAYED);
-      }
-    },
-
-    reportExtension(name) {
-      this.reporter(EXTENSION.TYPE, EXTENSION.DETECTED, name);
-    },
-
-    pluginLoaded(name) {
-      if (name.startsWith('metrics-')) {
-        this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
-      }
-    },
-
-    pluginsLoaded(pluginsList) {
-      this.timeEnd(TIMER.PLUGINS_LOADED);
-      this.reporter(
-          PLUGINS.TYPE, PLUGINS.INSTALLED, PLUGINS.INSTALLED, undefined,
-          {pluginsList: pluginsList || []}, true);
-    },
-
-    /**
-     * Reset named timer.
-     */
-    time(name) {
-      this._baselines[name] = this.now();
-      window.performance.mark(`${name}-start`);
-    },
-
-    /**
-     * Finish named timer and report it to server.
-     */
-    timeEnd(name, eventDetails) {
-      if (!this._baselines.hasOwnProperty(name)) { return; }
-      const baseTime = this._baselines[name];
-      delete this._baselines[name];
-      this._reportTiming(name, this.now() - baseTime, eventDetails);
-
-      // Finalize the interval. Either from a registered start mark or
-      // the navigation start time (if baseTime is 0).
-      if (baseTime !== 0) {
-        window.performance.measure(name, `${name}-start`);
-      } else {
-        // Microsft Edge does not handle the 2nd param correctly
-        // (if undefined).
-        window.performance.measure(name);
-      }
-    },
-
-    /**
-     * Reports just line timeEnd, but additionally reports an average given a
-     * denominator and a separate reporiting name for the average.
-     *
-     * @param {string} name Timing name.
-     * @param {string} averageName Average timing name.
-     * @param {number} denominator Number by which to divide the total to
-     *     compute the average.
-     */
-    timeEndWithAverage(name, averageName, denominator) {
-      if (!this._baselines.hasOwnProperty(name)) { return; }
-      const baseTime = this._baselines[name];
-      this.timeEnd(name);
-
-      // Guard against division by zero.
-      if (!denominator) { return; }
-      const time = this.now() - baseTime;
-      this._reportTiming(averageName, time / denominator);
-    },
-
-    /**
-     * Send a timing report with an arbitrary time value.
-     *
-     * @param {string} name Timing name.
-     * @param {number} time The time to report as an integer of milliseconds.
-     * @param {Object} eventDetails non sensitive details
-     */
-    _reportTiming(name, time, eventDetails) {
-      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, name, time,
-          eventDetails);
-    },
-
-    /**
-     * Get a timer object to for reporing a user timing. The start time will be
-     * the time that the object has been created, and the end time will be the
-     * time that the "end" method is called on the object.
-     *
-     * @param {string} name Timing name.
-     * @returns {!Object} The timer object.
-     */
-    getTimer(name) {
-      let called = false;
-      let start;
-      let max = null;
-
-      const timer = {
-
-        // Clear the timer and reset the start time.
-        reset: () => {
-          called = false;
-          start = this.now();
-          return timer;
-        },
-
-        // Stop the timer and report the intervening time.
-        end: () => {
-          if (called) {
-            throw new Error(`Timer for "${name}" already ended.`);
-          }
-          called = true;
-          const time = this.now() - start;
-
-          // If a maximum is specified and the time exceeds it, do not report.
-          if (max && time > max) { return timer; }
-
-          this._reportTiming(name, time);
-          return timer;
-        },
-
-        // Set a maximum reportable time. If a maximum is set and the timer is
-        // ended after the specified amount of time, the value is not reported.
-        withMaximum(maximum) {
-          max = maximum;
-          return timer;
-        },
-      };
-
-      // The timer is initialized to its creation time.
-      return timer.reset();
-    },
-
-    /**
-     * Log timing information for an RPC.
-     *
-     * @param {string} anonymizedUrl The URL of the RPC with tokens obfuscated.
-     * @param {number} elapsed The time elapsed of the RPC.
-     */
-    reportRpcTiming(anonymizedUrl, elapsed) {
-      this.reporter(TIMING.TYPE, TIMING.CATEGORY_RPC, 'RPC-' + anonymizedUrl,
-          elapsed, {}, true);
-      if (elapsed >= SLOW_RPC_THRESHOLD) {
-        slowRpcList.push({anonymizedUrl, elapsed});
-      }
-    },
-
-    reportInteraction(eventName, details) {
-      this.reporter(INTERACTION_TYPE, this.category, eventName, undefined,
-          details, true);
-    },
-
-    /**
-     * A draft interaction was started. Update the time-betweeen-draft-actions
-     * timer.
-     */
-    recordDraftInteraction() {
-      // If there is no timer defined, then this is the first interaction.
-      // Set up the timer so that it's ready to record the intervening time when
-      // called again.
-      const timer = this._timers.timeBetweenDraftActions;
-      if (!timer) {
-        // Create a timer with a maximum length.
-        this._timers.timeBetweenDraftActions = this.getTimer(DRAFT_ACTION_TIMER)
-            .withMaximum(DRAFT_ACTION_TIMER_MAX);
-        return;
-      }
-
-      // Mark the time and reinitialize the timer.
-      timer.end().reset();
-    },
-
-    reportErrorDialog(message) {
-      this.reporter(ERROR_DIALOG.TYPE, ERROR_DIALOG.CATEGORY,
-          'ErrorDialog: ' + message, {error: new Error(message)});
-    },
-
-    setRepoName(repoName) {
-      reportRepoName = repoName;
-    },
-  });
-
-  window.GrReporting = GrReporting;
-  // Expose onerror installation so it would be accessible from tests.
-  window.GrReporting._catchErrors = catchErrors;
-  window.GrReporting.STARTUP_TIMERS = Object.assign({}, STARTUP_TIMERS);
-})();
+window.GrReporting = GrReporting;
+// Expose onerror installation so it would be accessible from tests.
+window.GrReporting._catchErrors = catchErrors;
+window.GrReporting.STARTUP_TIMERS = Object.assign({}, STARTUP_TIMERS);
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
index 19e4b74..ad9903e 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reporting</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-reporting.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-reporting.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-reporting.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,407 +40,409 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-reporting tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let clock;
-    let fakePerformance;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-reporting.js';
+suite('gr-reporting tests', () => {
+  let element;
+  let sandbox;
+  let clock;
+  let fakePerformance;
 
-    const NOW_TIME = 100;
+  const NOW_TIME = 100;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    clock = sinon.useFakeTimers(NOW_TIME);
+    element = fixture('basic');
+    element._baselines = Object.assign({}, GrReporting.STARTUP_TIMERS);
+    fakePerformance = {
+      navigationStart: 1,
+      loadEventEnd: 2,
+    };
+    fakePerformance.toJSON = () => fakePerformance;
+    sinon.stub(element, 'performanceTiming',
+        {get() { return fakePerformance; }});
+    sandbox.stub(element, 'reporter');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    clock.restore();
+  });
+
+  test('appStarted', () => {
+    sandbox.stub(element, 'now').returns(42);
+    element.appStarted();
+    assert.isTrue(
+        element.reporter.calledWithMatch(
+            'timing-report', 'UI Latency', 'App Started', 42
+        ));
+  });
+
+  test('WebComponentsReady', () => {
+    sandbox.stub(element, 'now').returns(42);
+    element.timeEnd('WebComponentsReady');
+    assert.isTrue(element.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'WebComponentsReady', 42
+    ));
+  });
+
+  test('pageLoaded', () => {
+    element.pageLoaded();
+    assert.isTrue(
+        element.reporter.calledWithExactly(
+            'timing-report', 'UI Latency', 'NavResTime - loadEventEnd',
+            fakePerformance.loadEventEnd - fakePerformance.navigationStart,
+            undefined, true)
+    );
+  });
+
+  test('beforeLocationChanged', () => {
+    element._baselines['garbage'] = 'monster';
+    sandbox.stub(element, 'time');
+    element.beforeLocationChanged();
+    assert.isTrue(element.time.calledWithExactly('DashboardDisplayed'));
+    assert.isTrue(element.time.calledWithExactly('ChangeDisplayed'));
+    assert.isTrue(element.time.calledWithExactly('ChangeFullyLoaded'));
+    assert.isTrue(element.time.calledWithExactly('DiffViewDisplayed'));
+    assert.isTrue(element.time.calledWithExactly('FileListDisplayed'));
+    assert.isFalse(element._baselines.hasOwnProperty('garbage'));
+  });
+
+  test('changeDisplayed', () => {
+    sandbox.spy(element, 'timeEnd');
+    element.changeDisplayed();
+    assert.isFalse(
+        element.timeEnd.calledWithExactly('ChangeDisplayed', {rpcList: []}));
+    assert.isTrue(
+        element.timeEnd.calledWithExactly('StartupChangeDisplayed',
+            {rpcList: []}));
+    element.changeDisplayed();
+    assert.isTrue(element.timeEnd.calledWithExactly('ChangeDisplayed',
+        {rpcList: []}));
+  });
+
+  test('changeFullyLoaded', () => {
+    sandbox.spy(element, 'timeEnd');
+    element.changeFullyLoaded();
+    assert.isFalse(
+        element.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+    assert.isTrue(
+        element.timeEnd.calledWithExactly('StartupChangeFullyLoaded'));
+    element.changeFullyLoaded();
+    assert.isTrue(element.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+  });
+
+  test('diffViewDisplayed', () => {
+    sandbox.spy(element, 'timeEnd');
+    element.diffViewDisplayed();
+    assert.isFalse(
+        element.timeEnd.calledWithExactly('DiffViewDisplayed',
+            {rpcList: []}));
+    assert.isTrue(
+        element.timeEnd.calledWithExactly('StartupDiffViewDisplayed',
+            {rpcList: []}));
+    element.diffViewDisplayed();
+    assert.isTrue(element.timeEnd.calledWithExactly('DiffViewDisplayed',
+        {rpcList: []}));
+  });
+
+  test('fileListDisplayed', () => {
+    sandbox.spy(element, 'timeEnd');
+    element.fileListDisplayed();
+    assert.isFalse(
+        element.timeEnd.calledWithExactly('FileListDisplayed'));
+    assert.isTrue(
+        element.timeEnd.calledWithExactly('StartupFileListDisplayed'));
+    element.fileListDisplayed();
+    assert.isTrue(element.timeEnd.calledWithExactly('FileListDisplayed'));
+  });
+
+  test('dashboardDisplayed', () => {
+    sandbox.spy(element, 'timeEnd');
+    element.dashboardDisplayed();
+    assert.isFalse(
+        element.timeEnd.calledWithExactly('DashboardDisplayed',
+            {rpcList: []}));
+    assert.isTrue(
+        element.timeEnd.calledWithExactly('StartupDashboardDisplayed',
+            {rpcList: []}));
+    element.dashboardDisplayed();
+    assert.isTrue(element.timeEnd.calledWithExactly('DashboardDisplayed',
+        {rpcList: []}));
+  });
+
+  test('dashboardDisplayed', () => {
+    sandbox.spy(element, 'timeEnd');
+    element.reportRpcTiming('/changes/*~*/comments', 500);
+    element.dashboardDisplayed();
+    assert.isTrue(
+        element.timeEnd.calledWithExactly('StartupDashboardDisplayed',
+            {rpcList: [
+              {
+                anonymizedUrl: '/changes/*~*/comments',
+                elapsed: 500,
+              },
+            ]}
+        ));
+  });
+
+  test('time and timeEnd', () => {
+    const nowStub = sandbox.stub(element, 'now').returns(0);
+    element.time('foo');
+    nowStub.returns(1);
+    element.time('bar');
+    nowStub.returns(2);
+    element.timeEnd('bar');
+    nowStub.returns(3);
+    element.timeEnd('foo');
+    assert.isTrue(element.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'foo', 3
+    ));
+    assert.isTrue(element.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'bar', 1
+    ));
+  });
+
+  test('timer object', () => {
+    const nowStub = sandbox.stub(element, 'now').returns(100);
+    const timer = element.getTimer('foo-bar');
+    nowStub.returns(150);
+    timer.end();
+    assert.isTrue(element.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'foo-bar', 50));
+  });
+
+  test('timer object double call', () => {
+    const timer = element.getTimer('foo-bar');
+    timer.end();
+    assert.isTrue(element.reporter.calledOnce);
+    assert.throws(() => {
+      timer.end();
+    }, 'Timer for "foo-bar" already ended.');
+  });
+
+  test('timer object maximum', () => {
+    const nowStub = sandbox.stub(element, 'now').returns(100);
+    const timer = element.getTimer('foo-bar').withMaximum(100);
+    nowStub.returns(150);
+    timer.end();
+    assert.isTrue(element.reporter.calledOnce);
+
+    timer.reset();
+    nowStub.returns(260);
+    timer.end();
+    assert.isTrue(element.reporter.calledOnce);
+  });
+
+  test('recordDraftInteraction', () => {
+    const key = 'TimeBetweenDraftActions';
+    const nowStub = sandbox.stub(element, 'now').returns(100);
+    const timingStub = sandbox.stub(element, '_reportTiming');
+    element.recordDraftInteraction();
+    assert.isFalse(timingStub.called);
+
+    nowStub.returns(200);
+    element.recordDraftInteraction();
+    assert.isTrue(timingStub.calledOnce);
+    assert.equal(timingStub.lastCall.args[0], key);
+    assert.equal(timingStub.lastCall.args[1], 100);
+
+    nowStub.returns(350);
+    element.recordDraftInteraction();
+    assert.isTrue(timingStub.calledTwice);
+    assert.equal(timingStub.lastCall.args[0], key);
+    assert.equal(timingStub.lastCall.args[1], 150);
+
+    nowStub.returns(370 + 2 * 60 * 1000);
+    element.recordDraftInteraction();
+    assert.isFalse(timingStub.calledThrice);
+  });
+
+  test('timeEndWithAverage', () => {
+    const nowStub = sandbox.stub(element, 'now').returns(0);
+    nowStub.returns(1000);
+    element.time('foo');
+    nowStub.returns(1100);
+    element.timeEndWithAverage('foo', 'bar', 10);
+    assert.isTrue(element.reporter.calledTwice);
+    assert.isTrue(element.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'foo', 100));
+    assert.isTrue(element.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'bar', 10));
+  });
+
+  test('reportExtension', () => {
+    element.reportExtension('foo');
+    assert.isTrue(element.reporter.calledWithExactly(
+        'lifecycle', 'Extension detected', 'foo'
+    ));
+  });
+
+  test('reportInteraction', () => {
+    element.reporter.restore();
+    sandbox.spy(element, '_reportEvent');
+    element.pluginsLoaded(); // so we don't cache
+    element.reportInteraction('button-click', {name: 'sendReply'});
+    assert.isTrue(element._reportEvent.getCall(2).calledWithMatch(
+        {
+          type: 'interaction',
+          name: 'button-click',
+          eventDetails: JSON.stringify({name: 'sendReply'}),
+        }
+    ));
+  });
+
+  test('report start time', () => {
+    element.reporter.restore();
+    sandbox.stub(element, 'now').returns(42);
+    sandbox.spy(element, '_reportEvent');
+    const dispatchStub = sandbox.spy(document, 'dispatchEvent');
+    element.pluginsLoaded();
+    element.time('timeAction');
+    element.timeEnd('timeAction');
+    assert.isTrue(element._reportEvent.getCall(2).calledWithMatch(
+        {
+          type: 'timing-report',
+          category: 'UI Latency',
+          name: 'timeAction',
+          value: 0,
+          eventStart: 42,
+        }
+    ));
+    assert.equal(dispatchStub.getCall(2).args[0].detail.eventStart, 42);
+  });
+
+  suite('plugins', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      clock = sinon.useFakeTimers(NOW_TIME);
-      element = fixture('basic');
-      element._baselines = Object.assign({}, GrReporting.STARTUP_TIMERS);
-      fakePerformance = {
-        navigationStart: 1,
-        loadEventEnd: 2,
-      };
-      fakePerformance.toJSON = () => fakePerformance;
-      sinon.stub(element, 'performanceTiming',
-          {get() { return fakePerformance; }});
-      sandbox.stub(element, 'reporter');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-      clock.restore();
-    });
-
-    test('appStarted', () => {
-      sandbox.stub(element, 'now').returns(42);
-      element.appStarted();
-      assert.isTrue(
-          element.reporter.calledWithMatch(
-              'timing-report', 'UI Latency', 'App Started', 42
-          ));
-    });
-
-    test('WebComponentsReady', () => {
-      sandbox.stub(element, 'now').returns(42);
-      element.timeEnd('WebComponentsReady');
-      assert.isTrue(element.reporter.calledWithMatch(
-          'timing-report', 'UI Latency', 'WebComponentsReady', 42
-      ));
-    });
-
-    test('pageLoaded', () => {
-      element.pageLoaded();
-      assert.isTrue(
-          element.reporter.calledWithExactly(
-              'timing-report', 'UI Latency', 'NavResTime - loadEventEnd',
-              fakePerformance.loadEventEnd - fakePerformance.navigationStart,
-              undefined, true)
-      );
-    });
-
-    test('beforeLocationChanged', () => {
-      element._baselines['garbage'] = 'monster';
-      sandbox.stub(element, 'time');
-      element.beforeLocationChanged();
-      assert.isTrue(element.time.calledWithExactly('DashboardDisplayed'));
-      assert.isTrue(element.time.calledWithExactly('ChangeDisplayed'));
-      assert.isTrue(element.time.calledWithExactly('ChangeFullyLoaded'));
-      assert.isTrue(element.time.calledWithExactly('DiffViewDisplayed'));
-      assert.isTrue(element.time.calledWithExactly('FileListDisplayed'));
-      assert.isFalse(element._baselines.hasOwnProperty('garbage'));
-    });
-
-    test('changeDisplayed', () => {
-      sandbox.spy(element, 'timeEnd');
-      element.changeDisplayed();
-      assert.isFalse(
-          element.timeEnd.calledWithExactly('ChangeDisplayed', {rpcList: []}));
-      assert.isTrue(
-          element.timeEnd.calledWithExactly('StartupChangeDisplayed',
-              {rpcList: []}));
-      element.changeDisplayed();
-      assert.isTrue(element.timeEnd.calledWithExactly('ChangeDisplayed',
-          {rpcList: []}));
-    });
-
-    test('changeFullyLoaded', () => {
-      sandbox.spy(element, 'timeEnd');
-      element.changeFullyLoaded();
-      assert.isFalse(
-          element.timeEnd.calledWithExactly('ChangeFullyLoaded'));
-      assert.isTrue(
-          element.timeEnd.calledWithExactly('StartupChangeFullyLoaded'));
-      element.changeFullyLoaded();
-      assert.isTrue(element.timeEnd.calledWithExactly('ChangeFullyLoaded'));
-    });
-
-    test('diffViewDisplayed', () => {
-      sandbox.spy(element, 'timeEnd');
-      element.diffViewDisplayed();
-      assert.isFalse(
-          element.timeEnd.calledWithExactly('DiffViewDisplayed',
-              {rpcList: []}));
-      assert.isTrue(
-          element.timeEnd.calledWithExactly('StartupDiffViewDisplayed',
-              {rpcList: []}));
-      element.diffViewDisplayed();
-      assert.isTrue(element.timeEnd.calledWithExactly('DiffViewDisplayed',
-          {rpcList: []}));
-    });
-
-    test('fileListDisplayed', () => {
-      sandbox.spy(element, 'timeEnd');
-      element.fileListDisplayed();
-      assert.isFalse(
-          element.timeEnd.calledWithExactly('FileListDisplayed'));
-      assert.isTrue(
-          element.timeEnd.calledWithExactly('StartupFileListDisplayed'));
-      element.fileListDisplayed();
-      assert.isTrue(element.timeEnd.calledWithExactly('FileListDisplayed'));
-    });
-
-    test('dashboardDisplayed', () => {
-      sandbox.spy(element, 'timeEnd');
-      element.dashboardDisplayed();
-      assert.isFalse(
-          element.timeEnd.calledWithExactly('DashboardDisplayed',
-              {rpcList: []}));
-      assert.isTrue(
-          element.timeEnd.calledWithExactly('StartupDashboardDisplayed',
-              {rpcList: []}));
-      element.dashboardDisplayed();
-      assert.isTrue(element.timeEnd.calledWithExactly('DashboardDisplayed',
-          {rpcList: []}));
-    });
-
-    test('dashboardDisplayed', () => {
-      sandbox.spy(element, 'timeEnd');
-      element.reportRpcTiming('/changes/*~*/comments', 500);
-      element.dashboardDisplayed();
-      assert.isTrue(
-          element.timeEnd.calledWithExactly('StartupDashboardDisplayed',
-              {rpcList: [
-                {
-                  anonymizedUrl: '/changes/*~*/comments',
-                  elapsed: 500,
-                },
-              ]}
-          ));
-    });
-
-    test('time and timeEnd', () => {
-      const nowStub = sandbox.stub(element, 'now').returns(0);
-      element.time('foo');
-      nowStub.returns(1);
-      element.time('bar');
-      nowStub.returns(2);
-      element.timeEnd('bar');
-      nowStub.returns(3);
-      element.timeEnd('foo');
-      assert.isTrue(element.reporter.calledWithMatch(
-          'timing-report', 'UI Latency', 'foo', 3
-      ));
-      assert.isTrue(element.reporter.calledWithMatch(
-          'timing-report', 'UI Latency', 'bar', 1
-      ));
-    });
-
-    test('timer object', () => {
-      const nowStub = sandbox.stub(element, 'now').returns(100);
-      const timer = element.getTimer('foo-bar');
-      nowStub.returns(150);
-      timer.end();
-      assert.isTrue(element.reporter.calledWithMatch(
-          'timing-report', 'UI Latency', 'foo-bar', 50));
-    });
-
-    test('timer object double call', () => {
-      const timer = element.getTimer('foo-bar');
-      timer.end();
-      assert.isTrue(element.reporter.calledOnce);
-      assert.throws(() => {
-        timer.end();
-      }, 'Timer for "foo-bar" already ended.');
-    });
-
-    test('timer object maximum', () => {
-      const nowStub = sandbox.stub(element, 'now').returns(100);
-      const timer = element.getTimer('foo-bar').withMaximum(100);
-      nowStub.returns(150);
-      timer.end();
-      assert.isTrue(element.reporter.calledOnce);
-
-      timer.reset();
-      nowStub.returns(260);
-      timer.end();
-      assert.isTrue(element.reporter.calledOnce);
-    });
-
-    test('recordDraftInteraction', () => {
-      const key = 'TimeBetweenDraftActions';
-      const nowStub = sandbox.stub(element, 'now').returns(100);
-      const timingStub = sandbox.stub(element, '_reportTiming');
-      element.recordDraftInteraction();
-      assert.isFalse(timingStub.called);
-
-      nowStub.returns(200);
-      element.recordDraftInteraction();
-      assert.isTrue(timingStub.calledOnce);
-      assert.equal(timingStub.lastCall.args[0], key);
-      assert.equal(timingStub.lastCall.args[1], 100);
-
-      nowStub.returns(350);
-      element.recordDraftInteraction();
-      assert.isTrue(timingStub.calledTwice);
-      assert.equal(timingStub.lastCall.args[0], key);
-      assert.equal(timingStub.lastCall.args[1], 150);
-
-      nowStub.returns(370 + 2 * 60 * 1000);
-      element.recordDraftInteraction();
-      assert.isFalse(timingStub.calledThrice);
-    });
-
-    test('timeEndWithAverage', () => {
-      const nowStub = sandbox.stub(element, 'now').returns(0);
-      nowStub.returns(1000);
-      element.time('foo');
-      nowStub.returns(1100);
-      element.timeEndWithAverage('foo', 'bar', 10);
-      assert.isTrue(element.reporter.calledTwice);
-      assert.isTrue(element.reporter.calledWithMatch(
-          'timing-report', 'UI Latency', 'foo', 100));
-      assert.isTrue(element.reporter.calledWithMatch(
-          'timing-report', 'UI Latency', 'bar', 10));
-    });
-
-    test('reportExtension', () => {
-      element.reportExtension('foo');
-      assert.isTrue(element.reporter.calledWithExactly(
-          'lifecycle', 'Extension detected', 'foo'
-      ));
-    });
-
-    test('reportInteraction', () => {
       element.reporter.restore();
-      sandbox.spy(element, '_reportEvent');
-      element.pluginsLoaded(); // so we don't cache
-      element.reportInteraction('button-click', {name: 'sendReply'});
-      assert.isTrue(element._reportEvent.getCall(2).calledWithMatch(
-          {
-            type: 'interaction',
-            name: 'button-click',
-            eventDetails: JSON.stringify({name: 'sendReply'}),
-          }
-      ));
+      sandbox.stub(element, '_reportEvent');
     });
 
-    test('report start time', () => {
-      element.reporter.restore();
+    test('pluginsLoaded reports time', () => {
       sandbox.stub(element, 'now').returns(42);
-      sandbox.spy(element, '_reportEvent');
-      const dispatchStub = sandbox.spy(document, 'dispatchEvent');
       element.pluginsLoaded();
-      element.time('timeAction');
-      element.timeEnd('timeAction');
-      assert.isTrue(element._reportEvent.getCall(2).calledWithMatch(
+      assert.isTrue(element._reportEvent.calledWithMatch(
           {
             type: 'timing-report',
             category: 'UI Latency',
-            name: 'timeAction',
-            value: 0,
-            eventStart: 42,
+            name: 'PluginsLoaded',
+            value: 42,
           }
       ));
-      assert.equal(dispatchStub.getCall(2).args[0].detail.eventStart, 42);
     });
 
-    suite('plugins', () => {
-      setup(() => {
-        element.reporter.restore();
-        sandbox.stub(element, '_reportEvent');
-      });
-
-      test('pluginsLoaded reports time', () => {
-        sandbox.stub(element, 'now').returns(42);
-        element.pluginsLoaded();
-        assert.isTrue(element._reportEvent.calledWithMatch(
-            {
-              type: 'timing-report',
-              category: 'UI Latency',
-              name: 'PluginsLoaded',
-              value: 42,
-            }
-        ));
-      });
-
-      test('pluginsLoaded reports plugins', () => {
-        element.pluginsLoaded(['foo', 'bar']);
-        assert.isTrue(element._reportEvent.calledWithMatch(
-            {
-              type: 'lifecycle',
-              category: 'Plugins installed',
-              eventDetails: JSON.stringify({pluginsList: ['foo', 'bar']}),
-            }
-        ));
-      });
-
-      test('caches reports if plugins are not loaded', () => {
-        element.timeEnd('foo');
-        assert.isFalse(element._reportEvent.called);
-      });
-
-      test('reports if plugins are loaded', () => {
-        element.pluginsLoaded();
-        assert.isTrue(element._reportEvent.called);
-      });
-
-      test('reports if metrics plugin xyz is loaded', () => {
-        element.pluginLoaded('metrics-xyz');
-        assert.isTrue(element._reportEvent.called);
-      });
-
-      test('reports cached events preserving order', () => {
-        element.time('foo');
-        element.time('bar');
-        element.timeEnd('foo');
-        element.pluginsLoaded();
-        element.timeEnd('bar');
-        assert.isTrue(element._reportEvent.getCall(0).calledWithMatch(
-            {type: 'timing-report', category: 'UI Latency', name: 'foo'}
-        ));
-        assert.isTrue(element._reportEvent.getCall(1).calledWithMatch(
-            {type: 'timing-report', category: 'UI Latency',
-              name: 'PluginsLoaded'}
-        ));
-        assert.isTrue(element._reportEvent.getCall(2).calledWithMatch(
-            {type: 'lifecycle', category: 'Plugins installed'}
-        ));
-        assert.isTrue(element._reportEvent.getCall(3).calledWithMatch(
-            {type: 'timing-report', category: 'UI Latency', name: 'bar'}
-        ));
-      });
+    test('pluginsLoaded reports plugins', () => {
+      element.pluginsLoaded(['foo', 'bar']);
+      assert.isTrue(element._reportEvent.calledWithMatch(
+          {
+            type: 'lifecycle',
+            category: 'Plugins installed',
+            eventDetails: JSON.stringify({pluginsList: ['foo', 'bar']}),
+          }
+      ));
     });
 
-    test('search', () => {
-      element.locationChanged('_handleSomeRoute');
-      assert.isTrue(element.reporter.calledWithExactly(
-          'nav-report', 'Location Changed', 'Page', '_handleSomeRoute'));
+    test('caches reports if plugins are not loaded', () => {
+      element.timeEnd('foo');
+      assert.isFalse(element._reportEvent.called);
     });
 
-    suite('exception logging', () => {
-      let fakeWindow;
-      let reporter;
+    test('reports if plugins are loaded', () => {
+      element.pluginsLoaded();
+      assert.isTrue(element._reportEvent.called);
+    });
 
-      const emulateThrow = function(msg, url, line, column, error) {
-        return fakeWindow.onerror(msg, url, line, column, error);
-      };
+    test('reports if metrics plugin xyz is loaded', () => {
+      element.pluginLoaded('metrics-xyz');
+      assert.isTrue(element._reportEvent.called);
+    });
 
-      setup(() => {
-        reporter = sandbox.stub(GrReporting.prototype, 'reporter');
-        fakeWindow = {
-          handlers: {},
-          addEventListener(type, handler) {
-            this.handlers[type] = handler;
-          },
-        };
-        sandbox.stub(console, 'error');
-        window.GrReporting._catchErrors(fakeWindow);
-      });
-
-      test('is reported', () => {
-        const error = new Error('bar');
-        error.stack = undefined;
-        emulateThrow('bar', 'http://url', 4, 2, error);
-        assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
-        const payload = reporter.lastCall.args[3];
-        assert.deepEqual(payload, {
-          url: 'http://url',
-          line: 4,
-          column: 2,
-          error,
-        });
-      });
-
-      test('is reported with 3 lines of stack', () => {
-        const error = new Error('bar');
-        emulateThrow('bar', 'http://url', 4, 2, error);
-        const expectedStack = error.stack.split('\n').slice(0, 3)
-            .join('\n');
-        assert.isTrue(reporter.calledWith('error', 'exception',
-            expectedStack));
-      });
-
-      test('prevent default event handler', () => {
-        assert.isTrue(emulateThrow());
-      });
-
-      test('unhandled rejection', () => {
-        fakeWindow.handlers['unhandledrejection']({
-          reason: {
-            message: 'bar',
-          },
-        });
-        assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
-      });
+    test('reports cached events preserving order', () => {
+      element.time('foo');
+      element.time('bar');
+      element.timeEnd('foo');
+      element.pluginsLoaded();
+      element.timeEnd('bar');
+      assert.isTrue(element._reportEvent.getCall(0).calledWithMatch(
+          {type: 'timing-report', category: 'UI Latency', name: 'foo'}
+      ));
+      assert.isTrue(element._reportEvent.getCall(1).calledWithMatch(
+          {type: 'timing-report', category: 'UI Latency',
+            name: 'PluginsLoaded'}
+      ));
+      assert.isTrue(element._reportEvent.getCall(2).calledWithMatch(
+          {type: 'lifecycle', category: 'Plugins installed'}
+      ));
+      assert.isTrue(element._reportEvent.getCall(3).calledWithMatch(
+          {type: 'timing-report', category: 'UI Latency', name: 'bar'}
+      ));
     });
   });
+
+  test('search', () => {
+    element.locationChanged('_handleSomeRoute');
+    assert.isTrue(element.reporter.calledWithExactly(
+        'nav-report', 'Location Changed', 'Page', '_handleSomeRoute'));
+  });
+
+  suite('exception logging', () => {
+    let fakeWindow;
+    let reporter;
+
+    const emulateThrow = function(msg, url, line, column, error) {
+      return fakeWindow.onerror(msg, url, line, column, error);
+    };
+
+    setup(() => {
+      reporter = sandbox.stub(GrReporting.prototype, 'reporter');
+      fakeWindow = {
+        handlers: {},
+        addEventListener(type, handler) {
+          this.handlers[type] = handler;
+        },
+      };
+      sandbox.stub(console, 'error');
+      window.GrReporting._catchErrors(fakeWindow);
+    });
+
+    test('is reported', () => {
+      const error = new Error('bar');
+      error.stack = undefined;
+      emulateThrow('bar', 'http://url', 4, 2, error);
+      assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
+      const payload = reporter.lastCall.args[3];
+      assert.deepEqual(payload, {
+        url: 'http://url',
+        line: 4,
+        column: 2,
+        error,
+      });
+    });
+
+    test('is reported with 3 lines of stack', () => {
+      const error = new Error('bar');
+      emulateThrow('bar', 'http://url', 4, 2, error);
+      const expectedStack = error.stack.split('\n').slice(0, 3)
+          .join('\n');
+      assert.isTrue(reporter.calledWith('error', 'exception',
+          expectedStack));
+    });
+
+    test('prevent default event handler', () => {
+      assert.isTrue(emulateThrow());
+    });
+
+    test('unhandled rejection', () => {
+      fakeWindow.handlers['unhandledrejection']({
+        reason: {
+          message: 'bar',
+        },
+      });
+      assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index ebac1e1..e461d1d 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -14,1520 +14,1535 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const RoutePattern = {
-    ROOT: '/',
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '../gr-navigation/gr-navigation.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-reporting/gr-reporting.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import page from 'page/page.mjs';
+self.page = page;
+import {htmlTemplate} from './gr-router_html.js';
 
-    DASHBOARD: /^\/dashboard\/(.+)$/,
-    CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
-    PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
+const RoutePattern = {
+  ROOT: '/',
 
-    AGREEMENTS: /^\/settings\/agreements\/?/,
-    NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
-    REGISTER: /^\/register(\/.*)?$/,
+  DASHBOARD: /^\/dashboard\/(.+)$/,
+  CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
+  PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
 
-    // Pattern for login and logout URLs intended to be passed-through. May
-    // include a return URL.
-    LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
+  AGREEMENTS: /^\/settings\/agreements\/?/,
+  NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
+  REGISTER: /^\/register(\/.*)?$/,
 
-    // Pattern for a catchall route when no other pattern is matched.
-    DEFAULT: /.*/,
+  // Pattern for login and logout URLs intended to be passed-through. May
+  // include a return URL.
+  LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
 
-    // Matches /admin/groups/[uuid-]<group>
-    GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
+  // Pattern for a catchall route when no other pattern is matched.
+  DEFAULT: /.*/,
 
-    // Redirects /groups/self to /settings/#Groups for GWT compatibility
-    GROUP_SELF: /^\/groups\/self/,
+  // Matches /admin/groups/[uuid-]<group>
+  GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
 
-    // Matches /admin/groups/[uuid-]<group>,info (backwords compat with gwtui)
-    // Redirects to /admin/groups/[uuid-]<group>
-    GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
+  // Redirects /groups/self to /settings/#Groups for GWT compatibility
+  GROUP_SELF: /^\/groups\/self/,
 
-    // Matches /admin/groups/<group>,audit-log
-    GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
+  // Matches /admin/groups/[uuid-]<group>,info (backwords compat with gwtui)
+  // Redirects to /admin/groups/[uuid-]<group>
+  GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
 
-    // Matches /admin/groups/[uuid-]<group>,members
-    GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
+  // Matches /admin/groups/<group>,audit-log
+  GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
 
-    // Matches /admin/groups[,<offset>][/].
-    GROUP_LIST_OFFSET: /^\/admin\/groups(,(\d+))?(\/)?$/,
-    GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
-    GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
+  // Matches /admin/groups/[uuid-]<group>,members
+  GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
 
-    // Matches /admin/create-project
-    LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
+  // Matches /admin/groups[,<offset>][/].
+  GROUP_LIST_OFFSET: /^\/admin\/groups(,(\d+))?(\/)?$/,
+  GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
+  GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
 
-    // Matches /admin/create-project
-    LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,
+  // Matches /admin/create-project
+  LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
 
-    PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
+  // Matches /admin/create-project
+  LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,
 
-    // Matches /admin/repos/<repo>
-    REPO: /^\/admin\/repos\/([^,]+)$/,
+  PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
 
-    // Matches /admin/repos/<repo>,commands.
-    REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
+  // Matches /admin/repos/<repo>
+  REPO: /^\/admin\/repos\/([^,]+)$/,
 
-    // Matches /admin/repos/<repos>,access.
-    REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
+  // Matches /admin/repos/<repo>,commands.
+  REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
 
-    // Matches /admin/repos/<repos>,access.
-    REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
+  // Matches /admin/repos/<repos>,access.
+  REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
 
-    // Matches /admin/repos[,<offset>][/].
-    REPO_LIST_OFFSET: /^\/admin\/repos(,(\d+))?(\/)?$/,
-    REPO_LIST_FILTER: '/admin/repos/q/filter::filter',
-    REPO_LIST_FILTER_OFFSET: '/admin/repos/q/filter::filter,:offset',
+  // Matches /admin/repos/<repos>,access.
+  REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
 
-    // Matches /admin/repos/<repo>,branches[,<offset>].
-    BRANCH_LIST_OFFSET: /^\/admin\/repos\/(.+),branches(,(.+))?$/,
-    BRANCH_LIST_FILTER: '/admin/repos/:repo,branches/q/filter::filter',
-    BRANCH_LIST_FILTER_OFFSET:
-        '/admin/repos/:repo,branches/q/filter::filter,:offset',
+  // Matches /admin/repos[,<offset>][/].
+  REPO_LIST_OFFSET: /^\/admin\/repos(,(\d+))?(\/)?$/,
+  REPO_LIST_FILTER: '/admin/repos/q/filter::filter',
+  REPO_LIST_FILTER_OFFSET: '/admin/repos/q/filter::filter,:offset',
 
-    // Matches /admin/repos/<repo>,tags[,<offset>].
-    TAG_LIST_OFFSET: /^\/admin\/repos\/(.+),tags(,(.+))?$/,
-    TAG_LIST_FILTER: '/admin/repos/:repo,tags/q/filter::filter',
-    TAG_LIST_FILTER_OFFSET:
-        '/admin/repos/:repo,tags/q/filter::filter,:offset',
+  // Matches /admin/repos/<repo>,branches[,<offset>].
+  BRANCH_LIST_OFFSET: /^\/admin\/repos\/(.+),branches(,(.+))?$/,
+  BRANCH_LIST_FILTER: '/admin/repos/:repo,branches/q/filter::filter',
+  BRANCH_LIST_FILTER_OFFSET:
+      '/admin/repos/:repo,branches/q/filter::filter,:offset',
 
-    PLUGINS: /^\/plugins\/(.+)$/,
+  // Matches /admin/repos/<repo>,tags[,<offset>].
+  TAG_LIST_OFFSET: /^\/admin\/repos\/(.+),tags(,(.+))?$/,
+  TAG_LIST_FILTER: '/admin/repos/:repo,tags/q/filter::filter',
+  TAG_LIST_FILTER_OFFSET:
+      '/admin/repos/:repo,tags/q/filter::filter,:offset',
 
-    PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
+  PLUGINS: /^\/plugins\/(.+)$/,
 
-    // Matches /admin/plugins[,<offset>][/].
-    PLUGIN_LIST_OFFSET: /^\/admin\/plugins(,(\d+))?(\/)?$/,
-    PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
-    PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
+  PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
 
-    QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
+  // Matches /admin/plugins[,<offset>][/].
+  PLUGIN_LIST_OFFSET: /^\/admin\/plugins(,(\d+))?(\/)?$/,
+  PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
+  PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
 
-    /**
-     * Support vestigial params from GWT UI.
-     *
-     * @see Issue 7673.
-     * @type {!RegExp}
-     */
-    QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
-
-    // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
-    CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
-    CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
-
-    // Matches
-    // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..][<patchNum|edit>].
-    // TODO(kaspern): Migrate completely to project based URLs, with backwards
-    // compatibility for change-only.
-    CHANGE: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
-
-    // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>],edit
-    CHANGE_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(\d+))?,edit\/?$/,
-
-    // Matches
-    // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..]<patchNum|edit>/<path>.
-    // TODO(kaspern): Migrate completely to project based URLs, with backwards
-    // compatibility for change-only.
-    // eslint-disable-next-line max-len
-    DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,
-
-    // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>]/<path>,edit[#lineNum]
-    DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(\#\d+)?$/,
-
-    // Matches non-project-relative
-    // /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
-    DIFF_LEGACY: /^\/c\/(\d+)\/((-?\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
-
-    // Matches diff routes using @\d+ to specify a file name (whether or not
-    // the project name is included).
-    // eslint-disable-next-line max-len
-    DIFF_LEGACY_LINENUM: /^\/c\/((.+)\/\+\/)?(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?\/(.+))?)@[ab]?\d+$/,
-
-    SETTINGS: /^\/settings\/?/,
-    SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
-
-    // Matches /c/<changeNum>/ /<URL tail>
-    // Catches improperly encoded URLs (context: Issue 7100)
-    IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/\ \/(.+)$/,
-
-    PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
-
-    DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
-    DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
-    DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
-  };
+  QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
 
   /**
-   * Pattern to recognize and parse the diff line locations as they appear in
-   * the hash of diff URLs. In this format, a number on its own indicates that
-   * line number in the revision of the diff. A number prefixed by either an 'a'
-   * or a 'b' indicates that line number of the base of the diff.
+   * Support vestigial params from GWT UI.
    *
-   * @type {RegExp}
+   * @see Issue 7673.
+   * @type {!RegExp}
    */
-  const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
+  QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
 
-  /**
-   * Pattern to recognize '+' in url-encoded strings for replacement with ' '.
-   */
-  const PLUS_PATTERN = /\+/g;
+  // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
+  CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+  CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
 
-  /**
-   * Pattern to recognize leading '?' in window.location.search, for stripping.
-   */
-  const QUESTION_PATTERN = /^\?*/;
+  // Matches
+  // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..][<patchNum|edit>].
+  // TODO(kaspern): Migrate completely to project based URLs, with backwards
+  // compatibility for change-only.
+  CHANGE: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
 
-  /**
-   * GWT UI would use @\d+ at the end of a path to indicate linenum.
-   */
-  const LEGACY_LINENUM_PATTERN = /@([ab]?\d+)$/;
+  // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>],edit
+  CHANGE_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(\d+))?,edit\/?$/,
 
-  const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
+  // Matches
+  // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..]<patchNum|edit>/<path>.
+  // TODO(kaspern): Migrate completely to project based URLs, with backwards
+  // compatibility for change-only.
+  // eslint-disable-next-line max-len
+  DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,
 
-  const REPO_TOKEN_PATTERN = /\$\{(project|repo)\}/g;
+  // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>]/<path>,edit[#lineNum]
+  DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(\#\d+)?$/,
 
-  // Polymer makes `app` intrinsically defined on the window by virtue of the
-  // custom element having the id "app", but it is made explicit here.
-  const app = document.querySelector('#app');
-  if (!app) {
-    console.log('No gr-app found (running tests)');
+  // Matches non-project-relative
+  // /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
+  DIFF_LEGACY: /^\/c\/(\d+)\/((-?\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
+
+  // Matches diff routes using @\d+ to specify a file name (whether or not
+  // the project name is included).
+  // eslint-disable-next-line max-len
+  DIFF_LEGACY_LINENUM: /^\/c\/((.+)\/\+\/)?(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?\/(.+))?)@[ab]?\d+$/,
+
+  SETTINGS: /^\/settings\/?/,
+  SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
+
+  // Matches /c/<changeNum>/ /<URL tail>
+  // Catches improperly encoded URLs (context: Issue 7100)
+  IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/\ \/(.+)$/,
+
+  PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
+
+  DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+  DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
+  DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
+};
+
+/**
+ * Pattern to recognize and parse the diff line locations as they appear in
+ * the hash of diff URLs. In this format, a number on its own indicates that
+ * line number in the revision of the diff. A number prefixed by either an 'a'
+ * or a 'b' indicates that line number of the base of the diff.
+ *
+ * @type {RegExp}
+ */
+const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
+
+/**
+ * Pattern to recognize '+' in url-encoded strings for replacement with ' '.
+ */
+const PLUS_PATTERN = /\+/g;
+
+/**
+ * Pattern to recognize leading '?' in window.location.search, for stripping.
+ */
+const QUESTION_PATTERN = /^\?*/;
+
+/**
+ * GWT UI would use @\d+ at the end of a path to indicate linenum.
+ */
+const LEGACY_LINENUM_PATTERN = /@([ab]?\d+)$/;
+
+const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
+
+const REPO_TOKEN_PATTERN = /\$\{(project|repo)\}/g;
+
+// Polymer makes `app` intrinsically defined on the window by virtue of the
+// custom element having the id "app", but it is made explicit here.
+const app = document.querySelector('#app');
+if (!app) {
+  console.log('No gr-app found (running tests)');
+}
+
+// Setup listeners outside of the router component initialization.
+(function() {
+  const reporting = document.createElement('gr-reporting');
+
+  window.addEventListener('WebComponentsReady', () => {
+    reporting.timeEnd('WebComponentsReady');
+  });
+})();
+
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrRouter extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.FireBehavior,
+  Gerrit.PatchSetBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-router'; }
+
+  static get properties() {
+    return {
+      _app: {
+        type: Object,
+        value: app,
+      },
+      _isRedirecting: Boolean,
+      // This variable is to differentiate between internal navigation (false)
+      // and for first navigation in app after loaded from server (true).
+      _isInitialLoad: {
+        type: Boolean,
+        value: true,
+      },
+    };
   }
 
-  // Setup listeners outside of the router component initialization.
-  (function() {
-    const reporting = document.createElement('gr-reporting');
+  start() {
+    if (!this._app) { return; }
+    this._startRouter();
+  }
 
-    window.addEventListener('WebComponentsReady', () => {
-      reporting.timeEnd('WebComponentsReady');
-    });
-  })();
+  _setParams(params) {
+    this._appElement().params = params;
+  }
+
+  _appElement() {
+    // In Polymer2 you have to reach through the shadow root of the app
+    // element. This obviously breaks encapsulation.
+    // TODO(brohlfs): Make this more elegant, e.g. by exposing app-element
+    // explicitly in app, or by delegating to it.
+    return document.getElementById('app-element') ||
+        document.getElementById('app').shadowRoot.getElementById(
+            'app-element');
+  }
+
+  _redirect(url) {
+    this._isRedirecting = true;
+    page.redirect(url);
+  }
 
   /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
+   * @param {!Object} params
+   * @return {string}
    */
-  class GrRouter extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.FireBehavior,
-    Gerrit.PatchSetBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-router'; }
+  _generateUrl(params) {
+    const base = this.getBaseUrl();
+    let url = '';
+    const Views = Gerrit.Nav.View;
 
-    static get properties() {
-      return {
-        _app: {
-          type: Object,
-          value: app,
-        },
-        _isRedirecting: Boolean,
-        // This variable is to differentiate between internal navigation (false)
-        // and for first navigation in app after loaded from server (true).
-        _isInitialLoad: {
-          type: Boolean,
-          value: true,
-        },
-      };
+    if (params.view === Views.SEARCH) {
+      url = this._generateSearchUrl(params);
+    } else if (params.view === Views.CHANGE) {
+      url = this._generateChangeUrl(params);
+    } else if (params.view === Views.DASHBOARD) {
+      url = this._generateDashboardUrl(params);
+    } else if (params.view === Views.DIFF || params.view === Views.EDIT) {
+      url = this._generateDiffOrEditUrl(params);
+    } else if (params.view === Views.GROUP) {
+      url = this._generateGroupUrl(params);
+    } else if (params.view === Views.REPO) {
+      url = this._generateRepoUrl(params);
+    } else if (params.view === Views.ROOT) {
+      url = '/';
+    } else if (params.view === Views.SETTINGS) {
+      url = this._generateSettingsUrl(params);
+    } else {
+      throw new Error('Can\'t generate');
     }
 
-    start() {
-      if (!this._app) { return; }
-      this._startRouter();
+    return base + url;
+  }
+
+  _generateWeblinks(params) {
+    const type = params.type;
+    switch (type) {
+      case Gerrit.Nav.WeblinkType.FILE:
+        return this._getFileWebLinks(params);
+      case Gerrit.Nav.WeblinkType.CHANGE:
+        return this._getChangeWeblinks(params);
+      case Gerrit.Nav.WeblinkType.PATCHSET:
+        return this._getPatchSetWeblink(params);
+      default:
+        console.warn(`Unsupported weblink ${type}!`);
+    }
+  }
+
+  _getPatchSetWeblink(params) {
+    const {commit, options} = params;
+    const {weblinks, config} = options || {};
+    const name = commit && commit.slice(0, 7);
+    const weblink = this._getBrowseCommitWeblink(weblinks, config);
+    if (!weblink || !weblink.url) {
+      return {name};
+    } else {
+      return {name, url: weblink.url};
+    }
+  }
+
+  _firstCodeBrowserWeblink(weblinks) {
+    // This is an ordered whitelist of web link types that provide direct
+    // links to the commit in the url property.
+    const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
+    for (let i = 0; i < codeBrowserLinks.length; i++) {
+      const weblink =
+        weblinks.find(weblink => weblink.name === codeBrowserLinks[i]);
+      if (weblink) { return weblink; }
+    }
+    return null;
+  }
+
+  _getBrowseCommitWeblink(weblinks, config) {
+    if (!weblinks) { return null; }
+    let weblink;
+    // Use primary weblink if configured and exists.
+    if (config && config.gerrit && config.gerrit.primary_weblink_name) {
+      weblink = weblinks.find(
+          weblink => weblink.name === config.gerrit.primary_weblink_name
+      );
+    }
+    if (!weblink) {
+      weblink = this._firstCodeBrowserWeblink(weblinks);
+    }
+    if (!weblink) { return null; }
+    return weblink;
+  }
+
+  _getChangeWeblinks({repo, commit, options: {weblinks, config}}) {
+    if (!weblinks || !weblinks.length) return [];
+    const commitWeblink = this._getBrowseCommitWeblink(weblinks, config);
+    return weblinks.filter(weblink =>
+      !commitWeblink ||
+      !commitWeblink.name ||
+      weblink.name !== commitWeblink.name);
+  }
+
+  _getFileWebLinks({repo, commit, file, options: {weblinks}}) {
+    return weblinks;
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateSearchUrl(params) {
+    let offsetExpr = '';
+    if (params.offset && params.offset > 0) {
+      offsetExpr = ',' + params.offset;
     }
 
-    _setParams(params) {
-      this._appElement().params = params;
+    if (params.query) {
+      return '/q/' + this.encodeURL(params.query, true) + offsetExpr;
     }
 
-    _appElement() {
-      // In Polymer2 you have to reach through the shadow root of the app
-      // element. This obviously breaks encapsulation.
-      // TODO(brohlfs): Make this more elegant, e.g. by exposing app-element
-      // explicitly in app, or by delegating to it.
-      return document.getElementById('app-element') ||
-          document.getElementById('app').shadowRoot.getElementById(
-              'app-element');
+    const operators = [];
+    if (params.owner) {
+      operators.push('owner:' + this.encodeURL(params.owner, false));
+    }
+    if (params.project) {
+      operators.push('project:' + this.encodeURL(params.project, false));
+    }
+    if (params.branch) {
+      operators.push('branch:' + this.encodeURL(params.branch, false));
+    }
+    if (params.topic) {
+      operators.push('topic:"' + this.encodeURL(params.topic, false) + '"');
+    }
+    if (params.hashtag) {
+      operators.push('hashtag:"' +
+          this.encodeURL(params.hashtag.toLowerCase(), false) + '"');
+    }
+    if (params.statuses) {
+      if (params.statuses.length === 1) {
+        operators.push(
+            'status:' + this.encodeURL(params.statuses[0], false));
+      } else if (params.statuses.length > 1) {
+        operators.push(
+            '(' +
+            params.statuses.map(s => `status:${this.encodeURL(s, false)}`)
+                .join(' OR ') +
+            ')');
+      }
     }
 
-    _redirect(url) {
-      this._isRedirecting = true;
-      page.redirect(url);
+    return '/q/' + operators.join('+') + offsetExpr;
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateChangeUrl(params) {
+    let range = this._getPatchRangeExpression(params);
+    if (range.length) { range = '/' + range; }
+    let suffix = `${range}`;
+    if (params.querystring) {
+      suffix += '?' + params.querystring;
+    } else if (params.edit) {
+      suffix += ',edit';
+    }
+    if (params.messageHash) {
+      suffix += params.messageHash;
+    }
+    if (params.project) {
+      const encodedProject = this.encodeURL(params.project, true);
+      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
+    } else {
+      return `/c/${params.changeNum}${suffix}`;
+    }
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateDashboardUrl(params) {
+    const repoName = params.repo || params.project || null;
+    if (params.sections) {
+      // Custom dashboard.
+      const queryParams = this._sectionsToEncodedParams(params.sections,
+          repoName);
+      if (params.title) {
+        queryParams.push('title=' + encodeURIComponent(params.title));
+      }
+      const user = params.user ? params.user : '';
+      return `/dashboard/${user}?${queryParams.join('&')}`;
+    } else if (repoName) {
+      // Project dashboard.
+      const encodedRepo = this.encodeURL(repoName, true);
+      return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
+    } else {
+      // User dashboard.
+      return `/dashboard/${params.user || 'self'}`;
+    }
+  }
+
+  /**
+   * @param {!Array<!{name: string, query: string}>} sections
+   * @param {string=} opt_repoName
+   * @return {!Array<string>}
+   */
+  _sectionsToEncodedParams(sections, opt_repoName) {
+    return sections.map(section => {
+      // If there is a repo name provided, make sure to substitute it into the
+      // ${repo} (or legacy ${project}) query tokens.
+      const query = opt_repoName ?
+        section.query.replace(REPO_TOKEN_PATTERN, opt_repoName) :
+        section.query;
+      return encodeURIComponent(section.name) + '=' +
+          encodeURIComponent(query);
+    });
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateDiffOrEditUrl(params) {
+    let range = this._getPatchRangeExpression(params);
+    if (range.length) { range = '/' + range; }
+
+    let suffix = `${range}/${this.encodeURL(params.path, true)}`;
+
+    if (params.view === Gerrit.Nav.View.EDIT) { suffix += ',edit'; }
+
+    if (params.lineNum) {
+      suffix += '#';
+      if (params.leftSide) { suffix += 'b'; }
+      suffix += params.lineNum;
     }
 
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateUrl(params) {
-      const base = this.getBaseUrl();
-      let url = '';
-      const Views = Gerrit.Nav.View;
+    if (params.project) {
+      const encodedProject = this.encodeURL(params.project, true);
+      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
+    } else {
+      return `/c/${params.changeNum}${suffix}`;
+    }
+  }
 
-      if (params.view === Views.SEARCH) {
-        url = this._generateSearchUrl(params);
-      } else if (params.view === Views.CHANGE) {
-        url = this._generateChangeUrl(params);
-      } else if (params.view === Views.DASHBOARD) {
-        url = this._generateDashboardUrl(params);
-      } else if (params.view === Views.DIFF || params.view === Views.EDIT) {
-        url = this._generateDiffOrEditUrl(params);
-      } else if (params.view === Views.GROUP) {
-        url = this._generateGroupUrl(params);
-      } else if (params.view === Views.REPO) {
-        url = this._generateRepoUrl(params);
-      } else if (params.view === Views.ROOT) {
-        url = '/';
-      } else if (params.view === Views.SETTINGS) {
-        url = this._generateSettingsUrl(params);
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateGroupUrl(params) {
+    let url = `/admin/groups/${this.encodeURL(params.groupId + '', true)}`;
+    if (params.detail === Gerrit.Nav.GroupDetailView.MEMBERS) {
+      url += ',members';
+    } else if (params.detail === Gerrit.Nav.GroupDetailView.LOG) {
+      url += ',audit-log';
+    }
+    return url;
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateRepoUrl(params) {
+    let url = `/admin/repos/${this.encodeURL(params.repoName + '', true)}`;
+    if (params.detail === Gerrit.Nav.RepoDetailView.ACCESS) {
+      url += ',access';
+    } else if (params.detail === Gerrit.Nav.RepoDetailView.BRANCHES) {
+      url += ',branches';
+    } else if (params.detail === Gerrit.Nav.RepoDetailView.TAGS) {
+      url += ',tags';
+    } else if (params.detail === Gerrit.Nav.RepoDetailView.COMMANDS) {
+      url += ',commands';
+    } else if (params.detail === Gerrit.Nav.RepoDetailView.DASHBOARDS) {
+      url += ',dashboards';
+    }
+    return url;
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateSettingsUrl(params) {
+    return '/settings';
+  }
+
+  /**
+   * Given an object of parameters, potentially including a `patchNum` or a
+   * `basePatchNum` or both, return a string representation of that range. If
+   * no range is indicated in the params, the empty string is returned.
+   *
+   * @param {!Object} params
+   * @return {string}
+   */
+  _getPatchRangeExpression(params) {
+    let range = '';
+    if (params.patchNum) { range = '' + params.patchNum; }
+    if (params.basePatchNum) { range = params.basePatchNum + '..' + range; }
+    return range;
+  }
+
+  /**
+   * Given a set of params without a project, gets the project from the rest
+   * API project lookup and then sets the app params.
+   *
+   * @param {?Object} params
+   */
+  _normalizeLegacyRouteParams(params) {
+    if (!params.changeNum) { return Promise.resolve(); }
+
+    return this.$.restAPI.getFromProjectLookup(params.changeNum)
+        .then(project => {
+          // Show a 404 and terminate if the lookup request failed. Attempting
+          // to redirect after failing to get the project loops infinitely.
+          if (!project) {
+            this._show404();
+            return;
+          }
+
+          params.project = project;
+          this._normalizePatchRangeParams(params);
+          this._redirect(this._generateUrl(params));
+        });
+  }
+
+  /**
+   * Normalizes the params object, and determines if the URL needs to be
+   * modified to fit the proper schema.
+   *
+   * @param {*} params
+   * @return {boolean} whether or not the URL needs to be upgraded.
+   */
+  _normalizePatchRangeParams(params) {
+    const hasBasePatchNum = params.basePatchNum !== null &&
+        params.basePatchNum !== undefined;
+    const hasPatchNum = params.patchNum !== null &&
+        params.patchNum !== undefined;
+    let needsRedirect = false;
+
+    // Diffing a patch against itself is invalid, so if the base and revision
+    // patches are equal clear the base.
+    if (hasBasePatchNum &&
+        this.patchNumEquals(params.basePatchNum, params.patchNum)) {
+      needsRedirect = true;
+      params.basePatchNum = null;
+    } else if (hasBasePatchNum && !hasPatchNum) {
+      // Regexes set basePatchNum instead of patchNum when only one is
+      // specified. Redirect is not needed in this case.
+      params.patchNum = params.basePatchNum;
+      params.basePatchNum = null;
+    }
+    return needsRedirect;
+  }
+
+  /**
+   * Redirect the user to login using the given return-URL for redirection
+   * after authentication success.
+   *
+   * @param {string} returnUrl
+   */
+  _redirectToLogin(returnUrl) {
+    const basePath = this.getBaseUrl() || '';
+    page(
+        '/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
+  }
+
+  /**
+   * Hashes parsed by page.js exclude "inner" hashes, so a URL like "/a#b#c"
+   * is parsed to have a hash of "b" rather than "b#c". Instead, this method
+   * parses hashes correctly. Will return an empty string if there is no hash.
+   *
+   * @param {!string} canonicalPath
+   * @return {!string} Everything after the first '#' ("a#b#c" -> "b#c").
+   */
+  _getHashFromCanonicalPath(canonicalPath) {
+    return canonicalPath.split('#').slice(1)
+        .join('#');
+  }
+
+  _parseLineAddress(hash) {
+    const match = hash.match(LINE_ADDRESS_PATTERN);
+    if (!match) { return null; }
+    return {
+      leftSide: !!match[1],
+      lineNum: parseInt(match[2], 10),
+    };
+  }
+
+  /**
+   * Check to see if the user is logged in and return a promise that only
+   * resolves if the user is logged in. If the user us not logged in, the
+   * promise is rejected and the page is redirected to the login flow.
+   *
+   * @param {!Object} data The parsed route data.
+   * @return {!Promise<!Object>} A promise yielding the original route data
+   *     (if it resolves).
+   */
+  _redirectIfNotLoggedIn(data) {
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        return Promise.resolve();
       } else {
-        throw new Error('Can\'t generate');
+        this._redirectToLogin(data.canonicalPath);
+        return Promise.reject(new Error());
       }
+    });
+  }
 
-      return base + url;
+  /**  Page.js middleware that warms the REST API's logged-in cache line. */
+  _loadUserMiddleware(ctx, next) {
+    this.$.restAPI.getLoggedIn().then(() => { next(); });
+  }
+
+  /**
+   * Map a route to a method on the router.
+   *
+   * @param {!string|!RegExp} pattern The page.js pattern for the route.
+   * @param {!string} handlerName The method name for the handler. If the
+   *     route is matched, the handler will be executed with `this` referring
+   *     to the component. Its return value will be discarded so that it does
+   *     not interfere with page.js.
+   * @param  {?boolean=} opt_authRedirect If true, then auth is checked before
+   *     executing the handler. If the user is not logged in, it will redirect
+   *     to the login flow and the handler will not be executed. The login
+   *     redirect specifies the matched URL to be used after successfull auth.
+   */
+  _mapRoute(pattern, handlerName, opt_authRedirect) {
+    if (!this[handlerName]) {
+      console.error('Attempted to map route to unknown method: ',
+          handlerName);
+      return;
+    }
+    page(pattern, this._loadUserMiddleware.bind(this), data => {
+      this.$.reporting.locationChanged(handlerName);
+      const promise = opt_authRedirect ?
+        this._redirectIfNotLoggedIn(data) : Promise.resolve();
+      promise.then(() => { this[handlerName](data); });
+    });
+  }
+
+  _startRouter() {
+    const base = this.getBaseUrl();
+    if (base) {
+      page.base(base);
     }
 
-    _generateWeblinks(params) {
-      const type = params.type;
-      switch (type) {
-        case Gerrit.Nav.WeblinkType.FILE:
-          return this._getFileWebLinks(params);
-        case Gerrit.Nav.WeblinkType.CHANGE:
-          return this._getChangeWeblinks(params);
-        case Gerrit.Nav.WeblinkType.PATCHSET:
-          return this._getPatchSetWeblink(params);
-        default:
-          console.warn(`Unsupported weblink ${type}!`);
+    Gerrit.Nav.setup(
+        url => { page.show(url); },
+        this._generateUrl.bind(this),
+        params => this._generateWeblinks(params),
+        x => x
+    );
+
+    page.exit('*', (ctx, next) => {
+      if (!this._isRedirecting) {
+        this.$.reporting.beforeLocationChanged();
       }
-    }
+      this._isRedirecting = false;
+      this._isInitialLoad = false;
+      next();
+    });
 
-    _getPatchSetWeblink(params) {
-      const {commit, options} = params;
-      const {weblinks, config} = options || {};
-      const name = commit && commit.slice(0, 7);
-      const weblink = this._getBrowseCommitWeblink(weblinks, config);
-      if (!weblink || !weblink.url) {
-        return {name};
-      } else {
-        return {name, url: weblink.url};
-      }
-    }
+    // Middleware
+    page((ctx, next) => {
+      document.body.scrollTop = 0;
 
-    _firstCodeBrowserWeblink(weblinks) {
-      // This is an ordered whitelist of web link types that provide direct
-      // links to the commit in the url property.
-      const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
-      for (let i = 0; i < codeBrowserLinks.length; i++) {
-        const weblink =
-          weblinks.find(weblink => weblink.name === codeBrowserLinks[i]);
-        if (weblink) { return weblink; }
-      }
-      return null;
-    }
-
-    _getBrowseCommitWeblink(weblinks, config) {
-      if (!weblinks) { return null; }
-      let weblink;
-      // Use primary weblink if configured and exists.
-      if (config && config.gerrit && config.gerrit.primary_weblink_name) {
-        weblink = weblinks.find(
-            weblink => weblink.name === config.gerrit.primary_weblink_name
-        );
-      }
-      if (!weblink) {
-        weblink = this._firstCodeBrowserWeblink(weblinks);
-      }
-      if (!weblink) { return null; }
-      return weblink;
-    }
-
-    _getChangeWeblinks({repo, commit, options: {weblinks, config}}) {
-      if (!weblinks || !weblinks.length) return [];
-      const commitWeblink = this._getBrowseCommitWeblink(weblinks, config);
-      return weblinks.filter(weblink =>
-        !commitWeblink ||
-        !commitWeblink.name ||
-        weblink.name !== commitWeblink.name);
-    }
-
-    _getFileWebLinks({repo, commit, file, options: {weblinks}}) {
-      return weblinks;
-    }
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateSearchUrl(params) {
-      let offsetExpr = '';
-      if (params.offset && params.offset > 0) {
-        offsetExpr = ',' + params.offset;
-      }
-
-      if (params.query) {
-        return '/q/' + this.encodeURL(params.query, true) + offsetExpr;
-      }
-
-      const operators = [];
-      if (params.owner) {
-        operators.push('owner:' + this.encodeURL(params.owner, false));
-      }
-      if (params.project) {
-        operators.push('project:' + this.encodeURL(params.project, false));
-      }
-      if (params.branch) {
-        operators.push('branch:' + this.encodeURL(params.branch, false));
-      }
-      if (params.topic) {
-        operators.push('topic:"' + this.encodeURL(params.topic, false) + '"');
-      }
-      if (params.hashtag) {
-        operators.push('hashtag:"' +
-            this.encodeURL(params.hashtag.toLowerCase(), false) + '"');
-      }
-      if (params.statuses) {
-        if (params.statuses.length === 1) {
-          operators.push(
-              'status:' + this.encodeURL(params.statuses[0], false));
-        } else if (params.statuses.length > 1) {
-          operators.push(
-              '(' +
-              params.statuses.map(s => `status:${this.encodeURL(s, false)}`)
-                  .join(' OR ') +
-              ')');
-        }
-      }
-
-      return '/q/' + operators.join('+') + offsetExpr;
-    }
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateChangeUrl(params) {
-      let range = this._getPatchRangeExpression(params);
-      if (range.length) { range = '/' + range; }
-      let suffix = `${range}`;
-      if (params.querystring) {
-        suffix += '?' + params.querystring;
-      } else if (params.edit) {
-        suffix += ',edit';
-      }
-      if (params.messageHash) {
-        suffix += params.messageHash;
-      }
-      if (params.project) {
-        const encodedProject = this.encodeURL(params.project, true);
-        return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
-      } else {
-        return `/c/${params.changeNum}${suffix}`;
-      }
-    }
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateDashboardUrl(params) {
-      const repoName = params.repo || params.project || null;
-      if (params.sections) {
-        // Custom dashboard.
-        const queryParams = this._sectionsToEncodedParams(params.sections,
-            repoName);
-        if (params.title) {
-          queryParams.push('title=' + encodeURIComponent(params.title));
-        }
-        const user = params.user ? params.user : '';
-        return `/dashboard/${user}?${queryParams.join('&')}`;
-      } else if (repoName) {
-        // Project dashboard.
-        const encodedRepo = this.encodeURL(repoName, true);
-        return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
-      } else {
-        // User dashboard.
-        return `/dashboard/${params.user || 'self'}`;
-      }
-    }
-
-    /**
-     * @param {!Array<!{name: string, query: string}>} sections
-     * @param {string=} opt_repoName
-     * @return {!Array<string>}
-     */
-    _sectionsToEncodedParams(sections, opt_repoName) {
-      return sections.map(section => {
-        // If there is a repo name provided, make sure to substitute it into the
-        // ${repo} (or legacy ${project}) query tokens.
-        const query = opt_repoName ?
-          section.query.replace(REPO_TOKEN_PATTERN, opt_repoName) :
-          section.query;
-        return encodeURIComponent(section.name) + '=' +
-            encodeURIComponent(query);
-      });
-    }
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateDiffOrEditUrl(params) {
-      let range = this._getPatchRangeExpression(params);
-      if (range.length) { range = '/' + range; }
-
-      let suffix = `${range}/${this.encodeURL(params.path, true)}`;
-
-      if (params.view === Gerrit.Nav.View.EDIT) { suffix += ',edit'; }
-
-      if (params.lineNum) {
-        suffix += '#';
-        if (params.leftSide) { suffix += 'b'; }
-        suffix += params.lineNum;
-      }
-
-      if (params.project) {
-        const encodedProject = this.encodeURL(params.project, true);
-        return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
-      } else {
-        return `/c/${params.changeNum}${suffix}`;
-      }
-    }
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateGroupUrl(params) {
-      let url = `/admin/groups/${this.encodeURL(params.groupId + '', true)}`;
-      if (params.detail === Gerrit.Nav.GroupDetailView.MEMBERS) {
-        url += ',members';
-      } else if (params.detail === Gerrit.Nav.GroupDetailView.LOG) {
-        url += ',audit-log';
-      }
-      return url;
-    }
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateRepoUrl(params) {
-      let url = `/admin/repos/${this.encodeURL(params.repoName + '', true)}`;
-      if (params.detail === Gerrit.Nav.RepoDetailView.ACCESS) {
-        url += ',access';
-      } else if (params.detail === Gerrit.Nav.RepoDetailView.BRANCHES) {
-        url += ',branches';
-      } else if (params.detail === Gerrit.Nav.RepoDetailView.TAGS) {
-        url += ',tags';
-      } else if (params.detail === Gerrit.Nav.RepoDetailView.COMMANDS) {
-        url += ',commands';
-      } else if (params.detail === Gerrit.Nav.RepoDetailView.DASHBOARDS) {
-        url += ',dashboards';
-      }
-      return url;
-    }
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateSettingsUrl(params) {
-      return '/settings';
-    }
-
-    /**
-     * Given an object of parameters, potentially including a `patchNum` or a
-     * `basePatchNum` or both, return a string representation of that range. If
-     * no range is indicated in the params, the empty string is returned.
-     *
-     * @param {!Object} params
-     * @return {string}
-     */
-    _getPatchRangeExpression(params) {
-      let range = '';
-      if (params.patchNum) { range = '' + params.patchNum; }
-      if (params.basePatchNum) { range = params.basePatchNum + '..' + range; }
-      return range;
-    }
-
-    /**
-     * Given a set of params without a project, gets the project from the rest
-     * API project lookup and then sets the app params.
-     *
-     * @param {?Object} params
-     */
-    _normalizeLegacyRouteParams(params) {
-      if (!params.changeNum) { return Promise.resolve(); }
-
-      return this.$.restAPI.getFromProjectLookup(params.changeNum)
-          .then(project => {
-            // Show a 404 and terminate if the lookup request failed. Attempting
-            // to redirect after failing to get the project loops infinitely.
-            if (!project) {
-              this._show404();
-              return;
-            }
-
-            params.project = project;
-            this._normalizePatchRangeParams(params);
-            this._redirect(this._generateUrl(params));
-          });
-    }
-
-    /**
-     * Normalizes the params object, and determines if the URL needs to be
-     * modified to fit the proper schema.
-     *
-     * @param {*} params
-     * @return {boolean} whether or not the URL needs to be upgraded.
-     */
-    _normalizePatchRangeParams(params) {
-      const hasBasePatchNum = params.basePatchNum !== null &&
-          params.basePatchNum !== undefined;
-      const hasPatchNum = params.patchNum !== null &&
-          params.patchNum !== undefined;
-      let needsRedirect = false;
-
-      // Diffing a patch against itself is invalid, so if the base and revision
-      // patches are equal clear the base.
-      if (hasBasePatchNum &&
-          this.patchNumEquals(params.basePatchNum, params.patchNum)) {
-        needsRedirect = true;
-        params.basePatchNum = null;
-      } else if (hasBasePatchNum && !hasPatchNum) {
-        // Regexes set basePatchNum instead of patchNum when only one is
-        // specified. Redirect is not needed in this case.
-        params.patchNum = params.basePatchNum;
-        params.basePatchNum = null;
-      }
-      return needsRedirect;
-    }
-
-    /**
-     * Redirect the user to login using the given return-URL for redirection
-     * after authentication success.
-     *
-     * @param {string} returnUrl
-     */
-    _redirectToLogin(returnUrl) {
-      const basePath = this.getBaseUrl() || '';
-      page(
-          '/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
-    }
-
-    /**
-     * Hashes parsed by page.js exclude "inner" hashes, so a URL like "/a#b#c"
-     * is parsed to have a hash of "b" rather than "b#c". Instead, this method
-     * parses hashes correctly. Will return an empty string if there is no hash.
-     *
-     * @param {!string} canonicalPath
-     * @return {!string} Everything after the first '#' ("a#b#c" -> "b#c").
-     */
-    _getHashFromCanonicalPath(canonicalPath) {
-      return canonicalPath.split('#').slice(1)
-          .join('#');
-    }
-
-    _parseLineAddress(hash) {
-      const match = hash.match(LINE_ADDRESS_PATTERN);
-      if (!match) { return null; }
-      return {
-        leftSide: !!match[1],
-        lineNum: parseInt(match[2], 10),
-      };
-    }
-
-    /**
-     * Check to see if the user is logged in and return a promise that only
-     * resolves if the user is logged in. If the user us not logged in, the
-     * promise is rejected and the page is redirected to the login flow.
-     *
-     * @param {!Object} data The parsed route data.
-     * @return {!Promise<!Object>} A promise yielding the original route data
-     *     (if it resolves).
-     */
-    _redirectIfNotLoggedIn(data) {
-      return this.$.restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          return Promise.resolve();
-        } else {
-          this._redirectToLogin(data.canonicalPath);
-          return Promise.reject(new Error());
-        }
-      });
-    }
-
-    /**  Page.js middleware that warms the REST API's logged-in cache line. */
-    _loadUserMiddleware(ctx, next) {
-      this.$.restAPI.getLoggedIn().then(() => { next(); });
-    }
-
-    /**
-     * Map a route to a method on the router.
-     *
-     * @param {!string|!RegExp} pattern The page.js pattern for the route.
-     * @param {!string} handlerName The method name for the handler. If the
-     *     route is matched, the handler will be executed with `this` referring
-     *     to the component. Its return value will be discarded so that it does
-     *     not interfere with page.js.
-     * @param  {?boolean=} opt_authRedirect If true, then auth is checked before
-     *     executing the handler. If the user is not logged in, it will redirect
-     *     to the login flow and the handler will not be executed. The login
-     *     redirect specifies the matched URL to be used after successfull auth.
-     */
-    _mapRoute(pattern, handlerName, opt_authRedirect) {
-      if (!this[handlerName]) {
-        console.error('Attempted to map route to unknown method: ',
-            handlerName);
+      if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
+        // Redirect all urls using hash #/x/plugin/screen to /x/plugin/screen
+        // This is needed to allow plugins to add basic #/x/ screen links to
+        // any location.
+        this._redirect(ctx.hash);
         return;
       }
-      page(pattern, this._loadUserMiddleware.bind(this), data => {
-        this.$.reporting.locationChanged(handlerName);
-        const promise = opt_authRedirect ?
-          this._redirectIfNotLoggedIn(data) : Promise.resolve();
-        promise.then(() => { this[handlerName](data); });
-      });
-    }
 
-    _startRouter() {
+      // Fire asynchronously so that the URL is changed by the time the event
+      // is processed.
+      this.async(() => {
+        this.fire('location-change', {
+          hash: window.location.hash,
+          pathname: window.location.pathname,
+        });
+      }, 1);
+      next();
+    });
+
+    this._mapRoute(RoutePattern.ROOT, '_handleRootRoute');
+
+    this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');
+
+    this._mapRoute(RoutePattern.CUSTOM_DASHBOARD,
+        '_handleCustomDashboardRoute');
+
+    this._mapRoute(RoutePattern.PROJECT_DASHBOARD,
+        '_handleProjectDashboardRoute');
+
+    this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
+
+    this._mapRoute(RoutePattern.GROUP_AUDIT_LOG, '_handleGroupAuditLogRoute',
+        true);
+
+    this._mapRoute(RoutePattern.GROUP_MEMBERS, '_handleGroupMembersRoute',
+        true);
+
+    this._mapRoute(RoutePattern.GROUP_LIST_OFFSET,
+        '_handleGroupListOffsetRoute', true);
+
+    this._mapRoute(RoutePattern.GROUP_LIST_FILTER_OFFSET,
+        '_handleGroupListFilterOffsetRoute', true);
+
+    this._mapRoute(RoutePattern.GROUP_LIST_FILTER,
+        '_handleGroupListFilterRoute', true);
+
+    this._mapRoute(RoutePattern.GROUP_SELF, '_handleGroupSelfRedirectRoute',
+        true);
+
+    this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
+
+    this._mapRoute(RoutePattern.PROJECT_OLD,
+        '_handleProjectsOldRoute');
+
+    this._mapRoute(RoutePattern.REPO_COMMANDS,
+        '_handleRepoCommandsRoute', true);
+
+    this._mapRoute(RoutePattern.REPO_ACCESS,
+        '_handleRepoAccessRoute');
+
+    this._mapRoute(RoutePattern.REPO_DASHBOARDS,
+        '_handleRepoDashboardsRoute');
+
+    this._mapRoute(RoutePattern.BRANCH_LIST_OFFSET,
+        '_handleBranchListOffsetRoute');
+
+    this._mapRoute(RoutePattern.BRANCH_LIST_FILTER_OFFSET,
+        '_handleBranchListFilterOffsetRoute');
+
+    this._mapRoute(RoutePattern.BRANCH_LIST_FILTER,
+        '_handleBranchListFilterRoute');
+
+    this._mapRoute(RoutePattern.TAG_LIST_OFFSET,
+        '_handleTagListOffsetRoute');
+
+    this._mapRoute(RoutePattern.TAG_LIST_FILTER_OFFSET,
+        '_handleTagListFilterOffsetRoute');
+
+    this._mapRoute(RoutePattern.TAG_LIST_FILTER,
+        '_handleTagListFilterRoute');
+
+    this._mapRoute(RoutePattern.LEGACY_CREATE_GROUP,
+        '_handleCreateGroupRoute', true);
+
+    this._mapRoute(RoutePattern.LEGACY_CREATE_PROJECT,
+        '_handleCreateProjectRoute', true);
+
+    this._mapRoute(RoutePattern.REPO_LIST_OFFSET,
+        '_handleRepoListOffsetRoute');
+
+    this._mapRoute(RoutePattern.REPO_LIST_FILTER_OFFSET,
+        '_handleRepoListFilterOffsetRoute');
+
+    this._mapRoute(RoutePattern.REPO_LIST_FILTER,
+        '_handleRepoListFilterRoute');
+
+    this._mapRoute(RoutePattern.REPO, '_handleRepoRoute');
+
+    this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
+
+    this._mapRoute(RoutePattern.PLUGIN_LIST_OFFSET,
+        '_handlePluginListOffsetRoute', true);
+
+    this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
+        '_handlePluginListFilterOffsetRoute', true);
+
+    this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER,
+        '_handlePluginListFilterRoute', true);
+
+    this._mapRoute(RoutePattern.PLUGIN_LIST, '_handlePluginListRoute', true);
+
+    this._mapRoute(RoutePattern.QUERY_LEGACY_SUFFIX,
+        '_handleQueryLegacySuffixRoute');
+
+    this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
+
+    this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
+
+    this._mapRoute(RoutePattern.CHANGE_NUMBER_LEGACY,
+        '_handleChangeNumberLegacyRoute');
+
+    this._mapRoute(RoutePattern.DIFF_EDIT, '_handleDiffEditRoute', true);
+
+    this._mapRoute(RoutePattern.CHANGE_EDIT, '_handleChangeEditRoute', true);
+
+    this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
+
+    this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
+
+    this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
+
+    this._mapRoute(RoutePattern.DIFF_LEGACY, '_handleDiffLegacyRoute');
+
+    this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
+
+    this._mapRoute(RoutePattern.NEW_AGREEMENTS, '_handleNewAgreementsRoute',
+        true);
+
+    this._mapRoute(RoutePattern.SETTINGS_LEGACY,
+        '_handleSettingsLegacyRoute', true);
+
+    this._mapRoute(RoutePattern.SETTINGS, '_handleSettingsRoute', true);
+
+    this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');
+
+    this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');
+
+    this._mapRoute(RoutePattern.IMPROPERLY_ENCODED_PLUS,
+        '_handleImproperlyEncodedPlusRoute');
+
+    this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
+
+    this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH_FILTER,
+        '_handleDocumentationSearchRoute');
+
+    // redirects /Documentation/q/* to /Documentation/q/filter:*
+    this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH,
+        '_handleDocumentationSearchRedirectRoute');
+
+    // Makes sure /Documentation/* links work (doin't return 404)
+    this._mapRoute(RoutePattern.DOCUMENTATION,
+        '_handleDocumentationRedirectRoute');
+
+    // Note: this route should appear last so it only catches URLs unmatched
+    // by other patterns.
+    this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
+
+    page.start();
+  }
+
+  /**
+   * @param {!Object} data
+   * @return {Promise|null} if handling the route involves asynchrony, then a
+   *     promise is returned. Otherwise, synchronous handling returns null.
+   */
+  _handleRootRoute(data) {
+    if (data.querystring.match(/^closeAfterLogin/)) {
+      // Close child window on redirect after login.
+      window.close();
+      return null;
+    }
+    let hash = this._getHashFromCanonicalPath(data.canonicalPath);
+    // For backward compatibility with GWT links.
+    if (hash) {
+      // In certain login flows the server may redirect to a hash without
+      // a leading slash, which page.js doesn't handle correctly.
+      if (hash[0] !== '/') {
+        hash = '/' + hash;
+      }
+      if (hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
+        // Path decodes all '+' to ' ' -- this breaks project-based URLs.
+        // See Issue 6888.
+        hash = hash.replace('/ /', '/+/');
+      }
       const base = this.getBaseUrl();
-      if (base) {
-        page.base(base);
+      let newUrl = base + hash;
+      if (hash.startsWith('/VE/')) {
+        newUrl = base + '/settings' + hash;
       }
-
-      Gerrit.Nav.setup(
-          url => { page.show(url); },
-          this._generateUrl.bind(this),
-          params => this._generateWeblinks(params),
-          x => x
-      );
-
-      page.exit('*', (ctx, next) => {
-        if (!this._isRedirecting) {
-          this.$.reporting.beforeLocationChanged();
-        }
-        this._isRedirecting = false;
-        this._isInitialLoad = false;
-        next();
-      });
-
-      // Middleware
-      page((ctx, next) => {
-        document.body.scrollTop = 0;
-
-        if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
-          // Redirect all urls using hash #/x/plugin/screen to /x/plugin/screen
-          // This is needed to allow plugins to add basic #/x/ screen links to
-          // any location.
-          this._redirect(ctx.hash);
-          return;
-        }
-
-        // Fire asynchronously so that the URL is changed by the time the event
-        // is processed.
-        this.async(() => {
-          this.fire('location-change', {
-            hash: window.location.hash,
-            pathname: window.location.pathname,
-          });
-        }, 1);
-        next();
-      });
-
-      this._mapRoute(RoutePattern.ROOT, '_handleRootRoute');
-
-      this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');
-
-      this._mapRoute(RoutePattern.CUSTOM_DASHBOARD,
-          '_handleCustomDashboardRoute');
-
-      this._mapRoute(RoutePattern.PROJECT_DASHBOARD,
-          '_handleProjectDashboardRoute');
-
-      this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
-
-      this._mapRoute(RoutePattern.GROUP_AUDIT_LOG, '_handleGroupAuditLogRoute',
-          true);
-
-      this._mapRoute(RoutePattern.GROUP_MEMBERS, '_handleGroupMembersRoute',
-          true);
-
-      this._mapRoute(RoutePattern.GROUP_LIST_OFFSET,
-          '_handleGroupListOffsetRoute', true);
-
-      this._mapRoute(RoutePattern.GROUP_LIST_FILTER_OFFSET,
-          '_handleGroupListFilterOffsetRoute', true);
-
-      this._mapRoute(RoutePattern.GROUP_LIST_FILTER,
-          '_handleGroupListFilterRoute', true);
-
-      this._mapRoute(RoutePattern.GROUP_SELF, '_handleGroupSelfRedirectRoute',
-          true);
-
-      this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
-
-      this._mapRoute(RoutePattern.PROJECT_OLD,
-          '_handleProjectsOldRoute');
-
-      this._mapRoute(RoutePattern.REPO_COMMANDS,
-          '_handleRepoCommandsRoute', true);
-
-      this._mapRoute(RoutePattern.REPO_ACCESS,
-          '_handleRepoAccessRoute');
-
-      this._mapRoute(RoutePattern.REPO_DASHBOARDS,
-          '_handleRepoDashboardsRoute');
-
-      this._mapRoute(RoutePattern.BRANCH_LIST_OFFSET,
-          '_handleBranchListOffsetRoute');
-
-      this._mapRoute(RoutePattern.BRANCH_LIST_FILTER_OFFSET,
-          '_handleBranchListFilterOffsetRoute');
-
-      this._mapRoute(RoutePattern.BRANCH_LIST_FILTER,
-          '_handleBranchListFilterRoute');
-
-      this._mapRoute(RoutePattern.TAG_LIST_OFFSET,
-          '_handleTagListOffsetRoute');
-
-      this._mapRoute(RoutePattern.TAG_LIST_FILTER_OFFSET,
-          '_handleTagListFilterOffsetRoute');
-
-      this._mapRoute(RoutePattern.TAG_LIST_FILTER,
-          '_handleTagListFilterRoute');
-
-      this._mapRoute(RoutePattern.LEGACY_CREATE_GROUP,
-          '_handleCreateGroupRoute', true);
-
-      this._mapRoute(RoutePattern.LEGACY_CREATE_PROJECT,
-          '_handleCreateProjectRoute', true);
-
-      this._mapRoute(RoutePattern.REPO_LIST_OFFSET,
-          '_handleRepoListOffsetRoute');
-
-      this._mapRoute(RoutePattern.REPO_LIST_FILTER_OFFSET,
-          '_handleRepoListFilterOffsetRoute');
-
-      this._mapRoute(RoutePattern.REPO_LIST_FILTER,
-          '_handleRepoListFilterRoute');
-
-      this._mapRoute(RoutePattern.REPO, '_handleRepoRoute');
-
-      this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
-
-      this._mapRoute(RoutePattern.PLUGIN_LIST_OFFSET,
-          '_handlePluginListOffsetRoute', true);
-
-      this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
-          '_handlePluginListFilterOffsetRoute', true);
-
-      this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER,
-          '_handlePluginListFilterRoute', true);
-
-      this._mapRoute(RoutePattern.PLUGIN_LIST, '_handlePluginListRoute', true);
-
-      this._mapRoute(RoutePattern.QUERY_LEGACY_SUFFIX,
-          '_handleQueryLegacySuffixRoute');
-
-      this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
-
-      this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
-
-      this._mapRoute(RoutePattern.CHANGE_NUMBER_LEGACY,
-          '_handleChangeNumberLegacyRoute');
-
-      this._mapRoute(RoutePattern.DIFF_EDIT, '_handleDiffEditRoute', true);
-
-      this._mapRoute(RoutePattern.CHANGE_EDIT, '_handleChangeEditRoute', true);
-
-      this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
-
-      this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
-
-      this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
-
-      this._mapRoute(RoutePattern.DIFF_LEGACY, '_handleDiffLegacyRoute');
-
-      this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
-
-      this._mapRoute(RoutePattern.NEW_AGREEMENTS, '_handleNewAgreementsRoute',
-          true);
-
-      this._mapRoute(RoutePattern.SETTINGS_LEGACY,
-          '_handleSettingsLegacyRoute', true);
-
-      this._mapRoute(RoutePattern.SETTINGS, '_handleSettingsRoute', true);
-
-      this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');
-
-      this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');
-
-      this._mapRoute(RoutePattern.IMPROPERLY_ENCODED_PLUS,
-          '_handleImproperlyEncodedPlusRoute');
-
-      this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
-
-      this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH_FILTER,
-          '_handleDocumentationSearchRoute');
-
-      // redirects /Documentation/q/* to /Documentation/q/filter:*
-      this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH,
-          '_handleDocumentationSearchRedirectRoute');
-
-      // Makes sure /Documentation/* links work (doin't return 404)
-      this._mapRoute(RoutePattern.DOCUMENTATION,
-          '_handleDocumentationRedirectRoute');
-
-      // Note: this route should appear last so it only catches URLs unmatched
-      // by other patterns.
-      this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
-
-      page.start();
+      this._redirect(newUrl);
+      return null;
     }
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        this._redirect('/dashboard/self');
+      } else {
+        this._redirect('/q/status:open');
+      }
+    });
+  }
 
-    /**
-     * @param {!Object} data
-     * @return {Promise|null} if handling the route involves asynchrony, then a
-     *     promise is returned. Otherwise, synchronous handling returns null.
-     */
-    _handleRootRoute(data) {
-      if (data.querystring.match(/^closeAfterLogin/)) {
-        // Close child window on redirect after login.
-        window.close();
-        return null;
+  /**
+   * Decode an application/x-www-form-urlencoded string.
+   *
+   * @param {string} qs The application/x-www-form-urlencoded string.
+   * @return {string} The decoded string.
+   */
+  _decodeQueryString(qs) {
+    return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
+  }
+
+  /**
+   * Parse a query string (e.g. window.location.search) into an array of
+   * name/value pairs.
+   *
+   * @param {string} qs The application/x-www-form-urlencoded query string.
+   * @return {!Array<!Array<string>>} An array of name/value pairs, where each
+   *     element is a 2-element array.
+   */
+  _parseQueryString(qs) {
+    qs = qs.replace(QUESTION_PATTERN, '');
+    if (!qs) {
+      return [];
+    }
+    const params = [];
+    qs.split('&').forEach(param => {
+      const idx = param.indexOf('=');
+      let name;
+      let value;
+      if (idx < 0) {
+        name = this._decodeQueryString(param);
+        value = '';
+      } else {
+        name = this._decodeQueryString(param.substring(0, idx));
+        value = this._decodeQueryString(param.substring(idx + 1));
       }
-      let hash = this._getHashFromCanonicalPath(data.canonicalPath);
-      // For backward compatibility with GWT links.
-      if (hash) {
-        // In certain login flows the server may redirect to a hash without
-        // a leading slash, which page.js doesn't handle correctly.
-        if (hash[0] !== '/') {
-          hash = '/' + hash;
-        }
-        if (hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
-          // Path decodes all '+' to ' ' -- this breaks project-based URLs.
-          // See Issue 6888.
-          hash = hash.replace('/ /', '/+/');
-        }
-        const base = this.getBaseUrl();
-        let newUrl = base + hash;
-        if (hash.startsWith('/VE/')) {
-          newUrl = base + '/settings' + hash;
-        }
-        this._redirect(newUrl);
-        return null;
+      if (name) {
+        params.push([name, value]);
       }
-      return this.$.restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          this._redirect('/dashboard/self');
+    });
+    return params;
+  }
+
+  /**
+   * Handle dashboard routes. These may be user, or project dashboards.
+   *
+   * @param {!Object} data The parsed route data.
+   */
+  _handleDashboardRoute(data) {
+    // User dashboard. We require viewing user to be logged in, else we
+    // redirect to login for self dashboard or simple owner search for
+    // other user dashboard.
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) {
+        if (data.params[0].toLowerCase() === 'self') {
+          this._redirectToLogin(data.canonicalPath);
         } else {
-          this._redirect('/q/status:open');
+          this._redirect('/q/owner:' + encodeURIComponent(data.params[0]));
         }
-      });
-    }
-
-    /**
-     * Decode an application/x-www-form-urlencoded string.
-     *
-     * @param {string} qs The application/x-www-form-urlencoded string.
-     * @return {string} The decoded string.
-     */
-    _decodeQueryString(qs) {
-      return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
-    }
-
-    /**
-     * Parse a query string (e.g. window.location.search) into an array of
-     * name/value pairs.
-     *
-     * @param {string} qs The application/x-www-form-urlencoded query string.
-     * @return {!Array<!Array<string>>} An array of name/value pairs, where each
-     *     element is a 2-element array.
-     */
-    _parseQueryString(qs) {
-      qs = qs.replace(QUESTION_PATTERN, '');
-      if (!qs) {
-        return [];
-      }
-      const params = [];
-      qs.split('&').forEach(param => {
-        const idx = param.indexOf('=');
-        let name;
-        let value;
-        if (idx < 0) {
-          name = this._decodeQueryString(param);
-          value = '';
-        } else {
-          name = this._decodeQueryString(param.substring(0, idx));
-          value = this._decodeQueryString(param.substring(idx + 1));
-        }
-        if (name) {
-          params.push([name, value]);
-        }
-      });
-      return params;
-    }
-
-    /**
-     * Handle dashboard routes. These may be user, or project dashboards.
-     *
-     * @param {!Object} data The parsed route data.
-     */
-    _handleDashboardRoute(data) {
-      // User dashboard. We require viewing user to be logged in, else we
-      // redirect to login for self dashboard or simple owner search for
-      // other user dashboard.
-      return this.$.restAPI.getLoggedIn().then(loggedIn => {
-        if (!loggedIn) {
-          if (data.params[0].toLowerCase() === 'self') {
-            this._redirectToLogin(data.canonicalPath);
-          } else {
-            this._redirect('/q/owner:' + encodeURIComponent(data.params[0]));
-          }
-        } else {
-          this._setParams({
-            view: Gerrit.Nav.View.DASHBOARD,
-            user: data.params[0],
-          });
-        }
-      });
-    }
-
-    /**
-     * Handle custom dashboard routes.
-     *
-     * @param {!Object} data The parsed route data.
-     * @param {string=} opt_qs Optional query string associated with the route.
-     *     If not given, window.location.search is used. (Used by tests).
-     */
-    _handleCustomDashboardRoute(data, opt_qs) {
-      // opt_qs may be provided by a test, and it may have a falsy value
-      const qs = opt_qs !== undefined ? opt_qs : window.location.search;
-      const queryParams = this._parseQueryString(qs);
-      let title = 'Custom Dashboard';
-      const titleParam = queryParams.find(
-          elem => elem[0].toLowerCase() === 'title');
-      if (titleParam) {
-        title = titleParam[1];
-      }
-      // Dashboards support a foreach param which adds a base query to any
-      // additional query.
-      const forEachParam = queryParams.find(
-          elem => elem[0].toLowerCase() === 'foreach');
-      let forEachQuery = null;
-      if (forEachParam) {
-        forEachQuery = forEachParam[1];
-      }
-      const sectionParams = queryParams.filter(
-          elem => elem[0] && elem[1] && elem[0].toLowerCase() !== 'title' &&
-          elem[0].toLowerCase() !== 'foreach');
-      const sections = sectionParams.map(elem => {
-        const query = forEachQuery ? `${forEachQuery} ${elem[1]}` : elem[1];
-        return {
-          name: elem[0],
-          query,
-        };
-      });
-
-      if (sections.length > 0) {
-        // Custom dashboard view.
+      } else {
         this._setParams({
           view: Gerrit.Nav.View.DASHBOARD,
-          user: 'self',
-          sections,
-          title,
+          user: data.params[0],
         });
-        return Promise.resolve();
       }
+    });
+  }
 
-      // Redirect /dashboard/ -> /dashboard/self.
-      this._redirect('/dashboard/self');
+  /**
+   * Handle custom dashboard routes.
+   *
+   * @param {!Object} data The parsed route data.
+   * @param {string=} opt_qs Optional query string associated with the route.
+   *     If not given, window.location.search is used. (Used by tests).
+   */
+  _handleCustomDashboardRoute(data, opt_qs) {
+    // opt_qs may be provided by a test, and it may have a falsy value
+    const qs = opt_qs !== undefined ? opt_qs : window.location.search;
+    const queryParams = this._parseQueryString(qs);
+    let title = 'Custom Dashboard';
+    const titleParam = queryParams.find(
+        elem => elem[0].toLowerCase() === 'title');
+    if (titleParam) {
+      title = titleParam[1];
+    }
+    // Dashboards support a foreach param which adds a base query to any
+    // additional query.
+    const forEachParam = queryParams.find(
+        elem => elem[0].toLowerCase() === 'foreach');
+    let forEachQuery = null;
+    if (forEachParam) {
+      forEachQuery = forEachParam[1];
+    }
+    const sectionParams = queryParams.filter(
+        elem => elem[0] && elem[1] && elem[0].toLowerCase() !== 'title' &&
+        elem[0].toLowerCase() !== 'foreach');
+    const sections = sectionParams.map(elem => {
+      const query = forEachQuery ? `${forEachQuery} ${elem[1]}` : elem[1];
+      return {
+        name: elem[0],
+        query,
+      };
+    });
+
+    if (sections.length > 0) {
+      // Custom dashboard view.
+      this._setParams({
+        view: Gerrit.Nav.View.DASHBOARD,
+        user: 'self',
+        sections,
+        title,
+      });
       return Promise.resolve();
     }
 
-    _handleProjectDashboardRoute(data) {
-      const project = data.params[0];
-      this._setParams({
-        view: Gerrit.Nav.View.DASHBOARD,
-        project,
-        dashboard: decodeURIComponent(data.params[1]),
-      });
-      this.$.reporting.setRepoName(project);
-    }
+    // Redirect /dashboard/ -> /dashboard/self.
+    this._redirect('/dashboard/self');
+    return Promise.resolve();
+  }
 
-    _handleGroupInfoRoute(data) {
-      this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
-    }
+  _handleProjectDashboardRoute(data) {
+    const project = data.params[0];
+    this._setParams({
+      view: Gerrit.Nav.View.DASHBOARD,
+      project,
+      dashboard: decodeURIComponent(data.params[1]),
+    });
+    this.$.reporting.setRepoName(project);
+  }
 
-    _handleGroupSelfRedirectRoute(data) {
-      this._redirect('/settings/#Groups');
-    }
+  _handleGroupInfoRoute(data) {
+    this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
+  }
 
-    _handleGroupRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.GROUP,
-        groupId: data.params[0],
-      });
-    }
+  _handleGroupSelfRedirectRoute(data) {
+    this._redirect('/settings/#Groups');
+  }
 
-    _handleGroupAuditLogRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.GROUP,
-        detail: Gerrit.Nav.GroupDetailView.LOG,
-        groupId: data.params[0],
-      });
-    }
+  _handleGroupRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.GROUP,
+      groupId: data.params[0],
+    });
+  }
 
-    _handleGroupMembersRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.GROUP,
-        detail: Gerrit.Nav.GroupDetailView.MEMBERS,
-        groupId: data.params[0],
-      });
-    }
+  _handleGroupAuditLogRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.GROUP,
+      detail: Gerrit.Nav.GroupDetailView.LOG,
+      groupId: data.params[0],
+    });
+  }
 
-    _handleGroupListOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-admin-group-list',
-        offset: data.params[1] || 0,
-        filter: null,
-        openCreateModal: data.hash === 'create',
-      });
-    }
+  _handleGroupMembersRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.GROUP,
+      detail: Gerrit.Nav.GroupDetailView.MEMBERS,
+      groupId: data.params[0],
+    });
+  }
 
-    _handleGroupListFilterOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-admin-group-list',
-        offset: data.params.offset,
-        filter: data.params.filter,
-      });
-    }
+  _handleGroupListOffsetRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.ADMIN,
+      adminView: 'gr-admin-group-list',
+      offset: data.params[1] || 0,
+      filter: null,
+      openCreateModal: data.hash === 'create',
+    });
+  }
 
-    _handleGroupListFilterRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-admin-group-list',
-        filter: data.params.filter || null,
-      });
-    }
+  _handleGroupListFilterOffsetRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.ADMIN,
+      adminView: 'gr-admin-group-list',
+      offset: data.params.offset,
+      filter: data.params.filter,
+    });
+  }
 
-    _handleProjectsOldRoute(data) {
-      let params = '';
-      if (data.params[1]) {
-        params = encodeURIComponent(data.params[1]);
-        if (data.params[1].includes(',')) {
-          params =
-              encodeURIComponent(data.params[1]).replace('%2C', ',');
-        }
-      }
+  _handleGroupListFilterRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.ADMIN,
+      adminView: 'gr-admin-group-list',
+      filter: data.params.filter || null,
+    });
+  }
 
-      this._redirect(`/admin/repos/${params}`);
-    }
-
-    _handleRepoCommandsRoute(data) {
-      const repo = data.params[0];
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.COMMANDS,
-        repo,
-      });
-      this.$.reporting.setRepoName(repo);
-    }
-
-    _handleRepoAccessRoute(data) {
-      const repo = data.params[0];
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.ACCESS,
-        repo,
-      });
-      this.$.reporting.setRepoName(repo);
-    }
-
-    _handleRepoDashboardsRoute(data) {
-      const repo = data.params[0];
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.DASHBOARDS,
-        repo,
-      });
-      this.$.reporting.setRepoName(repo);
-    }
-
-    _handleBranchListOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-        repo: data.params[0],
-        offset: data.params[2] || 0,
-        filter: null,
-      });
-    }
-
-    _handleBranchListFilterOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-        repo: data.params.repo,
-        offset: data.params.offset,
-        filter: data.params.filter,
-      });
-    }
-
-    _handleBranchListFilterRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-        repo: data.params.repo,
-        filter: data.params.filter || null,
-      });
-    }
-
-    _handleTagListOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.TAGS,
-        repo: data.params[0],
-        offset: data.params[2] || 0,
-        filter: null,
-      });
-    }
-
-    _handleTagListFilterOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.TAGS,
-        repo: data.params.repo,
-        offset: data.params.offset,
-        filter: data.params.filter,
-      });
-    }
-
-    _handleTagListFilterRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.TAGS,
-        repo: data.params.repo,
-        filter: data.params.filter || null,
-      });
-    }
-
-    _handleRepoListOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-repo-list',
-        offset: data.params[1] || 0,
-        filter: null,
-        openCreateModal: data.hash === 'create',
-      });
-    }
-
-    _handleRepoListFilterOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-repo-list',
-        offset: data.params.offset,
-        filter: data.params.filter,
-      });
-    }
-
-    _handleRepoListFilterRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-repo-list',
-        filter: data.params.filter || null,
-      });
-    }
-
-    _handleCreateProjectRoute(data) {
-      // Redirects the legacy route to the new route, which displays the project
-      // list with a hash 'create'.
-      this._redirect('/admin/repos#create');
-    }
-
-    _handleCreateGroupRoute(data) {
-      // Redirects the legacy route to the new route, which displays the group
-      // list with a hash 'create'.
-      this._redirect('/admin/groups#create');
-    }
-
-    _handleRepoRoute(data) {
-      const repo = data.params[0];
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        repo,
-      });
-      this.$.reporting.setRepoName(repo);
-    }
-
-    _handlePluginListOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-plugin-list',
-        offset: data.params[1] || 0,
-        filter: null,
-      });
-    }
-
-    _handlePluginListFilterOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-plugin-list',
-        offset: data.params.offset,
-        filter: data.params.filter,
-      });
-    }
-
-    _handlePluginListFilterRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-plugin-list',
-        filter: data.params.filter || null,
-      });
-    }
-
-    _handlePluginListRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-plugin-list',
-      });
-    }
-
-    _handleQueryRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.SEARCH,
-        query: data.params[0],
-        offset: data.params[2],
-      });
-    }
-
-    _handleQueryLegacySuffixRoute(ctx) {
-      this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
-    }
-
-    _handleChangeNumberLegacyRoute(ctx) {
-      this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
-    }
-
-    _handleChangeRoute(ctx) {
-      // Parameter order is based on the regex group number matched.
-      const params = {
-        project: ctx.params[0],
-        changeNum: ctx.params[1],
-        basePatchNum: ctx.params[4],
-        patchNum: ctx.params[6],
-        view: Gerrit.Nav.View.CHANGE,
-      };
-
-      this.$.reporting.setRepoName(params.project);
-      this._redirectOrNavigate(params);
-    }
-
-    _handleDiffRoute(ctx) {
-      // Parameter order is based on the regex group number matched.
-      const params = {
-        project: ctx.params[0],
-        changeNum: ctx.params[1],
-        basePatchNum: ctx.params[4],
-        patchNum: ctx.params[6],
-        path: ctx.params[8],
-        view: Gerrit.Nav.View.DIFF,
-      };
-
-      const address = this._parseLineAddress(ctx.hash);
-      if (address) {
-        params.leftSide = address.leftSide;
-        params.lineNum = address.lineNum;
-      }
-      this.$.reporting.setRepoName(params.project);
-      this._redirectOrNavigate(params);
-    }
-
-    _handleChangeLegacyRoute(ctx) {
-      // Parameter order is based on the regex group number matched.
-      const params = {
-        changeNum: ctx.params[0],
-        basePatchNum: ctx.params[3],
-        patchNum: ctx.params[5],
-        view: Gerrit.Nav.View.CHANGE,
-        querystring: ctx.querystring,
-      };
-
-      this._normalizeLegacyRouteParams(params);
-    }
-
-    _handleLegacyLinenum(ctx) {
-      this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
-    }
-
-    _handleDiffLegacyRoute(ctx) {
-      // Parameter order is based on the regex group number matched.
-      const params = {
-        changeNum: ctx.params[0],
-        basePatchNum: ctx.params[2],
-        patchNum: ctx.params[4],
-        path: ctx.params[5],
-        view: Gerrit.Nav.View.DIFF,
-      };
-
-      const address = this._parseLineAddress(ctx.hash);
-      if (address) {
-        params.leftSide = address.leftSide;
-        params.lineNum = address.lineNum;
-      }
-
-      this._normalizeLegacyRouteParams(params);
-    }
-
-    _handleDiffEditRoute(ctx) {
-      // Parameter order is based on the regex group number matched.
-      const project = ctx.params[0];
-      this._redirectOrNavigate({
-        project,
-        changeNum: ctx.params[1],
-        patchNum: ctx.params[2],
-        path: ctx.params[3],
-        lineNum: ctx.hash,
-        view: Gerrit.Nav.View.EDIT,
-      });
-      this.$.reporting.setRepoName(project);
-    }
-
-    _handleChangeEditRoute(ctx) {
-      // Parameter order is based on the regex group number matched.
-      const project = ctx.params[0];
-      this._redirectOrNavigate({
-        project,
-        changeNum: ctx.params[1],
-        patchNum: ctx.params[3],
-        view: Gerrit.Nav.View.CHANGE,
-        edit: true,
-      });
-      this.$.reporting.setRepoName(project);
-    }
-
-    /**
-     * Normalize the patch range params for a the change or diff view and
-     * redirect if URL upgrade is needed.
-     */
-    _redirectOrNavigate(params) {
-      const needsRedirect = this._normalizePatchRangeParams(params);
-      if (needsRedirect) {
-        this._redirect(this._generateUrl(params));
-      } else {
-        this._setParams(params);
+  _handleProjectsOldRoute(data) {
+    let params = '';
+    if (data.params[1]) {
+      params = encodeURIComponent(data.params[1]);
+      if (data.params[1].includes(',')) {
+        params =
+            encodeURIComponent(data.params[1]).replace('%2C', ',');
       }
     }
 
-    _handleAgreementsRoute() {
-      this._redirect('/settings/#Agreements');
+    this._redirect(`/admin/repos/${params}`);
+  }
+
+  _handleRepoCommandsRoute(data) {
+    const repo = data.params[0];
+    this._setParams({
+      view: Gerrit.Nav.View.REPO,
+      detail: Gerrit.Nav.RepoDetailView.COMMANDS,
+      repo,
+    });
+    this.$.reporting.setRepoName(repo);
+  }
+
+  _handleRepoAccessRoute(data) {
+    const repo = data.params[0];
+    this._setParams({
+      view: Gerrit.Nav.View.REPO,
+      detail: Gerrit.Nav.RepoDetailView.ACCESS,
+      repo,
+    });
+    this.$.reporting.setRepoName(repo);
+  }
+
+  _handleRepoDashboardsRoute(data) {
+    const repo = data.params[0];
+    this._setParams({
+      view: Gerrit.Nav.View.REPO,
+      detail: Gerrit.Nav.RepoDetailView.DASHBOARDS,
+      repo,
+    });
+    this.$.reporting.setRepoName(repo);
+  }
+
+  _handleBranchListOffsetRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.REPO,
+      detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+      repo: data.params[0],
+      offset: data.params[2] || 0,
+      filter: null,
+    });
+  }
+
+  _handleBranchListFilterOffsetRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.REPO,
+      detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+      repo: data.params.repo,
+      offset: data.params.offset,
+      filter: data.params.filter,
+    });
+  }
+
+  _handleBranchListFilterRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.REPO,
+      detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+      repo: data.params.repo,
+      filter: data.params.filter || null,
+    });
+  }
+
+  _handleTagListOffsetRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.REPO,
+      detail: Gerrit.Nav.RepoDetailView.TAGS,
+      repo: data.params[0],
+      offset: data.params[2] || 0,
+      filter: null,
+    });
+  }
+
+  _handleTagListFilterOffsetRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.REPO,
+      detail: Gerrit.Nav.RepoDetailView.TAGS,
+      repo: data.params.repo,
+      offset: data.params.offset,
+      filter: data.params.filter,
+    });
+  }
+
+  _handleTagListFilterRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.REPO,
+      detail: Gerrit.Nav.RepoDetailView.TAGS,
+      repo: data.params.repo,
+      filter: data.params.filter || null,
+    });
+  }
+
+  _handleRepoListOffsetRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.ADMIN,
+      adminView: 'gr-repo-list',
+      offset: data.params[1] || 0,
+      filter: null,
+      openCreateModal: data.hash === 'create',
+    });
+  }
+
+  _handleRepoListFilterOffsetRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.ADMIN,
+      adminView: 'gr-repo-list',
+      offset: data.params.offset,
+      filter: data.params.filter,
+    });
+  }
+
+  _handleRepoListFilterRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.ADMIN,
+      adminView: 'gr-repo-list',
+      filter: data.params.filter || null,
+    });
+  }
+
+  _handleCreateProjectRoute(data) {
+    // Redirects the legacy route to the new route, which displays the project
+    // list with a hash 'create'.
+    this._redirect('/admin/repos#create');
+  }
+
+  _handleCreateGroupRoute(data) {
+    // Redirects the legacy route to the new route, which displays the group
+    // list with a hash 'create'.
+    this._redirect('/admin/groups#create');
+  }
+
+  _handleRepoRoute(data) {
+    const repo = data.params[0];
+    this._setParams({
+      view: Gerrit.Nav.View.REPO,
+      repo,
+    });
+    this.$.reporting.setRepoName(repo);
+  }
+
+  _handlePluginListOffsetRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.ADMIN,
+      adminView: 'gr-plugin-list',
+      offset: data.params[1] || 0,
+      filter: null,
+    });
+  }
+
+  _handlePluginListFilterOffsetRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.ADMIN,
+      adminView: 'gr-plugin-list',
+      offset: data.params.offset,
+      filter: data.params.filter,
+    });
+  }
+
+  _handlePluginListFilterRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.ADMIN,
+      adminView: 'gr-plugin-list',
+      filter: data.params.filter || null,
+    });
+  }
+
+  _handlePluginListRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.ADMIN,
+      adminView: 'gr-plugin-list',
+    });
+  }
+
+  _handleQueryRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.SEARCH,
+      query: data.params[0],
+      offset: data.params[2],
+    });
+  }
+
+  _handleQueryLegacySuffixRoute(ctx) {
+    this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
+  }
+
+  _handleChangeNumberLegacyRoute(ctx) {
+    this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
+  }
+
+  _handleChangeRoute(ctx) {
+    // Parameter order is based on the regex group number matched.
+    const params = {
+      project: ctx.params[0],
+      changeNum: ctx.params[1],
+      basePatchNum: ctx.params[4],
+      patchNum: ctx.params[6],
+      view: Gerrit.Nav.View.CHANGE,
+    };
+
+    this.$.reporting.setRepoName(params.project);
+    this._redirectOrNavigate(params);
+  }
+
+  _handleDiffRoute(ctx) {
+    // Parameter order is based on the regex group number matched.
+    const params = {
+      project: ctx.params[0],
+      changeNum: ctx.params[1],
+      basePatchNum: ctx.params[4],
+      patchNum: ctx.params[6],
+      path: ctx.params[8],
+      view: Gerrit.Nav.View.DIFF,
+    };
+
+    const address = this._parseLineAddress(ctx.hash);
+    if (address) {
+      params.leftSide = address.leftSide;
+      params.lineNum = address.lineNum;
+    }
+    this.$.reporting.setRepoName(params.project);
+    this._redirectOrNavigate(params);
+  }
+
+  _handleChangeLegacyRoute(ctx) {
+    // Parameter order is based on the regex group number matched.
+    const params = {
+      changeNum: ctx.params[0],
+      basePatchNum: ctx.params[3],
+      patchNum: ctx.params[5],
+      view: Gerrit.Nav.View.CHANGE,
+      querystring: ctx.querystring,
+    };
+
+    this._normalizeLegacyRouteParams(params);
+  }
+
+  _handleLegacyLinenum(ctx) {
+    this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
+  }
+
+  _handleDiffLegacyRoute(ctx) {
+    // Parameter order is based on the regex group number matched.
+    const params = {
+      changeNum: ctx.params[0],
+      basePatchNum: ctx.params[2],
+      patchNum: ctx.params[4],
+      path: ctx.params[5],
+      view: Gerrit.Nav.View.DIFF,
+    };
+
+    const address = this._parseLineAddress(ctx.hash);
+    if (address) {
+      params.leftSide = address.leftSide;
+      params.lineNum = address.lineNum;
     }
 
-    _handleNewAgreementsRoute(data) {
-      data.params.view = Gerrit.Nav.View.AGREEMENTS;
-      this._setParams(data.params);
-    }
+    this._normalizeLegacyRouteParams(params);
+  }
 
-    _handleSettingsLegacyRoute(data) {
-      // email tokens may contain '+' but no space.
-      // The parameter parsing replaces all '+' with a space,
-      // undo that to have valid tokens.
-      const token = data.params[0].replace(/ /g, '+');
-      this._setParams({
-        view: Gerrit.Nav.View.SETTINGS,
-        emailToken: token,
-      });
-    }
+  _handleDiffEditRoute(ctx) {
+    // Parameter order is based on the regex group number matched.
+    const project = ctx.params[0];
+    this._redirectOrNavigate({
+      project,
+      changeNum: ctx.params[1],
+      patchNum: ctx.params[2],
+      path: ctx.params[3],
+      lineNum: ctx.hash,
+      view: Gerrit.Nav.View.EDIT,
+    });
+    this.$.reporting.setRepoName(project);
+  }
 
-    _handleSettingsRoute(data) {
-      this._setParams({view: Gerrit.Nav.View.SETTINGS});
-    }
+  _handleChangeEditRoute(ctx) {
+    // Parameter order is based on the regex group number matched.
+    const project = ctx.params[0];
+    this._redirectOrNavigate({
+      project,
+      changeNum: ctx.params[1],
+      patchNum: ctx.params[3],
+      view: Gerrit.Nav.View.CHANGE,
+      edit: true,
+    });
+    this.$.reporting.setRepoName(project);
+  }
 
-    _handleRegisterRoute(ctx) {
-      this._setParams({justRegistered: true});
-      let path = ctx.params[0] || '/';
-
-      // Prevent redirect looping.
-      if (path.startsWith('/register')) { path = '/'; }
-
-      if (path[0] !== '/') { return; }
-      this._redirect(this.getBaseUrl() + path);
-    }
-
-    /**
-     * Handler for routes that should pass through the router and not be caught
-     * by the catchall _handleDefaultRoute handler.
-     */
-    _handlePassThroughRoute() {
-      location.reload();
-    }
-
-    /**
-     * URL may sometimes have /+/ encoded to / /.
-     * Context: Issue 6888, Issue 7100
-     */
-    _handleImproperlyEncodedPlusRoute(ctx) {
-      let hash = this._getHashFromCanonicalPath(ctx.canonicalPath);
-      if (hash.length) { hash = '#' + hash; }
-      this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
-    }
-
-    _handlePluginScreen(ctx) {
-      const view = Gerrit.Nav.View.PLUGIN_SCREEN;
-      const plugin = ctx.params[0];
-      const screen = ctx.params[1];
-      this._setParams({view, plugin, screen});
-    }
-
-    _handleDocumentationSearchRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.DOCUMENTATION_SEARCH,
-        filter: data.params.filter || null,
-      });
-    }
-
-    _handleDocumentationSearchRedirectRoute(data) {
-      this._redirect('/Documentation/q/filter:' +
-          encodeURIComponent(data.params[0]));
-    }
-
-    _handleDocumentationRedirectRoute(data) {
-      if (data.params[1]) {
-        location.reload();
-      } else {
-        // Redirect /Documentation to /Documentation/index.html
-        this._redirect('/Documentation/index.html');
-      }
-    }
-
-    /**
-     * Catchall route for when no other route is matched.
-     */
-    _handleDefaultRoute() {
-      if (this._isInitialLoad) {
-        // Server recognized this route as polygerrit, so we show 404.
-        this._show404();
-      } else {
-        // Route can be recognized by server, so we pass it to server.
-        this._handlePassThroughRoute();
-      }
-    }
-
-    _show404() {
-      // Note: the app's 404 display is tightly-coupled with catching 404
-      // network responses, so we simulate a 404 response status to display it.
-      // TODO: Decouple the gr-app error view from network responses.
-      this._appElement().dispatchEvent(new CustomEvent('page-error',
-          {detail: {response: {status: 404}}}));
+  /**
+   * Normalize the patch range params for a the change or diff view and
+   * redirect if URL upgrade is needed.
+   */
+  _redirectOrNavigate(params) {
+    const needsRedirect = this._normalizePatchRangeParams(params);
+    if (needsRedirect) {
+      this._redirect(this._generateUrl(params));
+    } else {
+      this._setParams(params);
     }
   }
 
-  customElements.define(GrRouter.is, GrRouter);
-})();
+  _handleAgreementsRoute() {
+    this._redirect('/settings/#Agreements');
+  }
+
+  _handleNewAgreementsRoute(data) {
+    data.params.view = Gerrit.Nav.View.AGREEMENTS;
+    this._setParams(data.params);
+  }
+
+  _handleSettingsLegacyRoute(data) {
+    // email tokens may contain '+' but no space.
+    // The parameter parsing replaces all '+' with a space,
+    // undo that to have valid tokens.
+    const token = data.params[0].replace(/ /g, '+');
+    this._setParams({
+      view: Gerrit.Nav.View.SETTINGS,
+      emailToken: token,
+    });
+  }
+
+  _handleSettingsRoute(data) {
+    this._setParams({view: Gerrit.Nav.View.SETTINGS});
+  }
+
+  _handleRegisterRoute(ctx) {
+    this._setParams({justRegistered: true});
+    let path = ctx.params[0] || '/';
+
+    // Prevent redirect looping.
+    if (path.startsWith('/register')) { path = '/'; }
+
+    if (path[0] !== '/') { return; }
+    this._redirect(this.getBaseUrl() + path);
+  }
+
+  /**
+   * Handler for routes that should pass through the router and not be caught
+   * by the catchall _handleDefaultRoute handler.
+   */
+  _handlePassThroughRoute() {
+    location.reload();
+  }
+
+  /**
+   * URL may sometimes have /+/ encoded to / /.
+   * Context: Issue 6888, Issue 7100
+   */
+  _handleImproperlyEncodedPlusRoute(ctx) {
+    let hash = this._getHashFromCanonicalPath(ctx.canonicalPath);
+    if (hash.length) { hash = '#' + hash; }
+    this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
+  }
+
+  _handlePluginScreen(ctx) {
+    const view = Gerrit.Nav.View.PLUGIN_SCREEN;
+    const plugin = ctx.params[0];
+    const screen = ctx.params[1];
+    this._setParams({view, plugin, screen});
+  }
+
+  _handleDocumentationSearchRoute(data) {
+    this._setParams({
+      view: Gerrit.Nav.View.DOCUMENTATION_SEARCH,
+      filter: data.params.filter || null,
+    });
+  }
+
+  _handleDocumentationSearchRedirectRoute(data) {
+    this._redirect('/Documentation/q/filter:' +
+        encodeURIComponent(data.params[0]));
+  }
+
+  _handleDocumentationRedirectRoute(data) {
+    if (data.params[1]) {
+      location.reload();
+    } else {
+      // Redirect /Documentation to /Documentation/index.html
+      this._redirect('/Documentation/index.html');
+    }
+  }
+
+  /**
+   * Catchall route for when no other route is matched.
+   */
+  _handleDefaultRoute() {
+    if (this._isInitialLoad) {
+      // Server recognized this route as polygerrit, so we show 404.
+      this._show404();
+    } else {
+      // Route can be recognized by server, so we pass it to server.
+      this._handlePassThroughRoute();
+    }
+  }
+
+  _show404() {
+    // Note: the app's 404 display is tightly-coupled with catching 404
+    // network responses, so we simulate a 404 response status to display it.
+    // TODO: Decouple the gr-app error view from network responses.
+    this._appElement().dispatchEvent(new CustomEvent('page-error',
+        {detail: {response: {status: 404}}}));
+  }
+}
+
+customElements.define(GrRouter.is, GrRouter);
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_html.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.js
index 5d2531e..01acaa3 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_html.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.js
@@ -1,34 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-reporting/gr-reporting.html">
-<script src="/bower_components/page/page.js"></script>
-
-<dom-module id="gr-router">
-  <template>
+export const htmlTemplate = html`
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-router.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index f127a91..6ea07a5 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-router</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-router.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-router.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-router.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,1617 +40,1619 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-router tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-router.js';
+suite('gr-router tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('_firstCodeBrowserWeblink', () => {
+    assert.deepEqual(element._firstCodeBrowserWeblink([
+      {name: 'gitweb'},
+      {name: 'gitiles'},
+      {name: 'browse'},
+      {name: 'test'}]), {name: 'gitiles'});
+
+    assert.deepEqual(element._firstCodeBrowserWeblink([
+      {name: 'gitweb'},
+      {name: 'test'}]), {name: 'gitweb'});
+  });
+
+  test('_getBrowseCommitWeblink', () => {
+    const browserLink = {name: 'browser', url: 'browser/url'};
+    const link = {name: 'test', url: 'test/url'};
+    const weblinks = [browserLink, link];
+    const config = {gerrit: {primary_weblink_name: browserLink.name}};
+    sandbox.stub(element, '_firstCodeBrowserWeblink').returns(link);
+
+    assert.deepEqual(element._getBrowseCommitWeblink(weblinks, config),
+        browserLink);
+
+    assert.deepEqual(element._getBrowseCommitWeblink(weblinks, {}), link);
+  });
+
+  test('_getChangeWeblinks', () => {
+    const link = {name: 'test', url: 'test/url'};
+    const browserLink = {name: 'browser', url: 'browser/url'};
+    const mapLinksToConfig = weblinks => { return {options: {weblinks}}; };
+    sandbox.stub(element, '_getBrowseCommitWeblink').returns(browserLink);
+
+    assert.deepEqual(
+        element._getChangeWeblinks(mapLinksToConfig([link, browserLink]))[0],
+        {name: 'test', url: 'test/url'});
+
+    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
+        {name: 'test', url: 'test/url'});
+
+    link.url = 'https://' + link.url;
+    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
+        {name: 'test', url: 'https://test/url'});
+  });
+
+  test('_getHashFromCanonicalPath', () => {
+    let url = '/foo/bar';
+    let hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, '');
+
+    url = '';
+    hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, '');
+
+    url = '/foo#bar';
+    hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, 'bar');
+
+    url = '/foo#bar#baz';
+    hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, 'bar#baz');
+
+    url = '#foo#bar#baz';
+    hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, 'foo#bar#baz');
+  });
+
+  suite('_parseLineAddress', () => {
+    test('returns null for empty and invalid hashes', () => {
+      let actual = element._parseLineAddress('');
+      assert.isNull(actual);
+
+      actual = element._parseLineAddress('foobar');
+      assert.isNull(actual);
+
+      actual = element._parseLineAddress('foo123');
+      assert.isNull(actual);
+
+      actual = element._parseLineAddress('123bar');
+      assert.isNull(actual);
+    });
+
+    test('parses correctly', () => {
+      let actual = element._parseLineAddress('1234');
+      assert.isOk(actual);
+      assert.equal(actual.lineNum, 1234);
+      assert.isFalse(actual.leftSide);
+
+      actual = element._parseLineAddress('a4');
+      assert.isOk(actual);
+      assert.equal(actual.lineNum, 4);
+      assert.isTrue(actual.leftSide);
+
+      actual = element._parseLineAddress('b77');
+      assert.isOk(actual);
+      assert.equal(actual.lineNum, 77);
+      assert.isTrue(actual.leftSide);
+    });
+  });
+
+  test('_startRouter requires auth for the right handlers', () => {
+    // This test encodes the lists of route handler methods that gr-router
+    // automatically checks for authentication before triggering.
+
+    const requiresAuth = {};
+    const doesNotRequireAuth = {};
+    sandbox.stub(Gerrit.Nav, 'setup');
+    sandbox.stub(window.page, 'start');
+    sandbox.stub(window.page, 'base');
+    sandbox.stub(window, 'page');
+    sandbox.stub(element, '_mapRoute', (pattern, methodName, usesAuth) => {
+      if (usesAuth) {
+        requiresAuth[methodName] = true;
+      } else {
+        doesNotRequireAuth[methodName] = true;
+      }
+    });
+    element._startRouter();
+
+    const actualRequiresAuth = Object.keys(requiresAuth);
+    actualRequiresAuth.sort();
+    const actualDoesNotRequireAuth = Object.keys(doesNotRequireAuth);
+    actualDoesNotRequireAuth.sort();
+
+    const shouldRequireAutoAuth = [
+      '_handleAgreementsRoute',
+      '_handleChangeEditRoute',
+      '_handleCreateGroupRoute',
+      '_handleCreateProjectRoute',
+      '_handleDiffEditRoute',
+      '_handleGroupAuditLogRoute',
+      '_handleGroupInfoRoute',
+      '_handleGroupListFilterOffsetRoute',
+      '_handleGroupListFilterRoute',
+      '_handleGroupListOffsetRoute',
+      '_handleGroupMembersRoute',
+      '_handleGroupRoute',
+      '_handleGroupSelfRedirectRoute',
+      '_handleNewAgreementsRoute',
+      '_handlePluginListFilterOffsetRoute',
+      '_handlePluginListFilterRoute',
+      '_handlePluginListOffsetRoute',
+      '_handlePluginListRoute',
+      '_handleRepoCommandsRoute',
+      '_handleSettingsLegacyRoute',
+      '_handleSettingsRoute',
+    ];
+    assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
+
+    const unauthenticatedHandlers = [
+      '_handleBranchListFilterOffsetRoute',
+      '_handleBranchListFilterRoute',
+      '_handleBranchListOffsetRoute',
+      '_handleChangeNumberLegacyRoute',
+      '_handleChangeRoute',
+      '_handleDiffRoute',
+      '_handleDefaultRoute',
+      '_handleChangeLegacyRoute',
+      '_handleDiffLegacyRoute',
+      '_handleDocumentationRedirectRoute',
+      '_handleDocumentationSearchRoute',
+      '_handleDocumentationSearchRedirectRoute',
+      '_handleLegacyLinenum',
+      '_handleImproperlyEncodedPlusRoute',
+      '_handlePassThroughRoute',
+      '_handleProjectDashboardRoute',
+      '_handleProjectsOldRoute',
+      '_handleRepoAccessRoute',
+      '_handleRepoDashboardsRoute',
+      '_handleRepoListFilterOffsetRoute',
+      '_handleRepoListFilterRoute',
+      '_handleRepoListOffsetRoute',
+      '_handleRepoRoute',
+      '_handleQueryLegacySuffixRoute',
+      '_handleQueryRoute',
+      '_handleRegisterRoute',
+      '_handleTagListFilterOffsetRoute',
+      '_handleTagListFilterRoute',
+      '_handleTagListOffsetRoute',
+      '_handlePluginScreen',
+    ];
+
+    // Handler names that check authentication themselves, and thus don't need
+    // it performed for them.
+    const selfAuthenticatingHandlers = [
+      '_handleDashboardRoute',
+      '_handleCustomDashboardRoute',
+      '_handleRootRoute',
+    ];
+
+    const shouldNotRequireAuth = unauthenticatedHandlers
+        .concat(selfAuthenticatingHandlers);
+    shouldNotRequireAuth.sort();
+    assert.deepEqual(actualDoesNotRequireAuth, shouldNotRequireAuth);
+  });
+
+  test('_redirectIfNotLoggedIn while logged in', () => {
+    sandbox.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(true));
+    const data = {canonicalPath: ''};
+    const redirectStub = sandbox.stub(element, '_redirectToLogin');
+    return element._redirectIfNotLoggedIn(data).then(() => {
+      assert.isFalse(redirectStub.called);
+    });
+  });
+
+  test('_redirectIfNotLoggedIn while logged out', () => {
+    sandbox.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(false));
+    const redirectStub = sandbox.stub(element, '_redirectToLogin');
+    const data = {canonicalPath: ''};
+    return new Promise(resolve => {
+      element._redirectIfNotLoggedIn(data)
+          .then(() => {
+            assert.isTrue(false, 'Should never execute');
+          })
+          .catch(() => {
+            assert.isTrue(redirectStub.calledOnce);
+            resolve();
+          });
+    });
+  });
+
+  suite('generateUrl', () => {
+    test('search', () => {
+      let params = {
+        view: Gerrit.Nav.View.SEARCH,
+        owner: 'a%b',
+        project: 'c%d',
+        branch: 'e%f',
+        topic: 'g%h',
+        statuses: ['op%en'],
+      };
+      assert.equal(element._generateUrl(params),
+          '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+          'topic:"g%2525h"+status:op%2525en');
+
+      params.offset = 100;
+      assert.equal(element._generateUrl(params),
+          '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+          'topic:"g%2525h"+status:op%2525en,100');
+      delete params.offset;
+
+      // The presence of the query param overrides other params.
+      params.query = 'foo$bar';
+      assert.equal(element._generateUrl(params), '/q/foo%2524bar');
+
+      params.offset = 100;
+      assert.equal(element._generateUrl(params), '/q/foo%2524bar,100');
+
+      params = {
+        view: Gerrit.Nav.View.SEARCH,
+        statuses: ['a', 'b', 'c'],
+      };
+      assert.equal(element._generateUrl(params),
+          '/q/(status:a OR status:b OR status:c)');
+    });
+
+    test('change', () => {
+      const params = {
+        view: Gerrit.Nav.View.CHANGE,
+        changeNum: '1234',
+        project: 'test',
+      };
+      const paramsWithQuery = {
+        view: Gerrit.Nav.View.CHANGE,
+        changeNum: '1234',
+        project: 'test',
+        querystring: 'revert&foo=bar',
+      };
+
+      assert.equal(element._generateUrl(params), '/c/test/+/1234');
+      assert.equal(element._generateUrl(paramsWithQuery),
+          '/c/test/+/1234?revert&foo=bar');
+
+      params.patchNum = 10;
+      assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
+      paramsWithQuery.patchNum = 10;
+      assert.equal(element._generateUrl(paramsWithQuery),
+          '/c/test/+/1234/10?revert&foo=bar');
+
+      params.basePatchNum = 5;
+      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
+      paramsWithQuery.basePatchNum = 5;
+      assert.equal(element._generateUrl(paramsWithQuery),
+          '/c/test/+/1234/5..10?revert&foo=bar');
+
+      params.messageHash = '#123';
+      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10#123');
+    });
+
+    test('change with repo name encoding', () => {
+      const params = {
+        view: Gerrit.Nav.View.CHANGE,
+        changeNum: '1234',
+        project: 'x+/y+/z+/w',
+      };
+      assert.equal(element._generateUrl(params),
+          '/c/x%252B/y%252B/z%252B/w/+/1234');
+    });
+
+    test('diff', () => {
+      const params = {
+        view: Gerrit.Nav.View.DIFF,
+        changeNum: '42',
+        path: 'x+y/path.cpp',
+        patchNum: 12,
+      };
+      assert.equal(element._generateUrl(params),
+          '/c/42/12/x%252By/path.cpp');
+
+      params.project = 'test';
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/12/x%252By/path.cpp');
+
+      params.basePatchNum = 6;
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/6..12/x%252By/path.cpp');
+
+      params.path = 'foo bar/my+file.txt%';
+      params.patchNum = 2;
+      delete params.basePatchNum;
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525');
+
+      params.path = 'file.cpp';
+      params.lineNum = 123;
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/2/file.cpp#123');
+
+      params.leftSide = true;
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/2/file.cpp#b123');
+    });
+
+    test('diff with repo name encoding', () => {
+      const params = {
+        view: Gerrit.Nav.View.DIFF,
+        changeNum: '42',
+        path: 'x+y/path.cpp',
+        patchNum: 12,
+        project: 'x+/y',
+      };
+      assert.equal(element._generateUrl(params),
+          '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+    });
+
+    test('edit', () => {
+      const params = {
+        view: Gerrit.Nav.View.EDIT,
+        changeNum: '42',
+        project: 'test',
+        path: 'x+y/path.cpp',
+      };
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/x%252By/path.cpp,edit');
+    });
+
+    test('_getPatchRangeExpression', () => {
+      const params = {};
+      let actual = element._getPatchRangeExpression(params);
+      assert.equal(actual, '');
+
+      params.patchNum = 4;
+      actual = element._getPatchRangeExpression(params);
+      assert.equal(actual, '4');
+
+      params.basePatchNum = 2;
+      actual = element._getPatchRangeExpression(params);
+      assert.equal(actual, '2..4');
+
+      delete params.patchNum;
+      actual = element._getPatchRangeExpression(params);
+      assert.equal(actual, '2..');
+    });
+
+    suite('dashboard', () => {
+      test('self dashboard', () => {
+        const params = {
+          view: Gerrit.Nav.View.DASHBOARD,
+        };
+        assert.equal(element._generateUrl(params), '/dashboard/self');
+      });
+
+      test('user dashboard', () => {
+        const params = {
+          view: Gerrit.Nav.View.DASHBOARD,
+          user: 'user',
+        };
+        assert.equal(element._generateUrl(params), '/dashboard/user');
+      });
+
+      test('custom self dashboard, no title', () => {
+        const params = {
+          view: Gerrit.Nav.View.DASHBOARD,
+          sections: [
+            {name: 'section 1', query: 'query 1'},
+            {name: 'section 2', query: 'query 2'},
+          ],
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/dashboard/?section%201=query%201&section%202=query%202');
+      });
+
+      test('custom repo dashboard', () => {
+        const params = {
+          view: Gerrit.Nav.View.DASHBOARD,
+          sections: [
+            {name: 'section 1', query: 'query 1 ${project}'},
+            {name: 'section 2', query: 'query 2 ${repo}'},
+          ],
+          repo: 'repo-name',
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/dashboard/?section%201=query%201%20repo-name&' +
+            'section%202=query%202%20repo-name');
+      });
+
+      test('custom user dashboard, with title', () => {
+        const params = {
+          view: Gerrit.Nav.View.DASHBOARD,
+          user: 'user',
+          sections: [{name: 'name', query: 'query'}],
+          title: 'custom dashboard',
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/dashboard/user?name=query&title=custom%20dashboard');
+      });
+
+      test('repo dashboard', () => {
+        const params = {
+          view: Gerrit.Nav.View.DASHBOARD,
+          repo: 'gerrit/repo',
+          dashboard: 'default:main',
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/p/gerrit/repo/+/dashboard/default:main');
+      });
+
+      test('project dashboard (legacy)', () => {
+        const params = {
+          view: Gerrit.Nav.View.DASHBOARD,
+          project: 'gerrit/project',
+          dashboard: 'default:main',
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/p/gerrit/project/+/dashboard/default:main');
+      });
+    });
+
+    suite('groups', () => {
+      test('group info', () => {
+        const params = {
+          view: Gerrit.Nav.View.GROUP,
+          groupId: 1234,
+        };
+        assert.equal(element._generateUrl(params), '/admin/groups/1234');
+      });
+
+      test('group members', () => {
+        const params = {
+          view: Gerrit.Nav.View.GROUP,
+          groupId: 1234,
+          detail: 'members',
+        };
+        assert.equal(element._generateUrl(params),
+            '/admin/groups/1234,members');
+      });
+
+      test('group audit log', () => {
+        const params = {
+          view: Gerrit.Nav.View.GROUP,
+          groupId: 1234,
+          detail: 'log',
+        };
+        assert.equal(element._generateUrl(params),
+            '/admin/groups/1234,audit-log');
+      });
+    });
+  });
+
+  suite('param normalization', () => {
+    let projectLookupStub;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+      projectLookupStub = sandbox
+          .stub(element.$.restAPI, 'getFromProjectLookup');
+      sandbox.stub(element, '_generateUrl');
     });
 
-    teardown(() => { sandbox.restore(); });
+    suite('_normalizeLegacyRouteParams', () => {
+      let rangeStub;
+      let redirectStub;
+      let show404Stub;
 
-    test('_firstCodeBrowserWeblink', () => {
-      assert.deepEqual(element._firstCodeBrowserWeblink([
-        {name: 'gitweb'},
-        {name: 'gitiles'},
-        {name: 'browse'},
-        {name: 'test'}]), {name: 'gitiles'});
-
-      assert.deepEqual(element._firstCodeBrowserWeblink([
-        {name: 'gitweb'},
-        {name: 'test'}]), {name: 'gitweb'});
-    });
-
-    test('_getBrowseCommitWeblink', () => {
-      const browserLink = {name: 'browser', url: 'browser/url'};
-      const link = {name: 'test', url: 'test/url'};
-      const weblinks = [browserLink, link];
-      const config = {gerrit: {primary_weblink_name: browserLink.name}};
-      sandbox.stub(element, '_firstCodeBrowserWeblink').returns(link);
-
-      assert.deepEqual(element._getBrowseCommitWeblink(weblinks, config),
-          browserLink);
-
-      assert.deepEqual(element._getBrowseCommitWeblink(weblinks, {}), link);
-    });
-
-    test('_getChangeWeblinks', () => {
-      const link = {name: 'test', url: 'test/url'};
-      const browserLink = {name: 'browser', url: 'browser/url'};
-      const mapLinksToConfig = weblinks => { return {options: {weblinks}}; };
-      sandbox.stub(element, '_getBrowseCommitWeblink').returns(browserLink);
-
-      assert.deepEqual(
-          element._getChangeWeblinks(mapLinksToConfig([link, browserLink]))[0],
-          {name: 'test', url: 'test/url'});
-
-      assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
-          {name: 'test', url: 'test/url'});
-
-      link.url = 'https://' + link.url;
-      assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
-          {name: 'test', url: 'https://test/url'});
-    });
-
-    test('_getHashFromCanonicalPath', () => {
-      let url = '/foo/bar';
-      let hash = element._getHashFromCanonicalPath(url);
-      assert.equal(hash, '');
-
-      url = '';
-      hash = element._getHashFromCanonicalPath(url);
-      assert.equal(hash, '');
-
-      url = '/foo#bar';
-      hash = element._getHashFromCanonicalPath(url);
-      assert.equal(hash, 'bar');
-
-      url = '/foo#bar#baz';
-      hash = element._getHashFromCanonicalPath(url);
-      assert.equal(hash, 'bar#baz');
-
-      url = '#foo#bar#baz';
-      hash = element._getHashFromCanonicalPath(url);
-      assert.equal(hash, 'foo#bar#baz');
-    });
-
-    suite('_parseLineAddress', () => {
-      test('returns null for empty and invalid hashes', () => {
-        let actual = element._parseLineAddress('');
-        assert.isNull(actual);
-
-        actual = element._parseLineAddress('foobar');
-        assert.isNull(actual);
-
-        actual = element._parseLineAddress('foo123');
-        assert.isNull(actual);
-
-        actual = element._parseLineAddress('123bar');
-        assert.isNull(actual);
+      setup(() => {
+        rangeStub = sandbox.stub(element, '_normalizePatchRangeParams')
+            .returns(Promise.resolve());
+        redirectStub = sandbox.stub(element, '_redirect');
+        show404Stub = sandbox.stub(element, '_show404');
       });
 
-      test('parses correctly', () => {
-        let actual = element._parseLineAddress('1234');
-        assert.isOk(actual);
-        assert.equal(actual.lineNum, 1234);
-        assert.isFalse(actual.leftSide);
+      test('w/o changeNum', () => {
+        projectLookupStub.returns(Promise.resolve('foo/bar'));
+        const params = {};
+        return element._normalizeLegacyRouteParams(params).then(() => {
+          assert.isFalse(projectLookupStub.called);
+          assert.isFalse(rangeStub.called);
+          assert.isNotOk(params.project);
+          assert.isFalse(redirectStub.called);
+          assert.isFalse(show404Stub.called);
+        });
+      });
 
-        actual = element._parseLineAddress('a4');
-        assert.isOk(actual);
-        assert.equal(actual.lineNum, 4);
-        assert.isTrue(actual.leftSide);
+      test('w/ changeNum', () => {
+        projectLookupStub.returns(Promise.resolve('foo/bar'));
+        const params = {changeNum: 1234};
+        return element._normalizeLegacyRouteParams(params).then(() => {
+          assert.isTrue(projectLookupStub.called);
+          assert.isTrue(rangeStub.called);
+          assert.equal(params.project, 'foo/bar');
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isFalse(show404Stub.called);
+        });
+      });
 
-        actual = element._parseLineAddress('b77');
-        assert.isOk(actual);
-        assert.equal(actual.lineNum, 77);
-        assert.isTrue(actual.leftSide);
+      test('halts on project lookup failure', () => {
+        projectLookupStub.returns(Promise.resolve(undefined));
+        const params = {changeNum: 1234};
+        return element._normalizeLegacyRouteParams(params).then(() => {
+          assert.isTrue(projectLookupStub.called);
+          assert.isFalse(rangeStub.called);
+          assert.isUndefined(params.project);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(show404Stub.calledOnce);
+        });
       });
     });
 
-    test('_startRouter requires auth for the right handlers', () => {
-      // This test encodes the lists of route handler methods that gr-router
-      // automatically checks for authentication before triggering.
+    suite('_normalizePatchRangeParams', () => {
+      test('range n..n normalizes to n', () => {
+        const params = {basePatchNum: 4, patchNum: 4};
+        const needsRedirect = element._normalizePatchRangeParams(params);
+        assert.isTrue(needsRedirect);
+        assert.isNotOk(params.basePatchNum);
+        assert.equal(params.patchNum, 4);
+      });
 
-      const requiresAuth = {};
-      const doesNotRequireAuth = {};
+      test('range n.. normalizes to n', () => {
+        const params = {basePatchNum: 4};
+        const needsRedirect = element._normalizePatchRangeParams(params);
+        assert.isFalse(needsRedirect);
+        assert.isNotOk(params.basePatchNum);
+        assert.equal(params.patchNum, 4);
+      });
+    });
+  });
+
+  suite('route handlers', () => {
+    let redirectStub;
+    let setParamsStub;
+    let handlePassThroughRoute;
+
+    // Simple route handlers are direct mappings from parsed route data to a
+    // new set of app.params. This test helper asserts that passing `data`
+    // into `methodName` results in setting the params specified in `params`.
+    function assertDataToParams(data, methodName, params) {
+      element[methodName](data);
+      assert.deepEqual(setParamsStub.lastCall.args[0], params);
+    }
+
+    setup(() => {
+      redirectStub = sandbox.stub(element, '_redirect');
+      setParamsStub = sandbox.stub(element, '_setParams');
+      handlePassThroughRoute = sandbox.stub(element, '_handlePassThroughRoute');
+    });
+
+    test('_handleAgreementsRoute', () => {
+      const data = {params: {}};
+      element._handleAgreementsRoute(data);
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
+    });
+
+    test('_handleNewAgreementsRoute', () => {
+      element._handleNewAgreementsRoute({params: {}});
+      assert.isTrue(setParamsStub.calledOnce);
+      assert.equal(setParamsStub.lastCall.args[0].view,
+          Gerrit.Nav.View.AGREEMENTS);
+    });
+
+    test('_handleSettingsLegacyRoute', () => {
+      const data = {params: {0: 'my-token'}};
+      assertDataToParams(data, '_handleSettingsLegacyRoute', {
+        view: Gerrit.Nav.View.SETTINGS,
+        emailToken: 'my-token',
+      });
+    });
+
+    test('_handleSettingsLegacyRoute with +', () => {
+      const data = {params: {0: 'my-token test'}};
+      assertDataToParams(data, '_handleSettingsLegacyRoute', {
+        view: Gerrit.Nav.View.SETTINGS,
+        emailToken: 'my-token+test',
+      });
+    });
+
+    test('_handleSettingsRoute', () => {
+      const data = {};
+      assertDataToParams(data, '_handleSettingsRoute', {
+        view: Gerrit.Nav.View.SETTINGS,
+      });
+    });
+
+    test('_handleDefaultRoute on first load', () => {
+      const appElementStub = {dispatchEvent: sinon.stub()};
+      element._appElement = () => appElementStub;
+      element._handleDefaultRoute();
+      assert.isTrue(appElementStub.dispatchEvent.calledOnce);
+      assert.equal(
+          appElementStub.dispatchEvent.lastCall.args[0].detail.response.status,
+          404);
+    });
+
+    test('_handleDefaultRoute after internal navigation', () => {
+      let onExit = null;
+      const onRegisteringExit = (match, _onExit) => {
+        onExit = _onExit;
+      };
+      sandbox.stub(window.page, 'exit', onRegisteringExit);
       sandbox.stub(Gerrit.Nav, 'setup');
       sandbox.stub(window.page, 'start');
       sandbox.stub(window.page, 'base');
       sandbox.stub(window, 'page');
-      sandbox.stub(element, '_mapRoute', (pattern, methodName, usesAuth) => {
-        if (usesAuth) {
-          requiresAuth[methodName] = true;
-        } else {
-          doesNotRequireAuth[methodName] = true;
-        }
-      });
       element._startRouter();
 
-      const actualRequiresAuth = Object.keys(requiresAuth);
-      actualRequiresAuth.sort();
-      const actualDoesNotRequireAuth = Object.keys(doesNotRequireAuth);
-      actualDoesNotRequireAuth.sort();
+      const appElementStub = {dispatchEvent: sinon.stub()};
+      element._appElement = () => appElementStub;
+      element._handleDefaultRoute();
 
-      const shouldRequireAutoAuth = [
-        '_handleAgreementsRoute',
-        '_handleChangeEditRoute',
-        '_handleCreateGroupRoute',
-        '_handleCreateProjectRoute',
-        '_handleDiffEditRoute',
-        '_handleGroupAuditLogRoute',
-        '_handleGroupInfoRoute',
-        '_handleGroupListFilterOffsetRoute',
-        '_handleGroupListFilterRoute',
-        '_handleGroupListOffsetRoute',
-        '_handleGroupMembersRoute',
-        '_handleGroupRoute',
-        '_handleGroupSelfRedirectRoute',
-        '_handleNewAgreementsRoute',
-        '_handlePluginListFilterOffsetRoute',
-        '_handlePluginListFilterRoute',
-        '_handlePluginListOffsetRoute',
-        '_handlePluginListRoute',
-        '_handleRepoCommandsRoute',
-        '_handleSettingsLegacyRoute',
-        '_handleSettingsRoute',
-      ];
-      assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
+      onExit('', () => {}); // we left page;
 
-      const unauthenticatedHandlers = [
-        '_handleBranchListFilterOffsetRoute',
-        '_handleBranchListFilterRoute',
-        '_handleBranchListOffsetRoute',
-        '_handleChangeNumberLegacyRoute',
-        '_handleChangeRoute',
-        '_handleDiffRoute',
-        '_handleDefaultRoute',
-        '_handleChangeLegacyRoute',
-        '_handleDiffLegacyRoute',
-        '_handleDocumentationRedirectRoute',
-        '_handleDocumentationSearchRoute',
-        '_handleDocumentationSearchRedirectRoute',
-        '_handleLegacyLinenum',
-        '_handleImproperlyEncodedPlusRoute',
-        '_handlePassThroughRoute',
-        '_handleProjectDashboardRoute',
-        '_handleProjectsOldRoute',
-        '_handleRepoAccessRoute',
-        '_handleRepoDashboardsRoute',
-        '_handleRepoListFilterOffsetRoute',
-        '_handleRepoListFilterRoute',
-        '_handleRepoListOffsetRoute',
-        '_handleRepoRoute',
-        '_handleQueryLegacySuffixRoute',
-        '_handleQueryRoute',
-        '_handleRegisterRoute',
-        '_handleTagListFilterOffsetRoute',
-        '_handleTagListFilterRoute',
-        '_handleTagListOffsetRoute',
-        '_handlePluginScreen',
-      ];
-
-      // Handler names that check authentication themselves, and thus don't need
-      // it performed for them.
-      const selfAuthenticatingHandlers = [
-        '_handleDashboardRoute',
-        '_handleCustomDashboardRoute',
-        '_handleRootRoute',
-      ];
-
-      const shouldNotRequireAuth = unauthenticatedHandlers
-          .concat(selfAuthenticatingHandlers);
-      shouldNotRequireAuth.sort();
-      assert.deepEqual(actualDoesNotRequireAuth, shouldNotRequireAuth);
+      element._handleDefaultRoute();
+      assert.isTrue(handlePassThroughRoute.calledOnce);
     });
 
-    test('_redirectIfNotLoggedIn while logged in', () => {
-      sandbox.stub(element.$.restAPI, 'getLoggedIn')
-          .returns(Promise.resolve(true));
-      const data = {canonicalPath: ''};
-      const redirectStub = sandbox.stub(element, '_redirectToLogin');
-      return element._redirectIfNotLoggedIn(data).then(() => {
+    test('_handleImproperlyEncodedPlusRoute', () => {
+      // Regression test for Issue 7100.
+      element._handleImproperlyEncodedPlusRoute(
+          {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(
+          redirectStub.lastCall.args[0],
+          '/c/test/+/42');
+
+      sandbox.stub(element, '_getHashFromCanonicalPath').returns('foo');
+      element._handleImproperlyEncodedPlusRoute(
+          {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
+      assert.equal(
+          redirectStub.lastCall.args[0],
+          '/c/test/+/42#foo');
+    });
+
+    test('_handleQueryRoute', () => {
+      const data = {params: ['project:foo/bar/baz']};
+      assertDataToParams(data, '_handleQueryRoute', {
+        view: Gerrit.Nav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: undefined,
+      });
+
+      data.params.push(',123', '123');
+      assertDataToParams(data, '_handleQueryRoute', {
+        view: Gerrit.Nav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: '123',
+      });
+    });
+
+    test('_handleQueryLegacySuffixRoute', () => {
+      element._handleQueryLegacySuffixRoute({path: '/q/foo+bar,n,z'});
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
+    });
+
+    test('_handleQueryRoute', () => {
+      const data = {params: ['project:foo/bar/baz']};
+      assertDataToParams(data, '_handleQueryRoute', {
+        view: Gerrit.Nav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: undefined,
+      });
+
+      data.params.push(',123', '123');
+      assertDataToParams(data, '_handleQueryRoute', {
+        view: Gerrit.Nav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: '123',
+      });
+    });
+
+    suite('_handleRegisterRoute', () => {
+      test('happy path', () => {
+        const ctx = {params: ['/foo/bar']};
+        element._handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+      });
+
+      test('no param', () => {
+        const ctx = {params: ['']};
+        element._handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/'));
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+      });
+
+      test('prevent redirect', () => {
+        const ctx = {params: ['/register']};
+        element._handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/'));
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+      });
+    });
+
+    suite('_handleRootRoute', () => {
+      test('closes for closeAfterLogin', () => {
+        const data = {querystring: 'closeAfterLogin', canonicalPath: ''};
+        const closeStub = sandbox.stub(window, 'close');
+        const result = element._handleRootRoute(data);
+        assert.isNotOk(result);
+        assert.isTrue(closeStub.called);
         assert.isFalse(redirectStub.called);
       });
-    });
 
-    test('_redirectIfNotLoggedIn while logged out', () => {
-      sandbox.stub(element.$.restAPI, 'getLoggedIn')
-          .returns(Promise.resolve(false));
-      const redirectStub = sandbox.stub(element, '_redirectToLogin');
-      const data = {canonicalPath: ''};
-      return new Promise(resolve => {
-        element._redirectIfNotLoggedIn(data)
-            .then(() => {
-              assert.isTrue(false, 'Should never execute');
-            })
-            .catch(() => {
-              assert.isTrue(redirectStub.calledOnce);
-              resolve();
-            });
-      });
-    });
-
-    suite('generateUrl', () => {
-      test('search', () => {
-        let params = {
-          view: Gerrit.Nav.View.SEARCH,
-          owner: 'a%b',
-          project: 'c%d',
-          branch: 'e%f',
-          topic: 'g%h',
-          statuses: ['op%en'],
+      test('redirects to dashboard if logged in', () => {
+        sandbox.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(true));
+        const data = {
+          canonicalPath: '/', path: '/', querystring: '', hash: '',
         };
-        assert.equal(element._generateUrl(params),
-            '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-            'topic:"g%2525h"+status:op%2525en');
+        const result = element._handleRootRoute(data);
+        assert.isOk(result);
+        return result.then(() => {
+          assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
+        });
+      });
 
-        params.offset = 100;
-        assert.equal(element._generateUrl(params),
-            '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-            'topic:"g%2525h"+status:op%2525en,100');
-        delete params.offset;
-
-        // The presence of the query param overrides other params.
-        params.query = 'foo$bar';
-        assert.equal(element._generateUrl(params), '/q/foo%2524bar');
-
-        params.offset = 100;
-        assert.equal(element._generateUrl(params), '/q/foo%2524bar,100');
-
-        params = {
-          view: Gerrit.Nav.View.SEARCH,
-          statuses: ['a', 'b', 'c'],
+      test('redirects to open changes if not logged in', () => {
+        sandbox.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(false));
+        const data = {
+          canonicalPath: '/', path: '/', querystring: '', hash: '',
         };
-        assert.equal(element._generateUrl(params),
-            '/q/(status:a OR status:b OR status:c)');
+        const result = element._handleRootRoute(data);
+        assert.isOk(result);
+        return result.then(() => {
+          assert.isTrue(redirectStub.calledWithExactly('/q/status:open'));
+        });
       });
 
-      test('change', () => {
-        const params = {
-          view: Gerrit.Nav.View.CHANGE,
-          changeNum: '1234',
-          project: 'test',
-        };
-        const paramsWithQuery = {
-          view: Gerrit.Nav.View.CHANGE,
-          changeNum: '1234',
-          project: 'test',
-          querystring: 'revert&foo=bar',
-        };
-
-        assert.equal(element._generateUrl(params), '/c/test/+/1234');
-        assert.equal(element._generateUrl(paramsWithQuery),
-            '/c/test/+/1234?revert&foo=bar');
-
-        params.patchNum = 10;
-        assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
-        paramsWithQuery.patchNum = 10;
-        assert.equal(element._generateUrl(paramsWithQuery),
-            '/c/test/+/1234/10?revert&foo=bar');
-
-        params.basePatchNum = 5;
-        assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
-        paramsWithQuery.basePatchNum = 5;
-        assert.equal(element._generateUrl(paramsWithQuery),
-            '/c/test/+/1234/5..10?revert&foo=bar');
-
-        params.messageHash = '#123';
-        assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10#123');
-      });
-
-      test('change with repo name encoding', () => {
-        const params = {
-          view: Gerrit.Nav.View.CHANGE,
-          changeNum: '1234',
-          project: 'x+/y+/z+/w',
-        };
-        assert.equal(element._generateUrl(params),
-            '/c/x%252B/y%252B/z%252B/w/+/1234');
-      });
-
-      test('diff', () => {
-        const params = {
-          view: Gerrit.Nav.View.DIFF,
-          changeNum: '42',
-          path: 'x+y/path.cpp',
-          patchNum: 12,
-        };
-        assert.equal(element._generateUrl(params),
-            '/c/42/12/x%252By/path.cpp');
-
-        params.project = 'test';
-        assert.equal(element._generateUrl(params),
-            '/c/test/+/42/12/x%252By/path.cpp');
-
-        params.basePatchNum = 6;
-        assert.equal(element._generateUrl(params),
-            '/c/test/+/42/6..12/x%252By/path.cpp');
-
-        params.path = 'foo bar/my+file.txt%';
-        params.patchNum = 2;
-        delete params.basePatchNum;
-        assert.equal(element._generateUrl(params),
-            '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525');
-
-        params.path = 'file.cpp';
-        params.lineNum = 123;
-        assert.equal(element._generateUrl(params),
-            '/c/test/+/42/2/file.cpp#123');
-
-        params.leftSide = true;
-        assert.equal(element._generateUrl(params),
-            '/c/test/+/42/2/file.cpp#b123');
-      });
-
-      test('diff with repo name encoding', () => {
-        const params = {
-          view: Gerrit.Nav.View.DIFF,
-          changeNum: '42',
-          path: 'x+y/path.cpp',
-          patchNum: 12,
-          project: 'x+/y',
-        };
-        assert.equal(element._generateUrl(params),
-            '/c/x%252B/y/+/42/12/x%252By/path.cpp');
-      });
-
-      test('edit', () => {
-        const params = {
-          view: Gerrit.Nav.View.EDIT,
-          changeNum: '42',
-          project: 'test',
-          path: 'x+y/path.cpp',
-        };
-        assert.equal(element._generateUrl(params),
-            '/c/test/+/42/x%252By/path.cpp,edit');
-      });
-
-      test('_getPatchRangeExpression', () => {
-        const params = {};
-        let actual = element._getPatchRangeExpression(params);
-        assert.equal(actual, '');
-
-        params.patchNum = 4;
-        actual = element._getPatchRangeExpression(params);
-        assert.equal(actual, '4');
-
-        params.basePatchNum = 2;
-        actual = element._getPatchRangeExpression(params);
-        assert.equal(actual, '2..4');
-
-        delete params.patchNum;
-        actual = element._getPatchRangeExpression(params);
-        assert.equal(actual, '2..');
-      });
-
-      suite('dashboard', () => {
-        test('self dashboard', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
+      suite('GWT hash-path URLs', () => {
+        test('redirects hash-path URLs', () => {
+          const data = {
+            canonicalPath: '/#/foo/bar/baz',
+            hash: '/foo/bar/baz',
+            querystring: '',
           };
-          assert.equal(element._generateUrl(params), '/dashboard/self');
-        });
-
-        test('user dashboard', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
-            user: 'user',
-          };
-          assert.equal(element._generateUrl(params), '/dashboard/user');
-        });
-
-        test('custom self dashboard, no title', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
-            sections: [
-              {name: 'section 1', query: 'query 1'},
-              {name: 'section 2', query: 'query 2'},
-            ],
-          };
-          assert.equal(
-              element._generateUrl(params),
-              '/dashboard/?section%201=query%201&section%202=query%202');
-        });
-
-        test('custom repo dashboard', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
-            sections: [
-              {name: 'section 1', query: 'query 1 ${project}'},
-              {name: 'section 2', query: 'query 2 ${repo}'},
-            ],
-            repo: 'repo-name',
-          };
-          assert.equal(
-              element._generateUrl(params),
-              '/dashboard/?section%201=query%201%20repo-name&' +
-              'section%202=query%202%20repo-name');
-        });
-
-        test('custom user dashboard, with title', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
-            user: 'user',
-            sections: [{name: 'name', query: 'query'}],
-            title: 'custom dashboard',
-          };
-          assert.equal(
-              element._generateUrl(params),
-              '/dashboard/user?name=query&title=custom%20dashboard');
-        });
-
-        test('repo dashboard', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
-            repo: 'gerrit/repo',
-            dashboard: 'default:main',
-          };
-          assert.equal(
-              element._generateUrl(params),
-              '/p/gerrit/repo/+/dashboard/default:main');
-        });
-
-        test('project dashboard (legacy)', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
-            project: 'gerrit/project',
-            dashboard: 'default:main',
-          };
-          assert.equal(
-              element._generateUrl(params),
-              '/p/gerrit/project/+/dashboard/default:main');
-        });
-      });
-
-      suite('groups', () => {
-        test('group info', () => {
-          const params = {
-            view: Gerrit.Nav.View.GROUP,
-            groupId: 1234,
-          };
-          assert.equal(element._generateUrl(params), '/admin/groups/1234');
-        });
-
-        test('group members', () => {
-          const params = {
-            view: Gerrit.Nav.View.GROUP,
-            groupId: 1234,
-            detail: 'members',
-          };
-          assert.equal(element._generateUrl(params),
-              '/admin/groups/1234,members');
-        });
-
-        test('group audit log', () => {
-          const params = {
-            view: Gerrit.Nav.View.GROUP,
-            groupId: 1234,
-            detail: 'log',
-          };
-          assert.equal(element._generateUrl(params),
-              '/admin/groups/1234,audit-log');
-        });
-      });
-    });
-
-    suite('param normalization', () => {
-      let projectLookupStub;
-
-      setup(() => {
-        projectLookupStub = sandbox
-            .stub(element.$.restAPI, 'getFromProjectLookup');
-        sandbox.stub(element, '_generateUrl');
-      });
-
-      suite('_normalizeLegacyRouteParams', () => {
-        let rangeStub;
-        let redirectStub;
-        let show404Stub;
-
-        setup(() => {
-          rangeStub = sandbox.stub(element, '_normalizePatchRangeParams')
-              .returns(Promise.resolve());
-          redirectStub = sandbox.stub(element, '_redirect');
-          show404Stub = sandbox.stub(element, '_show404');
-        });
-
-        test('w/o changeNum', () => {
-          projectLookupStub.returns(Promise.resolve('foo/bar'));
-          const params = {};
-          return element._normalizeLegacyRouteParams(params).then(() => {
-            assert.isFalse(projectLookupStub.called);
-            assert.isFalse(rangeStub.called);
-            assert.isNotOk(params.project);
-            assert.isFalse(redirectStub.called);
-            assert.isFalse(show404Stub.called);
-          });
-        });
-
-        test('w/ changeNum', () => {
-          projectLookupStub.returns(Promise.resolve('foo/bar'));
-          const params = {changeNum: 1234};
-          return element._normalizeLegacyRouteParams(params).then(() => {
-            assert.isTrue(projectLookupStub.called);
-            assert.isTrue(rangeStub.called);
-            assert.equal(params.project, 'foo/bar');
-            assert.isTrue(redirectStub.calledOnce);
-            assert.isFalse(show404Stub.called);
-          });
-        });
-
-        test('halts on project lookup failure', () => {
-          projectLookupStub.returns(Promise.resolve(undefined));
-          const params = {changeNum: 1234};
-          return element._normalizeLegacyRouteParams(params).then(() => {
-            assert.isTrue(projectLookupStub.called);
-            assert.isFalse(rangeStub.called);
-            assert.isUndefined(params.project);
-            assert.isFalse(redirectStub.called);
-            assert.isTrue(show404Stub.calledOnce);
-          });
-        });
-      });
-
-      suite('_normalizePatchRangeParams', () => {
-        test('range n..n normalizes to n', () => {
-          const params = {basePatchNum: 4, patchNum: 4};
-          const needsRedirect = element._normalizePatchRangeParams(params);
-          assert.isTrue(needsRedirect);
-          assert.isNotOk(params.basePatchNum);
-          assert.equal(params.patchNum, 4);
-        });
-
-        test('range n.. normalizes to n', () => {
-          const params = {basePatchNum: 4};
-          const needsRedirect = element._normalizePatchRangeParams(params);
-          assert.isFalse(needsRedirect);
-          assert.isNotOk(params.basePatchNum);
-          assert.equal(params.patchNum, 4);
-        });
-      });
-    });
-
-    suite('route handlers', () => {
-      let redirectStub;
-      let setParamsStub;
-      let handlePassThroughRoute;
-
-      // Simple route handlers are direct mappings from parsed route data to a
-      // new set of app.params. This test helper asserts that passing `data`
-      // into `methodName` results in setting the params specified in `params`.
-      function assertDataToParams(data, methodName, params) {
-        element[methodName](data);
-        assert.deepEqual(setParamsStub.lastCall.args[0], params);
-      }
-
-      setup(() => {
-        redirectStub = sandbox.stub(element, '_redirect');
-        setParamsStub = sandbox.stub(element, '_setParams');
-        handlePassThroughRoute = sandbox.stub(element, '_handlePassThroughRoute');
-      });
-
-      test('_handleAgreementsRoute', () => {
-        const data = {params: {}};
-        element._handleAgreementsRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
-      });
-
-      test('_handleNewAgreementsRoute', () => {
-        element._handleNewAgreementsRoute({params: {}});
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.equal(setParamsStub.lastCall.args[0].view,
-            Gerrit.Nav.View.AGREEMENTS);
-      });
-
-      test('_handleSettingsLegacyRoute', () => {
-        const data = {params: {0: 'my-token'}};
-        assertDataToParams(data, '_handleSettingsLegacyRoute', {
-          view: Gerrit.Nav.View.SETTINGS,
-          emailToken: 'my-token',
-        });
-      });
-
-      test('_handleSettingsLegacyRoute with +', () => {
-        const data = {params: {0: 'my-token test'}};
-        assertDataToParams(data, '_handleSettingsLegacyRoute', {
-          view: Gerrit.Nav.View.SETTINGS,
-          emailToken: 'my-token+test',
-        });
-      });
-
-      test('_handleSettingsRoute', () => {
-        const data = {};
-        assertDataToParams(data, '_handleSettingsRoute', {
-          view: Gerrit.Nav.View.SETTINGS,
-        });
-      });
-
-      test('_handleDefaultRoute on first load', () => {
-        const appElementStub = {dispatchEvent: sinon.stub()};
-        element._appElement = () => appElementStub;
-        element._handleDefaultRoute();
-        assert.isTrue(appElementStub.dispatchEvent.calledOnce);
-        assert.equal(
-            appElementStub.dispatchEvent.lastCall.args[0].detail.response.status,
-            404);
-      });
-
-      test('_handleDefaultRoute after internal navigation', () => {
-        let onExit = null;
-        const onRegisteringExit = (match, _onExit) => {
-          onExit = _onExit;
-        };
-        sandbox.stub(window.page, 'exit', onRegisteringExit);
-        sandbox.stub(Gerrit.Nav, 'setup');
-        sandbox.stub(window.page, 'start');
-        sandbox.stub(window.page, 'base');
-        sandbox.stub(window, 'page');
-        element._startRouter();
-
-        const appElementStub = {dispatchEvent: sinon.stub()};
-        element._appElement = () => appElementStub;
-        element._handleDefaultRoute();
-
-        onExit('', () => {}); // we left page;
-
-        element._handleDefaultRoute();
-        assert.isTrue(handlePassThroughRoute.calledOnce);
-      });
-
-      test('_handleImproperlyEncodedPlusRoute', () => {
-        // Regression test for Issue 7100.
-        element._handleImproperlyEncodedPlusRoute(
-            {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-            redirectStub.lastCall.args[0],
-            '/c/test/+/42');
-
-        sandbox.stub(element, '_getHashFromCanonicalPath').returns('foo');
-        element._handleImproperlyEncodedPlusRoute(
-            {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
-        assert.equal(
-            redirectStub.lastCall.args[0],
-            '/c/test/+/42#foo');
-      });
-
-      test('_handleQueryRoute', () => {
-        const data = {params: ['project:foo/bar/baz']};
-        assertDataToParams(data, '_handleQueryRoute', {
-          view: Gerrit.Nav.View.SEARCH,
-          query: 'project:foo/bar/baz',
-          offset: undefined,
-        });
-
-        data.params.push(',123', '123');
-        assertDataToParams(data, '_handleQueryRoute', {
-          view: Gerrit.Nav.View.SEARCH,
-          query: 'project:foo/bar/baz',
-          offset: '123',
-        });
-      });
-
-      test('_handleQueryLegacySuffixRoute', () => {
-        element._handleQueryLegacySuffixRoute({path: '/q/foo+bar,n,z'});
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
-      });
-
-      test('_handleQueryRoute', () => {
-        const data = {params: ['project:foo/bar/baz']};
-        assertDataToParams(data, '_handleQueryRoute', {
-          view: Gerrit.Nav.View.SEARCH,
-          query: 'project:foo/bar/baz',
-          offset: undefined,
-        });
-
-        data.params.push(',123', '123');
-        assertDataToParams(data, '_handleQueryRoute', {
-          view: Gerrit.Nav.View.SEARCH,
-          query: 'project:foo/bar/baz',
-          offset: '123',
-        });
-      });
-
-      suite('_handleRegisterRoute', () => {
-        test('happy path', () => {
-          const ctx = {params: ['/foo/bar']};
-          element._handleRegisterRoute(ctx);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
-          assert.isTrue(setParamsStub.calledOnce);
-          assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-        });
-
-        test('no param', () => {
-          const ctx = {params: ['']};
-          element._handleRegisterRoute(ctx);
-          assert.isTrue(redirectStub.calledWithExactly('/'));
-          assert.isTrue(setParamsStub.calledOnce);
-          assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-        });
-
-        test('prevent redirect', () => {
-          const ctx = {params: ['/register']};
-          element._handleRegisterRoute(ctx);
-          assert.isTrue(redirectStub.calledWithExactly('/'));
-          assert.isTrue(setParamsStub.calledOnce);
-          assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-        });
-      });
-
-      suite('_handleRootRoute', () => {
-        test('closes for closeAfterLogin', () => {
-          const data = {querystring: 'closeAfterLogin', canonicalPath: ''};
-          const closeStub = sandbox.stub(window, 'close');
           const result = element._handleRootRoute(data);
           assert.isNotOk(result);
-          assert.isTrue(closeStub.called);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        });
+
+        test('redirects hash-path URLs w/o leading slash', () => {
+          const data = {
+            canonicalPath: '/#foo/bar/baz',
+            querystring: '',
+            hash: 'foo/bar/baz',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        });
+
+        test('normalizes "/ /" in hash to "/+/"', () => {
+          const data = {
+            canonicalPath: '/#/foo/bar/+/123/4',
+            querystring: '',
+            hash: '/foo/bar/ /123/4',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
+        });
+
+        test('prepends baseurl to hash-path', () => {
+          const data = {
+            canonicalPath: '/#/foo/bar',
+            querystring: '',
+            hash: '/foo/bar',
+          };
+          sandbox.stub(element, 'getBaseUrl').returns('/baz');
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
+        });
+
+        test('normalizes /VE/ settings hash-paths', () => {
+          const data = {
+            canonicalPath: '/#/VE/foo/bar',
+            querystring: '',
+            hash: '/VE/foo/bar',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly(
+              '/settings/VE/foo/bar'));
+        });
+
+        test('does not drop "inner hashes"', () => {
+          const data = {
+            canonicalPath: '/#/foo/bar#baz',
+            querystring: '',
+            hash: '/foo/bar',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
+        });
+      });
+    });
+
+    suite('_handleDashboardRoute', () => {
+      let redirectToLoginStub;
+
+      setup(() => {
+        redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
+      });
+
+      test('own dashboard but signed out redirects to login', () => {
+        sandbox.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(false));
+        const data = {canonicalPath: '/dashboard/', params: {0: 'seLF'}};
+        return element._handleDashboardRoute(data, '').then(() => {
+          assert.isTrue(redirectToLoginStub.calledOnce);
           assert.isFalse(redirectStub.called);
-        });
-
-        test('redirects to dashboard if logged in', () => {
-          sandbox.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(true));
-          const data = {
-            canonicalPath: '/', path: '/', querystring: '', hash: '',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isOk(result);
-          return result.then(() => {
-            assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
-          });
-        });
-
-        test('redirects to open changes if not logged in', () => {
-          sandbox.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(false));
-          const data = {
-            canonicalPath: '/', path: '/', querystring: '', hash: '',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isOk(result);
-          return result.then(() => {
-            assert.isTrue(redirectStub.calledWithExactly('/q/status:open'));
-          });
-        });
-
-        suite('GWT hash-path URLs', () => {
-          test('redirects hash-path URLs', () => {
-            const data = {
-              canonicalPath: '/#/foo/bar/baz',
-              hash: '/foo/bar/baz',
-              querystring: '',
-            };
-            const result = element._handleRootRoute(data);
-            assert.isNotOk(result);
-            assert.isTrue(redirectStub.called);
-            assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
-          });
-
-          test('redirects hash-path URLs w/o leading slash', () => {
-            const data = {
-              canonicalPath: '/#foo/bar/baz',
-              querystring: '',
-              hash: 'foo/bar/baz',
-            };
-            const result = element._handleRootRoute(data);
-            assert.isNotOk(result);
-            assert.isTrue(redirectStub.called);
-            assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
-          });
-
-          test('normalizes "/ /" in hash to "/+/"', () => {
-            const data = {
-              canonicalPath: '/#/foo/bar/+/123/4',
-              querystring: '',
-              hash: '/foo/bar/ /123/4',
-            };
-            const result = element._handleRootRoute(data);
-            assert.isNotOk(result);
-            assert.isTrue(redirectStub.called);
-            assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
-          });
-
-          test('prepends baseurl to hash-path', () => {
-            const data = {
-              canonicalPath: '/#/foo/bar',
-              querystring: '',
-              hash: '/foo/bar',
-            };
-            sandbox.stub(element, 'getBaseUrl').returns('/baz');
-            const result = element._handleRootRoute(data);
-            assert.isNotOk(result);
-            assert.isTrue(redirectStub.called);
-            assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
-          });
-
-          test('normalizes /VE/ settings hash-paths', () => {
-            const data = {
-              canonicalPath: '/#/VE/foo/bar',
-              querystring: '',
-              hash: '/VE/foo/bar',
-            };
-            const result = element._handleRootRoute(data);
-            assert.isNotOk(result);
-            assert.isTrue(redirectStub.called);
-            assert.isTrue(redirectStub.calledWithExactly(
-                '/settings/VE/foo/bar'));
-          });
-
-          test('does not drop "inner hashes"', () => {
-            const data = {
-              canonicalPath: '/#/foo/bar#baz',
-              querystring: '',
-              hash: '/foo/bar',
-            };
-            const result = element._handleRootRoute(data);
-            assert.isNotOk(result);
-            assert.isTrue(redirectStub.called);
-            assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
-          });
+          assert.isFalse(setParamsStub.called);
         });
       });
 
-      suite('_handleDashboardRoute', () => {
-        let redirectToLoginStub;
-
-        setup(() => {
-          redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
-        });
-
-        test('own dashboard but signed out redirects to login', () => {
-          sandbox.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(false));
-          const data = {canonicalPath: '/dashboard/', params: {0: 'seLF'}};
-          return element._handleDashboardRoute(data, '').then(() => {
-            assert.isTrue(redirectToLoginStub.calledOnce);
-            assert.isFalse(redirectStub.called);
-            assert.isFalse(setParamsStub.called);
-          });
-        });
-
-        test('non-self dashboard but signed out does not redirect', () => {
-          sandbox.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(false));
-          const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
-          return element._handleDashboardRoute(data, '').then(() => {
-            assert.isFalse(redirectToLoginStub.called);
-            assert.isFalse(setParamsStub.called);
-            assert.isTrue(redirectStub.calledOnce);
-            assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
-          });
-        });
-
-        test('dashboard while signed in sets params', () => {
-          sandbox.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(true));
-          const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
-          return element._handleDashboardRoute(data, '').then(() => {
-            assert.isFalse(redirectToLoginStub.called);
-            assert.isFalse(redirectStub.called);
-            assert.isTrue(setParamsStub.calledOnce);
-            assert.deepEqual(setParamsStub.lastCall.args[0], {
-              view: Gerrit.Nav.View.DASHBOARD,
-              user: 'foo',
-            });
-          });
-        });
-      });
-
-      suite('_handleCustomDashboardRoute', () => {
-        let redirectToLoginStub;
-
-        setup(() => {
-          redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
-        });
-
-        test('no user specified', () => {
-          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-          return element._handleCustomDashboardRoute(data, '').then(() => {
-            assert.isFalse(setParamsStub.called);
-            assert.isTrue(redirectStub.called);
-            assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
-          });
-        });
-
-        test('custom dashboard without title', () => {
-          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-          return element._handleCustomDashboardRoute(data, '?a=b&c&d=e')
-              .then(() => {
-                assert.isFalse(redirectStub.called);
-                assert.isTrue(setParamsStub.calledOnce);
-                assert.deepEqual(setParamsStub.lastCall.args[0], {
-                  view: Gerrit.Nav.View.DASHBOARD,
-                  user: 'self',
-                  sections: [
-                    {name: 'a', query: 'b'},
-                    {name: 'd', query: 'e'},
-                  ],
-                  title: 'Custom Dashboard',
-                });
-              });
-        });
-
-        test('custom dashboard with title', () => {
-          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-          return element._handleCustomDashboardRoute(data,
-              '?a=b&c&d=&=e&title=t')
-              .then(() => {
-                assert.isFalse(redirectToLoginStub.called);
-                assert.isFalse(redirectStub.called);
-                assert.isTrue(setParamsStub.calledOnce);
-                assert.deepEqual(setParamsStub.lastCall.args[0], {
-                  view: Gerrit.Nav.View.DASHBOARD,
-                  user: 'self',
-                  sections: [
-                    {name: 'a', query: 'b'},
-                  ],
-                  title: 't',
-                });
-              });
-        });
-
-        test('custom dashboard with foreach', () => {
-          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-          return element._handleCustomDashboardRoute(data,
-              '?a=b&c&d=&=e&foreach=is:open')
-              .then(() => {
-                assert.isFalse(redirectToLoginStub.called);
-                assert.isFalse(redirectStub.called);
-                assert.isTrue(setParamsStub.calledOnce);
-                assert.deepEqual(setParamsStub.lastCall.args[0], {
-                  view: Gerrit.Nav.View.DASHBOARD,
-                  user: 'self',
-                  sections: [
-                    {name: 'a', query: 'is:open b'},
-                  ],
-                  title: 'Custom Dashboard',
-                });
-              });
-        });
-      });
-
-      suite('group routes', () => {
-        test('_handleGroupInfoRoute', () => {
-          const data = {params: {0: 1234}};
-          element._handleGroupInfoRoute(data);
+      test('non-self dashboard but signed out does not redirect', () => {
+        sandbox.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(false));
+        const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
+        return element._handleDashboardRoute(data, '').then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(setParamsStub.called);
           assert.isTrue(redirectStub.calledOnce);
-          assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
+          assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
+        });
+      });
+
+      test('dashboard while signed in sets params', () => {
+        sandbox.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(true));
+        const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
+        return element._handleDashboardRoute(data, '').then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setParamsStub.calledOnce);
+          assert.deepEqual(setParamsStub.lastCall.args[0], {
+            view: Gerrit.Nav.View.DASHBOARD,
+            user: 'foo',
+          });
+        });
+      });
+    });
+
+    suite('_handleCustomDashboardRoute', () => {
+      let redirectToLoginStub;
+
+      setup(() => {
+        redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
+      });
+
+      test('no user specified', () => {
+        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+        return element._handleCustomDashboardRoute(data, '').then(() => {
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.called);
+          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+        });
+      });
+
+      test('custom dashboard without title', () => {
+        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+        return element._handleCustomDashboardRoute(data, '?a=b&c&d=e')
+            .then(() => {
+              assert.isFalse(redirectStub.called);
+              assert.isTrue(setParamsStub.calledOnce);
+              assert.deepEqual(setParamsStub.lastCall.args[0], {
+                view: Gerrit.Nav.View.DASHBOARD,
+                user: 'self',
+                sections: [
+                  {name: 'a', query: 'b'},
+                  {name: 'd', query: 'e'},
+                ],
+                title: 'Custom Dashboard',
+              });
+            });
+      });
+
+      test('custom dashboard with title', () => {
+        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+        return element._handleCustomDashboardRoute(data,
+            '?a=b&c&d=&=e&title=t')
+            .then(() => {
+              assert.isFalse(redirectToLoginStub.called);
+              assert.isFalse(redirectStub.called);
+              assert.isTrue(setParamsStub.calledOnce);
+              assert.deepEqual(setParamsStub.lastCall.args[0], {
+                view: Gerrit.Nav.View.DASHBOARD,
+                user: 'self',
+                sections: [
+                  {name: 'a', query: 'b'},
+                ],
+                title: 't',
+              });
+            });
+      });
+
+      test('custom dashboard with foreach', () => {
+        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+        return element._handleCustomDashboardRoute(data,
+            '?a=b&c&d=&=e&foreach=is:open')
+            .then(() => {
+              assert.isFalse(redirectToLoginStub.called);
+              assert.isFalse(redirectStub.called);
+              assert.isTrue(setParamsStub.calledOnce);
+              assert.deepEqual(setParamsStub.lastCall.args[0], {
+                view: Gerrit.Nav.View.DASHBOARD,
+                user: 'self',
+                sections: [
+                  {name: 'a', query: 'is:open b'},
+                ],
+                title: 'Custom Dashboard',
+              });
+            });
+      });
+    });
+
+    suite('group routes', () => {
+      test('_handleGroupInfoRoute', () => {
+        const data = {params: {0: 1234}};
+        element._handleGroupInfoRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
+      });
+
+      test('_handleGroupAuditLogRoute', () => {
+        const data = {params: {0: 1234}};
+        assertDataToParams(data, '_handleGroupAuditLogRoute', {
+          view: Gerrit.Nav.View.GROUP,
+          detail: 'log',
+          groupId: 1234,
+        });
+      });
+
+      test('_handleGroupMembersRoute', () => {
+        const data = {params: {0: 1234}};
+        assertDataToParams(data, '_handleGroupMembersRoute', {
+          view: Gerrit.Nav.View.GROUP,
+          detail: 'members',
+          groupId: 1234,
+        });
+      });
+
+      test('_handleGroupListOffsetRoute', () => {
+        const data = {params: {}};
+        assertDataToParams(data, '_handleGroupListOffsetRoute', {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 0,
+          filter: null,
+          openCreateModal: false,
         });
 
-        test('_handleGroupAuditLogRoute', () => {
-          const data = {params: {0: 1234}};
-          assertDataToParams(data, '_handleGroupAuditLogRoute', {
-            view: Gerrit.Nav.View.GROUP,
-            detail: 'log',
-            groupId: 1234,
+        data.params[1] = 42;
+        assertDataToParams(data, '_handleGroupListOffsetRoute', {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 42,
+          filter: null,
+          openCreateModal: false,
+        });
+
+        data.hash = 'create';
+        assertDataToParams(data, '_handleGroupListOffsetRoute', {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 42,
+          filter: null,
+          openCreateModal: true,
+        });
+      });
+
+      test('_handleGroupListFilterOffsetRoute', () => {
+        const data = {params: {filter: 'foo', offset: 42}};
+        assertDataToParams(data, '_handleGroupListFilterOffsetRoute', {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 42,
+          filter: 'foo',
+        });
+      });
+
+      test('_handleGroupListFilterRoute', () => {
+        const data = {params: {filter: 'foo'}};
+        assertDataToParams(data, '_handleGroupListFilterRoute', {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          filter: 'foo',
+        });
+      });
+
+      test('_handleGroupRoute', () => {
+        const data = {params: {0: 4321}};
+        assertDataToParams(data, '_handleGroupRoute', {
+          view: Gerrit.Nav.View.GROUP,
+          groupId: 4321,
+        });
+      });
+    });
+
+    suite('repo routes', () => {
+      test('_handleProjectsOldRoute', () => {
+        const data = {params: {}};
+        element._handleProjectsOldRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
+      });
+
+      test('_handleProjectsOldRoute test', () => {
+        const data = {params: {1: 'test'}};
+        element._handleProjectsOldRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
+      });
+
+      test('_handleProjectsOldRoute test,branches', () => {
+        const data = {params: {1: 'test,branches'}};
+        element._handleProjectsOldRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(
+            redirectStub.lastCall.args[0], '/admin/repos/test,branches');
+      });
+
+      test('_handleRepoRoute', () => {
+        const data = {params: {0: 4321}};
+        assertDataToParams(data, '_handleRepoRoute', {
+          view: Gerrit.Nav.View.REPO,
+          repo: 4321,
+        });
+      });
+
+      test('_handleRepoCommandsRoute', () => {
+        const data = {params: {0: 4321}};
+        assertDataToParams(data, '_handleRepoCommandsRoute', {
+          view: Gerrit.Nav.View.REPO,
+          detail: Gerrit.Nav.RepoDetailView.COMMANDS,
+          repo: 4321,
+        });
+      });
+
+      test('_handleRepoAccessRoute', () => {
+        const data = {params: {0: 4321}};
+        assertDataToParams(data, '_handleRepoAccessRoute', {
+          view: Gerrit.Nav.View.REPO,
+          detail: Gerrit.Nav.RepoDetailView.ACCESS,
+          repo: 4321,
+        });
+      });
+
+      suite('branch list routes', () => {
+        test('_handleBranchListOffsetRoute', () => {
+          const data = {params: {0: 4321}};
+          assertDataToParams(data, '_handleBranchListOffsetRoute', {
+            view: Gerrit.Nav.View.REPO,
+            detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+            repo: 4321,
+            offset: 0,
+            filter: null,
+          });
+
+          data.params[2] = 42;
+          assertDataToParams(data, '_handleBranchListOffsetRoute', {
+            view: Gerrit.Nav.View.REPO,
+            detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+            repo: 4321,
+            offset: 42,
+            filter: null,
           });
         });
 
-        test('_handleGroupMembersRoute', () => {
-          const data = {params: {0: 1234}};
-          assertDataToParams(data, '_handleGroupMembersRoute', {
-            view: Gerrit.Nav.View.GROUP,
-            detail: 'members',
-            groupId: 1234,
+        test('_handleBranchListFilterOffsetRoute', () => {
+          const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
+          assertDataToParams(data, '_handleBranchListFilterOffsetRoute', {
+            view: Gerrit.Nav.View.REPO,
+            detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+            repo: 4321,
+            offset: 42,
+            filter: 'foo',
           });
         });
 
-        test('_handleGroupListOffsetRoute', () => {
+        test('_handleBranchListFilterRoute', () => {
+          const data = {params: {repo: 4321, filter: 'foo'}};
+          assertDataToParams(data, '_handleBranchListFilterRoute', {
+            view: Gerrit.Nav.View.REPO,
+            detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+            repo: 4321,
+            filter: 'foo',
+          });
+        });
+      });
+
+      suite('tag list routes', () => {
+        test('_handleTagListOffsetRoute', () => {
+          const data = {params: {0: 4321}};
+          assertDataToParams(data, '_handleTagListOffsetRoute', {
+            view: Gerrit.Nav.View.REPO,
+            detail: Gerrit.Nav.RepoDetailView.TAGS,
+            repo: 4321,
+            offset: 0,
+            filter: null,
+          });
+        });
+
+        test('_handleTagListFilterOffsetRoute', () => {
+          const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
+          assertDataToParams(data, '_handleTagListFilterOffsetRoute', {
+            view: Gerrit.Nav.View.REPO,
+            detail: Gerrit.Nav.RepoDetailView.TAGS,
+            repo: 4321,
+            offset: 42,
+            filter: 'foo',
+          });
+        });
+
+        test('_handleTagListFilterRoute', () => {
+          const data = {params: {repo: 4321}};
+          assertDataToParams(data, '_handleTagListFilterRoute', {
+            view: Gerrit.Nav.View.REPO,
+            detail: Gerrit.Nav.RepoDetailView.TAGS,
+            repo: 4321,
+            filter: null,
+          });
+
+          data.params.filter = 'foo';
+          assertDataToParams(data, '_handleTagListFilterRoute', {
+            view: Gerrit.Nav.View.REPO,
+            detail: Gerrit.Nav.RepoDetailView.TAGS,
+            repo: 4321,
+            filter: 'foo',
+          });
+        });
+      });
+
+      suite('repo list routes', () => {
+        test('_handleRepoListOffsetRoute', () => {
           const data = {params: {}};
-          assertDataToParams(data, '_handleGroupListOffsetRoute', {
+          assertDataToParams(data, '_handleRepoListOffsetRoute', {
             view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
+            adminView: 'gr-repo-list',
             offset: 0,
             filter: null,
             openCreateModal: false,
           });
 
           data.params[1] = 42;
-          assertDataToParams(data, '_handleGroupListOffsetRoute', {
+          assertDataToParams(data, '_handleRepoListOffsetRoute', {
             view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
+            adminView: 'gr-repo-list',
             offset: 42,
             filter: null,
             openCreateModal: false,
           });
 
           data.hash = 'create';
-          assertDataToParams(data, '_handleGroupListOffsetRoute', {
+          assertDataToParams(data, '_handleRepoListOffsetRoute', {
             view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
+            adminView: 'gr-repo-list',
             offset: 42,
             filter: null,
             openCreateModal: true,
           });
         });
 
-        test('_handleGroupListFilterOffsetRoute', () => {
+        test('_handleRepoListFilterOffsetRoute', () => {
           const data = {params: {filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handleGroupListFilterOffsetRoute', {
+          assertDataToParams(data, '_handleRepoListFilterOffsetRoute', {
             view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
+            adminView: 'gr-repo-list',
             offset: 42,
             filter: 'foo',
           });
         });
 
-        test('_handleGroupListFilterRoute', () => {
-          const data = {params: {filter: 'foo'}};
-          assertDataToParams(data, '_handleGroupListFilterRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
-            filter: 'foo',
-          });
-        });
-
-        test('_handleGroupRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleGroupRoute', {
-            view: Gerrit.Nav.View.GROUP,
-            groupId: 4321,
-          });
-        });
-      });
-
-      suite('repo routes', () => {
-        test('_handleProjectsOldRoute', () => {
+        test('_handleRepoListFilterRoute', () => {
           const data = {params: {}};
-          element._handleProjectsOldRoute(data);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
-        });
-
-        test('_handleProjectsOldRoute test', () => {
-          const data = {params: {1: 'test'}};
-          element._handleProjectsOldRoute(data);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
-        });
-
-        test('_handleProjectsOldRoute test,branches', () => {
-          const data = {params: {1: 'test,branches'}};
-          element._handleProjectsOldRoute(data);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(
-              redirectStub.lastCall.args[0], '/admin/repos/test,branches');
-        });
-
-        test('_handleRepoRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleRepoRoute', {
-            view: Gerrit.Nav.View.REPO,
-            repo: 4321,
-          });
-        });
-
-        test('_handleRepoCommandsRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleRepoCommandsRoute', {
-            view: Gerrit.Nav.View.REPO,
-            detail: Gerrit.Nav.RepoDetailView.COMMANDS,
-            repo: 4321,
-          });
-        });
-
-        test('_handleRepoAccessRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleRepoAccessRoute', {
-            view: Gerrit.Nav.View.REPO,
-            detail: Gerrit.Nav.RepoDetailView.ACCESS,
-            repo: 4321,
-          });
-        });
-
-        suite('branch list routes', () => {
-          test('_handleBranchListOffsetRoute', () => {
-            const data = {params: {0: 4321}};
-            assertDataToParams(data, '_handleBranchListOffsetRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-              repo: 4321,
-              offset: 0,
-              filter: null,
-            });
-
-            data.params[2] = 42;
-            assertDataToParams(data, '_handleBranchListOffsetRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-              repo: 4321,
-              offset: 42,
-              filter: null,
-            });
-          });
-
-          test('_handleBranchListFilterOffsetRoute', () => {
-            const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
-            assertDataToParams(data, '_handleBranchListFilterOffsetRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-              repo: 4321,
-              offset: 42,
-              filter: 'foo',
-            });
-          });
-
-          test('_handleBranchListFilterRoute', () => {
-            const data = {params: {repo: 4321, filter: 'foo'}};
-            assertDataToParams(data, '_handleBranchListFilterRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-              repo: 4321,
-              filter: 'foo',
-            });
-          });
-        });
-
-        suite('tag list routes', () => {
-          test('_handleTagListOffsetRoute', () => {
-            const data = {params: {0: 4321}};
-            assertDataToParams(data, '_handleTagListOffsetRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.TAGS,
-              repo: 4321,
-              offset: 0,
-              filter: null,
-            });
-          });
-
-          test('_handleTagListFilterOffsetRoute', () => {
-            const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
-            assertDataToParams(data, '_handleTagListFilterOffsetRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.TAGS,
-              repo: 4321,
-              offset: 42,
-              filter: 'foo',
-            });
-          });
-
-          test('_handleTagListFilterRoute', () => {
-            const data = {params: {repo: 4321}};
-            assertDataToParams(data, '_handleTagListFilterRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.TAGS,
-              repo: 4321,
-              filter: null,
-            });
-
-            data.params.filter = 'foo';
-            assertDataToParams(data, '_handleTagListFilterRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.TAGS,
-              repo: 4321,
-              filter: 'foo',
-            });
-          });
-        });
-
-        suite('repo list routes', () => {
-          test('_handleRepoListOffsetRoute', () => {
-            const data = {params: {}};
-            assertDataToParams(data, '_handleRepoListOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-repo-list',
-              offset: 0,
-              filter: null,
-              openCreateModal: false,
-            });
-
-            data.params[1] = 42;
-            assertDataToParams(data, '_handleRepoListOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-repo-list',
-              offset: 42,
-              filter: null,
-              openCreateModal: false,
-            });
-
-            data.hash = 'create';
-            assertDataToParams(data, '_handleRepoListOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-repo-list',
-              offset: 42,
-              filter: null,
-              openCreateModal: true,
-            });
-          });
-
-          test('_handleRepoListFilterOffsetRoute', () => {
-            const data = {params: {filter: 'foo', offset: 42}};
-            assertDataToParams(data, '_handleRepoListFilterOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-repo-list',
-              offset: 42,
-              filter: 'foo',
-            });
-          });
-
-          test('_handleRepoListFilterRoute', () => {
-            const data = {params: {}};
-            assertDataToParams(data, '_handleRepoListFilterRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-repo-list',
-              filter: null,
-            });
-
-            data.params.filter = 'foo';
-            assertDataToParams(data, '_handleRepoListFilterRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-repo-list',
-              filter: 'foo',
-            });
-          });
-        });
-      });
-
-      suite('plugin routes', () => {
-        test('_handlePluginListOffsetRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handlePluginListOffsetRoute', {
+          assertDataToParams(data, '_handleRepoListFilterRoute', {
             view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
-            offset: 0,
-            filter: null,
-          });
-
-          data.params[1] = 42;
-          assertDataToParams(data, '_handlePluginListOffsetRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
-            offset: 42,
-            filter: null,
-          });
-        });
-
-        test('_handlePluginListFilterOffsetRoute', () => {
-          const data = {params: {filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handlePluginListFilterOffsetRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
-            offset: 42,
-            filter: 'foo',
-          });
-        });
-
-        test('_handlePluginListFilterRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handlePluginListFilterRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
+            adminView: 'gr-repo-list',
             filter: null,
           });
 
           data.params.filter = 'foo';
-          assertDataToParams(data, '_handlePluginListFilterRoute', {
+          assertDataToParams(data, '_handleRepoListFilterRoute', {
             view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
+            adminView: 'gr-repo-list',
             filter: 'foo',
           });
         });
-
-        test('_handlePluginListRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handlePluginListRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
-          });
-        });
-      });
-
-      suite('change/diff routes', () => {
-        test('_handleChangeNumberLegacyRoute', () => {
-          const data = {params: {0: 12345}};
-          element._handleChangeNumberLegacyRoute(data);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
-        });
-
-        test('_handleChangeLegacyRoute', () => {
-          const normalizeRouteStub = sandbox.stub(element,
-              '_normalizeLegacyRouteParams');
-          const ctx = {
-            params: [
-              1234, // 0 Change number
-              null, // 1 Unused
-              null, // 2 Unused
-              6, // 3 Base patch number
-              null, // 4 Unused
-              9, // 5 Patch number
-            ],
-            querystring: '',
-          };
-          element._handleChangeLegacyRoute(ctx);
-          assert.isTrue(normalizeRouteStub.calledOnce);
-          assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
-            changeNum: 1234,
-            basePatchNum: 6,
-            patchNum: 9,
-            view: Gerrit.Nav.View.CHANGE,
-            querystring: '',
-          });
-        });
-
-        test('_handleDiffLegacyRoute', () => {
-          const normalizeRouteStub = sandbox.stub(element,
-              '_normalizeLegacyRouteParams');
-          const ctx = {
-            params: [
-              1234, // 0 Change number
-              null, // 1 Unused
-              3, // 2 Base patch number
-              null, // 3 Unused
-              8, // 4 Patch number
-              'foo/bar', // 5 Diff path
-            ],
-            path: '/c/1234/3..8/foo/bar',
-            hash: 'b123',
-          };
-          element._handleDiffLegacyRoute(ctx);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRouteStub.calledOnce);
-          assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
-            changeNum: 1234,
-            basePatchNum: 3,
-            patchNum: 8,
-            view: Gerrit.Nav.View.DIFF,
-            path: 'foo/bar',
-            lineNum: 123,
-            leftSide: true,
-          });
-        });
-
-        test('_handleLegacyLinenum w/ @321', () => {
-          const ctx = {path: '/c/1234/3..8/foo/bar@321'};
-          element._handleLegacyLinenum(ctx);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isTrue(redirectStub.calledWithExactly(
-              '/c/1234/3..8/foo/bar#321'));
-        });
-
-        test('_handleLegacyLinenum w/ @b123', () => {
-          const ctx = {path: '/c/1234/3..8/foo/bar@b123'};
-          element._handleLegacyLinenum(ctx);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isTrue(redirectStub.calledWithExactly(
-              '/c/1234/3..8/foo/bar#b123'));
-        });
-
-        suite('_handleChangeRoute', () => {
-          let normalizeRangeStub;
-
-          function makeParams(path, hash) {
-            return {
-              params: [
-                'foo/bar', // 0 Project
-                1234, // 1 Change number
-                null, // 2 Unused
-                null, // 3 Unused
-                4, // 4 Base patch number
-                null, // 5 Unused
-                7, // 6 Patch number
-              ],
-            };
-          }
-
-          setup(() => {
-            normalizeRangeStub = sandbox.stub(element,
-                '_normalizePatchRangeParams');
-            sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-          });
-
-          test('needs redirect', () => {
-            normalizeRangeStub.returns(true);
-            sandbox.stub(element, '_generateUrl').returns('foo');
-            const ctx = makeParams(null, '');
-            element._handleChangeRoute(ctx);
-            assert.isTrue(normalizeRangeStub.called);
-            assert.isFalse(setParamsStub.called);
-            assert.isTrue(redirectStub.calledOnce);
-            assert.isTrue(redirectStub.calledWithExactly('foo'));
-          });
-
-          test('change view', () => {
-            normalizeRangeStub.returns(false);
-            sandbox.stub(element, '_generateUrl').returns('foo');
-            const ctx = makeParams(null, '');
-            assertDataToParams(ctx, '_handleChangeRoute', {
-              view: Gerrit.Nav.View.CHANGE,
-              project: 'foo/bar',
-              changeNum: 1234,
-              basePatchNum: 4,
-              patchNum: 7,
-            });
-            assert.isFalse(redirectStub.called);
-            assert.isTrue(normalizeRangeStub.called);
-          });
-        });
-
-        suite('_handleDiffRoute', () => {
-          let normalizeRangeStub;
-
-          function makeParams(path, hash) {
-            return {
-              params: [
-                'foo/bar', // 0 Project
-                1234, // 1 Change number
-                null, // 2 Unused
-                null, // 3 Unused
-                4, // 4 Base patch number
-                null, // 5 Unused
-                7, // 6 Patch number
-                null, // 7 Unused,
-                path, // 8 Diff path
-              ],
-              hash,
-            };
-          }
-
-          setup(() => {
-            normalizeRangeStub = sandbox.stub(element,
-                '_normalizePatchRangeParams');
-            sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-          });
-
-          test('needs redirect', () => {
-            normalizeRangeStub.returns(true);
-            sandbox.stub(element, '_generateUrl').returns('foo');
-            const ctx = makeParams(null, '');
-            element._handleDiffRoute(ctx);
-            assert.isTrue(normalizeRangeStub.called);
-            assert.isFalse(setParamsStub.called);
-            assert.isTrue(redirectStub.calledOnce);
-            assert.isTrue(redirectStub.calledWithExactly('foo'));
-          });
-
-          test('diff view', () => {
-            normalizeRangeStub.returns(false);
-            sandbox.stub(element, '_generateUrl').returns('foo');
-            const ctx = makeParams('foo/bar/baz', 'b44');
-            assertDataToParams(ctx, '_handleDiffRoute', {
-              view: Gerrit.Nav.View.DIFF,
-              project: 'foo/bar',
-              changeNum: 1234,
-              basePatchNum: 4,
-              patchNum: 7,
-              path: 'foo/bar/baz',
-              leftSide: true,
-              lineNum: 44,
-            });
-            assert.isFalse(redirectStub.called);
-            assert.isTrue(normalizeRangeStub.called);
-          });
-        });
-
-        test('_handleDiffEditRoute', () => {
-          const normalizeRangeSpy =
-              sandbox.spy(element, '_normalizePatchRangeParams');
-          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-          const ctx = {
-            params: [
-              'foo/bar', // 0 Project
-              1234, // 1 Change number
-              3, // 2 Patch num
-              'foo/bar/baz', // 3 File path
-            ],
-          };
-          const appParams = {
-            project: 'foo/bar',
-            changeNum: 1234,
-            view: Gerrit.Nav.View.EDIT,
-            path: 'foo/bar/baz',
-            patchNum: 3,
-            lineNum: undefined,
-          };
-
-          element._handleDiffEditRoute(ctx);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeSpy.calledOnce);
-          assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-          assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-          assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-        });
-
-        test('_handleDiffEditRoute with lineNum', () => {
-          const normalizeRangeSpy =
-              sandbox.spy(element, '_normalizePatchRangeParams');
-          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-          const ctx = {
-            params: [
-              'foo/bar', // 0 Project
-              1234, // 1 Change number
-              3, // 2 Patch num
-              'foo/bar/baz', // 3 File path
-            ],
-            hash: 4,
-          };
-          const appParams = {
-            project: 'foo/bar',
-            changeNum: 1234,
-            view: Gerrit.Nav.View.EDIT,
-            path: 'foo/bar/baz',
-            patchNum: 3,
-            lineNum: 4,
-          };
-
-          element._handleDiffEditRoute(ctx);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeSpy.calledOnce);
-          assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-          assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-          assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-        });
-
-        test('_handleChangeEditRoute', () => {
-          const normalizeRangeSpy =
-              sandbox.spy(element, '_normalizePatchRangeParams');
-          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-          const ctx = {
-            params: [
-              'foo/bar', // 0 Project
-              1234, // 1 Change number
-              null,
-              3, // 3 Patch num
-            ],
-          };
-          const appParams = {
-            project: 'foo/bar',
-            changeNum: 1234,
-            view: Gerrit.Nav.View.CHANGE,
-            patchNum: 3,
-            edit: true,
-          };
-
-          element._handleChangeEditRoute(ctx);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeSpy.calledOnce);
-          assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-          assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-          assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-        });
-      });
-
-      test('_handlePluginScreen', () => {
-        const ctx = {params: ['foo', 'bar']};
-        assertDataToParams(ctx, '_handlePluginScreen', {
-          view: Gerrit.Nav.View.PLUGIN_SCREEN,
-          plugin: 'foo',
-          screen: 'bar',
-        });
-        assert.isFalse(redirectStub.called);
       });
     });
 
-    suite('_parseQueryString', () => {
-      test('empty queries', () => {
-        assert.deepEqual(element._parseQueryString(''), []);
-        assert.deepEqual(element._parseQueryString('?'), []);
-        assert.deepEqual(element._parseQueryString('??'), []);
-        assert.deepEqual(element._parseQueryString('&&&'), []);
+    suite('plugin routes', () => {
+      test('_handlePluginListOffsetRoute', () => {
+        const data = {params: {}};
+        assertDataToParams(data, '_handlePluginListOffsetRoute', {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: 0,
+          filter: null,
+        });
+
+        data.params[1] = 42;
+        assertDataToParams(data, '_handlePluginListOffsetRoute', {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: 42,
+          filter: null,
+        });
       });
 
-      test('url decoding', () => {
-        assert.deepEqual(element._parseQueryString('+'), [[' ', '']]);
-        assert.deepEqual(element._parseQueryString('???+%3d+'), [[' = ', '']]);
-        assert.deepEqual(
-            element._parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
-            [['name', 'value']]);
+      test('_handlePluginListFilterOffsetRoute', () => {
+        const data = {params: {filter: 'foo', offset: 42}};
+        assertDataToParams(data, '_handlePluginListFilterOffsetRoute', {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: 42,
+          filter: 'foo',
+        });
       });
 
-      test('multiple parameters', () => {
-        assert.deepEqual(
-            element._parseQueryString('a=b&c=d&e=f'),
-            [['a', 'b'], ['c', 'd'], ['e', 'f']]);
-        assert.deepEqual(
-            element._parseQueryString('&a=b&&&e=f&'),
-            [['a', 'b'], ['e', 'f']]);
+      test('_handlePluginListFilterRoute', () => {
+        const data = {params: {}};
+        assertDataToParams(data, '_handlePluginListFilterRoute', {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          filter: null,
+        });
+
+        data.params.filter = 'foo';
+        assertDataToParams(data, '_handlePluginListFilterRoute', {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          filter: 'foo',
+        });
       });
+
+      test('_handlePluginListRoute', () => {
+        const data = {params: {}};
+        assertDataToParams(data, '_handlePluginListRoute', {
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+        });
+      });
+    });
+
+    suite('change/diff routes', () => {
+      test('_handleChangeNumberLegacyRoute', () => {
+        const data = {params: {0: 12345}};
+        element._handleChangeNumberLegacyRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
+      });
+
+      test('_handleChangeLegacyRoute', () => {
+        const normalizeRouteStub = sandbox.stub(element,
+            '_normalizeLegacyRouteParams');
+        const ctx = {
+          params: [
+            1234, // 0 Change number
+            null, // 1 Unused
+            null, // 2 Unused
+            6, // 3 Base patch number
+            null, // 4 Unused
+            9, // 5 Patch number
+          ],
+          querystring: '',
+        };
+        element._handleChangeLegacyRoute(ctx);
+        assert.isTrue(normalizeRouteStub.calledOnce);
+        assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
+          changeNum: 1234,
+          basePatchNum: 6,
+          patchNum: 9,
+          view: Gerrit.Nav.View.CHANGE,
+          querystring: '',
+        });
+      });
+
+      test('_handleDiffLegacyRoute', () => {
+        const normalizeRouteStub = sandbox.stub(element,
+            '_normalizeLegacyRouteParams');
+        const ctx = {
+          params: [
+            1234, // 0 Change number
+            null, // 1 Unused
+            3, // 2 Base patch number
+            null, // 3 Unused
+            8, // 4 Patch number
+            'foo/bar', // 5 Diff path
+          ],
+          path: '/c/1234/3..8/foo/bar',
+          hash: 'b123',
+        };
+        element._handleDiffLegacyRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRouteStub.calledOnce);
+        assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
+          changeNum: 1234,
+          basePatchNum: 3,
+          patchNum: 8,
+          view: Gerrit.Nav.View.DIFF,
+          path: 'foo/bar',
+          lineNum: 123,
+          leftSide: true,
+        });
+      });
+
+      test('_handleLegacyLinenum w/ @321', () => {
+        const ctx = {path: '/c/1234/3..8/foo/bar@321'};
+        element._handleLegacyLinenum(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(redirectStub.calledWithExactly(
+            '/c/1234/3..8/foo/bar#321'));
+      });
+
+      test('_handleLegacyLinenum w/ @b123', () => {
+        const ctx = {path: '/c/1234/3..8/foo/bar@b123'};
+        element._handleLegacyLinenum(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(redirectStub.calledWithExactly(
+            '/c/1234/3..8/foo/bar#b123'));
+      });
+
+      suite('_handleChangeRoute', () => {
+        let normalizeRangeStub;
+
+        function makeParams(path, hash) {
+          return {
+            params: [
+              'foo/bar', // 0 Project
+              1234, // 1 Change number
+              null, // 2 Unused
+              null, // 3 Unused
+              4, // 4 Base patch number
+              null, // 5 Unused
+              7, // 6 Patch number
+            ],
+          };
+        }
+
+        setup(() => {
+          normalizeRangeStub = sandbox.stub(element,
+              '_normalizePatchRangeParams');
+          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+        });
+
+        test('needs redirect', () => {
+          normalizeRangeStub.returns(true);
+          sandbox.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams(null, '');
+          element._handleChangeRoute(ctx);
+          assert.isTrue(normalizeRangeStub.called);
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isTrue(redirectStub.calledWithExactly('foo'));
+        });
+
+        test('change view', () => {
+          normalizeRangeStub.returns(false);
+          sandbox.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams(null, '');
+          assertDataToParams(ctx, '_handleChangeRoute', {
+            view: Gerrit.Nav.View.CHANGE,
+            project: 'foo/bar',
+            changeNum: 1234,
+            basePatchNum: 4,
+            patchNum: 7,
+          });
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(normalizeRangeStub.called);
+        });
+      });
+
+      suite('_handleDiffRoute', () => {
+        let normalizeRangeStub;
+
+        function makeParams(path, hash) {
+          return {
+            params: [
+              'foo/bar', // 0 Project
+              1234, // 1 Change number
+              null, // 2 Unused
+              null, // 3 Unused
+              4, // 4 Base patch number
+              null, // 5 Unused
+              7, // 6 Patch number
+              null, // 7 Unused,
+              path, // 8 Diff path
+            ],
+            hash,
+          };
+        }
+
+        setup(() => {
+          normalizeRangeStub = sandbox.stub(element,
+              '_normalizePatchRangeParams');
+          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+        });
+
+        test('needs redirect', () => {
+          normalizeRangeStub.returns(true);
+          sandbox.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams(null, '');
+          element._handleDiffRoute(ctx);
+          assert.isTrue(normalizeRangeStub.called);
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isTrue(redirectStub.calledWithExactly('foo'));
+        });
+
+        test('diff view', () => {
+          normalizeRangeStub.returns(false);
+          sandbox.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams('foo/bar/baz', 'b44');
+          assertDataToParams(ctx, '_handleDiffRoute', {
+            view: Gerrit.Nav.View.DIFF,
+            project: 'foo/bar',
+            changeNum: 1234,
+            basePatchNum: 4,
+            patchNum: 7,
+            path: 'foo/bar/baz',
+            leftSide: true,
+            lineNum: 44,
+          });
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(normalizeRangeStub.called);
+        });
+      });
+
+      test('_handleDiffEditRoute', () => {
+        const normalizeRangeSpy =
+            sandbox.spy(element, '_normalizePatchRangeParams');
+        sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+        const ctx = {
+          params: [
+            'foo/bar', // 0 Project
+            1234, // 1 Change number
+            3, // 2 Patch num
+            'foo/bar/baz', // 3 File path
+          ],
+        };
+        const appParams = {
+          project: 'foo/bar',
+          changeNum: 1234,
+          view: Gerrit.Nav.View.EDIT,
+          path: 'foo/bar/baz',
+          patchNum: 3,
+          lineNum: undefined,
+        };
+
+        element._handleDiffEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRangeSpy.calledOnce);
+        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+      });
+
+      test('_handleDiffEditRoute with lineNum', () => {
+        const normalizeRangeSpy =
+            sandbox.spy(element, '_normalizePatchRangeParams');
+        sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+        const ctx = {
+          params: [
+            'foo/bar', // 0 Project
+            1234, // 1 Change number
+            3, // 2 Patch num
+            'foo/bar/baz', // 3 File path
+          ],
+          hash: 4,
+        };
+        const appParams = {
+          project: 'foo/bar',
+          changeNum: 1234,
+          view: Gerrit.Nav.View.EDIT,
+          path: 'foo/bar/baz',
+          patchNum: 3,
+          lineNum: 4,
+        };
+
+        element._handleDiffEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRangeSpy.calledOnce);
+        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+      });
+
+      test('_handleChangeEditRoute', () => {
+        const normalizeRangeSpy =
+            sandbox.spy(element, '_normalizePatchRangeParams');
+        sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+        const ctx = {
+          params: [
+            'foo/bar', // 0 Project
+            1234, // 1 Change number
+            null,
+            3, // 3 Patch num
+          ],
+        };
+        const appParams = {
+          project: 'foo/bar',
+          changeNum: 1234,
+          view: Gerrit.Nav.View.CHANGE,
+          patchNum: 3,
+          edit: true,
+        };
+
+        element._handleChangeEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRangeSpy.calledOnce);
+        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+      });
+    });
+
+    test('_handlePluginScreen', () => {
+      const ctx = {params: ['foo', 'bar']};
+      assertDataToParams(ctx, '_handlePluginScreen', {
+        view: Gerrit.Nav.View.PLUGIN_SCREEN,
+        plugin: 'foo',
+        screen: 'bar',
+      });
+      assert.isFalse(redirectStub.called);
     });
   });
+
+  suite('_parseQueryString', () => {
+    test('empty queries', () => {
+      assert.deepEqual(element._parseQueryString(''), []);
+      assert.deepEqual(element._parseQueryString('?'), []);
+      assert.deepEqual(element._parseQueryString('??'), []);
+      assert.deepEqual(element._parseQueryString('&&&'), []);
+    });
+
+    test('url decoding', () => {
+      assert.deepEqual(element._parseQueryString('+'), [[' ', '']]);
+      assert.deepEqual(element._parseQueryString('???+%3d+'), [[' = ', '']]);
+      assert.deepEqual(
+          element._parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
+          [['name', 'value']]);
+    });
+
+    test('multiple parameters', () => {
+      assert.deepEqual(
+          element._parseQueryString('a=b&c=d&e=f'),
+          [['a', 'b'], ['c', 'd'], ['e', 'f']]);
+      assert.deepEqual(
+          element._parseQueryString('&a=b&&&e=f&'),
+          [['a', 'b'], ['e', 'f']]);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 41caab5..0ed5291 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,320 +14,332 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 
-  // Possible static search options for auto complete, without negations.
-  const SEARCH_OPERATORS = [
-    'added:',
-    'age:',
-    'age:1week', // Give an example age
-    'assignee:',
-    'author:',
-    'branch:',
-    'bug:',
-    'cc:',
-    'cc:self',
-    'change:',
-    'cherrypickof:',
-    'comment:',
-    'commentby:',
-    'commit:',
-    'committer:',
-    'conflicts:',
-    'deleted:',
-    'delta:',
-    'dir:',
-    'directory:',
-    'ext:',
-    'extension:',
-    'file:',
-    'footer:',
-    'from:',
-    'has:',
-    'has:draft',
-    'has:edit',
-    'has:star',
-    'has:stars',
-    'has:unresolved',
-    'hashtag:',
-    'intopic:',
-    'is:',
-    'is:abandoned',
-    'is:assigned',
-    'is:closed',
-    'is:ignored',
-    'is:merged',
-    'is:open',
-    'is:owner',
-    'is:private',
-    'is:reviewed',
-    'is:reviewer',
-    'is:starred',
-    'is:submittable',
-    'is:watched',
-    'is:wip',
-    'label:',
-    'message:',
-    'onlyexts:',
-    'onlyextensions:',
-    'owner:',
-    'ownerin:',
-    'parentproject:',
-    'project:',
-    'projects:',
-    'query:',
-    'ref:',
-    'reviewedby:',
-    'reviewer:',
-    'reviewer:self',
-    'reviewerin:',
-    'size:',
-    'star:',
-    'status:',
-    'status:abandoned',
-    'status:closed',
-    'status:merged',
-    'status:open',
-    'status:reviewed',
-    'submissionid:',
-    'topic:',
-    'tr:',
-  ];
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../../../scripts/bundled-polymer.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-search-bar_html.js';
 
-  // All of the ops, with corresponding negations.
-  const SEARCH_OPERATORS_WITH_NEGATIONS_SET =
-    new Set(SEARCH_OPERATORS.concat(SEARCH_OPERATORS.map(op => `-${op}`)));
+// Possible static search options for auto complete, without negations.
+const SEARCH_OPERATORS = [
+  'added:',
+  'age:',
+  'age:1week', // Give an example age
+  'assignee:',
+  'author:',
+  'branch:',
+  'bug:',
+  'cc:',
+  'cc:self',
+  'change:',
+  'cherrypickof:',
+  'comment:',
+  'commentby:',
+  'commit:',
+  'committer:',
+  'conflicts:',
+  'deleted:',
+  'delta:',
+  'dir:',
+  'directory:',
+  'ext:',
+  'extension:',
+  'file:',
+  'footer:',
+  'from:',
+  'has:',
+  'has:draft',
+  'has:edit',
+  'has:star',
+  'has:stars',
+  'has:unresolved',
+  'hashtag:',
+  'intopic:',
+  'is:',
+  'is:abandoned',
+  'is:assigned',
+  'is:closed',
+  'is:ignored',
+  'is:merged',
+  'is:open',
+  'is:owner',
+  'is:private',
+  'is:reviewed',
+  'is:reviewer',
+  'is:starred',
+  'is:submittable',
+  'is:watched',
+  'is:wip',
+  'label:',
+  'message:',
+  'onlyexts:',
+  'onlyextensions:',
+  'owner:',
+  'ownerin:',
+  'parentproject:',
+  'project:',
+  'projects:',
+  'query:',
+  'ref:',
+  'reviewedby:',
+  'reviewer:',
+  'reviewer:self',
+  'reviewerin:',
+  'size:',
+  'star:',
+  'status:',
+  'status:abandoned',
+  'status:closed',
+  'status:merged',
+  'status:open',
+  'status:reviewed',
+  'submissionid:',
+  'topic:',
+  'tr:',
+];
 
-  const MAX_AUTOCOMPLETE_RESULTS = 10;
+// All of the ops, with corresponding negations.
+const SEARCH_OPERATORS_WITH_NEGATIONS_SET =
+  new Set(SEARCH_OPERATORS.concat(SEARCH_OPERATORS.map(op => `-${op}`)));
 
-  const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
+const MAX_AUTOCOMPLETE_RESULTS = 10;
 
+const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
+
+/**
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrSearchBar extends mixinBehaviors( [
+  Gerrit.KeyboardShortcutBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-search-bar'; }
   /**
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
+   * Fired when a search is committed
+   *
+   * @event handle-search
    */
-  class GrSearchBar extends Polymer.mixinBehaviors( [
-    Gerrit.KeyboardShortcutBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-search-bar'; }
-    /**
-     * Fired when a search is committed
-     *
-     * @event handle-search
-     */
 
-    static get properties() {
-      return {
-        value: {
-          type: String,
-          value: '',
-          notify: true,
-          observer: '_valueChanged',
+  static get properties() {
+    return {
+      value: {
+        type: String,
+        value: '',
+        notify: true,
+        observer: '_valueChanged',
+      },
+      keyEventTarget: {
+        type: Object,
+        value() { return document.body; },
+      },
+      query: {
+        type: Function,
+        value() {
+          return this._getSearchSuggestions.bind(this);
         },
-        keyEventTarget: {
-          type: Object,
-          value() { return document.body; },
+      },
+      projectSuggestions: {
+        type: Function,
+        value() {
+          return () => Promise.resolve([]);
         },
-        query: {
-          type: Function,
-          value() {
-            return this._getSearchSuggestions.bind(this);
-          },
+      },
+      groupSuggestions: {
+        type: Function,
+        value() {
+          return () => Promise.resolve([]);
         },
-        projectSuggestions: {
-          type: Function,
-          value() {
-            return () => Promise.resolve([]);
-          },
+      },
+      accountSuggestions: {
+        type: Function,
+        value() {
+          return () => Promise.resolve([]);
         },
-        groupSuggestions: {
-          type: Function,
-          value() {
-            return () => Promise.resolve([]);
-          },
-        },
-        accountSuggestions: {
-          type: Function,
-          value() {
-            return () => Promise.resolve([]);
-          },
-        },
-        _inputVal: String,
-        _threshold: {
-          type: Number,
-          value: 1,
-        },
-      };
-    }
+      },
+      _inputVal: String,
+      _threshold: {
+        type: Number,
+        value: 1,
+      },
+    };
+  }
 
-    attached() {
-      super.attached();
-      this.$.restAPI.getConfig().then(serverConfig => {
-        const mergeability = serverConfig
-         && serverConfig.index
-          && serverConfig.index.mergeabilityComputationBehavior;
-        if (mergeability === 'API_REF_UPDATED_AND_CHANGE_REINDEX'
-        || mergeability === 'REF_UPDATED_AND_CHANGE_REINDEX') {
-          // add 'is:mergeable' to SEARCH_OPERATORS_WITH_NEGATIONS_SET
-          this._addOperator('is:mergeable');
-        }
-      });
-    }
-
-    _addOperator(name, include_neg = true) {
-      SEARCH_OPERATORS_WITH_NEGATIONS_SET.add(name);
-      if (include_neg) {
-        SEARCH_OPERATORS_WITH_NEGATIONS_SET.add(`-${name}`);
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(serverConfig => {
+      const mergeability = serverConfig
+       && serverConfig.index
+        && serverConfig.index.mergeabilityComputationBehavior;
+      if (mergeability === 'API_REF_UPDATED_AND_CHANGE_REINDEX'
+      || mergeability === 'REF_UPDATED_AND_CHANGE_REINDEX') {
+        // add 'is:mergeable' to SEARCH_OPERATORS_WITH_NEGATIONS_SET
+        this._addOperator('is:mergeable');
       }
-    }
+    });
+  }
 
-    keyboardShortcuts() {
-      return {
-        [this.Shortcut.SEARCH]: '_handleSearch',
-      };
-    }
-
-    _valueChanged(value) {
-      this._inputVal = value;
-    }
-
-    _handleInputCommit(e) {
-      this._preventDefaultAndNavigateToInputVal(e);
-    }
-
-    /**
-     * This function is called in a few different cases:
-     *   - e.target is the search button
-     *   - e.target is the gr-autocomplete widget (#searchInput)
-     *   - e.target is the input element wrapped within #searchInput
-     *
-     * @param {!Event} e
-     */
-    _preventDefaultAndNavigateToInputVal(e) {
-      e.preventDefault();
-      const target = Polymer.dom(e).rootTarget;
-      // If the target is the #searchInput or has a sub-input component, that
-      // is what holds the focus as opposed to the target from the DOM event.
-      if (target.$.input) {
-        target.$.input.blur();
-      } else {
-        target.blur();
-      }
-      const trimmedInput = this._inputVal && this._inputVal.trim();
-      if (trimmedInput) {
-        const predefinedOpOnlyQuery = [...SEARCH_OPERATORS_WITH_NEGATIONS_SET]
-            .some(op => op.endsWith(':') && op === trimmedInput);
-        if (predefinedOpOnlyQuery) {
-          return;
-        }
-        this.dispatchEvent(new CustomEvent('handle-search', {
-          detail: {inputVal: this._inputVal},
-        }));
-      }
-    }
-
-    /**
-     * Determine what array of possible suggestions should be provided
-     *     to _getSearchSuggestions.
-     *
-     * @param {string} input - The full search term, in lowercase.
-     * @return {!Promise} This returns a promise that resolves to an array of
-     *     suggestion objects.
-     */
-    _fetchSuggestions(input) {
-      // Split the input on colon to get a two part predicate/expression.
-      const splitInput = input.split(':');
-      const predicate = splitInput[0];
-      const expression = splitInput[1] || '';
-      // Switch on the predicate to determine what to autocomplete.
-      switch (predicate) {
-        case 'ownerin':
-        case 'reviewerin':
-          // Fetch groups.
-          return this.groupSuggestions(predicate, expression);
-
-        case 'parentproject':
-        case 'project':
-          // Fetch projects.
-          return this.projectSuggestions(predicate, expression);
-
-        case 'author':
-        case 'cc':
-        case 'commentby':
-        case 'committer':
-        case 'from':
-        case 'owner':
-        case 'reviewedby':
-        case 'reviewer':
-          // Fetch accounts.
-          return this.accountSuggestions(predicate, expression);
-
-        default:
-          return Promise.resolve([...SEARCH_OPERATORS_WITH_NEGATIONS_SET]
-              .filter(operator => operator.includes(input))
-              .map(operator => { return {text: operator}; }));
-      }
-    }
-
-    /**
-     * Get the sorted, pruned list of suggestions for the current search query.
-     *
-     * @param {string} input - The complete search query.
-     * @return {!Promise} This returns a promise that resolves to an array of
-     *     suggestions.
-     */
-    _getSearchSuggestions(input) {
-      // Allow spaces within quoted terms.
-      const tokens = input.match(TOKENIZE_REGEX);
-      const trimmedInput = tokens[tokens.length - 1].toLowerCase();
-
-      return this._fetchSuggestions(trimmedInput)
-          .then(suggestions => {
-            if (!suggestions || !suggestions.length) { return []; }
-            return suggestions
-                // Prioritize results that start with the input.
-                .sort((a, b) => {
-                  const aContains = a.text.toLowerCase().indexOf(trimmedInput);
-                  const bContains = b.text.toLowerCase().indexOf(trimmedInput);
-                  if (aContains === bContains) {
-                    return a.text.localeCompare(b.text);
-                  }
-                  if (aContains === -1) {
-                    return 1;
-                  }
-                  if (bContains === -1) {
-                    return -1;
-                  }
-                  return aContains - bContains;
-                })
-                // Return only the first {MAX_AUTOCOMPLETE_RESULTS} results.
-                .slice(0, MAX_AUTOCOMPLETE_RESULTS - 1)
-                // Map to an object to play nice with gr-autocomplete.
-                .map(({text, label}) => {
-                  return {
-                    name: text,
-                    value: text,
-                    label,
-                  };
-                });
-          });
-    }
-
-    _handleSearch(e) {
-      const keyboardEvent = this.getKeyboardEvent(e);
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          (this.modifierPressed(e) && !keyboardEvent.shiftKey)) { return; }
-
-      e.preventDefault();
-      this.$.searchInput.focus();
-      this.$.searchInput.selectAll();
+  _addOperator(name, include_neg = true) {
+    SEARCH_OPERATORS_WITH_NEGATIONS_SET.add(name);
+    if (include_neg) {
+      SEARCH_OPERATORS_WITH_NEGATIONS_SET.add(`-${name}`);
     }
   }
 
-  customElements.define(GrSearchBar.is, GrSearchBar);
-})();
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.SEARCH]: '_handleSearch',
+    };
+  }
+
+  _valueChanged(value) {
+    this._inputVal = value;
+  }
+
+  _handleInputCommit(e) {
+    this._preventDefaultAndNavigateToInputVal(e);
+  }
+
+  /**
+   * This function is called in a few different cases:
+   *   - e.target is the search button
+   *   - e.target is the gr-autocomplete widget (#searchInput)
+   *   - e.target is the input element wrapped within #searchInput
+   *
+   * @param {!Event} e
+   */
+  _preventDefaultAndNavigateToInputVal(e) {
+    e.preventDefault();
+    const target = dom(e).rootTarget;
+    // If the target is the #searchInput or has a sub-input component, that
+    // is what holds the focus as opposed to the target from the DOM event.
+    if (target.$.input) {
+      target.$.input.blur();
+    } else {
+      target.blur();
+    }
+    const trimmedInput = this._inputVal && this._inputVal.trim();
+    if (trimmedInput) {
+      const predefinedOpOnlyQuery = [...SEARCH_OPERATORS_WITH_NEGATIONS_SET]
+          .some(op => op.endsWith(':') && op === trimmedInput);
+      if (predefinedOpOnlyQuery) {
+        return;
+      }
+      this.dispatchEvent(new CustomEvent('handle-search', {
+        detail: {inputVal: this._inputVal},
+      }));
+    }
+  }
+
+  /**
+   * Determine what array of possible suggestions should be provided
+   *     to _getSearchSuggestions.
+   *
+   * @param {string} input - The full search term, in lowercase.
+   * @return {!Promise} This returns a promise that resolves to an array of
+   *     suggestion objects.
+   */
+  _fetchSuggestions(input) {
+    // Split the input on colon to get a two part predicate/expression.
+    const splitInput = input.split(':');
+    const predicate = splitInput[0];
+    const expression = splitInput[1] || '';
+    // Switch on the predicate to determine what to autocomplete.
+    switch (predicate) {
+      case 'ownerin':
+      case 'reviewerin':
+        // Fetch groups.
+        return this.groupSuggestions(predicate, expression);
+
+      case 'parentproject':
+      case 'project':
+        // Fetch projects.
+        return this.projectSuggestions(predicate, expression);
+
+      case 'author':
+      case 'cc':
+      case 'commentby':
+      case 'committer':
+      case 'from':
+      case 'owner':
+      case 'reviewedby':
+      case 'reviewer':
+        // Fetch accounts.
+        return this.accountSuggestions(predicate, expression);
+
+      default:
+        return Promise.resolve([...SEARCH_OPERATORS_WITH_NEGATIONS_SET]
+            .filter(operator => operator.includes(input))
+            .map(operator => { return {text: operator}; }));
+    }
+  }
+
+  /**
+   * Get the sorted, pruned list of suggestions for the current search query.
+   *
+   * @param {string} input - The complete search query.
+   * @return {!Promise} This returns a promise that resolves to an array of
+   *     suggestions.
+   */
+  _getSearchSuggestions(input) {
+    // Allow spaces within quoted terms.
+    const tokens = input.match(TOKENIZE_REGEX);
+    const trimmedInput = tokens[tokens.length - 1].toLowerCase();
+
+    return this._fetchSuggestions(trimmedInput)
+        .then(suggestions => {
+          if (!suggestions || !suggestions.length) { return []; }
+          return suggestions
+              // Prioritize results that start with the input.
+              .sort((a, b) => {
+                const aContains = a.text.toLowerCase().indexOf(trimmedInput);
+                const bContains = b.text.toLowerCase().indexOf(trimmedInput);
+                if (aContains === bContains) {
+                  return a.text.localeCompare(b.text);
+                }
+                if (aContains === -1) {
+                  return 1;
+                }
+                if (bContains === -1) {
+                  return -1;
+                }
+                return aContains - bContains;
+              })
+              // Return only the first {MAX_AUTOCOMPLETE_RESULTS} results.
+              .slice(0, MAX_AUTOCOMPLETE_RESULTS - 1)
+              // Map to an object to play nice with gr-autocomplete.
+              .map(({text, label}) => {
+                return {
+                  name: text,
+                  value: text,
+                  label,
+                };
+              });
+        });
+  }
+
+  _handleSearch(e) {
+    const keyboardEvent = this.getKeyboardEvent(e);
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        (this.modifierPressed(e) && !keyboardEvent.shiftKey)) { return; }
+
+    e.preventDefault();
+    this.$.searchInput.focus();
+    this.$.searchInput.selectAll();
+  }
+}
+
+customElements.define(GrSearchBar.is, GrSearchBar);
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js
index cb8e142..831b080 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-search-bar">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       form {
         display: flex;
@@ -36,19 +29,7 @@
       }
     </style>
     <form>
-      <gr-autocomplete
-          show-search-icon
-          id="searchInput"
-          text="{{_inputVal}}"
-          query="[[query]]"
-          on-commit="_handleInputCommit"
-          allow-non-suggested-values
-          multi
-          threshold="[[_threshold]]"
-          tab-complete
-          vertical-offset="30"></gr-autocomplete>
+      <gr-autocomplete show-search-icon="" id="searchInput" text="{{_inputVal}}" query="[[query]]" on-commit="_handleInputCommit" allow-non-suggested-values="" multi="" threshold="[[_threshold]]" tab-complete="" vertical-offset="30"></gr-autocomplete>
     </form>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-search-bar.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
index 5b5dc02..1bd0fca 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
@@ -19,18 +19,24 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-search-bar</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
-<link rel="import" href="gr-search-bar.html">
-<script src="../../../scripts/util.js"></script>
+<script type="module" src="./gr-search-bar.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<script>void (0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-search-bar.js';
+import '../../../scripts/util.js';
+void (0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -38,199 +44,202 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-search-bar tests', async () => {
-    await readyToTest();
-    const kb = window.Gerrit.KeyboardShortcutBinder;
-    kb.bindShortcut(kb.Shortcut.SEARCH, '/');
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-search-bar.js';
+import '../../../scripts/util.js';
+suite('gr-search-bar tests', () => {
+  const kb = window.Gerrit.KeyboardShortcutBinder;
+  kb.bindShortcut(kb.Shortcut.SEARCH, '/');
 
-    let element;
-    let sandbox;
+  let element;
+  let sandbox;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      flush(done);
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    flush(done);
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('value is propagated to _inputVal', () => {
+    element.value = 'foo';
+    assert.equal(element._inputVal, 'foo');
+  });
+
+  const getActiveElement = () => (document.activeElement.shadowRoot ?
+    document.activeElement.shadowRoot.activeElement :
+    document.activeElement);
+
+  test('enter in search input fires event', done => {
+    element.addEventListener('handle-search', () => {
+      assert.notEqual(getActiveElement(), element.$.searchInput);
+      assert.notEqual(getActiveElement(), element.$.searchButton);
+      done();
+    });
+    element.value = 'test';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+  });
+
+  test('input blurred after commit', () => {
+    const blurSpy = sandbox.spy(element.$.searchInput.$.input, 'blur');
+    element.$.searchInput.text = 'fate/stay';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isTrue(blurSpy.called);
+  });
+
+  test('empty search query does not trigger nav', () => {
+    const searchSpy = sandbox.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = '';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isFalse(searchSpy.called);
+  });
+
+  test('Predefined query op with no predication doesnt trigger nav', () => {
+    const searchSpy = sandbox.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'added:';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isFalse(searchSpy.called);
+  });
+
+  test('predefined predicate query triggers nav', () => {
+    const searchSpy = sandbox.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'age:1week';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isTrue(searchSpy.called);
+  });
+
+  test('undefined predicate query triggers nav', () => {
+    const searchSpy = sandbox.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'random:1week';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isTrue(searchSpy.called);
+  });
+
+  test('empty undefined predicate query triggers nav', () => {
+    const searchSpy = sandbox.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'random:';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isTrue(searchSpy.called);
+  });
+
+  test('keyboard shortcuts', () => {
+    const focusSpy = sandbox.spy(element.$.searchInput, 'focus');
+    const selectAllSpy = sandbox.spy(element.$.searchInput, 'selectAll');
+    MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
+    assert.isTrue(focusSpy.called);
+    assert.isTrue(selectAllSpy.called);
+  });
+
+  suite('_getSearchSuggestions', () => {
+    test('Autocompletes accounts', () => {
+      sandbox.stub(element, 'accountSuggestions', () =>
+        Promise.resolve([{text: 'owner:fred@goog.co'}])
+      );
+      return element._getSearchSuggestions('owner:fr').then(s => {
+        assert.equal(s[0].value, 'owner:fred@goog.co');
+      });
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('value is propagated to _inputVal', () => {
-      element.value = 'foo';
-      assert.equal(element._inputVal, 'foo');
-    });
-
-    const getActiveElement = () => (document.activeElement.shadowRoot ?
-      document.activeElement.shadowRoot.activeElement :
-      document.activeElement);
-
-    test('enter in search input fires event', done => {
-      element.addEventListener('handle-search', () => {
-        assert.notEqual(getActiveElement(), element.$.searchInput);
-        assert.notEqual(getActiveElement(), element.$.searchButton);
+    test('Autocompletes groups', done => {
+      sandbox.stub(element, 'groupSuggestions', () =>
+        Promise.resolve([
+          {text: 'ownerin:Polygerrit'},
+          {text: 'ownerin:gerrit'},
+        ])
+      );
+      element._getSearchSuggestions('ownerin:pol').then(s => {
+        assert.equal(s[0].value, 'ownerin:Polygerrit');
         done();
       });
-      element.value = 'test';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
     });
 
-    test('input blurred after commit', () => {
-      const blurSpy = sandbox.spy(element.$.searchInput.$.input, 'blur');
-      element.$.searchInput.text = 'fate/stay';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
-      assert.isTrue(blurSpy.called);
+    test('Autocompletes projects', done => {
+      sandbox.stub(element, 'projectSuggestions', () =>
+        Promise.resolve([
+          {text: 'project:Polygerrit'},
+          {text: 'project:gerrit'},
+          {text: 'project:gerrittest'},
+        ])
+      );
+      element._getSearchSuggestions('project:pol').then(s => {
+        assert.equal(s[0].value, 'project:Polygerrit');
+        done();
+      });
     });
 
-    test('empty search query does not trigger nav', () => {
-      const searchSpy = sandbox.spy();
-      element.addEventListener('handle-search', searchSpy);
-      element.value = '';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
-      assert.isFalse(searchSpy.called);
+    test('Autocompletes simple searches', done => {
+      element._getSearchSuggestions('is:o').then(s => {
+        assert.equal(s[0].name, 'is:open');
+        assert.equal(s[0].value, 'is:open');
+        assert.equal(s[1].name, 'is:owner');
+        assert.equal(s[1].value, 'is:owner');
+        done();
+      });
     });
 
-    test('Predefined query op with no predication doesnt trigger nav', () => {
-      const searchSpy = sandbox.spy();
-      element.addEventListener('handle-search', searchSpy);
-      element.value = 'added:';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
-      assert.isFalse(searchSpy.called);
+    test('Does not autocomplete with no match', done => {
+      element._getSearchSuggestions('asdasdasdasd').then(s => {
+        assert.equal(s.length, 0);
+        done();
+      });
     });
 
-    test('predefined predicate query triggers nav', () => {
-      const searchSpy = sandbox.spy();
-      element.addEventListener('handle-search', searchSpy);
-      element.value = 'age:1week';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
-      assert.isTrue(searchSpy.called);
+    test('Autocompltes without is:mergable when disabled', done => {
+      element._getSearchSuggestions('is:mergeab').then(s => {
+        assert.equal(s.length, 0);
+        done();
+      });
     });
+  });
 
-    test('undefined predicate query triggers nav', () => {
-      const searchSpy = sandbox.spy();
-      element.addEventListener('handle-search', searchSpy);
-      element.value = 'random:1week';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
-      assert.isTrue(searchSpy.called);
-    });
-
-    test('empty undefined predicate query triggers nav', () => {
-      const searchSpy = sandbox.spy();
-      element.addEventListener('handle-search', searchSpy);
-      element.value = 'random:';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
-      assert.isTrue(searchSpy.called);
-    });
-
-    test('keyboard shortcuts', () => {
-      const focusSpy = sandbox.spy(element.$.searchInput, 'focus');
-      const selectAllSpy = sandbox.spy(element.$.searchInput, 'selectAll');
-      MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
-      assert.isTrue(focusSpy.called);
-      assert.isTrue(selectAllSpy.called);
-    });
-
-    suite('_getSearchSuggestions', () => {
-      test('Autocompletes accounts', () => {
-        sandbox.stub(element, 'accountSuggestions', () =>
-          Promise.resolve([{text: 'owner:fred@goog.co'}])
-        );
-        return element._getSearchSuggestions('owner:fr').then(s => {
-          assert.equal(s[0].value, 'owner:fred@goog.co');
+  [
+    'API_REF_UPDATED_AND_CHANGE_REINDEX',
+    'REF_UPDATED_AND_CHANGE_REINDEX',
+  ].forEach(mergeability => {
+    suite(`mergeability as ${mergeability}`, () => {
+      setup(done => {
+        stub('gr-rest-api-interface', {
+          getConfig() {
+            return Promise.resolve({
+              index: {
+                mergeabilityComputationBehavior: mergeability,
+              },
+            });
+          },
         });
+
+        element = fixture('basic');
+        flush(done);
       });
 
-      test('Autocompletes groups', done => {
-        sandbox.stub(element, 'groupSuggestions', () =>
-          Promise.resolve([
-            {text: 'ownerin:Polygerrit'},
-            {text: 'ownerin:gerrit'},
-          ])
-        );
-        element._getSearchSuggestions('ownerin:pol').then(s => {
-          assert.equal(s[0].value, 'ownerin:Polygerrit');
-          done();
-        });
-      });
-
-      test('Autocompletes projects', done => {
-        sandbox.stub(element, 'projectSuggestions', () =>
-          Promise.resolve([
-            {text: 'project:Polygerrit'},
-            {text: 'project:gerrit'},
-            {text: 'project:gerrittest'},
-          ])
-        );
-        element._getSearchSuggestions('project:pol').then(s => {
-          assert.equal(s[0].value, 'project:Polygerrit');
-          done();
-        });
-      });
-
-      test('Autocompletes simple searches', done => {
-        element._getSearchSuggestions('is:o').then(s => {
-          assert.equal(s[0].name, 'is:open');
-          assert.equal(s[0].value, 'is:open');
-          assert.equal(s[1].name, 'is:owner');
-          assert.equal(s[1].value, 'is:owner');
-          done();
-        });
-      });
-
-      test('Does not autocomplete with no match', done => {
-        element._getSearchSuggestions('asdasdasdasd').then(s => {
-          assert.equal(s.length, 0);
-          done();
-        });
-      });
-
-      test('Autocompltes without is:mergable when disabled', done => {
+      test('Autocompltes with is:mergable when enabled', done => {
         element._getSearchSuggestions('is:mergeab').then(s => {
-          assert.equal(s.length, 0);
+          assert.equal(s.length, 2);
+          assert.equal(s[0].name, 'is:mergeable');
+          assert.equal(s[0].value, 'is:mergeable');
+          assert.equal(s[1].name, '-is:mergeable');
+          assert.equal(s[1].value, '-is:mergeable');
           done();
         });
       });
     });
-
-    [
-      'API_REF_UPDATED_AND_CHANGE_REINDEX',
-      'REF_UPDATED_AND_CHANGE_REINDEX',
-    ].forEach(mergeability => {
-      suite(`mergeability as ${mergeability}`, () => {
-        setup(done => {
-          stub('gr-rest-api-interface', {
-            getConfig() {
-              return Promise.resolve({
-                index: {
-                  mergeabilityComputationBehavior: mergeability,
-                },
-              });
-            },
-          });
-
-          element = fixture('basic');
-          flush(done);
-        });
-
-        test('Autocompltes with is:mergable when enabled', done => {
-          element._getSearchSuggestions('is:mergeab').then(s => {
-            assert.equal(s.length, 2);
-            assert.equal(s[0].name, 'is:mergeable');
-            assert.equal(s[0].value, 'is:mergeable');
-            assert.equal(s[1].name, '-is:mergeable');
-            assert.equal(s[1].value, '-is:mergeable');
-            done();
-          });
-        });
-      });
-    });
   });
+});
 </script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
index cfdd524..a93c139 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
@@ -14,152 +14,162 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const MAX_AUTOCOMPLETE_RESULTS = 10;
-  const SELF_EXPRESSION = 'self';
-  const ME_EXPRESSION = 'me';
+import '../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.js';
+import '../gr-navigation/gr-navigation.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-search-bar/gr-search-bar.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-smart-search_html.js';
 
-  /**
-   * @appliesMixin Gerrit.DisplayNameMixin
-   * @extends Polymer.Element
-   */
-  class GrSmartSearch extends Polymer.mixinBehaviors( [
-    Gerrit.DisplayNameBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-smart-search'; }
+const MAX_AUTOCOMPLETE_RESULTS = 10;
+const SELF_EXPRESSION = 'self';
+const ME_EXPRESSION = 'me';
 
-    static get properties() {
-      return {
-        searchQuery: String,
-        _config: Object,
-        _projectSuggestions: {
-          type: Function,
-          value() {
-            return this._fetchProjects.bind(this);
-          },
+/**
+ * @appliesMixin Gerrit.DisplayNameMixin
+ * @extends Polymer.Element
+ */
+class GrSmartSearch extends mixinBehaviors( [
+  Gerrit.DisplayNameBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-smart-search'; }
+
+  static get properties() {
+    return {
+      searchQuery: String,
+      _config: Object,
+      _projectSuggestions: {
+        type: Function,
+        value() {
+          return this._fetchProjects.bind(this);
         },
-        _groupSuggestions: {
-          type: Function,
-          value() {
-            return this._fetchGroups.bind(this);
-          },
+      },
+      _groupSuggestions: {
+        type: Function,
+        value() {
+          return this._fetchGroups.bind(this);
         },
-        _accountSuggestions: {
-          type: Function,
-          value() {
-            return this._fetchAccounts.bind(this);
-          },
+      },
+      _accountSuggestions: {
+        type: Function,
+        value() {
+          return this._fetchAccounts.bind(this);
         },
-      };
-    }
+      },
+    };
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this.$.restAPI.getConfig().then(cfg => {
-        this._config = cfg;
-      });
-    }
+  /** @override */
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(cfg => {
+      this._config = cfg;
+    });
+  }
 
-    _handleSearch(e) {
-      const input = e.detail.inputVal;
-      if (input) {
-        Gerrit.Nav.navigateToSearchQuery(input);
-      }
-    }
-
-    /**
-     * Fetch from the API the predicted projects.
-     *
-     * @param {string} predicate - The first part of the search term, e.g.
-     *     'project'
-     * @param {string} expression - The second part of the search term, e.g.
-     *     'gerr'
-     * @return {!Promise} This returns a promise that resolves to an array of
-     *     strings.
-     */
-    _fetchProjects(predicate, expression) {
-      return this.$.restAPI.getSuggestedProjects(
-          expression,
-          MAX_AUTOCOMPLETE_RESULTS)
-          .then(projects => {
-            if (!projects) { return []; }
-            const keys = Object.keys(projects);
-            return keys.map(key => { return {text: predicate + ':' + key}; });
-          });
-    }
-
-    /**
-     * Fetch from the API the predicted groups.
-     *
-     * @param {string} predicate - The first part of the search term, e.g.
-     *     'ownerin'
-     * @param {string} expression - The second part of the search term, e.g.
-     *     'polyger'
-     * @return {!Promise} This returns a promise that resolves to an array of
-     *     strings.
-     */
-    _fetchGroups(predicate, expression) {
-      if (expression.length === 0) { return Promise.resolve([]); }
-      return this.$.restAPI.getSuggestedGroups(
-          expression,
-          MAX_AUTOCOMPLETE_RESULTS)
-          .then(groups => {
-            if (!groups) { return []; }
-            const keys = Object.keys(groups);
-            return keys.map(key => { return {text: predicate + ':' + key}; });
-          });
-    }
-
-    /**
-     * Fetch from the API the predicted accounts.
-     *
-     * @param {string} predicate - The first part of the search term, e.g.
-     *     'owner'
-     * @param {string} expression - The second part of the search term, e.g.
-     *     'kasp'
-     * @return {!Promise} This returns a promise that resolves to an array of
-     *     strings.
-     */
-    _fetchAccounts(predicate, expression) {
-      if (expression.length === 0) { return Promise.resolve([]); }
-      return this.$.restAPI.getSuggestedAccounts(
-          expression,
-          MAX_AUTOCOMPLETE_RESULTS)
-          .then(accounts => {
-            if (!accounts) { return []; }
-            return this._mapAccountsHelper(accounts, predicate);
-          })
-          .then(accounts => {
-            // When the expression supplied is a beginning substring of 'self',
-            // add it as an autocomplete option.
-            if (SELF_EXPRESSION.startsWith(expression)) {
-              return accounts.concat(
-                  [{text: predicate + ':' + SELF_EXPRESSION}]);
-            } else if (ME_EXPRESSION.startsWith(expression)) {
-              return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
-            } else {
-              return accounts;
-            }
-          });
-    }
-
-    _mapAccountsHelper(accounts, predicate) {
-      return accounts.map(account => {
-        const userName = this.getUserName(this._serverConfig, account, false);
-        return {
-          label: account.name || '',
-          text: account.email ?
-            `${predicate}:${account.email}` :
-            `${predicate}:"${userName}"`,
-        };
-      });
+  _handleSearch(e) {
+    const input = e.detail.inputVal;
+    if (input) {
+      Gerrit.Nav.navigateToSearchQuery(input);
     }
   }
 
-  customElements.define(GrSmartSearch.is, GrSmartSearch);
-})();
+  /**
+   * Fetch from the API the predicted projects.
+   *
+   * @param {string} predicate - The first part of the search term, e.g.
+   *     'project'
+   * @param {string} expression - The second part of the search term, e.g.
+   *     'gerr'
+   * @return {!Promise} This returns a promise that resolves to an array of
+   *     strings.
+   */
+  _fetchProjects(predicate, expression) {
+    return this.$.restAPI.getSuggestedProjects(
+        expression,
+        MAX_AUTOCOMPLETE_RESULTS)
+        .then(projects => {
+          if (!projects) { return []; }
+          const keys = Object.keys(projects);
+          return keys.map(key => { return {text: predicate + ':' + key}; });
+        });
+  }
+
+  /**
+   * Fetch from the API the predicted groups.
+   *
+   * @param {string} predicate - The first part of the search term, e.g.
+   *     'ownerin'
+   * @param {string} expression - The second part of the search term, e.g.
+   *     'polyger'
+   * @return {!Promise} This returns a promise that resolves to an array of
+   *     strings.
+   */
+  _fetchGroups(predicate, expression) {
+    if (expression.length === 0) { return Promise.resolve([]); }
+    return this.$.restAPI.getSuggestedGroups(
+        expression,
+        MAX_AUTOCOMPLETE_RESULTS)
+        .then(groups => {
+          if (!groups) { return []; }
+          const keys = Object.keys(groups);
+          return keys.map(key => { return {text: predicate + ':' + key}; });
+        });
+  }
+
+  /**
+   * Fetch from the API the predicted accounts.
+   *
+   * @param {string} predicate - The first part of the search term, e.g.
+   *     'owner'
+   * @param {string} expression - The second part of the search term, e.g.
+   *     'kasp'
+   * @return {!Promise} This returns a promise that resolves to an array of
+   *     strings.
+   */
+  _fetchAccounts(predicate, expression) {
+    if (expression.length === 0) { return Promise.resolve([]); }
+    return this.$.restAPI.getSuggestedAccounts(
+        expression,
+        MAX_AUTOCOMPLETE_RESULTS)
+        .then(accounts => {
+          if (!accounts) { return []; }
+          return this._mapAccountsHelper(accounts, predicate);
+        })
+        .then(accounts => {
+          // When the expression supplied is a beginning substring of 'self',
+          // add it as an autocomplete option.
+          if (SELF_EXPRESSION.startsWith(expression)) {
+            return accounts.concat(
+                [{text: predicate + ':' + SELF_EXPRESSION}]);
+          } else if (ME_EXPRESSION.startsWith(expression)) {
+            return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
+          } else {
+            return accounts;
+          }
+        });
+  }
+
+  _mapAccountsHelper(accounts, predicate) {
+    return accounts.map(account => {
+      const userName = this.getUserName(this._serverConfig, account, false);
+      return {
+        label: account.name || '',
+        text: account.email ?
+          `${predicate}:${account.email}` :
+          `${predicate}:"${userName}"`,
+      };
+    });
+  }
+}
+
+customElements.define(GrSmartSearch.is, GrSmartSearch);
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js
index c4ae41b..78906a8 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js
@@ -1,38 +1,25 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-search-bar/gr-search-bar.html">
-
-<dom-module id="gr-smart-search">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
 
     </style>
-    <gr-search-bar id="search"
-        value="{{searchQuery}}"
-        on-handle-search="_handleSearch"
-        project-suggestions="[[_projectSuggestions]]"
-        group-suggestions="[[_groupSuggestions]]"
-        account-suggestions="[[_accountSuggestions]]"></gr-search-bar>
+    <gr-search-bar id="search" value="{{searchQuery}}" on-handle-search="_handleSearch" project-suggestions="[[_projectSuggestions]]" group-suggestions="[[_groupSuggestions]]" account-suggestions="[[_accountSuggestions]]"></gr-search-bar>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-smart-search.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
index 6fd00c7..a0ba203 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-smart-search</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-smart-search.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-smart-search.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-smart-search.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,125 +40,127 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-smart-search tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-smart-search.js';
+suite('gr-smart-search tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('Autocompletes accounts', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-        Promise.resolve([
-          {
-            name: 'fred',
-            email: 'fred@goog.co',
-          },
-        ])
-      );
-      return element._fetchAccounts('owner', 'fr').then(s => {
-        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-      });
-    });
-
-    test('Inserts self as option when valid', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-        Promise.resolve([
-          {
-            name: 'fred',
-            email: 'fred@goog.co',
-          },
-        ])
-      );
-      element._fetchAccounts('owner', 's')
-          .then(s => {
-            assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-            assert.deepEqual(s[1], {text: 'owner:self'});
-          })
-          .then(() => element._fetchAccounts('owner', 'selfs'))
-          .then(s => {
-            assert.notEqual(s[0], {text: 'owner:self'});
-          });
-    });
-
-    test('Inserts me as option when valid', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-        Promise.resolve([
-          {
-            name: 'fred',
-            email: 'fred@goog.co',
-          },
-        ])
-      );
-      return element._fetchAccounts('owner', 'm')
-          .then(s => {
-            assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-            assert.deepEqual(s[1], {text: 'owner:me'});
-          })
-          .then(() => element._fetchAccounts('owner', 'meme'))
-          .then(s => {
-            assert.notEqual(s[0], {text: 'owner:me'});
-          });
-    });
-
-    test('Autocompletes groups', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
-        Promise.resolve({
-          Polygerrit: 0,
-          gerrit: 0,
-          gerrittest: 0,
-        })
-      );
-      return element._fetchGroups('ownerin', 'pol').then(s => {
-        assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
-      });
-    });
-
-    test('Autocompletes projects', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedProjects', () =>
-        Promise.resolve({Polygerrit: 0}));
-      return element._fetchProjects('project', 'pol').then(s => {
-        assert.deepEqual(s[0], {text: 'project:Polygerrit'});
-      });
-    });
-
-    test('Autocomplete doesnt override exact matches to input', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
-        Promise.resolve({
-          Polygerrit: 0,
-          gerrit: 0,
-          gerrittest: 0,
-        })
-      );
-      return element._fetchGroups('ownerin', 'gerrit').then(s => {
-        assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
-        assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
-        assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
-      });
-    });
-
-    test('Autocompletes accounts with no email', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-        Promise.resolve([{name: 'fred'}]));
-      return element._fetchAccounts('owner', 'fr').then(s => {
-        assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
-      });
-    });
-
-    test('Autocompletes accounts with email', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-        Promise.resolve([{email: 'fred@goog.co'}]));
-      return element._fetchAccounts('owner', 'fr').then(s => {
-        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
-      });
+  test('Autocompletes accounts', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co',
+        },
+      ])
+    );
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
     });
   });
+
+  test('Inserts self as option when valid', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co',
+        },
+      ])
+    );
+    element._fetchAccounts('owner', 's')
+        .then(s => {
+          assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+          assert.deepEqual(s[1], {text: 'owner:self'});
+        })
+        .then(() => element._fetchAccounts('owner', 'selfs'))
+        .then(s => {
+          assert.notEqual(s[0], {text: 'owner:self'});
+        });
+  });
+
+  test('Inserts me as option when valid', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co',
+        },
+      ])
+    );
+    return element._fetchAccounts('owner', 'm')
+        .then(s => {
+          assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+          assert.deepEqual(s[1], {text: 'owner:me'});
+        })
+        .then(() => element._fetchAccounts('owner', 'meme'))
+        .then(s => {
+          assert.notEqual(s[0], {text: 'owner:me'});
+        });
+  });
+
+  test('Autocompletes groups', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
+      Promise.resolve({
+        Polygerrit: 0,
+        gerrit: 0,
+        gerrittest: 0,
+      })
+    );
+    return element._fetchGroups('ownerin', 'pol').then(s => {
+      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+    });
+  });
+
+  test('Autocompletes projects', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedProjects', () =>
+      Promise.resolve({Polygerrit: 0}));
+    return element._fetchProjects('project', 'pol').then(s => {
+      assert.deepEqual(s[0], {text: 'project:Polygerrit'});
+    });
+  });
+
+  test('Autocomplete doesnt override exact matches to input', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
+      Promise.resolve({
+        Polygerrit: 0,
+        gerrit: 0,
+        gerrittest: 0,
+      })
+    );
+    return element._fetchGroups('ownerin', 'gerrit').then(s => {
+      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+      assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
+      assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
+    });
+  });
+
+  test('Autocompletes accounts with no email', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+      Promise.resolve([{name: 'fred'}]));
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
+    });
+  });
+
+  test('Autocompletes accounts with email', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+      Promise.resolve([{email: 'fred@goog.co'}]));
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.html b/polygerrit-ui/app/elements/custom-dark-theme_test.html
index cd07a67..308e2ee 100644
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.html
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app-it_test</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../test/test-pre-setup.js"></script>
-<link rel="import" href="../test/common-test-setup.html"/>
-<link rel="import" href="./gr-app.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../test/test-pre-setup.js"></script>
+<script type="module" src="../test/common-test-setup.js"></script>
+<script type="module" src="./gr-app.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../test/test-pre-setup.js';
+import '../test/common-test-setup.js';
+import './gr-app.js';
+void(0);
+</script>
 
 <test-fixture id="element">
   <template>
@@ -35,69 +40,71 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-app custom dark theme tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let element;
+<script type="module">
+import '../test/test-pre-setup.js';
+import '../test/common-test-setup.js';
+import './gr-app.js';
+suite('gr-app custom dark theme tests', () => {
+  let sandbox;
+  let element;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-reporting', {
-        appStarted: sandbox.stub(),
-      });
-      stub('gr-account-dropdown', {
-        _getTopContent: sinon.stub(),
-      });
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-        getAccountCapabilities() { return Promise.resolve({}); },
-        getConfig() {
-          return Promise.resolve({
-            plugin: {
-              js_resource_paths: [],
-              html_resource_paths: [
-                new URL('test/plugin.html', window.location.href).toString(),
-              ],
-            },
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-reporting', {
+      appStarted: sandbox.stub(),
+    });
+    stub('gr-account-dropdown', {
+      _getTopContent: sinon.stub(),
+    });
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(null); },
+      getAccountCapabilities() { return Promise.resolve({}); },
+      getConfig() {
+        return Promise.resolve({
+          plugin: {
+            js_resource_paths: [],
+            html_resource_paths: [
+              new URL('test/plugin.html', window.location.href).toString(),
+            ],
+          },
+        });
+      },
+      getVersion() { return Promise.resolve(42); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+
+    window.localStorage.setItem('dark-theme', 'true');
+
+    element = fixture('element');
+
+    const importSpy = sandbox.spy(
+        element.$['app-element'].$.externalStyleForAll,
+        '_import');
+    const importForThemeSpy = sandbox.spy(
+        element.$['app-element'].$.externalStyleForTheme,
+        '_import');
+    Gerrit.awaitPluginsLoaded().then(() => {
+      Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
+          .then(() => {
+            flush(done);
           });
-        },
-        getVersion() { return Promise.resolve(42); },
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-
-      window.localStorage.setItem('dark-theme', 'true');
-
-      element = fixture('element');
-
-      const importSpy = sandbox.spy(
-          element.$['app-element'].$.externalStyleForAll,
-          '_import');
-      const importForThemeSpy = sandbox.spy(
-          element.$['app-element'].$.externalStyleForTheme,
-          '_import');
-      Gerrit.awaitPluginsLoaded().then(() => {
-        Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
-            .then(() => {
-              flush(done);
-            });
-      });
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('applies the right theme', () => {
-      assert.equal(
-          util.getComputedStyleValue('--primary-text-color', element),
-          'red');
-      assert.equal(
-          util.getComputedStyleValue('--header-background-color', element),
-          'black');
-      assert.equal(
-          util.getComputedStyleValue('--footer-background-color', element),
-          'yellow');
     });
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('applies the right theme', () => {
+    assert.equal(
+        util.getComputedStyleValue('--primary-text-color', element),
+        'red');
+    assert.equal(
+        util.getComputedStyleValue('--header-background-color', element),
+        'black');
+    assert.equal(
+        util.getComputedStyleValue('--footer-background-color', element),
+        'yellow');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.html b/polygerrit-ui/app/elements/custom-light-theme_test.html
index 13b872e..66567be 100644
--- a/polygerrit-ui/app/elements/custom-light-theme_test.html
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app-it_test</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../test/test-pre-setup.js"></script>
-<link rel="import" href="../test/common-test-setup.html"/>
-<link rel="import" href="gr-app.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../test/test-pre-setup.js"></script>
+<script type="module" src="../test/common-test-setup.js"></script>
+<script type="module" src="./gr-app.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../test/test-pre-setup.js';
+import '../test/common-test-setup.js';
+import './gr-app.js';
+void(0);
+</script>
 
 <test-fixture id="element">
   <template>
@@ -35,69 +40,71 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-app custom light theme tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let element;
+<script type="module">
+import '../test/test-pre-setup.js';
+import '../test/common-test-setup.js';
+import './gr-app.js';
+suite('gr-app custom light theme tests', () => {
+  let sandbox;
+  let element;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-reporting', {
-        appStarted: sandbox.stub(),
-      });
-      stub('gr-account-dropdown', {
-        _getTopContent: sinon.stub(),
-      });
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-        getAccountCapabilities() { return Promise.resolve({}); },
-        getConfig() {
-          return Promise.resolve({
-            plugin: {
-              js_resource_paths: [],
-              html_resource_paths: [
-                new URL('test/plugin.html', window.location.href).toString(),
-              ],
-            },
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-reporting', {
+      appStarted: sandbox.stub(),
+    });
+    stub('gr-account-dropdown', {
+      _getTopContent: sinon.stub(),
+    });
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(null); },
+      getAccountCapabilities() { return Promise.resolve({}); },
+      getConfig() {
+        return Promise.resolve({
+          plugin: {
+            js_resource_paths: [],
+            html_resource_paths: [
+              new URL('test/plugin.html', window.location.href).toString(),
+            ],
+          },
+        });
+      },
+      getVersion() { return Promise.resolve(42); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+
+    window.localStorage.removeItem('dark-theme');
+
+    element = fixture('element');
+
+    const importSpy = sandbox.spy(
+        element.$['app-element'].$.externalStyleForAll,
+        '_import');
+    const importForThemeSpy = sandbox.spy(
+        element.$['app-element'].$.externalStyleForTheme,
+        '_import');
+    Gerrit.awaitPluginsLoaded().then(() => {
+      Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
+          .then(() => {
+            flush(done);
           });
-        },
-        getVersion() { return Promise.resolve(42); },
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-
-      window.localStorage.removeItem('dark-theme');
-
-      element = fixture('element');
-
-      const importSpy = sandbox.spy(
-          element.$['app-element'].$.externalStyleForAll,
-          '_import');
-      const importForThemeSpy = sandbox.spy(
-          element.$['app-element'].$.externalStyleForTheme,
-          '_import');
-      Gerrit.awaitPluginsLoaded().then(() => {
-        Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
-            .then(() => {
-              flush(done);
-            });
-      });
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('applies the right theme', () => {
-      assert.equal(
-          util.getComputedStyleValue('--primary-text-color', element),
-          '#F00BAA');
-      assert.equal(
-          util.getComputedStyleValue('--header-background-color', element),
-          '#F01BAA');
-      assert.equal(
-          util.getComputedStyleValue('--footer-background-color', element),
-          '#F02BAA');
     });
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('applies the right theme', () => {
+    assert.equal(
+        util.getComputedStyleValue('--primary-text-color', element),
+        '#F00BAA');
+    assert.equal(
+        util.getComputedStyleValue('--header-background-color', element),
+        '#F01BAA');
+    assert.equal(
+        util.getComputedStyleValue('--footer-background-color', element),
+        '#F02BAA');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js
index 9104b90..d5075d7 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js
@@ -14,200 +14,213 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-diff/gr-diff.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-apply-fix-dialog_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrApplyFixDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-apply-fix-dialog'; }
+
+  static get properties() {
+    return {
+    // Diff rendering preference API response.
+      prefs: Array,
+      // ChangeInfo API response object.
+      change: Object,
+      changeNum: String,
+      _patchNum: Number,
+      // robot ID associated with a robot comment.
+      _robotId: String,
+      // Selected FixSuggestionInfo entity from robot comment API response.
+      _currentFix: Object,
+      // Flattened /preview API response DiffInfo map object.
+      _currentPreviews: {type: Array, value: () => []},
+      // FixSuggestionInfo entities from robot comment API response.
+      _fixSuggestions: Array,
+      _isApplyFixLoading: {
+        type: Boolean,
+        value: false,
+      },
+      // Index of currently showing suggested fix.
+      _selectedFixIdx: Number,
+    };
+  }
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Given robot comment CustomEvent objevt, fetch diffs associated
+   * with first robot comment suggested fix and open dialog.
+   *
+   * @param {*} e CustomEvent to be passed from gr-comment with
+   * robot comment detail.
+   * @return {Promise<undefined>} Promise that resolves either when all
+   * preview diffs are fetched or no fix suggestions in custom event detail.
    */
-  class GrApplyFixDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-apply-fix-dialog'; }
-
-    static get properties() {
-      return {
-      // Diff rendering preference API response.
-        prefs: Array,
-        // ChangeInfo API response object.
-        change: Object,
-        changeNum: String,
-        _patchNum: Number,
-        // robot ID associated with a robot comment.
-        _robotId: String,
-        // Selected FixSuggestionInfo entity from robot comment API response.
-        _currentFix: Object,
-        // Flattened /preview API response DiffInfo map object.
-        _currentPreviews: {type: Array, value: () => []},
-        // FixSuggestionInfo entities from robot comment API response.
-        _fixSuggestions: Array,
-        _isApplyFixLoading: {
-          type: Boolean,
-          value: false,
-        },
-        // Index of currently showing suggested fix.
-        _selectedFixIdx: Number,
-      };
+  open(e) {
+    this._patchNum = e.detail.patchNum;
+    this._fixSuggestions = e.detail.comment.fix_suggestions;
+    this._robotId = e.detail.comment.robot_id;
+    if (this._fixSuggestions == null || this._fixSuggestions.length == 0) {
+      return Promise.resolve();
     }
+    this._selectedFixIdx = 0;
+    const promises = [];
+    promises.push(
+        this._showSelectedFixSuggestion(this._fixSuggestions[0]),
+        this.$.applyFixOverlay.open()
+    );
+    return Promise.all(promises)
+        .then(() => {
+          // ensures gr-overlay repositions overlay in center
+          this.$.applyFixOverlay.fire('iron-resize');
+        });
+  }
 
-    /**
-     * Given robot comment CustomEvent objevt, fetch diffs associated
-     * with first robot comment suggested fix and open dialog.
-     *
-     * @param {*} e CustomEvent to be passed from gr-comment with
-     * robot comment detail.
-     * @return {Promise<undefined>} Promise that resolves either when all
-     * preview diffs are fetched or no fix suggestions in custom event detail.
-     */
-    open(e) {
-      this._patchNum = e.detail.patchNum;
-      this._fixSuggestions = e.detail.comment.fix_suggestions;
-      this._robotId = e.detail.comment.robot_id;
-      if (this._fixSuggestions == null || this._fixSuggestions.length == 0) {
-        return Promise.resolve();
-      }
-      this._selectedFixIdx = 0;
-      const promises = [];
-      promises.push(
-          this._showSelectedFixSuggestion(this._fixSuggestions[0]),
-          this.$.applyFixOverlay.open()
-      );
-      return Promise.all(promises)
-          .then(() => {
-            // ensures gr-overlay repositions overlay in center
-            this.$.applyFixOverlay.fire('iron-resize');
-          });
+  attached() {
+    super.attached();
+    this.refitOverlay = () => {
+      // re-center the dialog as content changed
+      this.$.applyFixOverlay.fire('iron-resize');
+    };
+    this.addEventListener('diff-context-expanded', this.refitOverlay);
+  }
+
+  detached() {
+    super.detached();
+    this.removeEventListener('diff-context-expanded', this.refitOverlay);
+  }
+
+  _showSelectedFixSuggestion(fixSuggestion) {
+    this._currentFix = fixSuggestion;
+    return this._fetchFixPreview(fixSuggestion.fix_id);
+  }
+
+  _fetchFixPreview(fixId) {
+    return this.$.restAPI
+        .getRobotCommentFixPreview(this.changeNum, this._patchNum, fixId)
+        .then(res => {
+          if (res != null) {
+            const previews = Object.keys(res).map(key => {
+              return {filepath: key, preview: res[key]};
+            });
+            this._currentPreviews = previews;
+          }
+        })
+        .catch(err => {
+          this._close();
+          throw err;
+        });
+  }
+
+  hasSingleFix(_fixSuggestions) {
+    return (_fixSuggestions || {}).length === 1;
+  }
+
+  overridePartialPrefs(prefs) {
+    // generate a smaller gr-diff than fullscreen for dialog
+    return Object.assign({}, prefs, {line_length: 50});
+  }
+
+  onCancel(e) {
+    if (e) {
+      e.stopPropagation();
     }
+    this._close();
+  }
 
-    attached() {
-      super.attached();
-      this.refitOverlay = () => {
-        // re-center the dialog as content changed
-        this.$.applyFixOverlay.fire('iron-resize');
-      };
-      this.addEventListener('diff-context-expanded', this.refitOverlay);
-    }
+  addOneTo(_selectedFixIdx) {
+    return _selectedFixIdx + 1;
+  }
 
-    detached() {
-      super.detached();
-      this.removeEventListener('diff-context-expanded', this.refitOverlay);
-    }
-
-    _showSelectedFixSuggestion(fixSuggestion) {
-      this._currentFix = fixSuggestion;
-      return this._fetchFixPreview(fixSuggestion.fix_id);
-    }
-
-    _fetchFixPreview(fixId) {
-      return this.$.restAPI
-          .getRobotCommentFixPreview(this.changeNum, this._patchNum, fixId)
-          .then(res => {
-            if (res != null) {
-              const previews = Object.keys(res).map(key => {
-                return {filepath: key, preview: res[key]};
-              });
-              this._currentPreviews = previews;
-            }
-          })
-          .catch(err => {
-            this._close();
-            throw err;
-          });
-    }
-
-    hasSingleFix(_fixSuggestions) {
-      return (_fixSuggestions || {}).length === 1;
-    }
-
-    overridePartialPrefs(prefs) {
-      // generate a smaller gr-diff than fullscreen for dialog
-      return Object.assign({}, prefs, {line_length: 50});
-    }
-
-    onCancel(e) {
-      if (e) {
-        e.stopPropagation();
-      }
-      this._close();
-    }
-
-    addOneTo(_selectedFixIdx) {
-      return _selectedFixIdx + 1;
-    }
-
-    _onPrevFixClick(e) {
-      if (e) e.stopPropagation();
-      if (this._selectedFixIdx >= 1 && this._fixSuggestions != null) {
-        this._selectedFixIdx -= 1;
-        return this._showSelectedFixSuggestion(
-            this._fixSuggestions[this._selectedFixIdx]);
-      }
-    }
-
-    _onNextFixClick(e) {
-      if (e) e.stopPropagation();
-      if (this._fixSuggestions &&
-        this._selectedFixIdx < this._fixSuggestions.length) {
-        this._selectedFixIdx += 1;
-        return this._showSelectedFixSuggestion(
-            this._fixSuggestions[this._selectedFixIdx]);
-      }
-    }
-
-    _noPrevFix(_selectedFixIdx) {
-      return _selectedFixIdx === 0;
-    }
-
-    _noNextFix(_selectedFixIdx, fixSuggestions) {
-      if (fixSuggestions == null) return true;
-      return _selectedFixIdx === fixSuggestions.length - 1;
-    }
-
-    _close() {
-      this._currentFix = {};
-      this._currentPreviews = [];
-      this._isApplyFixLoading = false;
-
-      this.dispatchEvent(new CustomEvent('close-fix-preview', {
-        bubbles: true,
-        composed: true,
-      }));
-      this.$.applyFixOverlay.close();
-    }
-
-    _getApplyFixButtonLabel(isLoading) {
-      return isLoading ? 'Saving...' : 'Apply Fix';
-    }
-
-    _handleApplyFix(e) {
-      if (e) {
-        e.stopPropagation();
-      }
-      if (this._currentFix == null || this._currentFix.fix_id == null) {
-        return;
-      }
-      this._isApplyFixLoading = true;
-      return this.$.restAPI
-          .applyFixSuggestion(
-              this.changeNum, this._patchNum, this._currentFix.fix_id
-          )
-          .then(res => {
-            if (res && res.ok) {
-              Gerrit.Nav.navigateToChange(this.change, 'edit', this._patchNum);
-              this._close();
-            }
-            this._isApplyFixLoading = false;
-          });
-    }
-
-    getFixDescription(currentFix) {
-      return currentFix != null && currentFix.description ?
-        currentFix.description : '';
+  _onPrevFixClick(e) {
+    if (e) e.stopPropagation();
+    if (this._selectedFixIdx >= 1 && this._fixSuggestions != null) {
+      this._selectedFixIdx -= 1;
+      return this._showSelectedFixSuggestion(
+          this._fixSuggestions[this._selectedFixIdx]);
     }
   }
 
-  customElements.define(GrApplyFixDialog.is, GrApplyFixDialog);
-})();
+  _onNextFixClick(e) {
+    if (e) e.stopPropagation();
+    if (this._fixSuggestions &&
+      this._selectedFixIdx < this._fixSuggestions.length) {
+      this._selectedFixIdx += 1;
+      return this._showSelectedFixSuggestion(
+          this._fixSuggestions[this._selectedFixIdx]);
+    }
+  }
+
+  _noPrevFix(_selectedFixIdx) {
+    return _selectedFixIdx === 0;
+  }
+
+  _noNextFix(_selectedFixIdx, fixSuggestions) {
+    if (fixSuggestions == null) return true;
+    return _selectedFixIdx === fixSuggestions.length - 1;
+  }
+
+  _close() {
+    this._currentFix = {};
+    this._currentPreviews = [];
+    this._isApplyFixLoading = false;
+
+    this.dispatchEvent(new CustomEvent('close-fix-preview', {
+      bubbles: true,
+      composed: true,
+    }));
+    this.$.applyFixOverlay.close();
+  }
+
+  _getApplyFixButtonLabel(isLoading) {
+    return isLoading ? 'Saving...' : 'Apply Fix';
+  }
+
+  _handleApplyFix(e) {
+    if (e) {
+      e.stopPropagation();
+    }
+    if (this._currentFix == null || this._currentFix.fix_id == null) {
+      return;
+    }
+    this._isApplyFixLoading = true;
+    return this.$.restAPI
+        .applyFixSuggestion(
+            this.changeNum, this._patchNum, this._currentFix.fix_id
+        )
+        .then(res => {
+          if (res && res.ok) {
+            Gerrit.Nav.navigateToChange(this.change, 'edit', this._patchNum);
+            this._close();
+          }
+          this._isApplyFixLoading = false;
+        });
+  }
+
+  getFixDescription(currentFix) {
+    return currentFix != null && currentFix.description ?
+      currentFix.description : '';
+  }
+}
+
+customElements.define(GrApplyFixDialog.is, GrApplyFixDialog);
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.js b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.js
index c650bb5..f6cd1ec 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-diff/gr-diff.html">
-
-<dom-module id="gr-apply-fix-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       gr-diff {
         --content-width: 90vw;
@@ -52,13 +44,8 @@
         margin-right: var(--spacing-l);
       }
     </style>
-    <gr-overlay id="applyFixOverlay" with-backdrop>
-      <gr-dialog
-        id="applyFixDialog"
-        on-confirm="_handleApplyFix"
-        confirm-label="[[_getApplyFixButtonLabel(_isApplyFixLoading)]]"
-        disabled="[[_isApplyFixLoading]]"
-        on-cancel="onCancel">
+    <gr-overlay id="applyFixOverlay" with-backdrop="">
+      <gr-dialog id="applyFixDialog" on-confirm="_handleApplyFix" confirm-label="[[_getApplyFixButtonLabel(_isApplyFixLoading)]]" disabled="[[_isApplyFixLoading]]" on-cancel="onCancel">
         <div slot="header">[[_robotId]] - [[getFixDescription(_currentFix)]]</div>
         <div slot="main">
           <template is="dom-repeat" items="[[_currentPreviews]]">
@@ -66,26 +53,20 @@
               <span>[[item.filepath]]</span>
             </div>
             <div class="diffContainer">
-              <gr-diff
-                prefs="[[overridePartialPrefs(prefs)]]"
-                change-num="[[changeNum]]"
-                path="[[item.filepath]]"
-                diff="[[item.preview]]"></gr-diff>
+              <gr-diff prefs="[[overridePartialPrefs(prefs)]]" change-num="[[changeNum]]" path="[[item.filepath]]" diff="[[item.preview]]"></gr-diff>
             </div>
           </template>
         </div>
-        <div slot="footer" class="fix-picker" hidden$="[[hasSingleFix(_fixSuggestions)]]">
+        <div slot="footer" class="fix-picker" hidden\$="[[hasSingleFix(_fixSuggestions)]]">
           <span>Suggested fix [[addOneTo(_selectedFixIdx)]] of [[_fixSuggestions.length]]</span>
-          <gr-button id="prevFix" on-click="_onPrevFixClick" disabled$="[[_noPrevFix(_selectedFixIdx)]]">
+          <gr-button id="prevFix" on-click="_onPrevFixClick" disabled\$="[[_noPrevFix(_selectedFixIdx)]]">
             <iron-icon icon="gr-icons:chevron-left"></iron-icon>
           </gr-button>
-          <gr-button id="nextFix" on-click="_onNextFixClick" disabled$="[[_noNextFix(_selectedFixIdx, _fixSuggestions)]]">
+          <gr-button id="nextFix" on-click="_onNextFixClick" disabled\$="[[_noNextFix(_selectedFixIdx, _fixSuggestions)]]">
             <iron-icon icon="gr-icons:chevron-right"></iron-icon>
           </gr-button>
         </div>
       </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-apply-fix-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html
index f22ab57..386f829 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html
@@ -17,17 +17,23 @@
 -->
 <meta name='viewport' content='width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes'>
 <title>gr-apply-fix-dialog</title>
-<link rel='import' href='../../../test/common-test-setup.html'>
-<script src='/bower_components/webcomponentsjs/custom-elements-es5-adapter.js'></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src='/bower_components/webcomponentsjs/webcomponents-lite.js'></script>
-<script src='/bower_components/web-component-tester/browser.js'></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel='import' href='../../../test/common-test-setup.html' />
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel='import' href='./gr-apply-fix-dialog.html'>
+<script type="module" src="./gr-apply-fix-dialog.js"></script>
 
-<script>void (0);</script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-apply-fix-dialog.js';
+void (0);
+</script>
 
 <test-fixture id='basic'>
   <template>
@@ -35,229 +41,232 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-apply-fix-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    const ROBOT_COMMENT_WITH_TWO_FIXES = {
-      robot_id: 'robot_1',
-      fix_suggestions: [{fix_id: 'fix_1'}, {fix_id: 'fix_2'}],
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-apply-fix-dialog.js';
+suite('gr-apply-fix-dialog tests', () => {
+  let element;
+  let sandbox;
+  const ROBOT_COMMENT_WITH_TWO_FIXES = {
+    robot_id: 'robot_1',
+    fix_suggestions: [{fix_id: 'fix_1'}, {fix_id: 'fix_2'}],
+  };
+
+  const ROBOT_COMMENT_WITH_ONE_FIX = {
+    robot_id: 'robot_1',
+    fix_suggestions: [{fix_id: 'fix_1'}],
+  };
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.changeNum = '1';
+    element._patchNum = 2;
+    element.change = {
+      _number: '1',
+      project: 'project',
     };
-
-    const ROBOT_COMMENT_WITH_ONE_FIX = {
-      robot_id: 'robot_1',
-      fix_suggestions: [{fix_id: 'fix_1'}],
+    element.prefs = {
+      font_size: 12,
+      line_length: 100,
+      tab_size: 4,
     };
+  });
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.changeNum = '1';
-      element._patchNum = 2;
-      element.change = {
-        _number: '1',
-        project: 'project',
-      };
-      element.prefs = {
-        font_size: 12,
-        line_length: 100,
-        tab_size: 4,
-      };
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  test('dialog opens fetch and sets previews', done => {
+    sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview')
+        .returns(Promise.resolve({
+          f1: {
+            meta_a: {},
+            meta_b: {},
+            content: [
+              {
+                ab: ['loqlwkqll'],
+              },
+              {
+                b: ['qwqqsqw'],
+              },
+              {
+                ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
+              },
+            ],
+          },
+          f2: {
+            meta_a: {},
+            meta_b: {},
+            content: [
+              {
+                ab: ['eqweqweqwex'],
+              },
+              {
+                b: ['zassdasd'],
+              },
+              {
+                ab: ['zassdasd', 'dasdasda', 'asdasdad'],
+              },
+            ],
+          },
+        }));
+    sandbox.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
 
-    test('dialog opens fetch and sets previews', done => {
-      sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview')
-          .returns(Promise.resolve({
-            f1: {
-              meta_a: {},
-              meta_b: {},
-              content: [
-                {
-                  ab: ['loqlwkqll'],
-                },
-                {
-                  b: ['qwqqsqw'],
-                },
-                {
-                  ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
-                },
-              ],
-            },
-            f2: {
-              meta_a: {},
-              meta_b: {},
-              content: [
-                {
-                  ab: ['eqweqweqwex'],
-                },
-                {
-                  b: ['zassdasd'],
-                },
-                {
-                  ab: ['zassdasd', 'dasdasda', 'asdasdad'],
-                },
-              ],
-            },
-          }));
-      sandbox.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
-
-      element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
-          .then(() => {
-            assert.equal(element._currentFix.fix_id, 'fix_1');
-            assert.equal(element._currentPreviews.length, 2);
-            assert.equal(element._robotId, 'robot_1');
-            done();
-          });
-    });
-
-    test('next button state updated when suggestions changed', done => {
-      sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview')
-          .returns(Promise.resolve({}));
-      sandbox.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
-
-      element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_ONE_FIX}})
-          .then(() => assert.isTrue(element.$.nextFix.disabled))
-          .then(() =>
-            element.open({detail: {patchNum: 2,
-              comment: ROBOT_COMMENT_WITH_TWO_FIXES}}))
-          .then(() => {
-            assert.isFalse(element.$.nextFix.disabled);
-            done();
-          });
-    });
-
-    test('preview endpoint throws error should reset dialog', done => {
-      sandbox.stub(window, 'fetch', (url => {
-        if (url.endsWith('/preview')) {
-          return Promise.reject(new Error('backend error'));
-        }
-        return Promise.resolve({
-          ok: true,
-          text() { return Promise.resolve(''); },
-          status: 200,
+    element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
+        .then(() => {
+          assert.equal(element._currentFix.fix_id, 'fix_1');
+          assert.equal(element._currentPreviews.length, 2);
+          assert.equal(element._robotId, 'robot_1');
+          done();
         });
-      }));
-      const errorStub = sinon.stub();
-      document.addEventListener('network-error', errorStub);
-      element.open({detail: {patchNum: 2,
-        comment: ROBOT_COMMENT_WITH_TWO_FIXES}});
-      flush(() => {
-        assert.isTrue(errorStub.called);
-        assert.deepEqual(element._currentFix, {});
-        done();
-      });
-    });
+  });
 
-    test('apply fix button should call apply ' +
-    'and navigate to change view', done => {
-      sandbox.stub(element.$.restAPI, 'applyFixSuggestion')
-          .returns(Promise.resolve({ok: true}));
-      sandbox.stub(Gerrit.Nav, 'navigateToChange');
-      element._currentFix = {fix_id: '123'};
+  test('next button state updated when suggestions changed', done => {
+    sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview')
+        .returns(Promise.resolve({}));
+    sandbox.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
 
-      element._handleApplyFix().then(() => {
-        assert.isTrue(element.$.restAPI.applyFixSuggestion
-            .calledWithExactly('1', 2, '123'));
-        assert.isTrue(Gerrit.Nav.navigateToChange.calledWithExactly({
-          _number: '1',
-          project: 'project',
-        }, 'edit', 2));
-
-        // reset gr-apply-fix-dialog and close
-        assert.deepEqual(element._currentFix, {});
-        assert.equal(element._currentPreviews.length, 0);
-        done();
-      });
-    });
-
-    test('should not navigate to change view if incorect reponse', done => {
-      sandbox.stub(element.$.restAPI, 'applyFixSuggestion')
-          .returns(Promise.resolve({}));
-      sandbox.stub(Gerrit.Nav, 'navigateToChange');
-      element._currentFix = {fix_id: '123'};
-
-      element._handleApplyFix().then(() => {
-        assert.isTrue(element.$.restAPI.applyFixSuggestion
-            .calledWithExactly('1', 2, '123'));
-        assert.isTrue(Gerrit.Nav.navigateToChange.notCalled);
-
-        assert.equal(element._isApplyFixLoading, false);
-        done();
-      });
-    });
-
-    test('select fix forward and back of multiple suggested fixes', done => {
-      sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview')
-          .returns(Promise.resolve({
-            f1: {
-              meta_a: {},
-              meta_b: {},
-              content: [
-                {
-                  ab: ['loqlwkqll'],
-                },
-                {
-                  b: ['qwqqsqw'],
-                },
-                {
-                  ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
-                },
-              ],
-            },
-            f2: {
-              meta_a: {},
-              meta_b: {},
-              content: [
-                {
-                  ab: ['eqweqweqwex'],
-                },
-                {
-                  b: ['zassdasd'],
-                },
-                {
-                  ab: ['zassdasd', 'dasdasda', 'asdasdad'],
-                },
-              ],
-            },
-          }));
-      sandbox.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
-
-      element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
-          .then(() => {
-            element._onNextFixClick();
-            assert.equal(element._currentFix.fix_id, 'fix_2');
-            element._onPrevFixClick();
-            assert.equal(element._currentFix.fix_id, 'fix_1');
-            done();
-          });
-    });
-
-    test('server-error should throw for failed apply call', done => {
-      sandbox.stub(window, 'fetch', (url => {
-        if (url.endsWith('/apply')) {
-          return Promise.reject(new Error('backend error'));
-        }
-        return Promise.resolve({
-          ok: true,
-          text() { return Promise.resolve(''); },
-          status: 200,
+    element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_ONE_FIX}})
+        .then(() => assert.isTrue(element.$.nextFix.disabled))
+        .then(() =>
+          element.open({detail: {patchNum: 2,
+            comment: ROBOT_COMMENT_WITH_TWO_FIXES}}))
+        .then(() => {
+          assert.isFalse(element.$.nextFix.disabled);
+          done();
         });
-      }));
-      const errorStub = sinon.stub();
-      document.addEventListener('network-error', errorStub);
-      sandbox.stub(Gerrit.Nav, 'navigateToChange');
-      element._currentFix = {fix_id: '123'};
-      element._handleApplyFix();
-      flush(() => {
-        assert.isFalse(Gerrit.Nav.navigateToChange.called);
-        assert.isTrue(errorStub.called);
-        done();
+  });
+
+  test('preview endpoint throws error should reset dialog', done => {
+    sandbox.stub(window, 'fetch', (url => {
+      if (url.endsWith('/preview')) {
+        return Promise.reject(new Error('backend error'));
+      }
+      return Promise.resolve({
+        ok: true,
+        text() { return Promise.resolve(''); },
+        status: 200,
       });
+    }));
+    const errorStub = sinon.stub();
+    document.addEventListener('network-error', errorStub);
+    element.open({detail: {patchNum: 2,
+      comment: ROBOT_COMMENT_WITH_TWO_FIXES}});
+    flush(() => {
+      assert.isTrue(errorStub.called);
+      assert.deepEqual(element._currentFix, {});
+      done();
     });
   });
+
+  test('apply fix button should call apply ' +
+  'and navigate to change view', done => {
+    sandbox.stub(element.$.restAPI, 'applyFixSuggestion')
+        .returns(Promise.resolve({ok: true}));
+    sandbox.stub(Gerrit.Nav, 'navigateToChange');
+    element._currentFix = {fix_id: '123'};
+
+    element._handleApplyFix().then(() => {
+      assert.isTrue(element.$.restAPI.applyFixSuggestion
+          .calledWithExactly('1', 2, '123'));
+      assert.isTrue(Gerrit.Nav.navigateToChange.calledWithExactly({
+        _number: '1',
+        project: 'project',
+      }, 'edit', 2));
+
+      // reset gr-apply-fix-dialog and close
+      assert.deepEqual(element._currentFix, {});
+      assert.equal(element._currentPreviews.length, 0);
+      done();
+    });
+  });
+
+  test('should not navigate to change view if incorect reponse', done => {
+    sandbox.stub(element.$.restAPI, 'applyFixSuggestion')
+        .returns(Promise.resolve({}));
+    sandbox.stub(Gerrit.Nav, 'navigateToChange');
+    element._currentFix = {fix_id: '123'};
+
+    element._handleApplyFix().then(() => {
+      assert.isTrue(element.$.restAPI.applyFixSuggestion
+          .calledWithExactly('1', 2, '123'));
+      assert.isTrue(Gerrit.Nav.navigateToChange.notCalled);
+
+      assert.equal(element._isApplyFixLoading, false);
+      done();
+    });
+  });
+
+  test('select fix forward and back of multiple suggested fixes', done => {
+    sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview')
+        .returns(Promise.resolve({
+          f1: {
+            meta_a: {},
+            meta_b: {},
+            content: [
+              {
+                ab: ['loqlwkqll'],
+              },
+              {
+                b: ['qwqqsqw'],
+              },
+              {
+                ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
+              },
+            ],
+          },
+          f2: {
+            meta_a: {},
+            meta_b: {},
+            content: [
+              {
+                ab: ['eqweqweqwex'],
+              },
+              {
+                b: ['zassdasd'],
+              },
+              {
+                ab: ['zassdasd', 'dasdasda', 'asdasdad'],
+              },
+            ],
+          },
+        }));
+    sandbox.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+
+    element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
+        .then(() => {
+          element._onNextFixClick();
+          assert.equal(element._currentFix.fix_id, 'fix_2');
+          element._onPrevFixClick();
+          assert.equal(element._currentFix.fix_id, 'fix_1');
+          done();
+        });
+  });
+
+  test('server-error should throw for failed apply call', done => {
+    sandbox.stub(window, 'fetch', (url => {
+      if (url.endsWith('/apply')) {
+        return Promise.reject(new Error('backend error'));
+      }
+      return Promise.resolve({
+        ok: true,
+        text() { return Promise.resolve(''); },
+        status: 200,
+      });
+    }));
+    const errorStub = sinon.stub();
+    document.addEventListener('network-error', errorStub);
+    sandbox.stub(Gerrit.Nav, 'navigateToChange');
+    element._currentFix = {fix_id: '123'};
+    element._handleApplyFix();
+    flush(() => {
+      assert.isFalse(Gerrit.Nav.navigateToChange.called);
+      assert.isTrue(errorStub.called);
+      done();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
index 490367a..95de0d1 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
@@ -14,520 +14,528 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const PARENT = 'PARENT';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-comment-api_html.js';
 
-  /**
-   * Construct a change comments object, which can be data-bound to child
-   * elements of that which uses the gr-comment-api.
-   *
-   * @constructor
-   * @param {!Object} comments
-   * @param {!Object} robotComments
-   * @param {!Object} drafts
-   * @param {number} changeNum
-   */
-  function ChangeComments(comments, robotComments, drafts, changeNum) {
-    this._comments = comments;
-    this._robotComments = robotComments;
-    this._drafts = drafts;
-    this._changeNum = changeNum;
+const PARENT = 'PARENT';
+
+/**
+ * Construct a change comments object, which can be data-bound to child
+ * elements of that which uses the gr-comment-api.
+ *
+ * @constructor
+ * @param {!Object} comments
+ * @param {!Object} robotComments
+ * @param {!Object} drafts
+ * @param {number} changeNum
+ */
+function ChangeComments(comments, robotComments, drafts, changeNum) {
+  this._comments = comments;
+  this._robotComments = robotComments;
+  this._drafts = drafts;
+  this._changeNum = changeNum;
+}
+
+ChangeComments.prototype = {
+  get comments() {
+    return this._comments;
+  },
+  get drafts() {
+    return this._drafts;
+  },
+  get robotComments() {
+    return this._robotComments;
+  },
+};
+
+ChangeComments.prototype._patchNumEquals =
+    Gerrit.PatchSetBehavior.patchNumEquals;
+ChangeComments.prototype._isMergeParent =
+    Gerrit.PatchSetBehavior.isMergeParent;
+ChangeComments.prototype._getParentIndex =
+    Gerrit.PatchSetBehavior.getParentIndex;
+
+/**
+ * Get an object mapping file paths to a boolean representing whether that
+ * path contains diff comments in the given patch set (including drafts and
+ * robot comments).
+ *
+ * Paths with comments are mapped to true, whereas paths without comments
+ * are not mapped.
+ *
+ * @param {Gerrit.PatchRange=} opt_patchRange The patch-range object containing
+ *     patchNum and basePatchNum properties to represent the range.
+ * @return {!Object}
+ */
+ChangeComments.prototype.getPaths = function(opt_patchRange) {
+  const responses = [this.comments, this.drafts, this.robotComments];
+  const commentMap = {};
+  for (const response of responses) {
+    for (const path in response) {
+      if (response.hasOwnProperty(path) &&
+          response[path].some(c => {
+            // If don't care about patch range, we know that the path exists.
+            if (!opt_patchRange) { return true; }
+            return this._isInPatchRange(c, opt_patchRange);
+          })) {
+        commentMap[path] = true;
+      }
+    }
   }
+  return commentMap;
+};
 
-  ChangeComments.prototype = {
-    get comments() {
-      return this._comments;
-    },
-    get drafts() {
-      return this._drafts;
-    },
-    get robotComments() {
-      return this._robotComments;
-    },
-  };
+/**
+ * Gets all the comments and robot comments for the given change.
+ *
+ * @param {number=} opt_patchNum
+ * @return {!Object}
+ */
+ChangeComments.prototype.getAllPublishedComments = function(opt_patchNum) {
+  return this.getAllComments(false, opt_patchNum);
+};
 
-  ChangeComments.prototype._patchNumEquals =
-      Gerrit.PatchSetBehavior.patchNumEquals;
-  ChangeComments.prototype._isMergeParent =
-      Gerrit.PatchSetBehavior.isMergeParent;
-  ChangeComments.prototype._getParentIndex =
-      Gerrit.PatchSetBehavior.getParentIndex;
+/**
+ * Gets all the comments for a particular thread group. Used for refreshing
+ * comments after the thread group has already been built.
+ *
+ * @param {string} rootId
+ * @return {!Array} an array of comments
+ */
+ChangeComments.prototype.getCommentsForThread = function(rootId) {
+  const allThreads = this.getAllThreadsForChange();
+  const threadMatch = allThreads.find(t => t.rootId === rootId);
 
-  /**
-   * Get an object mapping file paths to a boolean representing whether that
-   * path contains diff comments in the given patch set (including drafts and
-   * robot comments).
-   *
-   * Paths with comments are mapped to true, whereas paths without comments
-   * are not mapped.
-   *
-   * @param {Gerrit.PatchRange=} opt_patchRange The patch-range object containing
-   *     patchNum and basePatchNum properties to represent the range.
-   * @return {!Object}
-   */
-  ChangeComments.prototype.getPaths = function(opt_patchRange) {
-    const responses = [this.comments, this.drafts, this.robotComments];
-    const commentMap = {};
-    for (const response of responses) {
-      for (const path in response) {
-        if (response.hasOwnProperty(path) &&
-            response[path].some(c => {
-              // If don't care about patch range, we know that the path exists.
-              if (!opt_patchRange) { return true; }
-              return this._isInPatchRange(c, opt_patchRange);
-            })) {
-          commentMap[path] = true;
-        }
-      }
+  // In the event that a single draft comment was removed by the thread-list
+  // and the diff view is updating comments, there will no longer be a thread
+  // found.  In this case, return null.
+  return threadMatch ? threadMatch.comments : null;
+};
+
+/**
+ * Filters an array of comments by line and side
+ *
+ * @param {!Array} comments
+ * @param {boolean} parentOnly whether the only comments returned should have
+ *   the side attribute set to PARENT
+ * @param {string} commentSide whether the comment was left on the left or the
+ *   right side regardless or unified or side-by-side
+ * @param {number=} opt_line line number, can be undefined if file comment
+ * @return {!Array} an array of comments
+ */
+ChangeComments.prototype._filterCommentsBySideAndLine = function(comments,
+    parentOnly, commentSide, opt_line) {
+  return comments.filter(c => {
+    // if parentOnly, only match comments with PARENT for the side.
+    let sideMatch = parentOnly ? c.side === PARENT : c.side !== PARENT;
+    if (parentOnly) {
+      sideMatch = sideMatch && c.side === PARENT;
     }
-    return commentMap;
-  };
+    return sideMatch && c.line === opt_line;
+  }).map(c => {
+    c.__commentSide = commentSide;
+    return c;
+  });
+};
 
-  /**
-   * Gets all the comments and robot comments for the given change.
-   *
-   * @param {number=} opt_patchNum
-   * @return {!Object}
-   */
-  ChangeComments.prototype.getAllPublishedComments = function(opt_patchNum) {
-    return this.getAllComments(false, opt_patchNum);
-  };
-
-  /**
-   * Gets all the comments for a particular thread group. Used for refreshing
-   * comments after the thread group has already been built.
-   *
-   * @param {string} rootId
-   * @return {!Array} an array of comments
-   */
-  ChangeComments.prototype.getCommentsForThread = function(rootId) {
-    const allThreads = this.getAllThreadsForChange();
-    const threadMatch = allThreads.find(t => t.rootId === rootId);
-
-    // In the event that a single draft comment was removed by the thread-list
-    // and the diff view is updating comments, there will no longer be a thread
-    // found.  In this case, return null.
-    return threadMatch ? threadMatch.comments : null;
-  };
-
-  /**
-   * Filters an array of comments by line and side
-   *
-   * @param {!Array} comments
-   * @param {boolean} parentOnly whether the only comments returned should have
-   *   the side attribute set to PARENT
-   * @param {string} commentSide whether the comment was left on the left or the
-   *   right side regardless or unified or side-by-side
-   * @param {number=} opt_line line number, can be undefined if file comment
-   * @return {!Array} an array of comments
-   */
-  ChangeComments.prototype._filterCommentsBySideAndLine = function(comments,
-      parentOnly, commentSide, opt_line) {
-    return comments.filter(c => {
-      // if parentOnly, only match comments with PARENT for the side.
-      let sideMatch = parentOnly ? c.side === PARENT : c.side !== PARENT;
-      if (parentOnly) {
-        sideMatch = sideMatch && c.side === PARENT;
-      }
-      return sideMatch && c.line === opt_line;
-    }).map(c => {
-      c.__commentSide = commentSide;
-      return c;
-    });
-  };
-
-  /**
-   * Gets all the comments and robot comments for the given change.
-   *
-   * @param {boolean=} opt_includeDrafts
-   * @param {number=} opt_patchNum
-   * @return {!Object}
-   */
-  ChangeComments.prototype.getAllComments = function(opt_includeDrafts,
-      opt_patchNum) {
-    const paths = this.getPaths();
-    const publishedComments = {};
-    for (const path of Object.keys(paths)) {
-      let commentsToAdd = this.getAllCommentsForPath(path, opt_patchNum);
-      if (opt_includeDrafts) {
-        const drafts = this.getAllDraftsForPath(path, opt_patchNum)
-            .map(d => Object.assign({__draft: true}, d));
-        commentsToAdd = commentsToAdd.concat(drafts);
-      }
-      publishedComments[path] = commentsToAdd;
-    }
-    return publishedComments;
-  };
-
-  /**
-   * Gets all the comments and robot comments for the given change.
-   *
-   * @param {number=} opt_patchNum
-   * @return {!Object}
-   */
-  ChangeComments.prototype.getAllDrafts = function(opt_patchNum) {
-    const paths = this.getPaths();
-    const drafts = {};
-    for (const path of Object.keys(paths)) {
-      drafts[path] = this.getAllDraftsForPath(path, opt_patchNum);
-    }
-    return drafts;
-  };
-
-  /**
-   * Get the comments (robot comments) for a path and optional patch num.
-   *
-   * @param {!string} path
-   * @param {number=} opt_patchNum
-   * @param {boolean=} opt_includeDrafts
-   * @return {!Array}
-   */
-  ChangeComments.prototype.getAllCommentsForPath = function(path,
-      opt_patchNum, opt_includeDrafts) {
-    const comments = this._comments[path] || [];
-    const robotComments = this._robotComments[path] || [];
-    let allComments = comments.concat(robotComments);
+/**
+ * Gets all the comments and robot comments for the given change.
+ *
+ * @param {boolean=} opt_includeDrafts
+ * @param {number=} opt_patchNum
+ * @return {!Object}
+ */
+ChangeComments.prototype.getAllComments = function(opt_includeDrafts,
+    opt_patchNum) {
+  const paths = this.getPaths();
+  const publishedComments = {};
+  for (const path of Object.keys(paths)) {
+    let commentsToAdd = this.getAllCommentsForPath(path, opt_patchNum);
     if (opt_includeDrafts) {
-      const drafts = this.getAllDraftsForPath(path)
+      const drafts = this.getAllDraftsForPath(path, opt_patchNum)
           .map(d => Object.assign({__draft: true}, d));
-      allComments = allComments.concat(drafts);
+      commentsToAdd = commentsToAdd.concat(drafts);
     }
-    if (!opt_patchNum) { return allComments; }
-    return (allComments || []).filter(c =>
-      this._patchNumEquals(c.patch_set, opt_patchNum)
-    );
-  };
+    publishedComments[path] = commentsToAdd;
+  }
+  return publishedComments;
+};
 
-  /**
-   * Get the drafts for a path and optional patch num.
-   *
-   * @param {!string} path
-   * @param {number=} opt_patchNum
-   * @return {!Array}
-   */
-  ChangeComments.prototype.getAllDraftsForPath = function(path,
-      opt_patchNum) {
-    const comments = this._drafts[path] || [];
-    if (!opt_patchNum) { return comments; }
-    return (comments || []).filter(c =>
-      this._patchNumEquals(c.patch_set, opt_patchNum)
-    );
-  };
+/**
+ * Gets all the comments and robot comments for the given change.
+ *
+ * @param {number=} opt_patchNum
+ * @return {!Object}
+ */
+ChangeComments.prototype.getAllDrafts = function(opt_patchNum) {
+  const paths = this.getPaths();
+  const drafts = {};
+  for (const path of Object.keys(paths)) {
+    drafts[path] = this.getAllDraftsForPath(path, opt_patchNum);
+  }
+  return drafts;
+};
 
-  /**
-   * Get the comments (with drafts and robot comments) for a path and
-   * patch-range. Returns an object with left and right properties mapping to
-   * arrays of comments in on either side of the patch range for that path.
-   *
-   * @param {!string} path
-   * @param {!Gerrit.PatchRange} patchRange The patch-range object containing patchNum
-   *     and basePatchNum properties to represent the range.
-   * @param {Object=} opt_projectConfig Optional project config object to
-   *     include in the meta sub-object.
-   * @return {!Gerrit.CommentsBySide}
-   */
-  ChangeComments.prototype.getCommentsBySideForPath = function(path,
-      patchRange, opt_projectConfig) {
-    let comments = [];
-    let drafts = [];
-    let robotComments = [];
-    if (this.comments && this.comments[path]) {
-      comments = this.comments[path];
-    }
-    if (this.drafts && this.drafts[path]) {
-      drafts = this.drafts[path];
-    }
-    if (this.robotComments && this.robotComments[path]) {
-      robotComments = this.robotComments[path];
-    }
+/**
+ * Get the comments (robot comments) for a path and optional patch num.
+ *
+ * @param {!string} path
+ * @param {number=} opt_patchNum
+ * @param {boolean=} opt_includeDrafts
+ * @return {!Array}
+ */
+ChangeComments.prototype.getAllCommentsForPath = function(path,
+    opt_patchNum, opt_includeDrafts) {
+  const comments = this._comments[path] || [];
+  const robotComments = this._robotComments[path] || [];
+  let allComments = comments.concat(robotComments);
+  if (opt_includeDrafts) {
+    const drafts = this.getAllDraftsForPath(path)
+        .map(d => Object.assign({__draft: true}, d));
+    allComments = allComments.concat(drafts);
+  }
+  if (!opt_patchNum) { return allComments; }
+  return (allComments || []).filter(c =>
+    this._patchNumEquals(c.patch_set, opt_patchNum)
+  );
+};
 
-    drafts.forEach(d => { d.__draft = true; });
+/**
+ * Get the drafts for a path and optional patch num.
+ *
+ * @param {!string} path
+ * @param {number=} opt_patchNum
+ * @return {!Array}
+ */
+ChangeComments.prototype.getAllDraftsForPath = function(path,
+    opt_patchNum) {
+  const comments = this._drafts[path] || [];
+  if (!opt_patchNum) { return comments; }
+  return (comments || []).filter(c =>
+    this._patchNumEquals(c.patch_set, opt_patchNum)
+  );
+};
 
-    const all = comments.concat(drafts).concat(robotComments);
-
-    const baseComments = all.filter(c =>
-      this._isInBaseOfPatchRange(c, patchRange));
-    const revisionComments = all.filter(c =>
-      this._isInRevisionOfPatchRange(c, patchRange));
-
-    return {
-      meta: {
-        changeNum: this._changeNum,
-        path,
-        patchRange,
-        projectConfig: opt_projectConfig,
-      },
-      left: baseComments,
-      right: revisionComments,
-    };
-  };
-
-  /**
-   * @param {!Object} comments Object keyed by file, with a value of an array
-   *   of comments left on that file.
-   * @return {!Array} A flattened list of all comments, where each comment
-   *   also includes the file that it was left on, which was the key of the
-   *   originall object.
-   */
-  ChangeComments.prototype._commentObjToArrayWithFile = function(comments) {
-    let commentArr = [];
-    for (const file of Object.keys(comments)) {
-      const commentsForFile = [];
-      for (const comment of comments[file]) {
-        commentsForFile.push(Object.assign({__path: file}, comment));
-      }
-      commentArr = commentArr.concat(commentsForFile);
-    }
-    return commentArr;
-  };
-
-  ChangeComments.prototype._commentObjToArray = function(comments) {
-    let commentArr = [];
-    for (const file of Object.keys(comments)) {
-      commentArr = commentArr.concat(comments[file]);
-    }
-    return commentArr;
-  };
-
-  /**
-   * Computes a string counting the number of commens in a given file and path.
-   *
-   * @param {number} patchNum
-   * @param {string=} opt_path
-   * @return {number}
-   */
-  ChangeComments.prototype.computeCommentCount = function(patchNum, opt_path) {
-    if (opt_path) {
-      return this.getAllCommentsForPath(opt_path, patchNum).length;
-    }
-    const allComments = this.getAllPublishedComments(patchNum);
-    return this._commentObjToArray(allComments).length;
-  };
-
-  /**
-   * Computes a string counting the number of draft comments in the entire
-   * change, optionally filtered by path and/or patchNum.
-   *
-   * @param {number=} opt_patchNum
-   * @param {string=} opt_path
-   * @return {number}
-   */
-  ChangeComments.prototype.computeDraftCount = function(opt_patchNum,
-      opt_path) {
-    if (opt_path) {
-      return this.getAllDraftsForPath(opt_path, opt_patchNum).length;
-    }
-    const allDrafts = this.getAllDrafts(opt_patchNum);
-    return this._commentObjToArray(allDrafts).length;
-  };
-
-  /**
-   * Computes a number of unresolved comment threads in a given file and path.
-   *
-   * @param {number} patchNum
-   * @param {string=} opt_path
-   * @return {number}
-   */
-  ChangeComments.prototype.computeUnresolvedNum = function(patchNum,
-      opt_path) {
-    let comments = [];
-    let drafts = [];
-
-    if (opt_path) {
-      comments = this.getAllCommentsForPath(opt_path, patchNum);
-      drafts = this.getAllDraftsForPath(opt_path, patchNum);
-    } else {
-      comments = this._commentObjToArray(
-          this.getAllPublishedComments(patchNum));
-    }
-
-    comments = comments.concat(drafts);
-
-    const threads = this.getCommentThreads(this._sortComments(comments));
-
-    const unresolvedThreads = threads
-        .filter(thread =>
-          thread.comments.length &&
-          thread.comments[thread.comments.length - 1].unresolved);
-
-    return unresolvedThreads.length;
-  };
-
-  ChangeComments.prototype.getAllThreadsForChange = function() {
-    const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
-    const sortedComments = this._sortComments(comments);
-    return this.getCommentThreads(sortedComments);
-  };
-
-  ChangeComments.prototype._sortComments = function(comments) {
-    return comments.slice(0)
-        .sort(
-            (c1, c2) => util.parseDate(c1.updated) - util.parseDate(c2.updated)
-        );
-  };
-
-  /**
-   * Computes all of the comments in thread format.
-   *
-   * @param {!Array} comments sorted by updated timestamp.
-   * @return {!Array}
-   */
-  ChangeComments.prototype.getCommentThreads = function(comments) {
-    const threads = [];
-    const idThreadMap = {};
-    for (const comment of comments) {
-      // If the comment is in reply to another comment, find that comment's
-      // thread and append to it.
-      if (comment.in_reply_to) {
-        const thread = idThreadMap[comment.in_reply_to];
-        if (thread) {
-          thread.comments.push(comment);
-          idThreadMap[comment.id] = thread;
-          continue;
-        }
-      }
-
-      // Otherwise, this comment starts its own thread.
-      const newThread = {
-        comments: [comment],
-        patchNum: comment.patch_set,
-        path: comment.__path,
-        line: comment.line,
-        rootId: comment.id,
-      };
-      if (comment.side) {
-        newThread.commentSide = comment.side;
-      }
-      threads.push(newThread);
-      idThreadMap[comment.id] = newThread;
-    }
-    return threads;
-  };
-
-  /**
-   * Whether the given comment should be included in the base side of the
-   * given patch range.
-   *
-   * @param {!Object} comment
-   * @param {!Gerrit.PatchRange} range
-   * @return {boolean}
-   */
-  ChangeComments.prototype._isInBaseOfPatchRange = function(comment, range) {
-    // If the base of the patch range is a parent of a merge, and the comment
-    // appears on a specific parent then only show the comment if the parent
-    // index of the comment matches that of the range.
-    if (comment.parent && comment.side === PARENT) {
-      return this._isMergeParent(range.basePatchNum) &&
-          comment.parent === this._getParentIndex(range.basePatchNum);
-    }
-
-    // If the base of the range is the parent of the patch:
-    if (range.basePatchNum === PARENT &&
-        comment.side === PARENT &&
-        this._patchNumEquals(comment.patch_set, range.patchNum)) {
-      return true;
-    }
-    // If the base of the range is not the parent of the patch:
-    if (range.basePatchNum !== PARENT &&
-        comment.side !== PARENT &&
-        this._patchNumEquals(comment.patch_set, range.basePatchNum)) {
-      return true;
-    }
-    return false;
-  };
-
-  /**
-   * Whether the given comment should be included in the revision side of the
-   * given patch range.
-   *
-   * @param {!Object} comment
-   * @param {!Gerrit.PatchRange} range
-   * @return {boolean}
-   */
-  ChangeComments.prototype._isInRevisionOfPatchRange = function(comment,
-      range) {
-    return comment.side !== PARENT &&
-        this._patchNumEquals(comment.patch_set, range.patchNum);
-  };
-
-  /**
-   * Whether the given comment should be included in the given patch range.
-   *
-   * @param {!Object} comment
-   * @param {!Gerrit.PatchRange} range
-   * @return {boolean|undefined}
-   */
-  ChangeComments.prototype._isInPatchRange = function(comment, range) {
-    return this._isInBaseOfPatchRange(comment, range) ||
-        this._isInRevisionOfPatchRange(comment, range);
-  };
-
-  /**
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @extends Polymer.Element
-   */
-  class GrCommentApi extends Polymer.mixinBehaviors( [
-    Gerrit.PatchSetBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-comment-api'; }
-
-    static get properties() {
-      return {
-        _changeComments: Object,
-      };
-    }
-
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('reload-drafts',
-          changeNum => this.reloadDrafts(changeNum));
-    }
-
-    /**
-     * Load all comments (with drafts and robot comments) for the given change
-     * number. The returned promise resolves when the comments have loaded, but
-     * does not yield the comment data.
-     *
-     * @param {number} changeNum
-     * @return {!Promise<!Object>}
-     */
-    loadAll(changeNum) {
-      const promises = [];
-      promises.push(this.$.restAPI.getDiffComments(changeNum));
-      promises.push(this.$.restAPI.getDiffRobotComments(changeNum));
-      promises.push(this.$.restAPI.getDiffDrafts(changeNum));
-
-      return Promise.all(promises).then(([comments, robotComments, drafts]) => {
-        this._changeComments = new ChangeComments(comments,
-            robotComments, drafts, changeNum);
-        return this._changeComments;
-      });
-    }
-
-    /**
-     * Re-initialize _changeComments with a new ChangeComments object, that
-     * uses the previous values for comments and robot comments, but fetches
-     * updated draft comments.
-     *
-     * @param {number} changeNum
-     * @return {!Promise<!Object>}
-     */
-    reloadDrafts(changeNum) {
-      if (!this._changeComments) {
-        return this.loadAll(changeNum);
-      }
-      return this.$.restAPI.getDiffDrafts(changeNum).then(drafts => {
-        this._changeComments = new ChangeComments(this._changeComments.comments,
-            this._changeComments.robotComments, drafts, changeNum);
-        return this._changeComments;
-      });
-    }
+/**
+ * Get the comments (with drafts and robot comments) for a path and
+ * patch-range. Returns an object with left and right properties mapping to
+ * arrays of comments in on either side of the patch range for that path.
+ *
+ * @param {!string} path
+ * @param {!Gerrit.PatchRange} patchRange The patch-range object containing patchNum
+ *     and basePatchNum properties to represent the range.
+ * @param {Object=} opt_projectConfig Optional project config object to
+ *     include in the meta sub-object.
+ * @return {!Gerrit.CommentsBySide}
+ */
+ChangeComments.prototype.getCommentsBySideForPath = function(path,
+    patchRange, opt_projectConfig) {
+  let comments = [];
+  let drafts = [];
+  let robotComments = [];
+  if (this.comments && this.comments[path]) {
+    comments = this.comments[path];
+  }
+  if (this.drafts && this.drafts[path]) {
+    drafts = this.drafts[path];
+  }
+  if (this.robotComments && this.robotComments[path]) {
+    robotComments = this.robotComments[path];
   }
 
-  customElements.define(GrCommentApi.is, GrCommentApi);
-})();
+  drafts.forEach(d => { d.__draft = true; });
+
+  const all = comments.concat(drafts).concat(robotComments);
+
+  const baseComments = all.filter(c =>
+    this._isInBaseOfPatchRange(c, patchRange));
+  const revisionComments = all.filter(c =>
+    this._isInRevisionOfPatchRange(c, patchRange));
+
+  return {
+    meta: {
+      changeNum: this._changeNum,
+      path,
+      patchRange,
+      projectConfig: opt_projectConfig,
+    },
+    left: baseComments,
+    right: revisionComments,
+  };
+};
+
+/**
+ * @param {!Object} comments Object keyed by file, with a value of an array
+ *   of comments left on that file.
+ * @return {!Array} A flattened list of all comments, where each comment
+ *   also includes the file that it was left on, which was the key of the
+ *   originall object.
+ */
+ChangeComments.prototype._commentObjToArrayWithFile = function(comments) {
+  let commentArr = [];
+  for (const file of Object.keys(comments)) {
+    const commentsForFile = [];
+    for (const comment of comments[file]) {
+      commentsForFile.push(Object.assign({__path: file}, comment));
+    }
+    commentArr = commentArr.concat(commentsForFile);
+  }
+  return commentArr;
+};
+
+ChangeComments.prototype._commentObjToArray = function(comments) {
+  let commentArr = [];
+  for (const file of Object.keys(comments)) {
+    commentArr = commentArr.concat(comments[file]);
+  }
+  return commentArr;
+};
+
+/**
+ * Computes a string counting the number of commens in a given file and path.
+ *
+ * @param {number} patchNum
+ * @param {string=} opt_path
+ * @return {number}
+ */
+ChangeComments.prototype.computeCommentCount = function(patchNum, opt_path) {
+  if (opt_path) {
+    return this.getAllCommentsForPath(opt_path, patchNum).length;
+  }
+  const allComments = this.getAllPublishedComments(patchNum);
+  return this._commentObjToArray(allComments).length;
+};
+
+/**
+ * Computes a string counting the number of draft comments in the entire
+ * change, optionally filtered by path and/or patchNum.
+ *
+ * @param {number=} opt_patchNum
+ * @param {string=} opt_path
+ * @return {number}
+ */
+ChangeComments.prototype.computeDraftCount = function(opt_patchNum,
+    opt_path) {
+  if (opt_path) {
+    return this.getAllDraftsForPath(opt_path, opt_patchNum).length;
+  }
+  const allDrafts = this.getAllDrafts(opt_patchNum);
+  return this._commentObjToArray(allDrafts).length;
+};
+
+/**
+ * Computes a number of unresolved comment threads in a given file and path.
+ *
+ * @param {number} patchNum
+ * @param {string=} opt_path
+ * @return {number}
+ */
+ChangeComments.prototype.computeUnresolvedNum = function(patchNum,
+    opt_path) {
+  let comments = [];
+  let drafts = [];
+
+  if (opt_path) {
+    comments = this.getAllCommentsForPath(opt_path, patchNum);
+    drafts = this.getAllDraftsForPath(opt_path, patchNum);
+  } else {
+    comments = this._commentObjToArray(
+        this.getAllPublishedComments(patchNum));
+  }
+
+  comments = comments.concat(drafts);
+
+  const threads = this.getCommentThreads(this._sortComments(comments));
+
+  const unresolvedThreads = threads
+      .filter(thread =>
+        thread.comments.length &&
+        thread.comments[thread.comments.length - 1].unresolved);
+
+  return unresolvedThreads.length;
+};
+
+ChangeComments.prototype.getAllThreadsForChange = function() {
+  const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
+  const sortedComments = this._sortComments(comments);
+  return this.getCommentThreads(sortedComments);
+};
+
+ChangeComments.prototype._sortComments = function(comments) {
+  return comments.slice(0)
+      .sort(
+          (c1, c2) => util.parseDate(c1.updated) - util.parseDate(c2.updated)
+      );
+};
+
+/**
+ * Computes all of the comments in thread format.
+ *
+ * @param {!Array} comments sorted by updated timestamp.
+ * @return {!Array}
+ */
+ChangeComments.prototype.getCommentThreads = function(comments) {
+  const threads = [];
+  const idThreadMap = {};
+  for (const comment of comments) {
+    // If the comment is in reply to another comment, find that comment's
+    // thread and append to it.
+    if (comment.in_reply_to) {
+      const thread = idThreadMap[comment.in_reply_to];
+      if (thread) {
+        thread.comments.push(comment);
+        idThreadMap[comment.id] = thread;
+        continue;
+      }
+    }
+
+    // Otherwise, this comment starts its own thread.
+    const newThread = {
+      comments: [comment],
+      patchNum: comment.patch_set,
+      path: comment.__path,
+      line: comment.line,
+      rootId: comment.id,
+    };
+    if (comment.side) {
+      newThread.commentSide = comment.side;
+    }
+    threads.push(newThread);
+    idThreadMap[comment.id] = newThread;
+  }
+  return threads;
+};
+
+/**
+ * Whether the given comment should be included in the base side of the
+ * given patch range.
+ *
+ * @param {!Object} comment
+ * @param {!Gerrit.PatchRange} range
+ * @return {boolean}
+ */
+ChangeComments.prototype._isInBaseOfPatchRange = function(comment, range) {
+  // If the base of the patch range is a parent of a merge, and the comment
+  // appears on a specific parent then only show the comment if the parent
+  // index of the comment matches that of the range.
+  if (comment.parent && comment.side === PARENT) {
+    return this._isMergeParent(range.basePatchNum) &&
+        comment.parent === this._getParentIndex(range.basePatchNum);
+  }
+
+  // If the base of the range is the parent of the patch:
+  if (range.basePatchNum === PARENT &&
+      comment.side === PARENT &&
+      this._patchNumEquals(comment.patch_set, range.patchNum)) {
+    return true;
+  }
+  // If the base of the range is not the parent of the patch:
+  if (range.basePatchNum !== PARENT &&
+      comment.side !== PARENT &&
+      this._patchNumEquals(comment.patch_set, range.basePatchNum)) {
+    return true;
+  }
+  return false;
+};
+
+/**
+ * Whether the given comment should be included in the revision side of the
+ * given patch range.
+ *
+ * @param {!Object} comment
+ * @param {!Gerrit.PatchRange} range
+ * @return {boolean}
+ */
+ChangeComments.prototype._isInRevisionOfPatchRange = function(comment,
+    range) {
+  return comment.side !== PARENT &&
+      this._patchNumEquals(comment.patch_set, range.patchNum);
+};
+
+/**
+ * Whether the given comment should be included in the given patch range.
+ *
+ * @param {!Object} comment
+ * @param {!Gerrit.PatchRange} range
+ * @return {boolean|undefined}
+ */
+ChangeComments.prototype._isInPatchRange = function(comment, range) {
+  return this._isInBaseOfPatchRange(comment, range) ||
+      this._isInRevisionOfPatchRange(comment, range);
+};
+
+/**
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @extends Polymer.Element
+ */
+class GrCommentApi extends mixinBehaviors( [
+  Gerrit.PatchSetBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-comment-api'; }
+
+  static get properties() {
+    return {
+      _changeComments: Object,
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('reload-drafts',
+        changeNum => this.reloadDrafts(changeNum));
+  }
+
+  /**
+   * Load all comments (with drafts and robot comments) for the given change
+   * number. The returned promise resolves when the comments have loaded, but
+   * does not yield the comment data.
+   *
+   * @param {number} changeNum
+   * @return {!Promise<!Object>}
+   */
+  loadAll(changeNum) {
+    const promises = [];
+    promises.push(this.$.restAPI.getDiffComments(changeNum));
+    promises.push(this.$.restAPI.getDiffRobotComments(changeNum));
+    promises.push(this.$.restAPI.getDiffDrafts(changeNum));
+
+    return Promise.all(promises).then(([comments, robotComments, drafts]) => {
+      this._changeComments = new ChangeComments(comments,
+          robotComments, drafts, changeNum);
+      return this._changeComments;
+    });
+  }
+
+  /**
+   * Re-initialize _changeComments with a new ChangeComments object, that
+   * uses the previous values for comments and robot comments, but fetches
+   * updated draft comments.
+   *
+   * @param {number} changeNum
+   * @return {!Promise<!Object>}
+   */
+  reloadDrafts(changeNum) {
+    if (!this._changeComments) {
+      return this.loadAll(changeNum);
+    }
+    return this.$.restAPI.getDiffDrafts(changeNum).then(drafts => {
+      this._changeComments = new ChangeComments(this._changeComments.comments,
+          this._changeComments.robotComments, drafts, changeNum);
+      return this._changeComments;
+    });
+  }
+}
+
+customElements.define(GrCommentApi.is, GrCommentApi);
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.js
index 317e9e5..215bfac 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.js
@@ -1,27 +1,21 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-comment-api">
-  <template>
+export const htmlTemplate = html`
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-comment-api.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
index f2f7c0f..e39319a 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment-api</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel="import" href="./gr-comment-api.html">
+<script type="module" src="./gr-comment-api.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-comment-api.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,697 +41,699 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-comment-api tests', async () => {
-    await readyToTest();
-    const PARENT = 'PARENT';
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-comment-api.js';
+suite('gr-comment-api tests', () => {
+  const PARENT = 'PARENT';
 
-    let element;
-    let sandbox;
+  let element;
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('loads logged-out', () => {
+    const changeNum = 1234;
+
+    sandbox.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(false));
+    sandbox.stub(element.$.restAPI, 'getDiffComments')
+        .returns(Promise.resolve({
+          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
+        }));
+    sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
+        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+    sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+        .returns(Promise.resolve({}));
+
+    return element.loadAll(changeNum).then(() => {
+      assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
+          changeNum));
+      assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
+          changeNum));
+      assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
+          changeNum));
+      assert.isOk(element._changeComments._comments);
+      assert.isOk(element._changeComments._robotComments);
+      assert.deepEqual(element._changeComments._drafts, {});
+    });
+  });
+
+  test('loads logged-in', () => {
+    const changeNum = 1234;
+
+    sandbox.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(true));
+    sandbox.stub(element.$.restAPI, 'getDiffComments')
+        .returns(Promise.resolve({
+          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
+        }));
+    sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
+        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+    sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+        .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
+
+    return element.loadAll(changeNum).then(() => {
+      assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
+          changeNum));
+      assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
+          changeNum));
+      assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
+          changeNum));
+      assert.isOk(element._changeComments._comments);
+      assert.isOk(element._changeComments._robotComments);
+      assert.notDeepEqual(element._changeComments._drafts, {});
+    });
+  });
+
+  suite('reloadDrafts', () => {
+    let commentStub;
+    let robotCommentStub;
+    let draftStub;
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('loads logged-out', () => {
-      const changeNum = 1234;
-
-      sandbox.stub(element.$.restAPI, 'getLoggedIn')
-          .returns(Promise.resolve(false));
-      sandbox.stub(element.$.restAPI, 'getDiffComments')
-          .returns(Promise.resolve({
-            'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-          }));
-      sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
-          .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-      sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+      commentStub = sandbox.stub(element.$.restAPI, 'getDiffComments')
           .returns(Promise.resolve({}));
+      robotCommentStub = sandbox.stub(element.$.restAPI,
+          'getDiffRobotComments').returns(Promise.resolve({}));
+      draftStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+          .returns(Promise.resolve({}));
+    });
 
-      return element.loadAll(changeNum).then(() => {
-        assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
-            changeNum));
-        assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
-            changeNum));
-        assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
-            changeNum));
-        assert.isOk(element._changeComments._comments);
-        assert.isOk(element._changeComments._robotComments);
-        assert.deepEqual(element._changeComments._drafts, {});
+    test('without loadAll first', done => {
+      assert.isNotOk(element._changeComments);
+      sandbox.spy(element, 'loadAll');
+      element.reloadDrafts().then(() => {
+        assert.isTrue(element.loadAll.called);
+        assert.isOk(element._changeComments);
+        assert.equal(commentStub.callCount, 1);
+        assert.equal(robotCommentStub.callCount, 1);
+        assert.equal(draftStub.callCount, 1);
+        done();
       });
     });
 
-    test('loads logged-in', () => {
+    test('with loadAll first', done => {
+      assert.isNotOk(element._changeComments);
+      element.loadAll()
+          .then(() => {
+            assert.isOk(element._changeComments);
+            assert.equal(commentStub.callCount, 1);
+            assert.equal(robotCommentStub.callCount, 1);
+            assert.equal(draftStub.callCount, 1);
+            return element.reloadDrafts();
+          })
+          .then(() => {
+            assert.isOk(element._changeComments);
+            assert.equal(commentStub.callCount, 1);
+            assert.equal(robotCommentStub.callCount, 1);
+            assert.equal(draftStub.callCount, 2);
+            done();
+          });
+    });
+  });
+
+  suite('_changeComment methods', () => {
+    setup(done => {
       const changeNum = 1234;
-
-      sandbox.stub(element.$.restAPI, 'getLoggedIn')
-          .returns(Promise.resolve(true));
-      sandbox.stub(element.$.restAPI, 'getDiffComments')
-          .returns(Promise.resolve({
-            'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-          }));
-      sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
-          .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-      sandbox.stub(element.$.restAPI, 'getDiffDrafts')
-          .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
-
-      return element.loadAll(changeNum).then(() => {
-        assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
-            changeNum));
-        assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
-            changeNum));
-        assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
-            changeNum));
-        assert.isOk(element._changeComments._comments);
-        assert.isOk(element._changeComments._robotComments);
-        assert.notDeepEqual(element._changeComments._drafts, {});
+      stub('gr-rest-api-interface', {
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+      element.loadAll(changeNum).then(() => {
+        done();
       });
     });
 
-    suite('reloadDrafts', () => {
-      let commentStub;
-      let robotCommentStub;
-      let draftStub;
+    test('_isInBaseOfPatchRange', () => {
+      const comment = {patch_set: 1};
+      const patchRange = {basePatchNum: 1, patchNum: 2};
+      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      patchRange.basePatchNum = PARENT;
+      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      comment.side = PARENT;
+      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      comment.patch_set = 2;
+      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      patchRange.basePatchNum = -2;
+      comment.side = PARENT;
+      comment.parent = 1;
+      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      comment.parent = 2;
+      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+    });
+
+    test('_isInRevisionOfPatchRange', () => {
+      const comment = {patch_set: 123};
+      const patchRange = {basePatchNum: 122, patchNum: 124};
+      assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
+          comment, patchRange));
+
+      patchRange.patchNum = 123;
+      assert.isTrue(element._changeComments._isInRevisionOfPatchRange(
+          comment, patchRange));
+
+      comment.side = PARENT;
+      assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
+          comment, patchRange));
+    });
+
+    test('_isInPatchRange', () => {
+      const patchRange1 = {basePatchNum: 122, patchNum: 124};
+      const patchRange2 = {basePatchNum: 123, patchNum: 125};
+      const patchRange3 = {basePatchNum: 124, patchNum: 125};
+
+      const isInBasePatchStub = sandbox.stub(element._changeComments,
+          '_isInBaseOfPatchRange');
+      const isInRevisionPatchStub = sandbox.stub(element._changeComments,
+          '_isInRevisionOfPatchRange');
+
+      isInBasePatchStub.withArgs({}, patchRange1).returns(true);
+      isInBasePatchStub.withArgs({}, patchRange2).returns(false);
+      isInBasePatchStub.withArgs({}, patchRange3).returns(false);
+
+      isInRevisionPatchStub.withArgs({}, patchRange1).returns(false);
+      isInRevisionPatchStub.withArgs({}, patchRange2).returns(true);
+      isInRevisionPatchStub.withArgs({}, patchRange3).returns(false);
+
+      assert.isTrue(element._changeComments._isInPatchRange({}, patchRange1));
+      assert.isTrue(element._changeComments._isInPatchRange({}, patchRange2));
+      assert.isFalse(element._changeComments._isInPatchRange({},
+          patchRange3));
+    });
+
+    suite('comment ranges and paths', () => {
+      function makeTime(mins) {
+        return `2013-02-26 15:0${mins}:43.986000000`;
+      }
+
       setup(() => {
-        commentStub = sandbox.stub(element.$.restAPI, 'getDiffComments')
-            .returns(Promise.resolve({}));
-        robotCommentStub = sandbox.stub(element.$.restAPI,
-            'getDiffRobotComments').returns(Promise.resolve({}));
-        draftStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts')
-            .returns(Promise.resolve({}));
+        element._changeComments._drafts = {
+          'file/one': [
+            {
+              id: 11,
+              patch_set: 2,
+              side: PARENT,
+              line: 1,
+              updated: makeTime(3),
+            },
+            {
+              id: 12,
+              in_reply_to: 2,
+              patch_set: 2,
+              line: 1,
+              updated: makeTime(3),
+            },
+          ],
+          'file/two': [
+            {
+              id: 5,
+              patch_set: 3,
+              line: 1,
+              updated: makeTime(3),
+            },
+          ],
+        };
+        element._changeComments._robotComments = {
+          'file/one': [
+            {
+              id: 1,
+              patch_set: 2,
+              side: PARENT,
+              line: 1,
+              updated: makeTime(1),
+              range: {
+                start_line: 1,
+                start_character: 2,
+                end_line: 2,
+                end_character: 2,
+              },
+            }, {
+              id: 2,
+              in_reply_to: 4,
+              patch_set: 2,
+              unresolved: true,
+              line: 1,
+              updated: makeTime(2),
+            },
+          ],
+        };
+        element._changeComments._comments = {
+          'file/one': [
+            {id: 3, patch_set: 2, side: PARENT, line: 2, updated: makeTime(1)},
+            {id: 4, patch_set: 2, line: 1, updated: makeTime(1)},
+          ],
+          'file/two': [
+            {id: 5, patch_set: 2, line: 2, updated: makeTime(1)},
+            {id: 6, patch_set: 3, line: 2, updated: makeTime(1)},
+          ],
+          'file/three': [
+            {
+              id: 7,
+              patch_set: 2,
+              side: PARENT,
+              unresolved: true,
+              line: 1,
+              updated: makeTime(1),
+            },
+            {id: 8, patch_set: 3, line: 1, updated: makeTime(1)},
+          ],
+          'file/four': [
+            {id: 9, patch_set: 5, side: PARENT, line: 1, updated: makeTime(1)},
+            {id: 10, patch_set: 5, line: 1, updated: makeTime(1)},
+          ],
+        };
       });
 
-      test('without loadAll first', done => {
-        assert.isNotOk(element._changeComments);
-        sandbox.spy(element, 'loadAll');
-        element.reloadDrafts().then(() => {
-          assert.isTrue(element.loadAll.called);
-          assert.isOk(element._changeComments);
-          assert.equal(commentStub.callCount, 1);
-          assert.equal(robotCommentStub.callCount, 1);
-          assert.equal(draftStub.callCount, 1);
-          done();
-        });
-      });
-
-      test('with loadAll first', done => {
-        assert.isNotOk(element._changeComments);
-        element.loadAll()
-            .then(() => {
-              assert.isOk(element._changeComments);
-              assert.equal(commentStub.callCount, 1);
-              assert.equal(robotCommentStub.callCount, 1);
-              assert.equal(draftStub.callCount, 1);
-              return element.reloadDrafts();
-            })
-            .then(() => {
-              assert.isOk(element._changeComments);
-              assert.equal(commentStub.callCount, 1);
-              assert.equal(robotCommentStub.callCount, 1);
-              assert.equal(draftStub.callCount, 2);
-              done();
-            });
-      });
-    });
-
-    suite('_changeComment methods', () => {
-      setup(done => {
-        const changeNum = 1234;
-        stub('gr-rest-api-interface', {
-          getDiffComments() { return Promise.resolve({}); },
-          getDiffRobotComments() { return Promise.resolve({}); },
-          getDiffDrafts() { return Promise.resolve({}); },
-        });
-        element.loadAll(changeNum).then(() => {
-          done();
-        });
-      });
-
-      test('_isInBaseOfPatchRange', () => {
-        const comment = {patch_set: 1};
-        const patchRange = {basePatchNum: 1, patchNum: 2};
-        assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
-            patchRange));
+      test('getPaths', () => {
+        const patchRange = {basePatchNum: 1, patchNum: 4};
+        let paths = element._changeComments.getPaths(patchRange);
+        assert.equal(Object.keys(paths).length, 0);
 
         patchRange.basePatchNum = PARENT;
-        assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
-            patchRange));
+        patchRange.patchNum = 3;
+        paths = element._changeComments.getPaths(patchRange);
+        assert.notProperty(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.notProperty(paths, 'file/four');
 
-        comment.side = PARENT;
-        assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
-            patchRange));
+        patchRange.patchNum = 2;
+        paths = element._changeComments.getPaths(patchRange);
+        assert.property(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.notProperty(paths, 'file/four');
 
-        comment.patch_set = 2;
-        assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
-            patchRange));
-
-        patchRange.basePatchNum = -2;
-        comment.side = PARENT;
-        comment.parent = 1;
-        assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
-            patchRange));
-
-        comment.parent = 2;
-        assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
-            patchRange));
+        paths = element._changeComments.getPaths();
+        assert.property(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.property(paths, 'file/four');
       });
 
-      test('_isInRevisionOfPatchRange', () => {
-        const comment = {patch_set: 123};
-        const patchRange = {basePatchNum: 122, patchNum: 124};
-        assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
-            comment, patchRange));
+      test('getCommentsBySideForPath', () => {
+        const patchRange = {basePatchNum: 1, patchNum: 3};
+        let path = 'file/one';
+        let comments = element._changeComments.getCommentsBySideForPath(path,
+            patchRange);
+        assert.equal(comments.meta.changeNum, 1234);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 0);
 
-        patchRange.patchNum = 123;
-        assert.isTrue(element._changeComments._isInRevisionOfPatchRange(
-            comment, patchRange));
+        path = 'file/two';
+        comments = element._changeComments.getCommentsBySideForPath(path,
+            patchRange);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 2);
 
-        comment.side = PARENT;
-        assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
-            comment, patchRange));
+        patchRange.basePatchNum = 2;
+        comments = element._changeComments.getCommentsBySideForPath(path,
+            patchRange);
+        assert.equal(comments.left.length, 1);
+        assert.equal(comments.right.length, 2);
+
+        patchRange.basePatchNum = PARENT;
+        path = 'file/three';
+        comments = element._changeComments.getCommentsBySideForPath(path,
+            patchRange);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 1);
       });
 
-      test('_isInPatchRange', () => {
-        const patchRange1 = {basePatchNum: 122, patchNum: 124};
-        const patchRange2 = {basePatchNum: 123, patchNum: 125};
-        const patchRange3 = {basePatchNum: 124, patchNum: 125};
-
-        const isInBasePatchStub = sandbox.stub(element._changeComments,
-            '_isInBaseOfPatchRange');
-        const isInRevisionPatchStub = sandbox.stub(element._changeComments,
-            '_isInRevisionOfPatchRange');
-
-        isInBasePatchStub.withArgs({}, patchRange1).returns(true);
-        isInBasePatchStub.withArgs({}, patchRange2).returns(false);
-        isInBasePatchStub.withArgs({}, patchRange3).returns(false);
-
-        isInRevisionPatchStub.withArgs({}, patchRange1).returns(false);
-        isInRevisionPatchStub.withArgs({}, patchRange2).returns(true);
-        isInRevisionPatchStub.withArgs({}, patchRange3).returns(false);
-
-        assert.isTrue(element._changeComments._isInPatchRange({}, patchRange1));
-        assert.isTrue(element._changeComments._isInPatchRange({}, patchRange2));
-        assert.isFalse(element._changeComments._isInPatchRange({},
-            patchRange3));
+      test('getAllCommentsForPath', () => {
+        let path = 'file/one';
+        let comments = element._changeComments.getAllCommentsForPath(path);
+        assert.deepEqual(comments.length, 4);
+        path = 'file/two';
+        comments = element._changeComments.getAllCommentsForPath(path, 2);
+        assert.deepEqual(comments.length, 1);
       });
 
-      suite('comment ranges and paths', () => {
-        function makeTime(mins) {
-          return `2013-02-26 15:0${mins}:43.986000000`;
-        }
+      test('getAllDraftsForPath', () => {
+        const path = 'file/one';
+        const drafts = element._changeComments.getAllDraftsForPath(path);
+        assert.deepEqual(drafts.length, 2);
+      });
 
-        setup(() => {
-          element._changeComments._drafts = {
-            'file/one': [
-              {
-                id: 11,
-                patch_set: 2,
-                side: PARENT,
-                line: 1,
-                updated: makeTime(3),
-              },
-              {
-                id: 12,
-                in_reply_to: 2,
-                patch_set: 2,
-                line: 1,
-                updated: makeTime(3),
-              },
-            ],
-            'file/two': [
+      test('computeUnresolvedNum', () => {
+        assert.equal(element._changeComments
+            .computeUnresolvedNum(2, 'file/one'), 0);
+        assert.equal(element._changeComments
+            .computeUnresolvedNum(1, 'file/one'), 0);
+        assert.equal(element._changeComments
+            .computeUnresolvedNum(2, 'file/three'), 1);
+      });
+
+      test('computeUnresolvedNum w/ non-linear thread', () => {
+        element._changeComments._drafts = {};
+        element._changeComments._robotComments = {};
+        element._changeComments._comments = {
+          path: [{
+            id: '9c6ba3c6_28b7d467',
+            patch_set: 1,
+            updated: '2018-02-28 14:41:13.000000000',
+            unresolved: true,
+          }, {
+            id: '3df7b331_0bead405',
+            patch_set: 1,
+            in_reply_to: '1c346623_ab85d14a',
+            updated: '2018-02-28 23:07:55.000000000',
+            unresolved: false,
+          }, {
+            id: '6153dce6_69958d1e',
+            patch_set: 1,
+            in_reply_to: '9c6ba3c6_28b7d467',
+            updated: '2018-02-28 17:11:31.000000000',
+            unresolved: true,
+          }, {
+            id: '1c346623_ab85d14a',
+            patch_set: 1,
+            in_reply_to: '9c6ba3c6_28b7d467',
+            updated: '2018-02-28 23:01:39.000000000',
+            unresolved: false,
+          }],
+        };
+        assert.equal(
+            element._changeComments.computeUnresolvedNum(1, 'path'), 0);
+      });
+
+      test('computeCommentCount', () => {
+        assert.equal(element._changeComments
+            .computeCommentCount(2, 'file/one'), 4);
+        assert.equal(element._changeComments
+            .computeCommentCount(1, 'file/one'), 0);
+        assert.equal(element._changeComments
+            .computeCommentCount(2, 'file/three'), 1);
+      });
+
+      test('computeDraftCount', () => {
+        assert.equal(element._changeComments
+            .computeDraftCount(2, 'file/one'), 2);
+        assert.equal(element._changeComments
+            .computeDraftCount(1, 'file/one'), 0);
+        assert.equal(element._changeComments
+            .computeDraftCount(2, 'file/three'), 0);
+        assert.equal(element._changeComments
+            .computeDraftCount(), 3);
+      });
+
+      test('getAllPublishedComments', () => {
+        let publishedComments = element._changeComments
+            .getAllPublishedComments();
+        assert.equal(Object.keys(publishedComments).length, 4);
+        assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
+        assert.equal(Object.keys(publishedComments[['file/two']]).length, 2);
+        publishedComments = element._changeComments
+            .getAllPublishedComments(2);
+        assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
+        assert.equal(Object.keys(publishedComments[['file/two']]).length, 1);
+      });
+
+      test('getAllComments', () => {
+        let comments = element._changeComments.getAllComments();
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments[['file/one']]).length, 4);
+        assert.equal(Object.keys(comments[['file/two']]).length, 2);
+        comments = element._changeComments.getAllComments(false, 2);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments[['file/one']]).length, 4);
+        assert.equal(Object.keys(comments[['file/two']]).length, 1);
+        // Include drafts
+        comments = element._changeComments.getAllComments(true);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments[['file/one']]).length, 6);
+        assert.equal(Object.keys(comments[['file/two']]).length, 3);
+        comments = element._changeComments.getAllComments(true, 2);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments[['file/one']]).length, 6);
+        assert.equal(Object.keys(comments[['file/two']]).length, 1);
+      });
+
+      test('computeAllThreads', () => {
+        const expectedThreads = [
+          {
+            comments: [
               {
                 id: 5,
-                patch_set: 3,
-                line: 1,
-                updated: makeTime(3),
+                patch_set: 2,
+                line: 2,
+                __path: 'file/two',
+                updated: '2013-02-26 15:01:43.986000000',
               },
             ],
-          };
-          element._changeComments._robotComments = {
-            'file/one': [
+            patchNum: 2,
+            path: 'file/two',
+            line: 2,
+            rootId: 5,
+          }, {
+            comments: [
+              {
+                id: 3,
+                patch_set: 2,
+                side: 'PARENT',
+                line: 2,
+                __path: 'file/one',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/one',
+            line: 2,
+            rootId: 3,
+          }, {
+            comments: [
               {
                 id: 1,
                 patch_set: 2,
-                side: PARENT,
+                side: 'PARENT',
                 line: 1,
-                updated: makeTime(1),
+                updated: '2013-02-26 15:01:43.986000000',
                 range: {
                   start_line: 1,
                   start_character: 2,
                   end_line: 2,
                   end_character: 2,
                 },
-              }, {
+                __path: 'file/one',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/one',
+            line: 1,
+            rootId: 1,
+          }, {
+            comments: [
+              {
+                id: 9,
+                patch_set: 5,
+                side: 'PARENT',
+                line: 1,
+                __path: 'file/four',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 5,
+            path: 'file/four',
+            line: 1,
+            rootId: 9,
+          }, {
+            comments: [
+              {
+                id: 8,
+                patch_set: 3,
+                line: 1,
+                __path: 'file/three',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            patchNum: 3,
+            path: 'file/three',
+            line: 1,
+            rootId: 8,
+          }, {
+            comments: [
+              {
+                id: 7,
+                patch_set: 2,
+                side: 'PARENT',
+                unresolved: true,
+                line: 1,
+                __path: 'file/three',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/three',
+            line: 1,
+            rootId: 7,
+          }, {
+            comments: [
+              {
+                id: 4,
+                patch_set: 2,
+                line: 1,
+                __path: 'file/one',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+              {
                 id: 2,
                 in_reply_to: 4,
                 patch_set: 2,
                 unresolved: true,
                 line: 1,
-                updated: makeTime(2),
+                __path: 'file/one',
+                updated: '2013-02-26 15:02:43.986000000',
               },
-            ],
-          };
-          element._changeComments._comments = {
-            'file/one': [
-              {id: 3, patch_set: 2, side: PARENT, line: 2, updated: makeTime(1)},
-              {id: 4, patch_set: 2, line: 1, updated: makeTime(1)},
-            ],
-            'file/two': [
-              {id: 5, patch_set: 2, line: 2, updated: makeTime(1)},
-              {id: 6, patch_set: 3, line: 2, updated: makeTime(1)},
-            ],
-            'file/three': [
               {
-                id: 7,
+                id: 12,
+                in_reply_to: 2,
                 patch_set: 2,
-                side: PARENT,
-                unresolved: true,
                 line: 1,
-                updated: makeTime(1),
+                __path: 'file/one',
+                __draft: true,
+                updated: '2013-02-26 15:03:43.986000000',
               },
-              {id: 8, patch_set: 3, line: 1, updated: makeTime(1)},
             ],
-            'file/four': [
-              {id: 9, patch_set: 5, side: PARENT, line: 1, updated: makeTime(1)},
-              {id: 10, patch_set: 5, line: 1, updated: makeTime(1)},
-            ],
-          };
-        });
-
-        test('getPaths', () => {
-          const patchRange = {basePatchNum: 1, patchNum: 4};
-          let paths = element._changeComments.getPaths(patchRange);
-          assert.equal(Object.keys(paths).length, 0);
-
-          patchRange.basePatchNum = PARENT;
-          patchRange.patchNum = 3;
-          paths = element._changeComments.getPaths(patchRange);
-          assert.notProperty(paths, 'file/one');
-          assert.property(paths, 'file/two');
-          assert.property(paths, 'file/three');
-          assert.notProperty(paths, 'file/four');
-
-          patchRange.patchNum = 2;
-          paths = element._changeComments.getPaths(patchRange);
-          assert.property(paths, 'file/one');
-          assert.property(paths, 'file/two');
-          assert.property(paths, 'file/three');
-          assert.notProperty(paths, 'file/four');
-
-          paths = element._changeComments.getPaths();
-          assert.property(paths, 'file/one');
-          assert.property(paths, 'file/two');
-          assert.property(paths, 'file/three');
-          assert.property(paths, 'file/four');
-        });
-
-        test('getCommentsBySideForPath', () => {
-          const patchRange = {basePatchNum: 1, patchNum: 3};
-          let path = 'file/one';
-          let comments = element._changeComments.getCommentsBySideForPath(path,
-              patchRange);
-          assert.equal(comments.meta.changeNum, 1234);
-          assert.equal(comments.left.length, 0);
-          assert.equal(comments.right.length, 0);
-
-          path = 'file/two';
-          comments = element._changeComments.getCommentsBySideForPath(path,
-              patchRange);
-          assert.equal(comments.left.length, 0);
-          assert.equal(comments.right.length, 2);
-
-          patchRange.basePatchNum = 2;
-          comments = element._changeComments.getCommentsBySideForPath(path,
-              patchRange);
-          assert.equal(comments.left.length, 1);
-          assert.equal(comments.right.length, 2);
-
-          patchRange.basePatchNum = PARENT;
-          path = 'file/three';
-          comments = element._changeComments.getCommentsBySideForPath(path,
-              patchRange);
-          assert.equal(comments.left.length, 0);
-          assert.equal(comments.right.length, 1);
-        });
-
-        test('getAllCommentsForPath', () => {
-          let path = 'file/one';
-          let comments = element._changeComments.getAllCommentsForPath(path);
-          assert.deepEqual(comments.length, 4);
-          path = 'file/two';
-          comments = element._changeComments.getAllCommentsForPath(path, 2);
-          assert.deepEqual(comments.length, 1);
-        });
-
-        test('getAllDraftsForPath', () => {
-          const path = 'file/one';
-          const drafts = element._changeComments.getAllDraftsForPath(path);
-          assert.deepEqual(drafts.length, 2);
-        });
-
-        test('computeUnresolvedNum', () => {
-          assert.equal(element._changeComments
-              .computeUnresolvedNum(2, 'file/one'), 0);
-          assert.equal(element._changeComments
-              .computeUnresolvedNum(1, 'file/one'), 0);
-          assert.equal(element._changeComments
-              .computeUnresolvedNum(2, 'file/three'), 1);
-        });
-
-        test('computeUnresolvedNum w/ non-linear thread', () => {
-          element._changeComments._drafts = {};
-          element._changeComments._robotComments = {};
-          element._changeComments._comments = {
-            path: [{
-              id: '9c6ba3c6_28b7d467',
-              patch_set: 1,
-              updated: '2018-02-28 14:41:13.000000000',
-              unresolved: true,
-            }, {
-              id: '3df7b331_0bead405',
-              patch_set: 1,
-              in_reply_to: '1c346623_ab85d14a',
-              updated: '2018-02-28 23:07:55.000000000',
-              unresolved: false,
-            }, {
-              id: '6153dce6_69958d1e',
-              patch_set: 1,
-              in_reply_to: '9c6ba3c6_28b7d467',
-              updated: '2018-02-28 17:11:31.000000000',
-              unresolved: true,
-            }, {
-              id: '1c346623_ab85d14a',
-              patch_set: 1,
-              in_reply_to: '9c6ba3c6_28b7d467',
-              updated: '2018-02-28 23:01:39.000000000',
-              unresolved: false,
-            }],
-          };
-          assert.equal(
-              element._changeComments.computeUnresolvedNum(1, 'path'), 0);
-        });
-
-        test('computeCommentCount', () => {
-          assert.equal(element._changeComments
-              .computeCommentCount(2, 'file/one'), 4);
-          assert.equal(element._changeComments
-              .computeCommentCount(1, 'file/one'), 0);
-          assert.equal(element._changeComments
-              .computeCommentCount(2, 'file/three'), 1);
-        });
-
-        test('computeDraftCount', () => {
-          assert.equal(element._changeComments
-              .computeDraftCount(2, 'file/one'), 2);
-          assert.equal(element._changeComments
-              .computeDraftCount(1, 'file/one'), 0);
-          assert.equal(element._changeComments
-              .computeDraftCount(2, 'file/three'), 0);
-          assert.equal(element._changeComments
-              .computeDraftCount(), 3);
-        });
-
-        test('getAllPublishedComments', () => {
-          let publishedComments = element._changeComments
-              .getAllPublishedComments();
-          assert.equal(Object.keys(publishedComments).length, 4);
-          assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
-          assert.equal(Object.keys(publishedComments[['file/two']]).length, 2);
-          publishedComments = element._changeComments
-              .getAllPublishedComments(2);
-          assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
-          assert.equal(Object.keys(publishedComments[['file/two']]).length, 1);
-        });
-
-        test('getAllComments', () => {
-          let comments = element._changeComments.getAllComments();
-          assert.equal(Object.keys(comments).length, 4);
-          assert.equal(Object.keys(comments[['file/one']]).length, 4);
-          assert.equal(Object.keys(comments[['file/two']]).length, 2);
-          comments = element._changeComments.getAllComments(false, 2);
-          assert.equal(Object.keys(comments).length, 4);
-          assert.equal(Object.keys(comments[['file/one']]).length, 4);
-          assert.equal(Object.keys(comments[['file/two']]).length, 1);
-          // Include drafts
-          comments = element._changeComments.getAllComments(true);
-          assert.equal(Object.keys(comments).length, 4);
-          assert.equal(Object.keys(comments[['file/one']]).length, 6);
-          assert.equal(Object.keys(comments[['file/two']]).length, 3);
-          comments = element._changeComments.getAllComments(true, 2);
-          assert.equal(Object.keys(comments).length, 4);
-          assert.equal(Object.keys(comments[['file/one']]).length, 6);
-          assert.equal(Object.keys(comments[['file/two']]).length, 1);
-        });
-
-        test('computeAllThreads', () => {
-          const expectedThreads = [
-            {
-              comments: [
-                {
-                  id: 5,
-                  patch_set: 2,
-                  line: 2,
-                  __path: 'file/two',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              patchNum: 2,
-              path: 'file/two',
-              line: 2,
-              rootId: 5,
-            }, {
-              comments: [
-                {
-                  id: 3,
-                  patch_set: 2,
-                  side: 'PARENT',
-                  line: 2,
-                  __path: 'file/one',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              commentSide: 'PARENT',
-              patchNum: 2,
-              path: 'file/one',
-              line: 2,
-              rootId: 3,
-            }, {
-              comments: [
-                {
-                  id: 1,
-                  patch_set: 2,
-                  side: 'PARENT',
-                  line: 1,
-                  updated: '2013-02-26 15:01:43.986000000',
-                  range: {
-                    start_line: 1,
-                    start_character: 2,
-                    end_line: 2,
-                    end_character: 2,
-                  },
-                  __path: 'file/one',
-                },
-              ],
-              commentSide: 'PARENT',
-              patchNum: 2,
-              path: 'file/one',
-              line: 1,
-              rootId: 1,
-            }, {
-              comments: [
-                {
-                  id: 9,
-                  patch_set: 5,
-                  side: 'PARENT',
-                  line: 1,
-                  __path: 'file/four',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              commentSide: 'PARENT',
-              patchNum: 5,
-              path: 'file/four',
-              line: 1,
-              rootId: 9,
-            }, {
-              comments: [
-                {
-                  id: 8,
-                  patch_set: 3,
-                  line: 1,
-                  __path: 'file/three',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              patchNum: 3,
-              path: 'file/three',
-              line: 1,
-              rootId: 8,
-            }, {
-              comments: [
-                {
-                  id: 7,
-                  patch_set: 2,
-                  side: 'PARENT',
-                  unresolved: true,
-                  line: 1,
-                  __path: 'file/three',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              commentSide: 'PARENT',
-              patchNum: 2,
-              path: 'file/three',
-              line: 1,
-              rootId: 7,
-            }, {
-              comments: [
-                {
-                  id: 4,
-                  patch_set: 2,
-                  line: 1,
-                  __path: 'file/one',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-                {
-                  id: 2,
-                  in_reply_to: 4,
-                  patch_set: 2,
-                  unresolved: true,
-                  line: 1,
-                  __path: 'file/one',
-                  updated: '2013-02-26 15:02:43.986000000',
-                },
-                {
-                  id: 12,
-                  in_reply_to: 2,
-                  patch_set: 2,
-                  line: 1,
-                  __path: 'file/one',
-                  __draft: true,
-                  updated: '2013-02-26 15:03:43.986000000',
-                },
-              ],
-              patchNum: 2,
-              path: 'file/one',
-              line: 1,
-              rootId: 4,
-            }, {
-              comments: [
-                {
-                  id: 6,
-                  patch_set: 3,
-                  line: 2,
-                  __path: 'file/two',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              patchNum: 3,
-              path: 'file/two',
-              line: 2,
-              rootId: 6,
-            }, {
-              comments: [
-                {
-                  id: 10,
-                  patch_set: 5,
-                  line: 1,
-                  __path: 'file/four',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              rootId: 10,
-              patchNum: 5,
-              path: 'file/four',
-              line: 1,
-            }, {
-              comments: [
-                {
-                  id: 5,
-                  patch_set: 3,
-                  line: 1,
-                  __path: 'file/two',
-                  __draft: true,
-                  updated: '2013-02-26 15:03:43.986000000',
-                },
-              ],
-              rootId: 5,
-              patchNum: 3,
-              path: 'file/two',
-              line: 1,
-            }, {
-              comments: [
-                {
-                  id: 11,
-                  patch_set: 2,
-                  side: 'PARENT',
-                  line: 1,
-                  __path: 'file/one',
-                  __draft: true,
-                  updated: '2013-02-26 15:03:43.986000000',
-                },
-              ],
-              rootId: 11,
-              commentSide: 'PARENT',
-              patchNum: 2,
-              path: 'file/one',
-              line: 1,
-            },
-          ];
-          const threads = element._changeComments.getAllThreadsForChange();
-          assert.deepEqual(threads, expectedThreads);
-        });
-
-        test('getCommentsForThreadGroup', () => {
-          let expectedComments = [
-            {
-              __path: 'file/one',
-              id: 4,
-              patch_set: 2,
-              line: 1,
-              updated: '2013-02-26 15:01:43.986000000',
-            },
-            {
-              __path: 'file/one',
-              id: 2,
-              in_reply_to: 4,
-              patch_set: 2,
-              unresolved: true,
-              line: 1,
-              updated: '2013-02-26 15:02:43.986000000',
-            },
-            {
-              __path: 'file/one',
-              __draft: true,
-              id: 12,
-              in_reply_to: 2,
-              patch_set: 2,
-              line: 1,
-              updated: '2013-02-26 15:03:43.986000000',
-            },
-          ];
-          assert.deepEqual(element._changeComments.getCommentsForThread(4),
-              expectedComments);
-
-          expectedComments = [{
-            id: 11,
-            patch_set: 2,
-            side: 'PARENT',
+            patchNum: 2,
+            path: 'file/one',
             line: 1,
+            rootId: 4,
+          }, {
+            comments: [
+              {
+                id: 6,
+                patch_set: 3,
+                line: 2,
+                __path: 'file/two',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            patchNum: 3,
+            path: 'file/two',
+            line: 2,
+            rootId: 6,
+          }, {
+            comments: [
+              {
+                id: 10,
+                patch_set: 5,
+                line: 1,
+                __path: 'file/four',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            rootId: 10,
+            patchNum: 5,
+            path: 'file/four',
+            line: 1,
+          }, {
+            comments: [
+              {
+                id: 5,
+                patch_set: 3,
+                line: 1,
+                __path: 'file/two',
+                __draft: true,
+                updated: '2013-02-26 15:03:43.986000000',
+              },
+            ],
+            rootId: 5,
+            patchNum: 3,
+            path: 'file/two',
+            line: 1,
+          }, {
+            comments: [
+              {
+                id: 11,
+                patch_set: 2,
+                side: 'PARENT',
+                line: 1,
+                __path: 'file/one',
+                __draft: true,
+                updated: '2013-02-26 15:03:43.986000000',
+              },
+            ],
+            rootId: 11,
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/one',
+            line: 1,
+          },
+        ];
+        const threads = element._changeComments.getAllThreadsForChange();
+        assert.deepEqual(threads, expectedThreads);
+      });
+
+      test('getCommentsForThreadGroup', () => {
+        let expectedComments = [
+          {
+            __path: 'file/one',
+            id: 4,
+            patch_set: 2,
+            line: 1,
+            updated: '2013-02-26 15:01:43.986000000',
+          },
+          {
+            __path: 'file/one',
+            id: 2,
+            in_reply_to: 4,
+            patch_set: 2,
+            unresolved: true,
+            line: 1,
+            updated: '2013-02-26 15:02:43.986000000',
+          },
+          {
             __path: 'file/one',
             __draft: true,
+            id: 12,
+            in_reply_to: 2,
+            patch_set: 2,
+            line: 1,
             updated: '2013-02-26 15:03:43.986000000',
-          }];
+          },
+        ];
+        assert.deepEqual(element._changeComments.getCommentsForThread(4),
+            expectedComments);
 
-          assert.deepEqual(element._changeComments.getCommentsForThread(11),
-              expectedComments);
+        expectedComments = [{
+          id: 11,
+          patch_set: 2,
+          side: 'PARENT',
+          line: 1,
+          __path: 'file/one',
+          __draft: true,
+          updated: '2013-02-26 15:03:43.986000000',
+        }];
 
-          assert.deepEqual(element._changeComments.getCommentsForThread(1000),
-              null);
-        });
+        assert.deepEqual(element._changeComments.getCommentsForThread(11),
+            expectedComments);
+
+        assert.deepEqual(element._changeComments.getCommentsForThread(1000),
+            null);
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
index 1bc4674..529c559 100644
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
@@ -14,101 +14,108 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const TOOLTIP_MAP = new Map([
-    [Gerrit.CoverageType.COVERED, 'Covered by tests.'],
-    [Gerrit.CoverageType.NOT_COVERED, 'Not covered by tests.'],
-    [Gerrit.CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'],
-    [Gerrit.CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
-  ]);
+import '../../../types/types.js';
+import '../gr-diff-highlight/gr-annotation.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-coverage-layer_html.js';
 
-  /** @extends Polymer.Element */
-  class GrCoverageLayer extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-coverage-layer'; }
+const TOOLTIP_MAP = new Map([
+  [Gerrit.CoverageType.COVERED, 'Covered by tests.'],
+  [Gerrit.CoverageType.NOT_COVERED, 'Not covered by tests.'],
+  [Gerrit.CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'],
+  [Gerrit.CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
+]);
 
-    static get properties() {
-      return {
-      /**
-       * Must be sorted by code_range.start_line.
-       * Must only contain ranges that match the side.
-       *
-       * @type {!Array<!Gerrit.CoverageRange>}
-       */
-        coverageRanges: Array,
-        side: String,
+/** @extends Polymer.Element */
+class GrCoverageLayer extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-        /**
-         * We keep track of the line number from the previous annotate() call,
-         * and also of the index of the coverage range that had matched.
-         * annotate() calls are coming in with increasing line numbers and
-         * coverage ranges are sorted by line number. So this is a very simple
-         * and efficient way for finding the coverage range that matches a given
-         * line number.
-         */
-        _lineNumber: {
-          type: Number,
-          value: 0,
-        },
-        _index: {
-          type: Number,
-          value: 0,
-        },
-      };
-    }
+  static get is() { return 'gr-coverage-layer'; }
 
+  static get properties() {
+    return {
     /**
-     * Layer method to add annotations to a line.
+     * Must be sorted by code_range.start_line.
+     * Must only contain ranges that match the side.
      *
-     * @param {!HTMLElement} el Not used for this layer.
-     * @param {!HTMLElement} lineNumberEl The <td> element with the line number.
-     * @param {!Object} line Not used for this layer.
+     * @type {!Array<!Gerrit.CoverageRange>}
      */
-    annotate(el, lineNumberEl, line) {
-      if (!lineNumberEl || !lineNumberEl.classList.contains(this.side)) {
-        return;
-      }
-      const elementLineNumber = parseInt(
-          lineNumberEl.getAttribute('data-value'), 10);
-      if (!elementLineNumber || elementLineNumber < 1) return;
+      coverageRanges: Array,
+      side: String,
 
-      // If the line number is smaller than before, then we have to reset our
-      // algorithm and start searching the coverage ranges from the beginning.
-      // That happens for example when you expand diff sections.
-      if (elementLineNumber < this._lineNumber) {
-        this._index = 0;
-      }
-      this._lineNumber = elementLineNumber;
-
-      // We simply loop through all the coverage ranges until we find one that
-      // matches the line number.
-      while (this._index < this.coverageRanges.length) {
-        const coverageRange = this.coverageRanges[this._index];
-
-        // If the line number has moved past the current coverage range, then
-        // try the next coverage range.
-        if (this._lineNumber > coverageRange.code_range.end_line) {
-          this._index++;
-          continue;
-        }
-
-        // If the line number has not reached the next coverage range (and the
-        // range before also did not match), then this line has not been
-        // instrumented. Nothing to do for this line.
-        if (this._lineNumber < coverageRange.code_range.start_line) {
-          return;
-        }
-
-        // The line number is within the current coverage range. Style it!
-        lineNumberEl.classList.add(coverageRange.type);
-        lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type);
-        return;
-      }
-    }
+      /**
+       * We keep track of the line number from the previous annotate() call,
+       * and also of the index of the coverage range that had matched.
+       * annotate() calls are coming in with increasing line numbers and
+       * coverage ranges are sorted by line number. So this is a very simple
+       * and efficient way for finding the coverage range that matches a given
+       * line number.
+       */
+      _lineNumber: {
+        type: Number,
+        value: 0,
+      },
+      _index: {
+        type: Number,
+        value: 0,
+      },
+    };
   }
 
-  customElements.define(GrCoverageLayer.is, GrCoverageLayer);
-})();
+  /**
+   * Layer method to add annotations to a line.
+   *
+   * @param {!HTMLElement} el Not used for this layer.
+   * @param {!HTMLElement} lineNumberEl The <td> element with the line number.
+   * @param {!Object} line Not used for this layer.
+   */
+  annotate(el, lineNumberEl, line) {
+    if (!lineNumberEl || !lineNumberEl.classList.contains(this.side)) {
+      return;
+    }
+    const elementLineNumber = parseInt(
+        lineNumberEl.getAttribute('data-value'), 10);
+    if (!elementLineNumber || elementLineNumber < 1) return;
+
+    // If the line number is smaller than before, then we have to reset our
+    // algorithm and start searching the coverage ranges from the beginning.
+    // That happens for example when you expand diff sections.
+    if (elementLineNumber < this._lineNumber) {
+      this._index = 0;
+    }
+    this._lineNumber = elementLineNumber;
+
+    // We simply loop through all the coverage ranges until we find one that
+    // matches the line number.
+    while (this._index < this.coverageRanges.length) {
+      const coverageRange = this.coverageRanges[this._index];
+
+      // If the line number has moved past the current coverage range, then
+      // try the next coverage range.
+      if (this._lineNumber > coverageRange.code_range.end_line) {
+        this._index++;
+        continue;
+      }
+
+      // If the line number has not reached the next coverage range (and the
+      // range before also did not match), then this line has not been
+      // instrumented. Nothing to do for this line.
+      if (this._lineNumber < coverageRange.code_range.start_line) {
+        return;
+      }
+
+      // The line number is within the current coverage range. Style it!
+      lineNumberEl.classList.add(coverageRange.type);
+      lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type);
+      return;
+    }
+  }
+}
+
+customElements.define(GrCoverageLayer.is, GrCoverageLayer);
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.js
index 63517cf..29757e5 100644
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.js
@@ -1,26 +1,21 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @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.js';
 
-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
+export const htmlTemplate = html`
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<script src="../../../types/types.js"></script>
-<script src="../gr-diff-highlight/gr-annotation.js"></script>
-
-<dom-module id="gr-coverage-layer">
-  <template>
-  </template>
-  <script src="gr-coverage-layer.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
index 8439a22..69948c3 100644
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-coverage-layer</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../gr-diff/gr-diff-line.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../gr-diff/gr-diff-line.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel="import" href="gr-coverage-layer.html">
+<script type="module" src="./gr-coverage-layer.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../gr-diff/gr-diff-line.js';
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-coverage-layer.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,106 +43,109 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-coverage-layer', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../gr-diff/gr-diff-line.js';
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-coverage-layer.js';
+suite('gr-coverage-layer', () => {
+  let element;
 
-    setup(() => {
-      const initialCoverageRanges = [
-        {
-          type: 'COVERED',
-          side: 'right',
-          code_range: {
-            start_line: 1,
-            end_line: 2,
-          },
+  setup(() => {
+    const initialCoverageRanges = [
+      {
+        type: 'COVERED',
+        side: 'right',
+        code_range: {
+          start_line: 1,
+          end_line: 2,
         },
-        {
-          type: 'NOT_COVERED',
-          side: 'right',
-          code_range: {
-            start_line: 3,
-            end_line: 4,
-          },
+      },
+      {
+        type: 'NOT_COVERED',
+        side: 'right',
+        code_range: {
+          start_line: 3,
+          end_line: 4,
         },
-        {
-          type: 'PARTIALLY_COVERED',
-          side: 'right',
-          code_range: {
-            start_line: 5,
-            end_line: 6,
-          },
+      },
+      {
+        type: 'PARTIALLY_COVERED',
+        side: 'right',
+        code_range: {
+          start_line: 5,
+          end_line: 6,
         },
-        {
-          type: 'NOT_INSTRUMENTED',
-          side: 'right',
-          code_range: {
-            start_line: 8,
-            end_line: 9,
-          },
+      },
+      {
+        type: 'NOT_INSTRUMENTED',
+        side: 'right',
+        code_range: {
+          start_line: 8,
+          end_line: 9,
         },
-      ];
+      },
+    ];
 
-      element = fixture('basic');
-      element.coverageRanges = initialCoverageRanges;
-      element.side = 'right';
+    element = fixture('basic');
+    element.coverageRanges = initialCoverageRanges;
+    element.side = 'right';
+  });
+
+  suite('annotate', () => {
+    function createLine(lineNumber) {
+      const lineEl = document.createElement('div');
+      lineEl.setAttribute('data-side', 'right');
+      lineEl.setAttribute('data-value', lineNumber);
+      lineEl.className = 'right';
+      return lineEl;
+    }
+
+    function checkLine(lineNumber, className, opt_negated) {
+      const line = createLine(lineNumber);
+      element.annotate(undefined, line, undefined);
+      let contains = line.classList.contains(className);
+      if (opt_negated) contains = !contains;
+      assert.isTrue(contains);
+    }
+
+    test('line 1-2 are covered', () => {
+      checkLine(1, 'COVERED');
+      checkLine(2, 'COVERED');
     });
 
-    suite('annotate', () => {
-      function createLine(lineNumber) {
-        const lineEl = document.createElement('div');
-        lineEl.setAttribute('data-side', 'right');
-        lineEl.setAttribute('data-value', lineNumber);
-        lineEl.className = 'right';
-        return lineEl;
-      }
+    test('line 3-4 are not covered', () => {
+      checkLine(3, 'NOT_COVERED');
+      checkLine(4, 'NOT_COVERED');
+    });
 
-      function checkLine(lineNumber, className, opt_negated) {
-        const line = createLine(lineNumber);
-        element.annotate(undefined, line, undefined);
-        let contains = line.classList.contains(className);
-        if (opt_negated) contains = !contains;
-        assert.isTrue(contains);
-      }
+    test('line 5-6 are partially covered', () => {
+      checkLine(5, 'PARTIALLY_COVERED');
+      checkLine(6, 'PARTIALLY_COVERED');
+    });
 
-      test('line 1-2 are covered', () => {
-        checkLine(1, 'COVERED');
-        checkLine(2, 'COVERED');
-      });
+    test('line 7 is implicitly not instrumented', () => {
+      checkLine(7, 'COVERED', true);
+      checkLine(7, 'NOT_COVERED', true);
+      checkLine(7, 'PARTIALLY_COVERED', true);
+      checkLine(7, 'NOT_INSTRUMENTED', true);
+    });
 
-      test('line 3-4 are not covered', () => {
-        checkLine(3, 'NOT_COVERED');
-        checkLine(4, 'NOT_COVERED');
-      });
+    test('line 8-9 are not instrumented', () => {
+      checkLine(8, 'NOT_INSTRUMENTED');
+      checkLine(9, 'NOT_INSTRUMENTED');
+    });
 
-      test('line 5-6 are partially covered', () => {
-        checkLine(5, 'PARTIALLY_COVERED');
-        checkLine(6, 'PARTIALLY_COVERED');
-      });
-
-      test('line 7 is implicitly not instrumented', () => {
-        checkLine(7, 'COVERED', true);
-        checkLine(7, 'NOT_COVERED', true);
-        checkLine(7, 'PARTIALLY_COVERED', true);
-        checkLine(7, 'NOT_INSTRUMENTED', true);
-      });
-
-      test('line 8-9 are not instrumented', () => {
-        checkLine(8, 'NOT_INSTRUMENTED');
-        checkLine(9, 'NOT_INSTRUMENTED');
-      });
-
-      test('coverage correct, if annotate is called out of order', () => {
-        checkLine(8, 'NOT_INSTRUMENTED');
-        checkLine(1, 'COVERED');
-        checkLine(5, 'PARTIALLY_COVERED');
-        checkLine(3, 'NOT_COVERED');
-        checkLine(6, 'PARTIALLY_COVERED');
-        checkLine(4, 'NOT_COVERED');
-        checkLine(9, 'NOT_INSTRUMENTED');
-        checkLine(2, 'COVERED');
-      });
+    test('coverage correct, if annotate is called out of order', () => {
+      checkLine(8, 'NOT_INSTRUMENTED');
+      checkLine(1, 'COVERED');
+      checkLine(5, 'PARTIALLY_COVERED');
+      checkLine(3, 'NOT_COVERED');
+      checkLine(6, 'PARTIALLY_COVERED');
+      checkLine(4, 'NOT_COVERED');
+      checkLine(9, 'NOT_INSTRUMENTED');
+      checkLine(2, 'COVERED');
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
index 9e1ec9e..637c8f7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
@@ -1,417 +1,438 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
+ * Copyright (C) 2016 The Android Open Source Project
  *
- * Licensed under the Apache License, Version 2.0 (the 'License');
+ * 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,
+ * 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.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../gr-coverage-layer/gr-coverage-layer.js';
+import '../gr-diff-processor/gr-diff-processor.js';
+import '../../shared/gr-hovercard/gr-hovercard.js';
+import '../gr-ranged-comment-layer/gr-ranged-comment-layer.js';
+import '../../../scripts/util.js';
+import '../gr-diff/gr-diff-line.js';
+import '../gr-diff/gr-diff-group.js';
+import '../gr-diff-highlight/gr-annotation.js';
+import './gr-diff-builder.js';
+import './gr-diff-builder-side-by-side.js';
+import './gr-diff-builder-unified.js';
+import './gr-diff-builder-image.js';
+import './gr-diff-builder-binary.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-builder-element_html.js';
 
-  const TRAILING_WHITESPACE_PATTERN = /\s+$/;
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
 
-  // https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740
-  const COMMIT_MSG_PATH = '/COMMIT_MSG';
-  const COMMIT_MSG_LINE_LENGTH = 72;
+const TRAILING_WHITESPACE_PATTERN = /\s+$/;
+
+// https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740
+const COMMIT_MSG_PATH = '/COMMIT_MSG';
+const COMMIT_MSG_LINE_LENGTH = 72;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ */
+class GrDiffBuilderElement extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-builder'; }
+  /**
+   * Fired when the diff begins rendering.
+   *
+   * @event render-start
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
+   * Fired when the diff finishes rendering text content.
+   *
+   * @event render-content
    */
-  class GrDiffBuilderElement extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-diff-builder'; }
-    /**
-     * Fired when the diff begins rendering.
-     *
-     * @event render-start
-     */
 
-    /**
-     * Fired when the diff finishes rendering text content.
-     *
-     * @event render-content
-     */
+  static get properties() {
+    return {
+      diff: Object,
+      changeNum: String,
+      patchNum: String,
+      viewMode: String,
+      isImageDiff: Boolean,
+      baseImage: Object,
+      revisionImage: Object,
+      parentIndex: Number,
+      path: String,
+      projectName: String,
 
-    static get properties() {
-      return {
-        diff: Object,
-        changeNum: String,
-        patchNum: String,
-        viewMode: String,
-        isImageDiff: Boolean,
-        baseImage: Object,
-        revisionImage: Object,
-        parentIndex: Number,
-        path: String,
-        projectName: String,
+      _builder: Object,
+      _groups: Array,
+      _layers: Array,
+      _showTabs: Boolean,
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      commentRanges: {
+        type: Array,
+        value: () => [],
+      },
+      /** @type {!Array<!Gerrit.CoverageRange>} */
+      coverageRanges: {
+        type: Array,
+        value: () => [],
+      },
+      _leftCoverageRanges: {
+        type: Array,
+        computed: '_computeLeftCoverageRanges(coverageRanges)',
+      },
+      _rightCoverageRanges: {
+        type: Array,
+        computed: '_computeRightCoverageRanges(coverageRanges)',
+      },
+      /**
+       * The promise last returned from `render()` while the asynchronous
+       * rendering is running - `null` otherwise. Provides a `cancel()`
+       * method that rejects it with `{isCancelled: true}`.
+       *
+       * @type {?Object}
+       */
+      _cancelableRenderPromise: Object,
+      layers: {
+        type: Array,
+        value: [],
+      },
+    };
+  }
 
-        _builder: Object,
-        _groups: Array,
-        _layers: Array,
-        _showTabs: Boolean,
-        /** @type {!Array<!Gerrit.HoveredRange>} */
-        commentRanges: {
-          type: Array,
-          value: () => [],
-        },
-        /** @type {!Array<!Gerrit.CoverageRange>} */
-        coverageRanges: {
-          type: Array,
-          value: () => [],
-        },
-        _leftCoverageRanges: {
-          type: Array,
-          computed: '_computeLeftCoverageRanges(coverageRanges)',
-        },
-        _rightCoverageRanges: {
-          type: Array,
-          computed: '_computeRightCoverageRanges(coverageRanges)',
-        },
-        /**
-         * The promise last returned from `render()` while the asynchronous
-         * rendering is running - `null` otherwise. Provides a `cancel()`
-         * method that rejects it with `{isCancelled: true}`.
-         *
-         * @type {?Object}
-         */
-        _cancelableRenderPromise: Object,
-        layers: {
-          type: Array,
-          value: [],
-        },
-      };
+  get diffElement() {
+    return this.queryEffectiveChildren('#diffTable');
+  }
+
+  static get observers() {
+    return [
+      '_groupsChanged(_groups.splices)',
+    ];
+  }
+
+  _computeLeftCoverageRanges(coverageRanges) {
+    return coverageRanges.filter(range => range && range.side === 'left');
+  }
+
+  _computeRightCoverageRanges(coverageRanges) {
+    return coverageRanges.filter(range => range && range.side === 'right');
+  }
+
+  render(keyLocations, prefs) {
+    // Setting up annotation layers must happen after plugins are
+    // installed, and |render| satisfies the requirement, however,
+    // |attached| doesn't because in the diff view page, the element is
+    // attached before plugins are installed.
+    this._setupAnnotationLayers();
+
+    this._showTabs = !!prefs.show_tabs;
+    this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
+
+    // Stop the processor if it's running.
+    this.cancel();
+
+    this._builder = this._getDiffBuilder(this.diff, prefs);
+
+    this.$.processor.context = prefs.context;
+    this.$.processor.keyLocations = keyLocations;
+
+    this._clearDiffContent();
+    this._builder.addColumns(this.diffElement, prefs.font_size);
+
+    const isBinary = !!(this.isImageDiff || this.diff.binary);
+
+    this.dispatchEvent(new CustomEvent(
+        'render-start', {bubbles: true, composed: true}));
+    this._cancelableRenderPromise = util.makeCancelable(
+        this.$.processor.process(this.diff.content, isBinary)
+            .then(() => {
+              if (this.isImageDiff) {
+                this._builder.renderDiff();
+              }
+              this.dispatchEvent(new CustomEvent('render-content',
+                  {bubbles: true, composed: true}));
+            }));
+    return this._cancelableRenderPromise
+        .finally(() => { this._cancelableRenderPromise = null; })
+    // Mocca testing does not like uncaught rejections, so we catch
+    // the cancels which are expected and should not throw errors in
+    // tests.
+        .catch(e => { if (!e.isCanceled) return Promise.reject(e); });
+  }
+
+  _setupAnnotationLayers() {
+    const layers = [
+      this._createTrailingWhitespaceLayer(),
+      this._createIntralineLayer(),
+      this._createTabIndicatorLayer(),
+      this.$.rangeLayer,
+      this.$.coverageLayerLeft,
+      this.$.coverageLayerRight,
+    ];
+
+    if (this.layers) {
+      layers.push(...this.layers);
     }
+    this._layers = layers;
+  }
 
-    get diffElement() {
-      return this.queryEffectiveChildren('#diffTable');
-    }
-
-    static get observers() {
-      return [
-        '_groupsChanged(_groups.splices)',
-      ];
-    }
-
-    _computeLeftCoverageRanges(coverageRanges) {
-      return coverageRanges.filter(range => range && range.side === 'left');
-    }
-
-    _computeRightCoverageRanges(coverageRanges) {
-      return coverageRanges.filter(range => range && range.side === 'right');
-    }
-
-    render(keyLocations, prefs) {
-      // Setting up annotation layers must happen after plugins are
-      // installed, and |render| satisfies the requirement, however,
-      // |attached| doesn't because in the diff view page, the element is
-      // attached before plugins are installed.
-      this._setupAnnotationLayers();
-
-      this._showTabs = !!prefs.show_tabs;
-      this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
-
-      // Stop the processor if it's running.
-      this.cancel();
-
-      this._builder = this._getDiffBuilder(this.diff, prefs);
-
-      this.$.processor.context = prefs.context;
-      this.$.processor.keyLocations = keyLocations;
-
-      this._clearDiffContent();
-      this._builder.addColumns(this.diffElement, prefs.font_size);
-
-      const isBinary = !!(this.isImageDiff || this.diff.binary);
-
-      this.dispatchEvent(new CustomEvent(
-          'render-start', {bubbles: true, composed: true}));
-      this._cancelableRenderPromise = util.makeCancelable(
-          this.$.processor.process(this.diff.content, isBinary)
-              .then(() => {
-                if (this.isImageDiff) {
-                  this._builder.renderDiff();
-                }
-                this.dispatchEvent(new CustomEvent('render-content',
-                    {bubbles: true, composed: true}));
-              }));
-      return this._cancelableRenderPromise
-          .finally(() => { this._cancelableRenderPromise = null; })
-      // Mocca testing does not like uncaught rejections, so we catch
-      // the cancels which are expected and should not throw errors in
-      // tests.
-          .catch(e => { if (!e.isCanceled) return Promise.reject(e); });
-    }
-
-    _setupAnnotationLayers() {
-      const layers = [
-        this._createTrailingWhitespaceLayer(),
-        this._createIntralineLayer(),
-        this._createTabIndicatorLayer(),
-        this.$.rangeLayer,
-        this.$.coverageLayerLeft,
-        this.$.coverageLayerRight,
-      ];
-
-      if (this.layers) {
-        layers.push(...this.layers);
-      }
-      this._layers = layers;
-    }
-
-    getLineElByChild(node) {
-      while (node) {
-        if (node instanceof Element) {
-          if (node.classList.contains('lineNum')) {
-            return node;
-          }
-          if (node.classList.contains('section')) {
-            return null;
-          }
+  getLineElByChild(node) {
+    while (node) {
+      if (node instanceof Element) {
+        if (node.classList.contains('lineNum')) {
+          return node;
         }
-        node = node.previousSibling || node.parentElement;
-      }
-      return null;
-    }
-
-    getLineNumberByChild(node) {
-      const lineEl = this.getLineElByChild(node);
-      return lineEl ?
-        parseInt(lineEl.getAttribute('data-value'), 10) :
-        null;
-    }
-
-    getContentByLine(lineNumber, opt_side, opt_root) {
-      return this._builder.getContentByLine(lineNumber, opt_side, opt_root);
-    }
-
-    getContentByLineEl(lineEl) {
-      const root = Polymer.dom(lineEl.parentElement);
-      const side = this.getSideByLineEl(lineEl);
-      const line = lineEl.getAttribute('data-value');
-      return this.getContentByLine(line, side, root);
-    }
-
-    getLineElByNumber(lineNumber, opt_side) {
-      const sideSelector = opt_side ? ('.' + opt_side) : '';
-      return this.diffElement.querySelector(
-          '.lineNum[data-value="' + lineNumber + '"]' + sideSelector);
-    }
-
-    getContentsByLineRange(startLine, endLine, opt_side) {
-      const result = [];
-      this._builder.findLinesByRange(startLine, endLine, opt_side, null,
-          result);
-      return result;
-    }
-
-    getSideByLineEl(lineEl) {
-      return lineEl.classList.contains(GrDiffBuilder.Side.RIGHT) ?
-        GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
-    }
-
-    emitGroup(group, sectionEl) {
-      this._builder.emitGroup(group, sectionEl);
-    }
-
-    showContext(newGroups, sectionEl) {
-      const groups = this._builder.groups;
-
-      const contextIndex = groups.findIndex(group =>
-        group.element === sectionEl
-      );
-      groups.splice(contextIndex, 1, ...newGroups);
-
-      for (const newGroup of newGroups) {
-        this._builder.emitGroup(newGroup, sectionEl);
-      }
-      sectionEl.parentNode.removeChild(sectionEl);
-
-      this.async(() => this.fire('render-content'), 1);
-    }
-
-    cancel() {
-      this.$.processor.cancel();
-      if (this._cancelableRenderPromise) {
-        this._cancelableRenderPromise.cancel();
-        this._cancelableRenderPromise = null;
-      }
-    }
-
-    _handlePreferenceError(pref) {
-      const message = `The value of the '${pref}' user preference is ` +
-          `invalid. Fix in diff preferences`;
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message,
-        }, bubbles: true, composed: true}));
-      throw Error(`Invalid preference value: ${pref}`);
-    }
-
-    _getDiffBuilder(diff, prefs) {
-      if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
-        this._handlePreferenceError('tab size');
-        return;
-      }
-
-      if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
-        this._handlePreferenceError('diff width');
-        return;
-      }
-
-      const localPrefs = Object.assign({}, prefs);
-      if (this.path === COMMIT_MSG_PATH) {
-        // override line_length for commit msg the same way as
-        // in gr-diff
-        localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
-      }
-
-      let builder = null;
-      if (this.isImageDiff) {
-        builder = new GrDiffBuilderImage(
-            diff,
-            localPrefs,
-            this.diffElement,
-            this.baseImage,
-            this.revisionImage);
-      } else if (diff.binary) {
-        // If the diff is binary, but not an image.
-        return new GrDiffBuilderBinary(
-            diff,
-            localPrefs,
-            this.diffElement);
-      } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-        builder = new GrDiffBuilderSideBySide(
-            diff,
-            localPrefs,
-            this.diffElement,
-            this._layers
-        );
-      } else if (this.viewMode === DiffViewMode.UNIFIED) {
-        builder = new GrDiffBuilderUnified(
-            diff,
-            localPrefs,
-            this.diffElement,
-            this._layers);
-      }
-      if (!builder) {
-        throw Error('Unsupported diff view mode: ' + this.viewMode);
-      }
-      return builder;
-    }
-
-    _clearDiffContent() {
-      this.diffElement.innerHTML = null;
-    }
-
-    _groupsChanged(changeRecord) {
-      if (!changeRecord) { return; }
-      for (const splice of changeRecord.indexSplices) {
-        let group;
-        for (let i = 0; i < splice.addedCount; i++) {
-          group = splice.object[splice.index + i];
-          this._builder.groups.push(group);
-          this._builder.emitGroup(group);
+        if (node.classList.contains('section')) {
+          return null;
         }
       }
+      node = node.previousSibling || node.parentElement;
     }
+    return null;
+  }
 
-    _createIntralineLayer() {
-      return {
-        // Take a DIV.contentText element and a line object with intraline
-        // differences to highlight and apply them to the element as
-        // annotations.
-        annotate(contentEl, lineNumberEl, line) {
-          const HL_CLASS = 'style-scope gr-diff intraline';
-          for (const highlight of line.highlights) {
-            // The start and end indices could be the same if a highlight is
-            // meant to start at the end of a line and continue onto the
-            // next one. Ignore it.
-            if (highlight.startIndex === highlight.endIndex) { continue; }
+  getLineNumberByChild(node) {
+    const lineEl = this.getLineElByChild(node);
+    return lineEl ?
+      parseInt(lineEl.getAttribute('data-value'), 10) :
+      null;
+  }
 
-            // If endIndex isn't present, continue to the end of the line.
-            const endIndex = highlight.endIndex === undefined ?
-              line.text.length :
-              highlight.endIndex;
+  getContentByLine(lineNumber, opt_side, opt_root) {
+    return this._builder.getContentByLine(lineNumber, opt_side, opt_root);
+  }
 
-            GrAnnotation.annotateElement(
-                contentEl,
-                highlight.startIndex,
-                endIndex - highlight.startIndex,
-                HL_CLASS);
-          }
-        },
-      };
+  getContentByLineEl(lineEl) {
+    const root = dom(lineEl.parentElement);
+    const side = this.getSideByLineEl(lineEl);
+    const line = lineEl.getAttribute('data-value');
+    return this.getContentByLine(line, side, root);
+  }
+
+  getLineElByNumber(lineNumber, opt_side) {
+    const sideSelector = opt_side ? ('.' + opt_side) : '';
+    return this.diffElement.querySelector(
+        '.lineNum[data-value="' + lineNumber + '"]' + sideSelector);
+  }
+
+  getContentsByLineRange(startLine, endLine, opt_side) {
+    const result = [];
+    this._builder.findLinesByRange(startLine, endLine, opt_side, null,
+        result);
+    return result;
+  }
+
+  getSideByLineEl(lineEl) {
+    return lineEl.classList.contains(GrDiffBuilder.Side.RIGHT) ?
+      GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
+  }
+
+  emitGroup(group, sectionEl) {
+    this._builder.emitGroup(group, sectionEl);
+  }
+
+  showContext(newGroups, sectionEl) {
+    const groups = this._builder.groups;
+
+    const contextIndex = groups.findIndex(group =>
+      group.element === sectionEl
+    );
+    groups.splice(contextIndex, 1, ...newGroups);
+
+    for (const newGroup of newGroups) {
+      this._builder.emitGroup(newGroup, sectionEl);
     }
+    sectionEl.parentNode.removeChild(sectionEl);
 
-    _createTabIndicatorLayer() {
-      const show = () => this._showTabs;
-      return {
-        annotate(contentEl, lineNumberEl, line) {
-          // If visible tabs are disabled, do nothing.
-          if (!show()) { return; }
+    this.async(() => this.fire('render-content'), 1);
+  }
 
-          // Find and annotate the locations of tabs.
-          const split = line.text.split('\t');
-          if (!split) { return; }
-          for (let i = 0, pos = 0; i < split.length - 1; i++) {
-            // Skip forward by the length of the content
-            pos += split[i].length;
-
-            GrAnnotation.annotateElement(contentEl, pos, 1,
-                'style-scope gr-diff tab-indicator');
-
-            // Skip forward by one tab character.
-            pos++;
-          }
-        },
-      };
-    }
-
-    _createTrailingWhitespaceLayer() {
-      const show = function() {
-        return this._showTrailingWhitespace;
-      }.bind(this);
-
-      return {
-        annotate(contentEl, lineNumberEl, line) {
-          if (!show()) { return; }
-
-          const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
-          if (match) {
-            // Normalize string positions in case there is unicode before or
-            // within the match.
-            const index = GrAnnotation.getStringLength(
-                line.text.substr(0, match.index));
-            const length = GrAnnotation.getStringLength(match[0]);
-            GrAnnotation.annotateElement(contentEl, index, length,
-                'style-scope gr-diff trailing-whitespace');
-          }
-        },
-      };
-    }
-
-    setBlame(blame) {
-      if (!this._builder || !blame) { return; }
-      this._builder.setBlame(blame);
+  cancel() {
+    this.$.processor.cancel();
+    if (this._cancelableRenderPromise) {
+      this._cancelableRenderPromise.cancel();
+      this._cancelableRenderPromise = null;
     }
   }
 
-  customElements.define(GrDiffBuilderElement.is, GrDiffBuilderElement);
-})();
+  _handlePreferenceError(pref) {
+    const message = `The value of the '${pref}' user preference is ` +
+        `invalid. Fix in diff preferences`;
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {
+        message,
+      }, bubbles: true, composed: true}));
+    throw Error(`Invalid preference value: ${pref}`);
+  }
+
+  _getDiffBuilder(diff, prefs) {
+    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+      this._handlePreferenceError('tab size');
+      return;
+    }
+
+    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+      this._handlePreferenceError('diff width');
+      return;
+    }
+
+    const localPrefs = Object.assign({}, prefs);
+    if (this.path === COMMIT_MSG_PATH) {
+      // override line_length for commit msg the same way as
+      // in gr-diff
+      localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
+    }
+
+    let builder = null;
+    if (this.isImageDiff) {
+      builder = new GrDiffBuilderImage(
+          diff,
+          localPrefs,
+          this.diffElement,
+          this.baseImage,
+          this.revisionImage);
+    } else if (diff.binary) {
+      // If the diff is binary, but not an image.
+      return new GrDiffBuilderBinary(
+          diff,
+          localPrefs,
+          this.diffElement);
+    } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      builder = new GrDiffBuilderSideBySide(
+          diff,
+          localPrefs,
+          this.diffElement,
+          this._layers
+      );
+    } else if (this.viewMode === DiffViewMode.UNIFIED) {
+      builder = new GrDiffBuilderUnified(
+          diff,
+          localPrefs,
+          this.diffElement,
+          this._layers);
+    }
+    if (!builder) {
+      throw Error('Unsupported diff view mode: ' + this.viewMode);
+    }
+    return builder;
+  }
+
+  _clearDiffContent() {
+    this.diffElement.innerHTML = null;
+  }
+
+  _groupsChanged(changeRecord) {
+    if (!changeRecord) { return; }
+    for (const splice of changeRecord.indexSplices) {
+      let group;
+      for (let i = 0; i < splice.addedCount; i++) {
+        group = splice.object[splice.index + i];
+        this._builder.groups.push(group);
+        this._builder.emitGroup(group);
+      }
+    }
+  }
+
+  _createIntralineLayer() {
+    return {
+      // Take a DIV.contentText element and a line object with intraline
+      // differences to highlight and apply them to the element as
+      // annotations.
+      annotate(contentEl, lineNumberEl, line) {
+        const HL_CLASS = 'style-scope gr-diff intraline';
+        for (const highlight of line.highlights) {
+          // The start and end indices could be the same if a highlight is
+          // meant to start at the end of a line and continue onto the
+          // next one. Ignore it.
+          if (highlight.startIndex === highlight.endIndex) { continue; }
+
+          // If endIndex isn't present, continue to the end of the line.
+          const endIndex = highlight.endIndex === undefined ?
+            line.text.length :
+            highlight.endIndex;
+
+          GrAnnotation.annotateElement(
+              contentEl,
+              highlight.startIndex,
+              endIndex - highlight.startIndex,
+              HL_CLASS);
+        }
+      },
+    };
+  }
+
+  _createTabIndicatorLayer() {
+    const show = () => this._showTabs;
+    return {
+      annotate(contentEl, lineNumberEl, line) {
+        // If visible tabs are disabled, do nothing.
+        if (!show()) { return; }
+
+        // Find and annotate the locations of tabs.
+        const split = line.text.split('\t');
+        if (!split) { return; }
+        for (let i = 0, pos = 0; i < split.length - 1; i++) {
+          // Skip forward by the length of the content
+          pos += split[i].length;
+
+          GrAnnotation.annotateElement(contentEl, pos, 1,
+              'style-scope gr-diff tab-indicator');
+
+          // Skip forward by one tab character.
+          pos++;
+        }
+      },
+    };
+  }
+
+  _createTrailingWhitespaceLayer() {
+    const show = function() {
+      return this._showTrailingWhitespace;
+    }.bind(this);
+
+    return {
+      annotate(contentEl, lineNumberEl, line) {
+        if (!show()) { return; }
+
+        const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
+        if (match) {
+          // Normalize string positions in case there is unicode before or
+          // within the match.
+          const index = GrAnnotation.getStringLength(
+              line.text.substr(0, match.index));
+          const length = GrAnnotation.getStringLength(match[0]);
+          GrAnnotation.annotateElement(contentEl, index, length,
+              'style-scope gr-diff trailing-whitespace');
+        }
+      },
+    };
+  }
+
+  setBlame(blame) {
+    if (!this._builder || !blame) { return; }
+    this._builder.setBlame(blame);
+  }
+}
+
+customElements.define(GrDiffBuilderElement.is, GrDiffBuilderElement);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.js
index bf8b0dc..c8df78f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.js
@@ -1,54 +1,27 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../gr-coverage-layer/gr-coverage-layer.html">
-<link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
-<link rel="import" href="../../../elements/shared/gr-hovercard/gr-hovercard.html">
-<link rel="import" href="../gr-ranged-comment-layer/gr-ranged-comment-layer.html">
-<script src="../../../scripts/util.js"></script>
-<script src="../gr-diff/gr-diff-line.js"></script>
-<script src="../gr-diff/gr-diff-group.js"></script>
-<script src="../gr-diff-highlight/gr-annotation.js"></script>
-<script src="gr-diff-builder.js"></script>
-<script src="gr-diff-builder-side-by-side.js"></script>
-<script src="gr-diff-builder-unified.js"></script>
-<script src="gr-diff-builder-image.js"></script>
-<script src="gr-diff-builder-binary.js"></script>
-
-<dom-module id="gr-diff-builder">
-  <template>
+export const htmlTemplate = html`
     <div class="contentWrapper">
       <slot></slot>
     </div>
-    <gr-ranged-comment-layer
-        id="rangeLayer"
-        comment-ranges="[[commentRanges]]"></gr-ranged-comment-layer>
-    <gr-coverage-layer
-        id="coverageLayerLeft"
-        coverage-ranges="[[_leftCoverageRanges]]"
-        side="left"></gr-coverage-layer>
-    <gr-coverage-layer
-        id="coverageLayerRight"
-        coverage-ranges="[[_rightCoverageRanges]]"
-        side="right"></gr-coverage-layer>
-    <gr-diff-processor
-        id="processor"
-        groups="{{_groups}}"></gr-diff-processor>
-  </template>
-  <script src="gr-diff-builder-element.js"></script>
-</dom-module>
+    <gr-ranged-comment-layer id="rangeLayer" comment-ranges="[[commentRanges]]"></gr-ranged-comment-layer>
+    <gr-coverage-layer id="coverageLayerLeft" coverage-ranges="[[_leftCoverageRanges]]" side="left"></gr-coverage-layer>
+    <gr-coverage-layer id="coverageLayerRight" coverage-ranges="[[_rightCoverageRanges]]" side="right"></gr-coverage-layer>
+    <gr-diff-processor id="processor" groups="{{_groups}}"></gr-diff-processor>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html
index 3af5522..07edc7d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html
@@ -19,23 +19,35 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-builder</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
-<script src="../gr-diff/gr-diff-line.js"></script>
-<script src="../gr-diff/gr-diff-group.js"></script>
-<script src="../gr-diff-highlight/gr-annotation.js"></script>
-<script src="gr-diff-builder.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
+<script type="module" src="../gr-diff/gr-diff-line.js"></script>
+<script type="module" src="../gr-diff/gr-diff-group.js"></script>
+<script type="module" src="../gr-diff-highlight/gr-annotation.js"></script>
+<script type="module" src="./gr-diff-builder.js"></script>
 
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
-<link rel="import" href="gr-diff-builder-element.html">
+<script type="module" src="../../shared/gr-rest-api-interface/gr-rest-api-interface.js"></script>
+<script type="module" src="../../shared/gr-rest-api-interface/mock-diff-response_test.js"></script>
+<script type="module" src="./gr-diff-builder-element.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import '../gr-diff/gr-diff-line.js';
+import '../gr-diff/gr-diff-group.js';
+import '../gr-diff-highlight/gr-annotation.js';
+import './gr-diff-builder.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-rest-api-interface/mock-diff-response_test.js';
+import './gr-diff-builder-element.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template is="dom-template">
@@ -59,985 +71,1024 @@
   </template>
 </test-fixture>
 
-<script>
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import '../gr-diff/gr-diff-line.js';
+import '../gr-diff/gr-diff-group.js';
+import '../gr-diff-highlight/gr-annotation.js';
+import './gr-diff-builder.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-rest-api-interface/mock-diff-response_test.js';
+import './gr-diff-builder-element.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
 
-  suite('gr-diff-builder tests', async () => {
-    await readyToTest();
-    let prefs;
-    let element;
-    let builder;
-    let sandbox;
-    const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
+suite('gr-diff-builder tests', () => {
+  let prefs;
+  let element;
+  let builder;
+  let sandbox;
+  const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        getProjectConfig() { return Promise.resolve({}); },
-      });
-      prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-      };
-      builder = new GrDiffBuilder({content: []}, prefs);
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getProjectConfig() { return Promise.resolve({}); },
     });
+    prefs = {
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    builder = new GrDiffBuilder({content: []}, prefs);
+  });
 
-    teardown(() => { sandbox.restore(); });
+  teardown(() => { sandbox.restore(); });
 
-    test('_createElement classStr applies all classes', () => {
-      const node = builder._createElement('div', 'test classes');
-      assert.isTrue(node.classList.contains('gr-diff'));
-      assert.isTrue(node.classList.contains('test'));
-      assert.isTrue(node.classList.contains('classes'));
-    });
+  test('_createElement classStr applies all classes', () => {
+    const node = builder._createElement('div', 'test classes');
+    assert.isTrue(node.classList.contains('gr-diff'));
+    assert.isTrue(node.classList.contains('test'));
+    assert.isTrue(node.classList.contains('classes'));
+  });
 
-    test('context control buttons', () => {
-      // Create 10 lines.
-      const lines = [];
-      for (let i = 0; i < 10; i++) {
-        const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-        line.beforeNumber = i + 1;
-        line.afterNumber = i + 1;
-        line.text = 'lorem upsum';
-        lines.push(line);
-      }
-
-      const contextLine = {
-        contextGroups: [new GrDiffGroup(GrDiffGroup.Type.BOTH, lines)],
-      };
-
-      const section = {};
-      // Does not include +10 buttons when there are fewer than 11 lines.
-      let td = builder._createContextControl(section, contextLine);
-      let buttons = td.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 1);
-      assert.equal(Polymer.dom(buttons[0]).textContent, 'Show 10 common lines');
-
-      // Add another line.
+  test('context control buttons', () => {
+    // Create 10 lines.
+    const lines = [];
+    for (let i = 0; i < 10; i++) {
       const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = i + 1;
+      line.afterNumber = i + 1;
       line.text = 'lorem upsum';
-      line.beforeNumber = 11;
-      line.afterNumber = 11;
-      contextLine.contextGroups[0].addLine(line);
+      lines.push(line);
+    }
 
-      // Includes +10 buttons when there are at least 11 lines.
-      td = builder._createContextControl(section, contextLine);
-      buttons = td.querySelectorAll('gr-button.showContext');
+    const contextLine = {
+      contextGroups: [new GrDiffGroup(GrDiffGroup.Type.BOTH, lines)],
+    };
 
-      assert.equal(buttons.length, 3);
-      assert.equal(Polymer.dom(buttons[0]).textContent, '+10 above');
-      assert.equal(Polymer.dom(buttons[1]).textContent, 'Show 11 common lines');
-      assert.equal(Polymer.dom(buttons[2]).textContent, '+10 below');
-    });
+    const section = {};
+    // Does not include +10 buttons when there are fewer than 11 lines.
+    let td = builder._createContextControl(section, contextLine);
+    let buttons = td.querySelectorAll('gr-button.showContext');
 
-    test('newlines 1', () => {
-      let text = 'abcdef';
+    assert.equal(buttons.length, 1);
+    assert.equal(dom(buttons[0]).textContent, 'Show 10 common lines');
 
-      assert.equal(builder._formatText(text, 4, 10).innerHTML, text);
-      text = 'a'.repeat(20);
-      assert.equal(builder._formatText(text, 4, 10).innerHTML,
-          'a'.repeat(10) +
-          LINE_FEED_HTML +
-          'a'.repeat(10));
-    });
+    // Add another line.
+    const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+    line.text = 'lorem upsum';
+    line.beforeNumber = 11;
+    line.afterNumber = 11;
+    contextLine.contextGroups[0].addLine(line);
 
-    test('newlines 2', () => {
-      const text = '<span class="thumbsup">👍</span>';
-      assert.equal(builder._formatText(text, 4, 10).innerHTML,
-          '&lt;span clas' +
-          LINE_FEED_HTML +
-          's="thumbsu' +
-          LINE_FEED_HTML +
-          'p"&gt;👍&lt;/span' +
-          LINE_FEED_HTML +
-          '&gt;');
-    });
+    // Includes +10 buttons when there are at least 11 lines.
+    td = builder._createContextControl(section, contextLine);
+    buttons = td.querySelectorAll('gr-button.showContext');
 
-    test('newlines 3', () => {
-      const text = '01234\t56789';
-      assert.equal(builder._formatText(text, 4, 10).innerHTML,
-          '01234' + builder._getTabWrapper(3).outerHTML + '56' +
-          LINE_FEED_HTML +
-          '789');
-    });
+    assert.equal(buttons.length, 3);
+    assert.equal(dom(buttons[0]).textContent, '+10 above');
+    assert.equal(dom(buttons[1]).textContent, 'Show 11 common lines');
+    assert.equal(dom(buttons[2]).textContent, '+10 below');
+  });
 
-    test('newlines 4', () => {
-      const text = '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍';
-      assert.equal(builder._formatText(text, 4, 20).innerHTML,
-          '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
-          LINE_FEED_HTML +
-          '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
-          LINE_FEED_HTML +
-          '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍');
-    });
+  test('newlines 1', () => {
+    let text = 'abcdef';
 
-    test('line_length ignored if line_wrapping is true', () => {
-      builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
-      const text = 'a'.repeat(51);
+    assert.equal(builder._formatText(text, 4, 10).innerHTML, text);
+    text = 'a'.repeat(20);
+    assert.equal(builder._formatText(text, 4, 10).innerHTML,
+        'a'.repeat(10) +
+        LINE_FEED_HTML +
+        'a'.repeat(10));
+  });
 
-      const line = {text, highlights: []};
-      const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
-      assert.equal(result, text);
-    });
+  test('newlines 2', () => {
+    const text = '<span class="thumbsup">👍</span>';
+    assert.equal(builder._formatText(text, 4, 10).innerHTML,
+        '&lt;span clas' +
+        LINE_FEED_HTML +
+        's="thumbsu' +
+        LINE_FEED_HTML +
+        'p"&gt;👍&lt;/span' +
+        LINE_FEED_HTML +
+        '&gt;');
+  });
 
-    test('line_length applied if line_wrapping is false', () => {
-      builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
-      const text = 'a'.repeat(51);
+  test('newlines 3', () => {
+    const text = '01234\t56789';
+    assert.equal(builder._formatText(text, 4, 10).innerHTML,
+        '01234' + builder._getTabWrapper(3).outerHTML + '56' +
+        LINE_FEED_HTML +
+        '789');
+  });
 
-      const line = {text, highlights: []};
-      const expected = 'a'.repeat(50) + LINE_FEED_HTML + 'a';
-      const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
-      assert.equal(result, expected);
-    });
+  test('newlines 4', () => {
+    const text = '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍';
+    assert.equal(builder._formatText(text, 4, 20).innerHTML,
+        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
+        LINE_FEED_HTML +
+        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
+        LINE_FEED_HTML +
+        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍');
+  });
 
-    [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE]
-        .forEach(mode => {
-          test(`line_length used for regular files under ${mode}`, () => {
-            element.path = '/a.txt';
-            element.viewMode = mode;
-            builder = element._getDiffBuilder(
-                {}, {tab_size: 4, line_length: 50}
-            );
-            assert.equal(builder._prefs.line_length, 50);
-          });
+  test('line_length ignored if line_wrapping is true', () => {
+    builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
+    const text = 'a'.repeat(51);
 
-          test(`line_length ignored for commit msg under ${mode}`, () => {
-            element.path = '/COMMIT_MSG';
-            element.viewMode = mode;
-            builder = element._getDiffBuilder(
-                {}, {tab_size: 4, line_length: 50}
-            );
-            assert.equal(builder._prefs.line_length, 72);
-          });
+    const line = {text, highlights: []};
+    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
+    assert.equal(result, text);
+  });
+
+  test('line_length applied if line_wrapping is false', () => {
+    builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
+    const text = 'a'.repeat(51);
+
+    const line = {text, highlights: []};
+    const expected = 'a'.repeat(50) + LINE_FEED_HTML + 'a';
+    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE]
+      .forEach(mode => {
+        test(`line_length used for regular files under ${mode}`, () => {
+          element.path = '/a.txt';
+          element.viewMode = mode;
+          builder = element._getDiffBuilder(
+              {}, {tab_size: 4, line_length: 50}
+          );
+          assert.equal(builder._prefs.line_length, 50);
         });
 
-    test('_createTextEl linewrap with tabs', () => {
-      const text = '\t'.repeat(7) + '!';
-      const line = {text, highlights: []};
-      const el = builder._createTextEl(undefined, line);
-      assert.equal(el.innerText, text);
-      // With line length 10 and tab size 2, there should be a line break
-      // after every two tabs.
-      const newlineEl = el.querySelector('.contentText > .br');
-      assert.isOk(newlineEl);
-      assert.equal(
-          el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
-          newlineEl);
-    });
+        test(`line_length ignored for commit msg under ${mode}`, () => {
+          element.path = '/COMMIT_MSG';
+          element.viewMode = mode;
+          builder = element._getDiffBuilder(
+              {}, {tab_size: 4, line_length: 50}
+          );
+          assert.equal(builder._prefs.line_length, 72);
+        });
+      });
 
-    test('text length with tabs and unicode', () => {
-      function expectTextLength(text, tabSize, expected) {
-        // Formatting to |expected| columns should not introduce line breaks.
-        const result = builder._formatText(text, tabSize, expected);
-        assert.isNotOk(result.querySelector('.contentText > .br'),
+  test('_createTextEl linewrap with tabs', () => {
+    const text = '\t'.repeat(7) + '!';
+    const line = {text, highlights: []};
+    const el = builder._createTextEl(undefined, line);
+    assert.equal(el.innerText, text);
+    // With line length 10 and tab size 2, there should be a line break
+    // after every two tabs.
+    const newlineEl = el.querySelector('.contentText > .br');
+    assert.isOk(newlineEl);
+    assert.equal(
+        el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
+        newlineEl);
+  });
+
+  test('text length with tabs and unicode', () => {
+    function expectTextLength(text, tabSize, expected) {
+      // Formatting to |expected| columns should not introduce line breaks.
+      const result = builder._formatText(text, tabSize, expected);
+      assert.isNotOk(result.querySelector('.contentText > .br'),
+          `  Expected the result of: \n` +
+          `      _formatText(${text}', ${tabSize}, ${expected})\n` +
+          `  to not contain a br. But the actual result HTML was:\n` +
+          `      '${result.innerHTML}'\nwhereupon`);
+
+      // Increasing the line limit should produce the same markup.
+      assert.equal(builder._formatText(text, tabSize, Infinity).innerHTML,
+          result.innerHTML);
+      assert.equal(builder._formatText(text, tabSize, expected + 1).innerHTML,
+          result.innerHTML);
+
+      // Decreasing the line limit should introduce line breaks.
+      if (expected > 0) {
+        const tooSmall = builder._formatText(text, tabSize, expected - 1);
+        assert.isOk(tooSmall.querySelector('.contentText > .br'),
             `  Expected the result of: \n` +
-            `      _formatText(${text}', ${tabSize}, ${expected})\n` +
-            `  to not contain a br. But the actual result HTML was:\n` +
-            `      '${result.innerHTML}'\nwhereupon`);
-
-        // Increasing the line limit should produce the same markup.
-        assert.equal(builder._formatText(text, tabSize, Infinity).innerHTML,
-            result.innerHTML);
-        assert.equal(builder._formatText(text, tabSize, expected + 1).innerHTML,
-            result.innerHTML);
-
-        // Decreasing the line limit should introduce line breaks.
-        if (expected > 0) {
-          const tooSmall = builder._formatText(text, tabSize, expected - 1);
-          assert.isOk(tooSmall.querySelector('.contentText > .br'),
-              `  Expected the result of: \n` +
-              `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
-              `  to contain a br. But the actual result HTML was:\n` +
-              `      '${tooSmall.innerHTML}'\nwhereupon`);
-        }
+            `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
+            `  to contain a br. But the actual result HTML was:\n` +
+            `      '${tooSmall.innerHTML}'\nwhereupon`);
       }
-      expectTextLength('12345', 4, 5);
-      expectTextLength('\t\t12', 4, 10);
-      expectTextLength('abc💢123', 4, 7);
-      expectTextLength('abc\t', 8, 8);
-      expectTextLength('abc\t\t', 10, 20);
-      expectTextLength('', 10, 0);
-      expectTextLength('', 10, 0);
-      // 17 Thai combining chars.
-      expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
-      expectTextLength('abc\tde', 10, 12);
-      expectTextLength('abc\tde\t', 10, 20);
-      expectTextLength('\t\t\t\t\t', 20, 100);
-    });
+    }
+    expectTextLength('12345', 4, 5);
+    expectTextLength('\t\t12', 4, 10);
+    expectTextLength('abc💢123', 4, 7);
+    expectTextLength('abc\t', 8, 8);
+    expectTextLength('abc\t\t', 10, 20);
+    expectTextLength('', 10, 0);
+    expectTextLength('', 10, 0);
+    // 17 Thai combining chars.
+    expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
+    expectTextLength('abc\tde', 10, 12);
+    expectTextLength('abc\tde\t', 10, 20);
+    expectTextLength('\t\t\t\t\t', 20, 100);
+  });
 
-    test('tab wrapper insertion', () => {
-      const html = 'abc\tdef';
-      const tabSize = builder._prefs.tab_size;
-      const wrapper = builder._getTabWrapper(tabSize - 3);
-      assert.ok(wrapper);
-      assert.equal(wrapper.innerText, '\t');
-      assert.equal(
-          builder._formatText(html, tabSize, Infinity).innerHTML,
-          'abc' + wrapper.outerHTML + 'def');
-    });
+  test('tab wrapper insertion', () => {
+    const html = 'abc\tdef';
+    const tabSize = builder._prefs.tab_size;
+    const wrapper = builder._getTabWrapper(tabSize - 3);
+    assert.ok(wrapper);
+    assert.equal(wrapper.innerText, '\t');
+    assert.equal(
+        builder._formatText(html, tabSize, Infinity).innerHTML,
+        'abc' + wrapper.outerHTML + 'def');
+  });
 
-    test('tab wrapper style', () => {
-      const pattern = new RegExp('^<span class="style-scope gr-diff tab" ' +
-        'style="(?:-moz-)?tab-size: (\\d+);">\\t<\\/span>$');
+  test('tab wrapper style', () => {
+    const pattern = new RegExp('^<span class="style-scope gr-diff tab" ' +
+      'style="(?:-moz-)?tab-size: (\\d+);">\\t<\\/span>$');
 
-      for (const size of [1, 3, 8, 55]) {
-        const html = builder._getTabWrapper(size).outerHTML;
-        expect(html).to.match(pattern);
-        assert.equal(html.match(pattern)[1], size);
+    for (const size of [1, 3, 8, 55]) {
+      const html = builder._getTabWrapper(size).outerHTML;
+      expect(html).to.match(pattern);
+      assert.equal(html.match(pattern)[1], size);
+    }
+  });
+
+  test('_handlePreferenceError called with invalid preference', () => {
+    sandbox.stub(element, '_handlePreferenceError');
+    const prefs = {tab_size: 0};
+    element._getDiffBuilder(element.diff, prefs);
+    assert.isTrue(element._handlePreferenceError.lastCall
+        .calledWithExactly('tab size'));
+  });
+
+  test('_handlePreferenceError triggers alert and javascript error', () => {
+    const errorStub = sinon.stub();
+    element.addEventListener('show-alert', errorStub);
+    assert.throws(element._handlePreferenceError.bind(element, 'tab size'));
+    assert.equal(errorStub.lastCall.args[0].detail.message,
+        `The value of the 'tab size' user preference is invalid. ` +
+      `Fix in diff preferences`);
+  });
+
+  suite('_isTotal', () => {
+    test('is total for add', () => {
+      const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      for (let idx = 0; idx < 10; idx++) {
+        group.addLine(new GrDiffLine(GrDiffLine.Type.ADD));
       }
+      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
     });
 
-    test('_handlePreferenceError called with invalid preference', () => {
-      sandbox.stub(element, '_handlePreferenceError');
-      const prefs = {tab_size: 0};
-      element._getDiffBuilder(element.diff, prefs);
-      assert.isTrue(element._handlePreferenceError.lastCall
-          .calledWithExactly('tab size'));
-    });
-
-    test('_handlePreferenceError triggers alert and javascript error', () => {
-      const errorStub = sinon.stub();
-      element.addEventListener('show-alert', errorStub);
-      assert.throws(element._handlePreferenceError.bind(element, 'tab size'));
-      assert.equal(errorStub.lastCall.args[0].detail.message,
-          `The value of the 'tab size' user preference is invalid. ` +
-        `Fix in diff preferences`);
-    });
-
-    suite('_isTotal', () => {
-      test('is total for add', () => {
-        const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-        for (let idx = 0; idx < 10; idx++) {
-          group.addLine(new GrDiffLine(GrDiffLine.Type.ADD));
-        }
-        assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
-      });
-
-      test('is total for remove', () => {
-        const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-        for (let idx = 0; idx < 10; idx++) {
-          group.addLine(new GrDiffLine(GrDiffLine.Type.REMOVE));
-        }
-        assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
-      });
-
-      test('not total for empty', () => {
-        const group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
-        assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
-      });
-
-      test('not total for non-delta', () => {
-        const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-        for (let idx = 0; idx < 10; idx++) {
-          group.addLine(new GrDiffLine(GrDiffLine.Type.BOTH));
-        }
-        assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
-      });
-    });
-
-    suite('intraline differences', () => {
-      let el;
-      let str;
-      let annotateElementSpy;
-      let layer;
-      const lineNumberEl = document.createElement('td');
-
-      function slice(str, start, end) {
-        return Array.from(str).slice(start, end)
-            .join('');
+    test('is total for remove', () => {
+      const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      for (let idx = 0; idx < 10; idx++) {
+        group.addLine(new GrDiffLine(GrDiffLine.Type.REMOVE));
       }
-
-      setup(() => {
-        el = fixture('div-with-text');
-        str = el.textContent;
-        annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-        layer = document.createElement('gr-diff-builder')
-            ._createIntralineLayer();
-      });
-
-      test('annotate no highlights', () => {
-        const line = {
-          text: str,
-          highlights: [],
-        };
-
-        layer.annotate(el, lineNumberEl, line);
-
-        // The content is unchanged.
-        assert.isFalse(annotateElementSpy.called);
-        assert.equal(el.childNodes.length, 1);
-        assert.instanceOf(el.childNodes[0], Text);
-        assert.equal(str, el.childNodes[0].textContent);
-      });
-
-      test('annotate with highlights', () => {
-        const line = {
-          text: str,
-          highlights: [
-            {startIndex: 6, endIndex: 12},
-            {startIndex: 18, endIndex: 22},
-          ],
-        };
-        const str0 = slice(str, 0, 6);
-        const str1 = slice(str, 6, 12);
-        const str2 = slice(str, 12, 18);
-        const str3 = slice(str, 18, 22);
-        const str4 = slice(str, 22);
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementSpy.called);
-        assert.equal(el.childNodes.length, 5);
-
-        assert.instanceOf(el.childNodes[0], Text);
-        assert.equal(el.childNodes[0].textContent, str0);
-
-        assert.notInstanceOf(el.childNodes[1], Text);
-        assert.equal(el.childNodes[1].textContent, str1);
-
-        assert.instanceOf(el.childNodes[2], Text);
-        assert.equal(el.childNodes[2].textContent, str2);
-
-        assert.notInstanceOf(el.childNodes[3], Text);
-        assert.equal(el.childNodes[3].textContent, str3);
-
-        assert.instanceOf(el.childNodes[4], Text);
-        assert.equal(el.childNodes[4].textContent, str4);
-      });
-
-      test('annotate without endIndex', () => {
-        const line = {
-          text: str,
-          highlights: [
-            {startIndex: 28},
-          ],
-        };
-
-        const str0 = slice(str, 0, 28);
-        const str1 = slice(str, 28);
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementSpy.called);
-        assert.equal(el.childNodes.length, 2);
-
-        assert.instanceOf(el.childNodes[0], Text);
-        assert.equal(el.childNodes[0].textContent, str0);
-
-        assert.notInstanceOf(el.childNodes[1], Text);
-        assert.equal(el.childNodes[1].textContent, str1);
-      });
-
-      test('annotate ignores empty highlights', () => {
-        const line = {
-          text: str,
-          highlights: [
-            {startIndex: 28, endIndex: 28},
-          ],
-        };
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isFalse(annotateElementSpy.called);
-        assert.equal(el.childNodes.length, 1);
-      });
-
-      test('annotate handles unicode', () => {
-        // Put some unicode into the string:
-        str = str.replace(/\s/g, '💢');
-        el.textContent = str;
-        const line = {
-          text: str,
-          highlights: [
-            {startIndex: 6, endIndex: 12},
-          ],
-        };
-
-        const str0 = slice(str, 0, 6);
-        const str1 = slice(str, 6, 12);
-        const str2 = slice(str, 12);
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementSpy.called);
-        assert.equal(el.childNodes.length, 3);
-
-        assert.instanceOf(el.childNodes[0], Text);
-        assert.equal(el.childNodes[0].textContent, str0);
-
-        assert.notInstanceOf(el.childNodes[1], Text);
-        assert.equal(el.childNodes[1].textContent, str1);
-
-        assert.instanceOf(el.childNodes[2], Text);
-        assert.equal(el.childNodes[2].textContent, str2);
-      });
-
-      test('annotate handles unicode w/o endIndex', () => {
-        // Put some unicode into the string:
-        str = str.replace(/\s/g, '💢');
-        el.textContent = str;
-
-        const line = {
-          text: str,
-          highlights: [
-            {startIndex: 6},
-          ],
-        };
-
-        const str0 = slice(str, 0, 6);
-        const str1 = slice(str, 6);
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementSpy.called);
-        assert.equal(el.childNodes.length, 2);
-
-        assert.instanceOf(el.childNodes[0], Text);
-        assert.equal(el.childNodes[0].textContent, str0);
-
-        assert.notInstanceOf(el.childNodes[1], Text);
-        assert.equal(el.childNodes[1].textContent, str1);
-      });
+      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
     });
 
-    suite('tab indicators', () => {
-      let element;
-      let layer;
-      const lineNumberEl = document.createElement('td');
-
-      setup(() => {
-        element = fixture('basic');
-        element._showTabs = true;
-        layer = element._createTabIndicatorLayer();
-      });
-
-      test('does nothing with empty line', () => {
-        const line = {text: ''};
-        const el = document.createElement('div');
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('does nothing with no tabs', () => {
-        const str = 'lorem ipsum no tabs';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('annotates tab at beginning', () => {
-        const str = '\tlorem upsum';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.equal(annotateElementStub.callCount, 1);
-        const args = annotateElementStub.getCalls()[0].args;
-        assert.equal(args[0], el);
-        assert.equal(args[1], 0, 'offset of tab indicator');
-        assert.equal(args[2], 1, 'length of tab indicator');
-        assert.include(args[3], 'tab-indicator');
-      });
-
-      test('does not annotate when disabled', () => {
-        element._showTabs = false;
-
-        const str = '\tlorem upsum';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('annotates multiple in beginning', () => {
-        const str = '\t\tlorem upsum';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.equal(annotateElementStub.callCount, 2);
-
-        let args = annotateElementStub.getCalls()[0].args;
-        assert.equal(args[0], el);
-        assert.equal(args[1], 0, 'offset of tab indicator');
-        assert.equal(args[2], 1, 'length of tab indicator');
-        assert.include(args[3], 'tab-indicator');
-
-        args = annotateElementStub.getCalls()[1].args;
-        assert.equal(args[0], el);
-        assert.equal(args[1], 1, 'offset of tab indicator');
-        assert.equal(args[2], 1, 'length of tab indicator');
-        assert.include(args[3], 'tab-indicator');
-      });
-
-      test('annotates intermediate tabs', () => {
-        const str = 'lorem\tupsum';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.equal(annotateElementStub.callCount, 1);
-        const args = annotateElementStub.getCalls()[0].args;
-        assert.equal(args[0], el);
-        assert.equal(args[1], 5, 'offset of tab indicator');
-        assert.equal(args[2], 1, 'length of tab indicator');
-        assert.include(args[3], 'tab-indicator');
-      });
+    test('not total for empty', () => {
+      const group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
     });
 
-    suite('layers', () => {
-      let element;
-      let initialLayersCount;
-      let withLayerCount;
+    test('not total for non-delta', () => {
+      const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      for (let idx = 0; idx < 10; idx++) {
+        group.addLine(new GrDiffLine(GrDiffLine.Type.BOTH));
+      }
+      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
+    });
+  });
+
+  suite('intraline differences', () => {
+    let el;
+    let str;
+    let annotateElementSpy;
+    let layer;
+    const lineNumberEl = document.createElement('td');
+
+    function slice(str, start, end) {
+      return Array.from(str).slice(start, end)
+          .join('');
+    }
+
+    setup(() => {
+      el = fixture('div-with-text');
+      str = el.textContent;
+      annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+      layer = document.createElement('gr-diff-builder')
+          ._createIntralineLayer();
+    });
+
+    test('annotate no highlights', () => {
+      const line = {
+        text: str,
+        highlights: [],
+      };
+
+      layer.annotate(el, lineNumberEl, line);
+
+      // The content is unchanged.
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(str, el.childNodes[0].textContent);
+    });
+
+    test('annotate with highlights', () => {
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 6, endIndex: 12},
+          {startIndex: 18, endIndex: 22},
+        ],
+      };
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12, 18);
+      const str3 = slice(str, 18, 22);
+      const str4 = slice(str, 22);
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 5);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+
+      assert.notInstanceOf(el.childNodes[3], Text);
+      assert.equal(el.childNodes[3].textContent, str3);
+
+      assert.instanceOf(el.childNodes[4], Text);
+      assert.equal(el.childNodes[4].textContent, str4);
+    });
+
+    test('annotate without endIndex', () => {
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 28},
+        ],
+      };
+
+      const str0 = slice(str, 0, 28);
+      const str1 = slice(str, 28);
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+
+    test('annotate ignores empty highlights', () => {
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 28, endIndex: 28},
+        ],
+      };
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+    });
+
+    test('annotate handles unicode', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 6, endIndex: 12},
+        ],
+      };
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12);
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 3);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+    });
+
+    test('annotate handles unicode w/o endIndex', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 6},
+        ],
+      };
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6);
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+  });
+
+  suite('tab indicators', () => {
+    let element;
+    let layer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element = fixture('basic');
+      element._showTabs = true;
+      layer = element._createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const line = {text: ''};
+      const el = document.createElement('div');
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no tabs', () => {
+      const str = 'lorem ipsum no tabs';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates tab at beginning', () => {
+      const str = '\tlorem upsum';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('does not annotate when disabled', () => {
+      element._showTabs = false;
+
+      const str = '\tlorem upsum';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates multiple in beginning', () => {
+      const str = '\t\tlorem upsum';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.equal(annotateElementStub.callCount, 2);
+
+      let args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+
+      args = annotateElementStub.getCalls()[1].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 1, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('annotates intermediate tabs', () => {
+      const str = 'lorem\tupsum';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 5, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+  });
+
+  suite('layers', () => {
+    let element;
+    let initialLayersCount;
+    let withLayerCount;
+    setup(() => {
+      const layers = [];
+      element = fixture('basic');
+      element.layers = layers;
+      element._showTrailingWhitespace = true;
+      element._setupAnnotationLayers();
+      initialLayersCount = element._layers.length;
+    });
+
+    test('no layers', () => {
+      element._setupAnnotationLayers();
+      assert.equal(element._layers.length, initialLayersCount);
+    });
+
+    suite('with layers', () => {
+      const layers = [{}, {}];
       setup(() => {
-        const layers = [];
         element = fixture('basic');
         element.layers = layers;
         element._showTrailingWhitespace = true;
         element._setupAnnotationLayers();
-        initialLayersCount = element._layers.length;
+        withLayerCount = element._layers.length;
       });
-
-      test('no layers', () => {
+      test('with layers', () => {
         element._setupAnnotationLayers();
-        assert.equal(element._layers.length, initialLayersCount);
+        assert.equal(element._layers.length, withLayerCount);
+        assert.equal(initialLayersCount + layers.length,
+            withLayerCount);
       });
+    });
+  });
 
-      suite('with layers', () => {
-        const layers = [{}, {}];
-        setup(() => {
-          element = fixture('basic');
-          element.layers = layers;
-          element._showTrailingWhitespace = true;
-          element._setupAnnotationLayers();
-          withLayerCount = element._layers.length;
-        });
-        test('with layers', () => {
-          element._setupAnnotationLayers();
-          assert.equal(element._layers.length, withLayerCount);
-          assert.equal(initialLayersCount + layers.length,
-              withLayerCount);
-        });
+  suite('trailing whitespace', () => {
+    let element;
+    let layer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element = fixture('basic');
+      element._showTrailingWhitespace = true;
+      layer = element._createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const line = {text: ''};
+      const el = document.createElement('div');
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no trailing whitespace', () => {
+      const str = 'lorem ipsum blah blah';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates trailing spaces', () => {
+      const str = 'lorem ipsum   ';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates trailing tabs', () => {
+      const str = 'lorem ipsum\t\t\t';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates mixed trailing whitespace', () => {
+      const str = 'lorem ipsum\t \t';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('unicode preceding trailing whitespace', () => {
+      const str = '💢\t';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 1);
+      assert.equal(annotateElementStub.lastCall.args[2], 1);
+    });
+
+    test('does not annotate when disabled', () => {
+      element._showTrailingWhitespace = false;
+      const str = 'lorem upsum\t \t ';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isFalse(annotateElementStub.called);
+    });
+  });
+
+  suite('rendering text, images and binary files', () => {
+    let processStub;
+    let keyLocations;
+    let prefs;
+    let content;
+
+    setup(() => {
+      element = fixture('basic');
+      element.viewMode = 'SIDE_BY_SIDE';
+      processStub = sandbox.stub(element.$.processor, 'process')
+          .returns(Promise.resolve());
+      keyLocations = {left: {}, right: {}};
+      prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+    });
+
+    test('text', () => {
+      element.diff = {content};
+      return element.render(keyLocations, prefs).then(() => {
+        assert.isTrue(processStub.calledOnce);
+        assert.isFalse(processStub.lastCall.args[1]);
       });
     });
 
-    suite('trailing whitespace', () => {
-      let element;
-      let layer;
-      const lineNumberEl = document.createElement('td');
-
-      setup(() => {
-        element = fixture('basic');
-        element._showTrailingWhitespace = true;
-        layer = element._createTrailingWhitespaceLayer();
-      });
-
-      test('does nothing with empty line', () => {
-        const line = {text: ''};
-        const el = document.createElement('div');
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('does nothing with no trailing whitespace', () => {
-        const str = 'lorem ipsum blah blah';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('annotates trailing spaces', () => {
-        const str = 'lorem ipsum   ';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isTrue(annotateElementStub.called);
-        assert.equal(annotateElementStub.lastCall.args[1], 11);
-        assert.equal(annotateElementStub.lastCall.args[2], 3);
-      });
-
-      test('annotates trailing tabs', () => {
-        const str = 'lorem ipsum\t\t\t';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isTrue(annotateElementStub.called);
-        assert.equal(annotateElementStub.lastCall.args[1], 11);
-        assert.equal(annotateElementStub.lastCall.args[2], 3);
-      });
-
-      test('annotates mixed trailing whitespace', () => {
-        const str = 'lorem ipsum\t \t';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isTrue(annotateElementStub.called);
-        assert.equal(annotateElementStub.lastCall.args[1], 11);
-        assert.equal(annotateElementStub.lastCall.args[2], 3);
-      });
-
-      test('unicode preceding trailing whitespace', () => {
-        const str = '💢\t';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isTrue(annotateElementStub.called);
-        assert.equal(annotateElementStub.lastCall.args[1], 1);
-        assert.equal(annotateElementStub.lastCall.args[2], 1);
-      });
-
-      test('does not annotate when disabled', () => {
-        element._showTrailingWhitespace = false;
-        const str = 'lorem upsum\t \t ';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isFalse(annotateElementStub.called);
+    test('image', () => {
+      element.diff = {content, binary: true};
+      element.isImageDiff = true;
+      return element.render(keyLocations, prefs).then(() => {
+        assert.isTrue(processStub.calledOnce);
+        assert.isTrue(processStub.lastCall.args[1]);
       });
     });
 
-    suite('rendering text, images and binary files', () => {
-      let processStub;
-      let keyLocations;
-      let prefs;
-      let content;
+    test('binary', () => {
+      element.diff = {content, binary: true};
+      return element.render(keyLocations, prefs).then(() => {
+        assert.isTrue(processStub.calledOnce);
+        assert.isTrue(processStub.lastCall.args[1]);
+      });
+    });
+  });
 
-      setup(() => {
-        element = fixture('basic');
-        element.viewMode = 'SIDE_BY_SIDE';
-        processStub = sandbox.stub(element.$.processor, 'process')
-            .returns(Promise.resolve());
-        keyLocations = {left: {}, right: {}};
-        prefs = {
-          line_length: 10,
-          show_tabs: true,
-          tab_size: 4,
-          context: -1,
-          syntax_highlighting: true,
-        };
-        content = [{
+  suite('rendering', () => {
+    let content;
+    let outputEl;
+    let keyLocations;
+
+    setup(done => {
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [
+        {
           a: ['all work and no play make andybons a dull boy'],
           b: ['elgoog elgoog elgoog'],
-        }, {
+        },
+        {
           ab: [
             'Non eram nescius, Brute, cum, quae summis ingeniis ',
             'exquisitaque doctrina philosophi Graeco sermone tractavissent',
           ],
-        }];
+        },
+      ];
+      element = fixture('basic');
+      outputEl = element.queryEffectiveChildren('#diffTable');
+      keyLocations = {left: {}, right: {}};
+      sandbox.stub(element, '_getDiffBuilder', () => {
+        const builder = new GrDiffBuilder({content}, prefs, outputEl);
+        sandbox.stub(builder, 'addColumns');
+        builder.buildSectionElement = function(group) {
+          const section = document.createElement('stub');
+          section.textContent = group.lines
+              .reduce((acc, line) => acc + line.text, '');
+          return section;
+        };
+        return builder;
       });
+      element.diff = {content};
+      element.render(keyLocations, prefs).then(done);
+    });
 
-      test('text', () => {
-        element.diff = {content};
-        return element.render(keyLocations, prefs).then(() => {
-          assert.isTrue(processStub.calledOnce);
-          assert.isFalse(processStub.lastCall.args[1]);
-        });
-      });
+    test('addColumns is called', done => {
+      element.render(keyLocations, {}).then(done);
+      assert.isTrue(element._builder.addColumns.called);
+    });
 
-      test('image', () => {
-        element.diff = {content, binary: true};
-        element.isImageDiff = true;
-        return element.render(keyLocations, prefs).then(() => {
-          assert.isTrue(processStub.calledOnce);
-          assert.isTrue(processStub.lastCall.args[1]);
-        });
-      });
+    test('getSectionsByLineRange one line', () => {
+      const section = outputEl.querySelector('stub:nth-of-type(2)');
+      const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
+      assert.equal(sections.length, 1);
+      assert.strictEqual(sections[0], section);
+    });
 
-      test('binary', () => {
-        element.diff = {content, binary: true};
-        return element.render(keyLocations, prefs).then(() => {
-          assert.isTrue(processStub.calledOnce);
-          assert.isTrue(processStub.lastCall.args[1]);
-        });
+    test('getSectionsByLineRange over diff', () => {
+      const section = [
+        outputEl.querySelector('stub:nth-of-type(2)'),
+        outputEl.querySelector('stub:nth-of-type(3)'),
+      ];
+      const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
+      assert.equal(sections.length, 2);
+      assert.strictEqual(sections[0], section[0]);
+      assert.strictEqual(sections[1], section[1]);
+    });
+
+    test('render-start and render-content are fired', done => {
+      const dispatchEventStub = sandbox.stub(element, 'dispatchEvent');
+      element.render(keyLocations, {}).then(() => {
+        const firedEventTypes = dispatchEventStub.getCalls()
+            .map(c => c.args[0].type);
+        assert.include(firedEventTypes, 'render-start');
+        assert.include(firedEventTypes, 'render-content');
+        done();
       });
     });
 
-    suite('rendering', () => {
-      let content;
-      let outputEl;
-      let keyLocations;
+    test('cancel', () => {
+      const processorCancelStub = sandbox.stub(element.$.processor, 'cancel');
+      element.cancel();
+      assert.isTrue(processorCancelStub.called);
+    });
+  });
 
-      setup(done => {
-        const prefs = {
-          line_length: 10,
-          show_tabs: true,
-          tab_size: 4,
-          context: -1,
-          syntax_highlighting: true,
-        };
-        content = [
-          {
-            a: ['all work and no play make andybons a dull boy'],
-            b: ['elgoog elgoog elgoog'],
-          },
-          {
-            ab: [
-              'Non eram nescius, Brute, cum, quae summis ingeniis ',
-              'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-            ],
-          },
-        ];
-        element = fixture('basic');
-        outputEl = element.queryEffectiveChildren('#diffTable');
-        keyLocations = {left: {}, right: {}};
-        sandbox.stub(element, '_getDiffBuilder', () => {
-          const builder = new GrDiffBuilder({content}, prefs, outputEl);
-          sandbox.stub(builder, 'addColumns');
-          builder.buildSectionElement = function(group) {
-            const section = document.createElement('stub');
-            section.textContent = group.lines
-                .reduce((acc, line) => acc + line.text, '');
-            return section;
-          };
-          return builder;
-        });
-        element.diff = {content};
-        element.render(keyLocations, prefs).then(done);
-      });
+  suite('mock-diff', () => {
+    let element;
+    let builder;
+    let diff;
+    let prefs;
+    let keyLocations;
 
-      test('addColumns is called', done => {
-        element.render(keyLocations, {}).then(done);
-        assert.isTrue(element._builder.addColumns.called);
-      });
+    setup(done => {
+      element = fixture('mock-diff');
+      diff = document.createElement('mock-diff-response').diffResponse;
+      element.diff = diff;
 
-      test('getSectionsByLineRange one line', () => {
-        const section = outputEl.querySelector('stub:nth-of-type(2)');
-        const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
-        assert.equal(sections.length, 1);
-        assert.strictEqual(sections[0], section);
-      });
+      prefs = {
+        line_length: 80,
+        show_tabs: true,
+        tab_size: 4,
+      };
+      keyLocations = {left: {}, right: {}};
 
-      test('getSectionsByLineRange over diff', () => {
-        const section = [
-          outputEl.querySelector('stub:nth-of-type(2)'),
-          outputEl.querySelector('stub:nth-of-type(3)'),
-        ];
-        const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
-        assert.equal(sections.length, 2);
-        assert.strictEqual(sections[0], section[0]);
-        assert.strictEqual(sections[1], section[1]);
-      });
-
-      test('render-start and render-content are fired', done => {
-        const dispatchEventStub = sandbox.stub(element, 'dispatchEvent');
-        element.render(keyLocations, {}).then(() => {
-          const firedEventTypes = dispatchEventStub.getCalls()
-              .map(c => c.args[0].type);
-          assert.include(firedEventTypes, 'render-start');
-          assert.include(firedEventTypes, 'render-content');
-          done();
-        });
-      });
-
-      test('cancel', () => {
-        const processorCancelStub = sandbox.stub(element.$.processor, 'cancel');
-        element.cancel();
-        assert.isTrue(processorCancelStub.called);
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+        done();
       });
     });
 
-    suite('mock-diff', () => {
-      let element;
-      let builder;
-      let diff;
-      let prefs;
-      let keyLocations;
+    test('getContentByLine', () => {
+      let actual;
 
-      setup(done => {
-        element = fixture('mock-diff');
-        diff = document.createElement('mock-diff-response').diffResponse;
-        element.diff = diff;
+      actual = builder.getContentByLine(2, 'left');
+      assert.equal(actual.textContent, diff.content[0].ab[1]);
 
-        prefs = {
-          line_length: 80,
-          show_tabs: true,
-          tab_size: 4,
-        };
-        keyLocations = {left: {}, right: {}};
+      actual = builder.getContentByLine(2, 'right');
+      assert.equal(actual.textContent, diff.content[0].ab[1]);
 
-        element.render(keyLocations, prefs).then(() => {
-          builder = element._builder;
-          done();
-        });
+      actual = builder.getContentByLine(5, 'left');
+      assert.equal(actual.textContent, diff.content[2].ab[0]);
+
+      actual = builder.getContentByLine(5, 'right');
+      assert.equal(actual.textContent, diff.content[1].b[0]);
+    });
+
+    test('findLinesByRange', () => {
+      const lines = [];
+      const elems = [];
+      const start = 6;
+      const end = 10;
+      const count = end - start + 1;
+
+      builder.findLinesByRange(start, end, 'right', lines, elems);
+
+      assert.equal(lines.length, count);
+      assert.equal(elems.length, count);
+
+      for (let i = 0; i < 5; i++) {
+        assert.instanceOf(lines[i], GrDiffLine);
+        assert.equal(lines[i].afterNumber, start + i);
+        assert.instanceOf(elems[i], HTMLElement);
+        assert.equal(lines[i].text, elems[i].textContent);
+      }
+    });
+
+    test('_renderContentByRange', () => {
+      const spy = sandbox.spy(builder, '_createTextEl');
+      const start = 9;
+      const end = 14;
+      const count = end - start + 1;
+
+      builder._renderContentByRange(start, end, 'left');
+
+      assert.equal(spy.callCount, count);
+      spy.getCalls().forEach((call, i) => {
+        assert.equal(call.args[1].beforeNumber, start + i);
       });
+    });
 
-      test('getContentByLine', () => {
-        let actual;
+    test('_renderContentByRange notexistent elements', () => {
+      const spy = sandbox.spy(builder, '_createTextEl');
 
-        actual = builder.getContentByLine(2, 'left');
-        assert.equal(actual.textContent, diff.content[0].ab[1]);
+      sandbox.stub(builder, 'findLinesByRange',
+          (s, e, d, lines, elements) => {
+            // Add a line and a corresponding element.
+            lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+            const tr = document.createElement('tr');
+            const td = document.createElement('td');
+            const el = document.createElement('div');
+            tr.appendChild(td);
+            td.appendChild(el);
+            elements.push(el);
 
-        actual = builder.getContentByLine(2, 'right');
-        assert.equal(actual.textContent, diff.content[0].ab[1]);
+            // Add 2 lines without corresponding elements.
+            lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+            lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+          });
 
-        actual = builder.getContentByLine(5, 'left');
-        assert.equal(actual.textContent, diff.content[2].ab[0]);
+      builder._renderContentByRange(1, 10, 'left');
+      // Should be called only once because only one line had a corresponding
+      // element.
+      assert.equal(spy.callCount, 1);
+    });
 
-        actual = builder.getContentByLine(5, 'right');
-        assert.equal(actual.textContent, diff.content[1].b[0]);
-      });
+    test('_getLineNumberEl side-by-side left', () => {
+      const contentEl = builder.getContentByLine(5, 'left',
+          element.$.diffTable);
+      const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
+      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl.classList.contains('left'));
+    });
 
-      test('findLinesByRange', () => {
-        const lines = [];
-        const elems = [];
-        const start = 6;
-        const end = 10;
-        const count = end - start + 1;
+    test('_getLineNumberEl side-by-side right', () => {
+      const contentEl = builder.getContentByLine(5, 'right',
+          element.$.diffTable);
+      const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
+      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl.classList.contains('right'));
+    });
 
-        builder.findLinesByRange(start, end, 'right', lines, elems);
+    test('_getLineNumberEl unified left', done => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
 
-        assert.equal(lines.length, count);
-        assert.equal(elems.length, count);
-
-        for (let i = 0; i < 5; i++) {
-          assert.instanceOf(lines[i], GrDiffLine);
-          assert.equal(lines[i].afterNumber, start + i);
-          assert.instanceOf(elems[i], HTMLElement);
-          assert.equal(lines[i].text, elems[i].textContent);
-        }
-      });
-
-      test('_renderContentByRange', () => {
-        const spy = sandbox.spy(builder, '_createTextEl');
-        const start = 9;
-        const end = 14;
-        const count = end - start + 1;
-
-        builder._renderContentByRange(start, end, 'left');
-
-        assert.equal(spy.callCount, count);
-        spy.getCalls().forEach((call, i) => {
-          assert.equal(call.args[1].beforeNumber, start + i);
-        });
-      });
-
-      test('_renderContentByRange notexistent elements', () => {
-        const spy = sandbox.spy(builder, '_createTextEl');
-
-        sandbox.stub(builder, 'findLinesByRange',
-            (s, e, d, lines, elements) => {
-              // Add a line and a corresponding element.
-              lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
-              const tr = document.createElement('tr');
-              const td = document.createElement('td');
-              const el = document.createElement('div');
-              tr.appendChild(td);
-              td.appendChild(el);
-              elements.push(el);
-
-              // Add 2 lines without corresponding elements.
-              lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
-              lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
-            });
-
-        builder._renderContentByRange(1, 10, 'left');
-        // Should be called only once because only one line had a corresponding
-        // element.
-        assert.equal(spy.callCount, 1);
-      });
-
-      test('_getLineNumberEl side-by-side left', () => {
         const contentEl = builder.getContentByLine(5, 'left',
             element.$.diffTable);
         const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
         assert.isTrue(lineNumberEl.classList.contains('lineNum'));
         assert.isTrue(lineNumberEl.classList.contains('left'));
+        done();
       });
+    });
 
-      test('_getLineNumberEl side-by-side right', () => {
+    test('_getLineNumberEl unified right', done => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+
         const contentEl = builder.getContentByLine(5, 'right',
             element.$.diffTable);
         const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
         assert.isTrue(lineNumberEl.classList.contains('lineNum'));
         assert.isTrue(lineNumberEl.classList.contains('right'));
+        done();
       });
+    });
 
-      test('_getLineNumberEl unified left', done => {
-        // Re-render as unified:
-        element.viewMode = 'UNIFIED_DIFF';
-        element.render(keyLocations, prefs).then(() => {
-          builder = element._builder;
+    test('_getNextContentOnSide side-by-side left', () => {
+      const startElem = builder.getContentByLine(5, 'left',
+          element.$.diffTable);
+      const expectedStartString = diff.content[2].ab[0];
+      const expectedNextString = diff.content[2].ab[1];
+      assert.equal(startElem.textContent, expectedStartString);
 
-          const contentEl = builder.getContentByLine(5, 'left',
-              element.$.diffTable);
-          const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
-          assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-          assert.isTrue(lineNumberEl.classList.contains('left'));
-          done();
-        });
-      });
+      const nextElem = builder._getNextContentOnSide(startElem,
+          'left');
+      assert.equal(nextElem.textContent, expectedNextString);
+    });
 
-      test('_getLineNumberEl unified right', done => {
-        // Re-render as unified:
-        element.viewMode = 'UNIFIED_DIFF';
-        element.render(keyLocations, prefs).then(() => {
-          builder = element._builder;
+    test('_getNextContentOnSide side-by-side right', () => {
+      const startElem = builder.getContentByLine(5, 'right',
+          element.$.diffTable);
+      const expectedStartString = diff.content[1].b[0];
+      const expectedNextString = diff.content[1].b[1];
+      assert.equal(startElem.textContent, expectedStartString);
 
-          const contentEl = builder.getContentByLine(5, 'right',
-              element.$.diffTable);
-          const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
-          assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-          assert.isTrue(lineNumberEl.classList.contains('right'));
-          done();
-        });
-      });
+      const nextElem = builder._getNextContentOnSide(startElem,
+          'right');
+      assert.equal(nextElem.textContent, expectedNextString);
+    });
 
-      test('_getNextContentOnSide side-by-side left', () => {
+    test('_getNextContentOnSide unified left', done => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+
         const startElem = builder.getContentByLine(5, 'left',
             element.$.diffTable);
         const expectedStartString = diff.content[2].ab[0];
@@ -1047,9 +1098,17 @@
         const nextElem = builder._getNextContentOnSide(startElem,
             'left');
         assert.equal(nextElem.textContent, expectedNextString);
-      });
 
-      test('_getNextContentOnSide side-by-side right', () => {
+        done();
+      });
+    });
+
+    test('_getNextContentOnSide unified right', done => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+
         const startElem = builder.getContentByLine(5, 'right',
             element.$.diffTable);
         const expectedStartString = diff.content[1].b[0];
@@ -1059,136 +1118,99 @@
         const nextElem = builder._getNextContentOnSide(startElem,
             'right');
         assert.equal(nextElem.textContent, expectedNextString);
-      });
 
-      test('_getNextContentOnSide unified left', done => {
-        // Re-render as unified:
-        element.viewMode = 'UNIFIED_DIFF';
-        element.render(keyLocations, prefs).then(() => {
-          builder = element._builder;
-
-          const startElem = builder.getContentByLine(5, 'left',
-              element.$.diffTable);
-          const expectedStartString = diff.content[2].ab[0];
-          const expectedNextString = diff.content[2].ab[1];
-          assert.equal(startElem.textContent, expectedStartString);
-
-          const nextElem = builder._getNextContentOnSide(startElem,
-              'left');
-          assert.equal(nextElem.textContent, expectedNextString);
-
-          done();
-        });
-      });
-
-      test('_getNextContentOnSide unified right', done => {
-        // Re-render as unified:
-        element.viewMode = 'UNIFIED_DIFF';
-        element.render(keyLocations, prefs).then(() => {
-          builder = element._builder;
-
-          const startElem = builder.getContentByLine(5, 'right',
-              element.$.diffTable);
-          const expectedStartString = diff.content[1].b[0];
-          const expectedNextString = diff.content[1].b[1];
-          assert.equal(startElem.textContent, expectedStartString);
-
-          const nextElem = builder._getNextContentOnSide(startElem,
-              'right');
-          assert.equal(nextElem.textContent, expectedNextString);
-
-          done();
-        });
-      });
-
-      test('escaping HTML', () => {
-        let input = '<script>alert("XSS");<' + '/script>';
-        let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
-        let result = builder._formatText(input, 1, Infinity).innerHTML;
-        assert.equal(result, expected);
-
-        input = '& < > " \' / `';
-        expected = '&amp; &lt; &gt; " \' / `';
-        result = builder._formatText(input, 1, Infinity).innerHTML;
-        assert.equal(result, expected);
+        done();
       });
     });
 
-    suite('blame', () => {
-      let mockBlame;
+    test('escaping HTML', () => {
+      let input = '<script>alert("XSS");<' + '/script>';
+      let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
+      let result = builder._formatText(input, 1, Infinity).innerHTML;
+      assert.equal(result, expected);
 
-      setup(() => {
-        mockBlame = [
-          {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
-          {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
-        ];
-      });
-
-      test('setBlame attempts to render each blamed line', () => {
-        const getBlameStub = sandbox.stub(builder, '_getBlameByLineNum')
-            .returns(null);
-        builder.setBlame(mockBlame);
-        assert.equal(getBlameStub.callCount, 32);
-      });
-
-      test('_getBlameCommitForBaseLine', () => {
-        builder.setBlame(mockBlame);
-        assert.isOk(builder._getBlameCommitForBaseLine(1));
-        assert.equal(builder._getBlameCommitForBaseLine(1).id, 'commit 1');
-
-        assert.isOk(builder._getBlameCommitForBaseLine(11));
-        assert.equal(builder._getBlameCommitForBaseLine(11).id, 'commit 1');
-
-        assert.isOk(builder._getBlameCommitForBaseLine(32));
-        assert.equal(builder._getBlameCommitForBaseLine(32).id, 'commit 2');
-
-        assert.isNull(builder._getBlameCommitForBaseLine(33));
-      });
-
-      test('_getBlameCommitForBaseLine w/o blame returns null', () => {
-        assert.isNull(builder._getBlameCommitForBaseLine(1));
-        assert.isNull(builder._getBlameCommitForBaseLine(11));
-        assert.isNull(builder._getBlameCommitForBaseLine(31));
-      });
-
-      test('_createBlameCell', () => {
-        const mocbBlameCell = document.createElement('span');
-        const getBlameStub = sinon.stub(builder, '_getBlameForBaseLine')
-            .returns(mocbBlameCell);
-        const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-        line.beforeNumber = 3;
-        line.afterNumber = 5;
-
-        const result = builder._createBlameCell(line);
-
-        assert.isTrue(getBlameStub.calledWithExactly(3));
-        assert.equal(result.getAttribute('data-line-number'), '3');
-        assert.equal(result.firstChild, mocbBlameCell);
-      });
-
-      test('_getBlameForBaseLine', () => {
-        const mockCommit = {
-          time: 1576105200,
-          id: 1234567890,
-          author: 'Clark Kent',
-          commit_msg: 'Testing Commit',
-          ranges: [1],
-        };
-        const blameNode = builder._getBlameForBaseLine(1, mockCommit);
-
-        const authors = blameNode.getElementsByClassName('blameAuthor');
-        assert.equal(authors.length, 1);
-        assert.equal(authors[0].innerText, ' Clark');
-
-        const date = (new Date(mockCommit.time * 1000)).toLocaleDateString();
-        Polymer.dom.flush();
-        const cards = blameNode.getElementsByClassName('blameHoverCard');
-        assert.equal(cards.length, 1);
-        assert.equal(cards[0].innerHTML,
-            `Commit 1234567890<br>Author: Clark Kent<br>Date: ${date}`
-          + '<br><br>Testing Commit'
-        );
-      });
+      input = '& < > " \' / `';
+      expected = '&amp; &lt; &gt; " \' / `';
+      result = builder._formatText(input, 1, Infinity).innerHTML;
+      assert.equal(result, expected);
     });
   });
+
+  suite('blame', () => {
+    let mockBlame;
+
+    setup(() => {
+      mockBlame = [
+        {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
+        {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
+      ];
+    });
+
+    test('setBlame attempts to render each blamed line', () => {
+      const getBlameStub = sandbox.stub(builder, '_getBlameByLineNum')
+          .returns(null);
+      builder.setBlame(mockBlame);
+      assert.equal(getBlameStub.callCount, 32);
+    });
+
+    test('_getBlameCommitForBaseLine', () => {
+      builder.setBlame(mockBlame);
+      assert.isOk(builder._getBlameCommitForBaseLine(1));
+      assert.equal(builder._getBlameCommitForBaseLine(1).id, 'commit 1');
+
+      assert.isOk(builder._getBlameCommitForBaseLine(11));
+      assert.equal(builder._getBlameCommitForBaseLine(11).id, 'commit 1');
+
+      assert.isOk(builder._getBlameCommitForBaseLine(32));
+      assert.equal(builder._getBlameCommitForBaseLine(32).id, 'commit 2');
+
+      assert.isNull(builder._getBlameCommitForBaseLine(33));
+    });
+
+    test('_getBlameCommitForBaseLine w/o blame returns null', () => {
+      assert.isNull(builder._getBlameCommitForBaseLine(1));
+      assert.isNull(builder._getBlameCommitForBaseLine(11));
+      assert.isNull(builder._getBlameCommitForBaseLine(31));
+    });
+
+    test('_createBlameCell', () => {
+      const mocbBlameCell = document.createElement('span');
+      const getBlameStub = sinon.stub(builder, '_getBlameForBaseLine')
+          .returns(mocbBlameCell);
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      const result = builder._createBlameCell(line);
+
+      assert.isTrue(getBlameStub.calledWithExactly(3));
+      assert.equal(result.getAttribute('data-line-number'), '3');
+      assert.equal(result.firstChild, mocbBlameCell);
+    });
+
+    test('_getBlameForBaseLine', () => {
+      const mockCommit = {
+        time: 1576105200,
+        id: 1234567890,
+        author: 'Clark Kent',
+        commit_msg: 'Testing Commit',
+        ranges: [1],
+      };
+      const blameNode = builder._getBlameForBaseLine(1, mockCommit);
+
+      const authors = blameNode.getElementsByClassName('blameAuthor');
+      assert.equal(authors.length, 1);
+      assert.equal(authors[0].innerText, ' Clark');
+
+      const date = (new Date(mockCommit.time * 1000)).toLocaleDateString();
+      flush();
+      const cards = blameNode.getElementsByClassName('blameHoverCard');
+      assert.equal(cards.length, 1);
+      assert.equal(cards[0].innerHTML,
+          `Commit 1234567890<br>Author: Clark Kent<br>Date: ${date}`
+        + '<br><br>Testing Commit'
+      );
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html
index 4f0a94f..0ce9a42 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html
@@ -19,189 +19,206 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>GrDiffBuilderUnified</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
-<script src="../gr-diff/gr-diff-line.js"></script>
-<script src="../gr-diff/gr-diff-group.js"></script>
-<script src="../gr-diff-highlight/gr-annotation.js"></script>
-<script src="gr-diff-builder.js"></script>
-<script src="gr-diff-builder-unified.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
+<script type="module" src="../gr-diff/gr-diff-line.js"></script>
+<script type="module" src="../gr-diff/gr-diff-group.js"></script>
+<script type="module" src="../gr-diff-highlight/gr-annotation.js"></script>
+<script type="module" src="./gr-diff-builder.js"></script>
+<script type="module" src="./gr-diff-builder-unified.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import '../gr-diff/gr-diff-line.js';
+import '../gr-diff/gr-diff-group.js';
+import '../gr-diff-highlight/gr-annotation.js';
+import './gr-diff-builder.js';
+import './gr-diff-builder-unified.js';
+void(0);
+</script>
 
-<script>
-  suite('GrDiffBuilderUnified tests', async () => {
-    await readyToTest();
-    let prefs;
-    let outputEl;
-    let diffBuilder;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import '../gr-diff/gr-diff-line.js';
+import '../gr-diff/gr-diff-group.js';
+import '../gr-diff-highlight/gr-annotation.js';
+import './gr-diff-builder.js';
+import './gr-diff-builder-unified.js';
+suite('GrDiffBuilderUnified tests', () => {
+  let prefs;
+  let outputEl;
+  let diffBuilder;
 
-    setup(()=> {
-      prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-      };
-      outputEl = document.createElement('div');
-      diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
+  setup(()=> {
+    prefs = {
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    outputEl = document.createElement('div');
+    diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
+  });
+
+  suite('buildSectionElement for BOTH group', () => {
+    let lines;
+    let group;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLine.Type.BOTH, 1, 2),
+        new GrDiffLine(GrDiffLine.Type.BOTH, 2, 3),
+        new GrDiffLine(GrDiffLine.Type.BOTH, 3, 4),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World";';
+      lines[2].text = '  return True';
+
+      group = new GrDiffGroup(GrDiffGroup.Type.BOTH, lines);
     });
 
-    suite('buildSectionElement for BOTH group', () => {
-      let lines;
-      let group;
-
-      setup(() => {
-        lines = [
-          new GrDiffLine(GrDiffLine.Type.BOTH, 1, 2),
-          new GrDiffLine(GrDiffLine.Type.BOTH, 2, 3),
-          new GrDiffLine(GrDiffLine.Type.BOTH, 3, 4),
-        ];
-        lines[0].text = 'def hello_world():';
-        lines[1].text = '  print "Hello World";';
-        lines[2].text = '  return True';
-
-        group = new GrDiffGroup(GrDiffGroup.Type.BOTH, lines);
-      });
-
-      test('creates the section', () => {
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        assert.isTrue(sectionEl.classList.contains('section'));
-        assert.isTrue(sectionEl.classList.contains('both'));
-      });
-
-      test('creates each unchanged row once', () => {
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-        assert.equal(rowEls.length, 3);
-
-        assert.equal(
-            rowEls[0].querySelector('.lineNum.left').textContent,
-            lines[0].beforeNumber);
-        assert.equal(
-            rowEls[0].querySelector('.lineNum.right').textContent,
-            lines[0].afterNumber);
-        assert.equal(
-            rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-        assert.equal(
-            rowEls[1].querySelector('.lineNum.left').textContent,
-            lines[1].beforeNumber);
-        assert.equal(
-            rowEls[1].querySelector('.lineNum.right').textContent,
-            lines[1].afterNumber);
-        assert.equal(
-            rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-        assert.equal(
-            rowEls[2].querySelector('.lineNum.left').textContent,
-            lines[2].beforeNumber);
-        assert.equal(
-            rowEls[2].querySelector('.lineNum.right').textContent,
-            lines[2].afterNumber);
-        assert.equal(
-            rowEls[2].querySelector('.content').textContent, lines[2].text);
-      });
+    test('creates the section', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('both'));
     });
 
-    suite('buildSectionElement for DELTA group', () => {
-      let lines;
-      let group;
+    test('creates each unchanged row once', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
 
-      setup(() => {
-        lines = [
-          new GrDiffLine(GrDiffLine.Type.REMOVE, 1),
-          new GrDiffLine(GrDiffLine.Type.REMOVE, 2),
-          new GrDiffLine(GrDiffLine.Type.ADD, 2),
-          new GrDiffLine(GrDiffLine.Type.ADD, 3),
-        ];
-        lines[0].text = 'def hello_world():';
-        lines[1].text = '  print "Hello World"';
-        lines[2].text = 'def hello_universe()';
-        lines[3].text = '  print "Hello Universe"';
+      assert.equal(rowEls.length, 3);
 
-        group = new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
-      });
+      assert.equal(
+          rowEls[0].querySelector('.lineNum.left').textContent,
+          lines[0].beforeNumber);
+      assert.equal(
+          rowEls[0].querySelector('.lineNum.right').textContent,
+          lines[0].afterNumber);
+      assert.equal(
+          rowEls[0].querySelector('.content').textContent, lines[0].text);
 
-      test('creates the section', () => {
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        assert.isTrue(sectionEl.classList.contains('section'));
-        assert.isTrue(sectionEl.classList.contains('delta'));
-      });
+      assert.equal(
+          rowEls[1].querySelector('.lineNum.left').textContent,
+          lines[1].beforeNumber);
+      assert.equal(
+          rowEls[1].querySelector('.lineNum.right').textContent,
+          lines[1].afterNumber);
+      assert.equal(
+          rowEls[1].querySelector('.content').textContent, lines[1].text);
 
-      test('creates the section with class if ignoredWhitespaceOnly', () => {
-        group.ignoredWhitespaceOnly = true;
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
-      });
-
-      test('creates the section with class if dueToRebase', () => {
-        group.dueToRebase = true;
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        assert.isTrue(sectionEl.classList.contains('dueToRebase'));
-      });
-
-      test('creates first the removed and then the added rows', () => {
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-        assert.equal(rowEls.length, 4);
-
-        assert.equal(
-            rowEls[0].querySelector('.lineNum.left').textContent,
-            lines[0].beforeNumber);
-        assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
-        assert.equal(
-            rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-        assert.equal(
-            rowEls[1].querySelector('.lineNum.left').textContent,
-            lines[1].beforeNumber);
-        assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
-        assert.equal(
-            rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-        assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
-        assert.equal(
-            rowEls[2].querySelector('.lineNum.right').textContent,
-            lines[2].afterNumber);
-        assert.equal(
-            rowEls[2].querySelector('.content').textContent, lines[2].text);
-
-        assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
-        assert.equal(
-            rowEls[3].querySelector('.lineNum.right').textContent,
-            lines[3].afterNumber);
-        assert.equal(
-            rowEls[3].querySelector('.content').textContent, lines[3].text);
-      });
-
-      test('creates only the added rows if only ignored whitespace', () => {
-        group.ignoredWhitespaceOnly = true;
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-        assert.equal(rowEls.length, 2);
-
-        assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
-        assert.equal(
-            rowEls[0].querySelector('.lineNum.right').textContent,
-            lines[2].afterNumber);
-        assert.equal(
-            rowEls[0].querySelector('.content').textContent, lines[2].text);
-
-        assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
-        assert.equal(
-            rowEls[1].querySelector('.lineNum.right').textContent,
-            lines[3].afterNumber);
-        assert.equal(
-            rowEls[1].querySelector('.content').textContent, lines[3].text);
-      });
+      assert.equal(
+          rowEls[2].querySelector('.lineNum.left').textContent,
+          lines[2].beforeNumber);
+      assert.equal(
+          rowEls[2].querySelector('.lineNum.right').textContent,
+          lines[2].afterNumber);
+      assert.equal(
+          rowEls[2].querySelector('.content').textContent, lines[2].text);
     });
   });
+
+  suite('buildSectionElement for DELTA group', () => {
+    let lines;
+    let group;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLine.Type.REMOVE, 1),
+        new GrDiffLine(GrDiffLine.Type.REMOVE, 2),
+        new GrDiffLine(GrDiffLine.Type.ADD, 2),
+        new GrDiffLine(GrDiffLine.Type.ADD, 3),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      lines[2].text = 'def hello_universe()';
+      lines[3].text = '  print "Hello Universe"';
+
+      group = new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
+    });
+
+    test('creates the section', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('delta'));
+    });
+
+    test('creates the section with class if ignoredWhitespaceOnly', () => {
+      group.ignoredWhitespaceOnly = true;
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
+    });
+
+    test('creates the section with class if dueToRebase', () => {
+      group.dueToRebase = true;
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
+    });
+
+    test('creates first the removed and then the added rows', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 4);
+
+      assert.equal(
+          rowEls[0].querySelector('.lineNum.left').textContent,
+          lines[0].beforeNumber);
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
+      assert.equal(
+          rowEls[0].querySelector('.content').textContent, lines[0].text);
+
+      assert.equal(
+          rowEls[1].querySelector('.lineNum.left').textContent,
+          lines[1].beforeNumber);
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
+      assert.equal(
+          rowEls[1].querySelector('.content').textContent, lines[1].text);
+
+      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
+      assert.equal(
+          rowEls[2].querySelector('.lineNum.right').textContent,
+          lines[2].afterNumber);
+      assert.equal(
+          rowEls[2].querySelector('.content').textContent, lines[2].text);
+
+      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
+      assert.equal(
+          rowEls[3].querySelector('.lineNum.right').textContent,
+          lines[3].afterNumber);
+      assert.equal(
+          rowEls[3].querySelector('.content').textContent, lines[3].text);
+    });
+
+    test('creates only the added rows if only ignored whitespace', () => {
+      group.ignoredWhitespaceOnly = true;
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 2);
+
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
+      assert.equal(
+          rowEls[0].querySelector('.lineNum.right').textContent,
+          lines[2].afterNumber);
+      assert.equal(
+          rowEls[0].querySelector('.content').textContent, lines[2].text);
+
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
+      assert.equal(
+          rowEls[1].querySelector('.lineNum.right').textContent,
+          lines[3].afterNumber);
+      assert.equal(
+          rowEls[1].querySelector('.content').textContent, lines[3].text);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index 87152d8..92ea310 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -14,485 +14,494 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const DiffSides = {
-    LEFT: 'left',
-    RIGHT: 'right',
-  };
+import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-cursor_html.js';
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+const DiffSides = {
+  LEFT: 'left',
+  RIGHT: 'right',
+};
 
-  const ScrollBehavior = {
-    KEEP_VISIBLE: 'keep-visible',
-    NEVER: 'never',
-  };
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
 
-  const LEFT_SIDE_CLASS = 'target-side-left';
-  const RIGHT_SIDE_CLASS = 'target-side-right';
+const ScrollBehavior = {
+  KEEP_VISIBLE: 'keep-visible',
+  NEVER: 'never',
+};
 
-  /** @extends Polymer.Element */
-  class GrDiffCursor extends Polymer.mixinBehaviors([Gerrit.FireBehavior],
-      Polymer.GestureEventListeners(
-          Polymer.LegacyElementMixin(Polymer.Element))) {
-    static get is() { return 'gr-diff-cursor'; }
+const LEFT_SIDE_CLASS = 'target-side-left';
+const RIGHT_SIDE_CLASS = 'target-side-right';
 
-    static get properties() {
-      return {
+/** @extends Polymer.Element */
+class GrDiffCursor extends mixinBehaviors([Gerrit.FireBehavior],
+    GestureEventListeners(
+        LegacyElementMixin(PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-cursor'; }
+
+  static get properties() {
+    return {
+    /**
+     * Either DiffSides.LEFT or DiffSides.RIGHT.
+     */
+      side: {
+        type: String,
+        value: DiffSides.RIGHT,
+      },
+      /** @type {!HTMLElement|undefined} */
+      diffRow: {
+        type: Object,
+        notify: true,
+        observer: '_rowChanged',
+      },
+
       /**
-       * Either DiffSides.LEFT or DiffSides.RIGHT.
+       * The diff views to cursor through and listen to.
        */
-        side: {
-          type: String,
-          value: DiffSides.RIGHT,
-        },
-        /** @type {!HTMLElement|undefined} */
-        diffRow: {
-          type: Object,
-          notify: true,
-          observer: '_rowChanged',
-        },
+      diffs: {
+        type: Array,
+        value() { return []; },
+      },
 
-        /**
-         * The diff views to cursor through and listen to.
-         */
-        diffs: {
-          type: Array,
-          value() { return []; },
-        },
+      /**
+       * If set, the cursor will attempt to move to the line number (instead of
+       * the first chunk) the next time the diff renders. It is set back to null
+       * when used. It should be only used if you want the line to be focused
+       * after initialization of the component and page should scroll
+       * to that position. This parameter should be set at most for one gr-diff
+       * element in the page.
+       *
+       * @type {?number}
+       */
+      initialLineNumber: {
+        type: Number,
+        value: null,
+      },
 
-        /**
-         * If set, the cursor will attempt to move to the line number (instead of
-         * the first chunk) the next time the diff renders. It is set back to null
-         * when used. It should be only used if you want the line to be focused
-         * after initialization of the component and page should scroll
-         * to that position. This parameter should be set at most for one gr-diff
-         * element in the page.
-         *
-         * @type {?number}
-         */
-        initialLineNumber: {
-          type: Number,
-          value: null,
-        },
+      /**
+       * The scroll behavior for the cursor. Values are 'never' and
+       * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+       * the viewport.
+       */
+      _scrollBehavior: {
+        type: String,
+        value: ScrollBehavior.KEEP_VISIBLE,
+      },
 
-        /**
-         * The scroll behavior for the cursor. Values are 'never' and
-         * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
-         * the viewport.
-         */
-        _scrollBehavior: {
-          type: String,
-          value: ScrollBehavior.KEEP_VISIBLE,
-        },
+      _focusOnMove: {
+        type: Boolean,
+        value: true,
+      },
 
-        _focusOnMove: {
-          type: Boolean,
-          value: true,
-        },
+      _listeningForScroll: Boolean,
 
-        _listeningForScroll: Boolean,
+      /**
+       * gr-diff-view has gr-fixed-panel on top. The panel can
+       * intersect a main element and partially hides a content of
+       * the main element. To correctly calculates visibility of an
+       * element, the cursor must know how much height occuped by a fixed
+       * panel.
+       * The scrollTopMargin defines margin occuped by fixed panel.
+       */
+      scrollTopMargin: {
+        type: Number,
+        value: 0,
+      },
+    };
+  }
 
-        /**
-         * gr-diff-view has gr-fixed-panel on top. The panel can
-         * intersect a main element and partially hides a content of
-         * the main element. To correctly calculates visibility of an
-         * element, the cursor must know how much height occuped by a fixed
-         * panel.
-         * The scrollTopMargin defines margin occuped by fixed panel.
-         */
-        scrollTopMargin: {
-          type: Number,
-          value: 0,
-        },
-      };
+  static get observers() {
+    return [
+      '_updateSideClass(side)',
+      '_diffsChanged(diffs.splices)',
+    ];
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    afterNextRender(this, () => {
+      /*
+      This represents the diff cursor is ready for interaction coming from
+      client components. It is more then Polymer "ready" lifecycle, as no
+      "ready" events are automatically fired by Polymer, it means
+      the cursor is completely interactable - in this case attached and
+      painted on the page. We name it "ready" instead of "rendered" as the
+      long-term goal is to make gr-diff-cursor a javascript class - not a DOM
+      element with an actual lifecycle. This will be triggered only once
+      per element.
+      */
+      this.fire('ready', null, {bubbles: false});
+    });
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    // Catch when users are scrolling as the view loads.
+    this.listen(window, 'scroll', '_handleWindowScroll');
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'scroll', '_handleWindowScroll');
+  }
+
+  moveLeft() {
+    this.side = DiffSides.LEFT;
+    if (this._isTargetBlank()) {
+      this.moveUp();
+    }
+  }
+
+  moveRight() {
+    this.side = DiffSides.RIGHT;
+    if (this._isTargetBlank()) {
+      this.moveUp();
+    }
+  }
+
+  moveDown() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.cursorManager.next(this._rowHasSide.bind(this));
+    } else {
+      this.$.cursorManager.next();
+    }
+  }
+
+  moveUp() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.cursorManager.previous(this._rowHasSide.bind(this));
+    } else {
+      this.$.cursorManager.previous();
+    }
+  }
+
+  moveToVisibleArea() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.cursorManager.moveToVisibleArea(
+          this._rowHasSide.bind(this));
+    } else {
+      this.$.cursorManager.moveToVisibleArea();
+    }
+  }
+
+  moveToNextChunk(opt_clipToTop) {
+    this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
+        target => target.parentNode.scrollHeight, opt_clipToTop);
+    this._fixSide();
+  }
+
+  moveToPreviousChunk() {
+    this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
+    this._fixSide();
+  }
+
+  moveToNextCommentThread() {
+    this.$.cursorManager.next(this._rowHasThread.bind(this));
+    this._fixSide();
+  }
+
+  moveToPreviousCommentThread() {
+    this.$.cursorManager.previous(this._rowHasThread.bind(this));
+    this._fixSide();
+  }
+
+  /**
+   * @param {number} number
+   * @param {string} side
+   * @param {string=} opt_path
+   */
+  moveToLineNumber(number, side, opt_path) {
+    const row = this._findRowByNumberAndFile(number, side, opt_path);
+    if (row) {
+      this.side = side;
+      this.$.cursorManager.setCursor(row);
+    }
+  }
+
+  /**
+   * Get the line number element targeted by the cursor row and side.
+   *
+   * @return {?Element|undefined}
+   */
+  getTargetLineElement() {
+    let lineElSelector = '.lineNum';
+
+    if (!this.diffRow) {
+      return;
     }
 
-    static get observers() {
-      return [
-        '_updateSideClass(side)',
-        '_diffsChanged(diffs.splices)',
-      ];
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      lineElSelector += this.side === DiffSides.LEFT ? '.left' : '.right';
     }
 
-    /** @override */
-    ready() {
-      super.ready();
-      Polymer.RenderStatus.afterNextRender(this, () => {
-        /*
-        This represents the diff cursor is ready for interaction coming from
-        client components. It is more then Polymer "ready" lifecycle, as no
-        "ready" events are automatically fired by Polymer, it means
-        the cursor is completely interactable - in this case attached and
-        painted on the page. We name it "ready" instead of "rendered" as the
-        long-term goal is to make gr-diff-cursor a javascript class - not a DOM
-        element with an actual lifecycle. This will be triggered only once
-        per element.
-        */
-        this.fire('ready', null, {bubbles: false});
-      });
+    return this.diffRow.querySelector(lineElSelector);
+  }
+
+  getTargetDiffElement() {
+    if (!this.diffRow) return null;
+
+    const hostOwner = dom( (this.diffRow))
+        .getOwnerRoot();
+    if (hostOwner && hostOwner.host &&
+        hostOwner.host.tagName === 'GR-DIFF') {
+      return hostOwner.host;
     }
+    return null;
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      // Catch when users are scrolling as the view loads.
-      this.listen(window, 'scroll', '_handleWindowScroll');
+  moveToFirstChunk() {
+    this.$.cursorManager.moveToStart();
+    this.moveToNextChunk(true);
+  }
+
+  moveToLastChunk() {
+    this.$.cursorManager.moveToEnd();
+    this.moveToPreviousChunk();
+  }
+
+  reInitCursor() {
+    this._updateStops();
+    if (this.initialLineNumber) {
+      this.moveToLineNumber(this.initialLineNumber, this.side);
+      this.initialLineNumber = null;
+    } else {
+      this.moveToFirstChunk();
     }
+  }
 
-    /** @override */
-    detached() {
-      super.detached();
-      this.unlisten(window, 'scroll', '_handleWindowScroll');
-    }
-
-    moveLeft() {
-      this.side = DiffSides.LEFT;
-      if (this._isTargetBlank()) {
-        this.moveUp();
-      }
-    }
-
-    moveRight() {
-      this.side = DiffSides.RIGHT;
-      if (this._isTargetBlank()) {
-        this.moveUp();
-      }
-    }
-
-    moveDown() {
-      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-        this.$.cursorManager.next(this._rowHasSide.bind(this));
-      } else {
-        this.$.cursorManager.next();
-      }
-    }
-
-    moveUp() {
-      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-        this.$.cursorManager.previous(this._rowHasSide.bind(this));
-      } else {
-        this.$.cursorManager.previous();
-      }
-    }
-
-    moveToVisibleArea() {
-      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-        this.$.cursorManager.moveToVisibleArea(
-            this._rowHasSide.bind(this));
-      } else {
-        this.$.cursorManager.moveToVisibleArea();
-      }
-    }
-
-    moveToNextChunk(opt_clipToTop) {
-      this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
-          target => target.parentNode.scrollHeight, opt_clipToTop);
-      this._fixSide();
-    }
-
-    moveToPreviousChunk() {
-      this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
-      this._fixSide();
-    }
-
-    moveToNextCommentThread() {
-      this.$.cursorManager.next(this._rowHasThread.bind(this));
-      this._fixSide();
-    }
-
-    moveToPreviousCommentThread() {
-      this.$.cursorManager.previous(this._rowHasThread.bind(this));
-      this._fixSide();
-    }
-
-    /**
-     * @param {number} number
-     * @param {string} side
-     * @param {string=} opt_path
-     */
-    moveToLineNumber(number, side, opt_path) {
-      const row = this._findRowByNumberAndFile(number, side, opt_path);
-      if (row) {
-        this.side = side;
-        this.$.cursorManager.setCursor(row);
-      }
-    }
-
-    /**
-     * Get the line number element targeted by the cursor row and side.
-     *
-     * @return {?Element|undefined}
-     */
-    getTargetLineElement() {
-      let lineElSelector = '.lineNum';
-
-      if (!this.diffRow) {
-        return;
-      }
-
-      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-        lineElSelector += this.side === DiffSides.LEFT ? '.left' : '.right';
-      }
-
-      return this.diffRow.querySelector(lineElSelector);
-    }
-
-    getTargetDiffElement() {
-      if (!this.diffRow) return null;
-
-      const hostOwner = Polymer.dom(/** @type {Node} */ (this.diffRow))
-          .getOwnerRoot();
-      if (hostOwner && hostOwner.host &&
-          hostOwner.host.tagName === 'GR-DIFF') {
-        return hostOwner.host;
-      }
-      return null;
-    }
-
-    moveToFirstChunk() {
-      this.$.cursorManager.moveToStart();
-      this.moveToNextChunk(true);
-    }
-
-    moveToLastChunk() {
-      this.$.cursorManager.moveToEnd();
-      this.moveToPreviousChunk();
-    }
-
-    reInitCursor() {
-      this._updateStops();
-      if (this.initialLineNumber) {
-        this.moveToLineNumber(this.initialLineNumber, this.side);
-        this.initialLineNumber = null;
-      } else {
-        this.moveToFirstChunk();
-      }
-    }
-
-    _handleWindowScroll() {
-      if (this._listeningForScroll) {
-        this._scrollBehavior = ScrollBehavior.NEVER;
-        this._focusOnMove = false;
-        this._listeningForScroll = false;
-      }
-    }
-
-    handleDiffUpdate() {
-      this._updateStops();
-      if (!this.diffRow) {
-        // does not scroll during init unless requested
-        const scrollingBehaviorForInit = this.initialLineNumber ?
-          ScrollBehavior.KEEP_VISIBLE :
-          ScrollBehavior.NEVER;
-        this._scrollBehavior = scrollingBehaviorForInit;
-        this.reInitCursor();
-      }
-      this._scrollBehavior = ScrollBehavior.KEEP_VISIBLE;
-      this._focusOnMove = true;
+  _handleWindowScroll() {
+    if (this._listeningForScroll) {
+      this._scrollBehavior = ScrollBehavior.NEVER;
+      this._focusOnMove = false;
       this._listeningForScroll = false;
     }
+  }
 
-    _handleDiffRenderStart() {
-      this._listeningForScroll = true;
+  handleDiffUpdate() {
+    this._updateStops();
+    if (!this.diffRow) {
+      // does not scroll during init unless requested
+      const scrollingBehaviorForInit = this.initialLineNumber ?
+        ScrollBehavior.KEEP_VISIBLE :
+        ScrollBehavior.NEVER;
+      this._scrollBehavior = scrollingBehaviorForInit;
+      this.reInitCursor();
     }
+    this._scrollBehavior = ScrollBehavior.KEEP_VISIBLE;
+    this._focusOnMove = true;
+    this._listeningForScroll = false;
+  }
 
-    createCommentInPlace() {
-      const diffWithRangeSelected = this.diffs
-          .find(diff => diff.isRangeSelected());
-      if (diffWithRangeSelected) {
-        diffWithRangeSelected.createRangeComment();
-      } else {
-        const line = this.getTargetLineElement();
-        if (line) {
-          this.getTargetDiffElement().addDraftAtLine(line);
-        }
-      }
-    }
+  _handleDiffRenderStart() {
+    this._listeningForScroll = true;
+  }
 
-    /**
-     * Get an object describing the location of the cursor. Such as
-     * {leftSide: false, number: 123} for line 123 of the revision, or
-     * {leftSide: true, number: 321} for line 321 of the base patch.
-     * Returns null if an address is not available.
-     *
-     * @return {?Object}
-     */
-    getAddress() {
-      if (!this.diffRow) { return null; }
-
-      // Get the line-number cell targeted by the cursor. If the mode is unified
-      // then prefer the revision cell if available.
-      let cell;
-      if (this._getViewMode() === DiffViewMode.UNIFIED) {
-        cell = this.diffRow.querySelector('.lineNum.right');
-        if (!cell) {
-          cell = this.diffRow.querySelector('.lineNum.left');
-        }
-      } else {
-        cell = this.diffRow.querySelector('.lineNum.' + this.side);
-      }
-      if (!cell) { return null; }
-
-      const number = cell.getAttribute('data-value');
-      if (!number || number === 'FILE') { return null; }
-
-      return {
-        leftSide: cell.matches('.left'),
-        number: parseInt(number, 10),
-      };
-    }
-
-    _getViewMode() {
-      if (!this.diffRow) {
-        return null;
-      }
-
-      if (this.diffRow.classList.contains('side-by-side')) {
-        return DiffViewMode.SIDE_BY_SIDE;
-      } else {
-        return DiffViewMode.UNIFIED;
-      }
-    }
-
-    _rowHasSide(row) {
-      const selector = (this.side === DiffSides.LEFT ? '.left' : '.right') +
-          ' + .content';
-      return !!row.querySelector(selector);
-    }
-
-    _isFirstRowOfChunk(row) {
-      const parentClassList = row.parentNode.classList;
-      return parentClassList.contains('section') &&
-          parentClassList.contains('delta') &&
-          !row.previousSibling;
-    }
-
-    _rowHasThread(row) {
-      return row.querySelector('.thread-group');
-    }
-
-    /**
-     * If we jumped to a row where there is no content on the current side then
-     * switch to the alternate side.
-     */
-    _fixSide() {
-      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
-          this._isTargetBlank()) {
-        this.side = this.side === DiffSides.LEFT ?
-          DiffSides.RIGHT : DiffSides.LEFT;
-      }
-    }
-
-    _isTargetBlank() {
-      if (!this.diffRow) {
-        return false;
-      }
-
-      const actions = this._getActionsForRow();
-      return (this.side === DiffSides.LEFT && !actions.left) ||
-          (this.side === DiffSides.RIGHT && !actions.right);
-    }
-
-    _rowChanged(newRow, oldRow) {
-      if (oldRow) {
-        oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
-      }
-      this._updateSideClass();
-    }
-
-    _updateSideClass() {
-      if (!this.diffRow) {
-        return;
-      }
-      this.toggleClass(LEFT_SIDE_CLASS, this.side === DiffSides.LEFT,
-          this.diffRow);
-      this.toggleClass(RIGHT_SIDE_CLASS, this.side === DiffSides.RIGHT,
-          this.diffRow);
-    }
-
-    _isActionType(type) {
-      return type !== 'blank' && type !== 'contextControl';
-    }
-
-    _getActionsForRow() {
-      const actions = {left: false, right: false};
-      if (this.diffRow) {
-        actions.left = this._isActionType(
-            this.diffRow.getAttribute('left-type'));
-        actions.right = this._isActionType(
-            this.diffRow.getAttribute('right-type'));
-      }
-      return actions;
-    }
-
-    _getStops() {
-      return this.diffs.reduce(
-          (stops, diff) => stops.concat(diff.getCursorStops()), []);
-    }
-
-    _updateStops() {
-      this.$.cursorManager.stops = this._getStops();
-    }
-
-    /**
-     * Setup and tear down on-render listeners for any diffs that are added or
-     * removed from the cursor.
-     *
-     * @private
-     */
-    _diffsChanged(changeRecord) {
-      if (!changeRecord) { return; }
-
-      this._updateStops();
-
-      let splice;
-      let i;
-      for (let spliceIdx = 0;
-        changeRecord.indexSplices &&
-            spliceIdx < changeRecord.indexSplices.length;
-        spliceIdx++) {
-        splice = changeRecord.indexSplices[spliceIdx];
-
-        for (i = splice.index;
-          i < splice.index + splice.addedCount;
-          i++) {
-          this.listen(this.diffs[i], 'render-start', '_handleDiffRenderStart');
-          this.listen(this.diffs[i], 'render-content', 'handleDiffUpdate');
-        }
-
-        for (i = 0;
-          i < splice.removed && splice.removed.length;
-          i++) {
-          this.unlisten(splice.removed[i],
-              'render-start', '_handleDiffRenderStart');
-          this.unlisten(splice.removed[i],
-              'render-content', 'handleDiffUpdate');
-        }
-      }
-    }
-
-    _findRowByNumberAndFile(targetNumber, side, opt_path) {
-      let stops;
-      if (opt_path) {
-        const diff = this.diffs.filter(diff => diff.path === opt_path)[0];
-        stops = diff.getCursorStops();
-      } else {
-        stops = this.$.cursorManager.stops;
-      }
-      let selector;
-      for (let i = 0; i < stops.length; i++) {
-        selector = '.lineNum.' + side + '[data-value="' + targetNumber + '"]';
-        if (stops[i].querySelector(selector)) {
-          return stops[i];
-        }
+  createCommentInPlace() {
+    const diffWithRangeSelected = this.diffs
+        .find(diff => diff.isRangeSelected());
+    if (diffWithRangeSelected) {
+      diffWithRangeSelected.createRangeComment();
+    } else {
+      const line = this.getTargetLineElement();
+      if (line) {
+        this.getTargetDiffElement().addDraftAtLine(line);
       }
     }
   }
 
-  customElements.define(GrDiffCursor.is, GrDiffCursor);
-})();
+  /**
+   * Get an object describing the location of the cursor. Such as
+   * {leftSide: false, number: 123} for line 123 of the revision, or
+   * {leftSide: true, number: 321} for line 321 of the base patch.
+   * Returns null if an address is not available.
+   *
+   * @return {?Object}
+   */
+  getAddress() {
+    if (!this.diffRow) { return null; }
+
+    // Get the line-number cell targeted by the cursor. If the mode is unified
+    // then prefer the revision cell if available.
+    let cell;
+    if (this._getViewMode() === DiffViewMode.UNIFIED) {
+      cell = this.diffRow.querySelector('.lineNum.right');
+      if (!cell) {
+        cell = this.diffRow.querySelector('.lineNum.left');
+      }
+    } else {
+      cell = this.diffRow.querySelector('.lineNum.' + this.side);
+    }
+    if (!cell) { return null; }
+
+    const number = cell.getAttribute('data-value');
+    if (!number || number === 'FILE') { return null; }
+
+    return {
+      leftSide: cell.matches('.left'),
+      number: parseInt(number, 10),
+    };
+  }
+
+  _getViewMode() {
+    if (!this.diffRow) {
+      return null;
+    }
+
+    if (this.diffRow.classList.contains('side-by-side')) {
+      return DiffViewMode.SIDE_BY_SIDE;
+    } else {
+      return DiffViewMode.UNIFIED;
+    }
+  }
+
+  _rowHasSide(row) {
+    const selector = (this.side === DiffSides.LEFT ? '.left' : '.right') +
+        ' + .content';
+    return !!row.querySelector(selector);
+  }
+
+  _isFirstRowOfChunk(row) {
+    const parentClassList = row.parentNode.classList;
+    return parentClassList.contains('section') &&
+        parentClassList.contains('delta') &&
+        !row.previousSibling;
+  }
+
+  _rowHasThread(row) {
+    return row.querySelector('.thread-group');
+  }
+
+  /**
+   * If we jumped to a row where there is no content on the current side then
+   * switch to the alternate side.
+   */
+  _fixSide() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
+        this._isTargetBlank()) {
+      this.side = this.side === DiffSides.LEFT ?
+        DiffSides.RIGHT : DiffSides.LEFT;
+    }
+  }
+
+  _isTargetBlank() {
+    if (!this.diffRow) {
+      return false;
+    }
+
+    const actions = this._getActionsForRow();
+    return (this.side === DiffSides.LEFT && !actions.left) ||
+        (this.side === DiffSides.RIGHT && !actions.right);
+  }
+
+  _rowChanged(newRow, oldRow) {
+    if (oldRow) {
+      oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+    }
+    this._updateSideClass();
+  }
+
+  _updateSideClass() {
+    if (!this.diffRow) {
+      return;
+    }
+    this.toggleClass(LEFT_SIDE_CLASS, this.side === DiffSides.LEFT,
+        this.diffRow);
+    this.toggleClass(RIGHT_SIDE_CLASS, this.side === DiffSides.RIGHT,
+        this.diffRow);
+  }
+
+  _isActionType(type) {
+    return type !== 'blank' && type !== 'contextControl';
+  }
+
+  _getActionsForRow() {
+    const actions = {left: false, right: false};
+    if (this.diffRow) {
+      actions.left = this._isActionType(
+          this.diffRow.getAttribute('left-type'));
+      actions.right = this._isActionType(
+          this.diffRow.getAttribute('right-type'));
+    }
+    return actions;
+  }
+
+  _getStops() {
+    return this.diffs.reduce(
+        (stops, diff) => stops.concat(diff.getCursorStops()), []);
+  }
+
+  _updateStops() {
+    this.$.cursorManager.stops = this._getStops();
+  }
+
+  /**
+   * Setup and tear down on-render listeners for any diffs that are added or
+   * removed from the cursor.
+   *
+   * @private
+   */
+  _diffsChanged(changeRecord) {
+    if (!changeRecord) { return; }
+
+    this._updateStops();
+
+    let splice;
+    let i;
+    for (let spliceIdx = 0;
+      changeRecord.indexSplices &&
+          spliceIdx < changeRecord.indexSplices.length;
+      spliceIdx++) {
+      splice = changeRecord.indexSplices[spliceIdx];
+
+      for (i = splice.index;
+        i < splice.index + splice.addedCount;
+        i++) {
+        this.listen(this.diffs[i], 'render-start', '_handleDiffRenderStart');
+        this.listen(this.diffs[i], 'render-content', 'handleDiffUpdate');
+      }
+
+      for (i = 0;
+        i < splice.removed && splice.removed.length;
+        i++) {
+        this.unlisten(splice.removed[i],
+            'render-start', '_handleDiffRenderStart');
+        this.unlisten(splice.removed[i],
+            'render-content', 'handleDiffUpdate');
+      }
+    }
+  }
+
+  _findRowByNumberAndFile(targetNumber, side, opt_path) {
+    let stops;
+    if (opt_path) {
+      const diff = this.diffs.filter(diff => diff.path === opt_path)[0];
+      stops = diff.getCursorStops();
+    } else {
+      stops = this.$.cursorManager.stops;
+    }
+    let selector;
+    for (let i = 0; i < stops.length; i++) {
+      selector = '.lineNum.' + side + '[data-value="' + targetNumber + '"]';
+      if (stops[i].querySelector(selector)) {
+        return stops[i];
+      }
+    }
+  }
+}
+
+customElements.define(GrDiffCursor.is, GrDiffCursor);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.js
index 1e2d963..81e0c9b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.js
@@ -1,33 +1,21 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-
-<dom-module id="gr-diff-cursor">
-  <template>
-    <gr-cursor-manager
-        id="cursorManager"
-        scroll-behavior="[[_scrollBehavior]]"
-        cursor-target-class="target-row"
-        focus-on-move="[[_focusOnMove]]"
-        target="{{diffRow}}"
-        scroll-top-margin="[[scrollTopMargin]]"
-    ></gr-cursor-manager>
-  </template>
-  <script src="gr-diff-cursor.js"></script>
-</dom-module>
+export const htmlTemplate = html`
+    <gr-cursor-manager id="cursorManager" scroll-behavior="[[_scrollBehavior]]" cursor-target-class="target-row" focus-on-move="[[_focusOnMove]]" target="{{diffRow}}" scroll-top-margin="[[scrollTopMargin]]"></gr-cursor-manager>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index 02b1d572..40507db 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -19,20 +19,29 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-cursor</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../gr-diff/gr-diff.html">
-<link rel="import" href="./gr-diff-cursor.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
+<script type="module" src="../gr-diff/gr-diff.js"></script>
+<script type="module" src="./gr-diff-cursor.js"></script>
+<script type="module" src="../../shared/gr-rest-api-interface/gr-rest-api-interface.js"></script>
+<script type="module" src="../../shared/gr-rest-api-interface/mock-diff-response_test.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import '../gr-diff/gr-diff.js';
+import './gr-diff-cursor.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-rest-api-interface/mock-diff-response_test.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -49,54 +58,122 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-cursor tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let cursorElement;
-    let diffElement;
-    let mockDiffResponse;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import '../gr-diff/gr-diff.js';
+import './gr-diff-cursor.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-rest-api-interface/mock-diff-response_test.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-diff-cursor tests', () => {
+  let sandbox;
+  let cursorElement;
+  let diffElement;
+  let mockDiffResponse;
 
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+
+    const fixtureElems = fixture('basic');
+    mockDiffResponse = fixtureElems[0];
+    diffElement = fixtureElems[1];
+    cursorElement = fixtureElems[2];
+    const restAPI = fixtureElems[3];
+
+    // Register the diff with the cursor.
+    cursorElement.push('diffs', diffElement);
+
+    diffElement.loggedIn = false;
+    diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
+    diffElement.comments = {
+      left: [],
+      right: [],
+      meta: {patchRange: undefined},
+    };
+    const setupDone = () => {
+      cursorElement._updateStops();
+      cursorElement.moveToFirstChunk();
+      diffElement.removeEventListener('render', setupDone);
+      done();
+    };
+    diffElement.addEventListener('render', setupDone);
+
+    restAPI.getDiffPreferences().then(prefs => {
+      diffElement.prefs = prefs;
+      diffElement.diff = mockDiffResponse.diffResponse;
+    });
+  });
+
+  teardown(() => sandbox.restore());
+
+  test('diff cursor functionality (side-by-side)', () => {
+    // The cursor has been initialized to the first delta.
+    assert.isOk(cursorElement.diffRow);
+
+    const firstDeltaRow = diffElement.shadowRoot
+        .querySelector('.section.delta .diff-row');
+    assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+    cursorElement.moveDown();
+
+    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+    assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+
+    cursorElement.moveUp();
+
+    assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
+    assert.equal(cursorElement.diffRow, firstDeltaRow);
+  });
+
+  test('moveToLastChunk', () => {
+    const chunks = Array.from(dom(diffElement.root).querySelectorAll(
+        '.section.delta'));
+    assert.isAbove(chunks.length, 1);
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
+
+    cursorElement.moveToLastChunk();
+
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement),
+        chunks.length - 1);
+  });
+
+  test('cursor scroll behavior', () => {
+    cursorElement._handleDiffRenderStart();
+    assert.equal(cursorElement._scrollBehavior, 'keep-visible');
+    assert.isTrue(cursorElement._focusOnMove);
+
+    cursorElement._handleWindowScroll();
+    assert.equal(cursorElement._scrollBehavior, 'never');
+    assert.isFalse(cursorElement._focusOnMove);
+
+    cursorElement.handleDiffUpdate();
+    assert.equal(cursorElement._scrollBehavior, 'keep-visible');
+    assert.isTrue(cursorElement._focusOnMove);
+  });
+
+  suite('unified diff', () => {
     setup(done => {
-      sandbox = sinon.sandbox.create();
-
-      const fixtureElems = fixture('basic');
-      mockDiffResponse = fixtureElems[0];
-      diffElement = fixtureElems[1];
-      cursorElement = fixtureElems[2];
-      const restAPI = fixtureElems[3];
-
-      // Register the diff with the cursor.
-      cursorElement.push('diffs', diffElement);
-
-      diffElement.loggedIn = false;
-      diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
-      diffElement.comments = {
-        left: [],
-        right: [],
-        meta: {patchRange: undefined},
-      };
-      const setupDone = () => {
-        cursorElement._updateStops();
-        cursorElement.moveToFirstChunk();
-        diffElement.removeEventListener('render', setupDone);
+      // We must allow the diff to re-render after setting the viewMode.
+      const renderHandler = function() {
+        diffElement.removeEventListener('render', renderHandler);
+        cursorElement.reInitCursor();
         done();
       };
-      diffElement.addEventListener('render', setupDone);
-
-      restAPI.getDiffPreferences().then(prefs => {
-        diffElement.prefs = prefs;
-        diffElement.diff = mockDiffResponse.diffResponse;
-      });
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.viewMode = 'UNIFIED_DIFF';
     });
 
-    teardown(() => sandbox.restore());
-
-    test('diff cursor functionality (side-by-side)', () => {
+    test('diff cursor functionality (unified)', () => {
       // The cursor has been initialized to the first delta.
       assert.isOk(cursorElement.diffRow);
 
-      const firstDeltaRow = diffElement.shadowRoot
+      let firstDeltaRow = diffElement.shadowRoot
+          .querySelector('.section.delta .diff-row');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+      firstDeltaRow = diffElement.shadowRoot
           .querySelector('.section.delta .diff-row');
       assert.equal(cursorElement.diffRow, firstDeltaRow);
 
@@ -110,309 +187,248 @@
       assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
       assert.equal(cursorElement.diffRow, firstDeltaRow);
     });
+  });
 
-    test('moveToLastChunk', () => {
-      const chunks = Array.from(Polymer.dom(diffElement.root).querySelectorAll(
-          '.section.delta'));
-      assert.isAbove(chunks.length, 1);
-      assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
+  test('cursor side functionality', () => {
+    // The side only applies to side-by-side mode, which should be the default
+    // mode.
+    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
 
-      cursorElement.moveToLastChunk();
+    const firstDeltaSection = diffElement.shadowRoot
+        .querySelector('.section.delta');
+    const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
 
-      assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement),
-          chunks.length - 1);
-    });
+    // Because the first delta in this diff is on the right, it should be set
+    // to the right side.
+    assert.equal(cursorElement.side, 'right');
+    assert.equal(cursorElement.diffRow, firstDeltaRow);
+    const firstIndex = cursorElement.$.cursorManager.index;
 
-    test('cursor scroll behavior', () => {
-      cursorElement._handleDiffRenderStart();
+    // Move the side to the left. Because this delta only has a right side, we
+    // should be moved up to the previous line where there is content on the
+    // right. The previous row is part of the previous section.
+    cursorElement.moveLeft();
+
+    assert.equal(cursorElement.side, 'left');
+    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+    assert.equal(cursorElement.$.cursorManager.index, firstIndex - 1);
+    assert.equal(cursorElement.diffRow.parentElement,
+        firstDeltaSection.previousSibling);
+
+    // If we move down, we should skip everything in the first delta because
+    // we are on the left side and the first delta has no content on the left.
+    cursorElement.moveDown();
+
+    assert.equal(cursorElement.side, 'left');
+    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+    assert.isTrue(cursorElement.$.cursorManager.index > firstIndex);
+    assert.equal(cursorElement.diffRow.parentElement,
+        firstDeltaSection.nextSibling);
+  });
+
+  test('chunk skip functionality', () => {
+    const chunks = dom(diffElement.root).querySelectorAll(
+        '.section.delta');
+    const indexOfChunk = function(chunk) {
+      return Array.prototype.indexOf.call(chunks, chunk);
+    };
+
+    // We should be initialized to the first chunk. Since this chunk only has
+    // content on the right side, our side should be right.
+    let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    assert.equal(currentIndex, 0);
+    assert.equal(cursorElement.side, 'right');
+
+    // Move to the next chunk.
+    cursorElement.moveToNextChunk();
+
+    // Since this chunk only has content on the left side. we should have been
+    // automatically mvoed over.
+    const previousIndex = currentIndex;
+    currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    assert.equal(currentIndex, previousIndex + 1);
+    assert.equal(cursorElement.side, 'left');
+  });
+
+  test('initialLineNumber not provided', done => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
+    const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk',
+        () => { scrollBehaviorDuringMove = cursorElement._scrollBehavior; });
+
+    function renderHandler() {
+      diffElement.removeEventListener('render', renderHandler);
+      assert.isFalse(moveToNumStub.called);
+      assert.isTrue(moveToChunkStub.called);
+      assert.equal(scrollBehaviorDuringMove, 'never');
       assert.equal(cursorElement._scrollBehavior, 'keep-visible');
-      assert.isTrue(cursorElement._focusOnMove);
+      done();
+    }
+    diffElement.addEventListener('render', renderHandler);
+    diffElement._diffChanged(mockDiffResponse.diffResponse);
+  });
 
-      cursorElement._handleWindowScroll();
-      assert.equal(cursorElement._scrollBehavior, 'never');
-      assert.isFalse(cursorElement._focusOnMove);
-
-      cursorElement.handleDiffUpdate();
+  test('initialLineNumber provided', done => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber',
+        () => { scrollBehaviorDuringMove = cursorElement._scrollBehavior; });
+    const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
+    function renderHandler() {
+      diffElement.removeEventListener('render', renderHandler);
+      assert.isFalse(moveToChunkStub.called);
+      assert.isTrue(moveToNumStub.called);
+      assert.equal(moveToNumStub.lastCall.args[0], 10);
+      assert.equal(moveToNumStub.lastCall.args[1], 'right');
+      assert.equal(scrollBehaviorDuringMove, 'keep-visible');
       assert.equal(cursorElement._scrollBehavior, 'keep-visible');
-      assert.isTrue(cursorElement._focusOnMove);
+      done();
+    }
+    diffElement.addEventListener('render', renderHandler);
+    cursorElement.initialLineNumber = 10;
+    cursorElement.side = 'right';
+
+    diffElement._diffChanged(mockDiffResponse.diffResponse);
+  });
+
+  test('getTargetDiffElement', () => {
+    cursorElement.initialLineNumber = 1;
+    assert.isTrue(!!cursorElement.diffRow);
+    assert.equal(
+        cursorElement.getTargetDiffElement(),
+        diffElement
+    );
+  });
+
+  suite('createCommentInPlace', () => {
+    setup(() => {
+      diffElement.loggedIn = true;
     });
 
-    suite('unified diff', () => {
-      setup(done => {
-        // We must allow the diff to re-render after setting the viewMode.
-        const renderHandler = function() {
-          diffElement.removeEventListener('render', renderHandler);
-          cursorElement.reInitCursor();
-          done();
-        };
-        diffElement.addEventListener('render', renderHandler);
-        diffElement.viewMode = 'UNIFIED_DIFF';
+    test('adds new draft for selected line on the left', done => {
+      cursorElement.moveToLineNumber(2, 'left');
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side, patchNum} = e.detail;
+        assert.equal(lineNum, 2);
+        assert.equal(range, undefined);
+        assert.equal(patchNum, 1);
+        assert.equal(side, 'left');
+        done();
       });
+      cursorElement.createCommentInPlace();
+    });
 
-      test('diff cursor functionality (unified)', () => {
-        // The cursor has been initialized to the first delta.
-        assert.isOk(cursorElement.diffRow);
-
-        let firstDeltaRow = diffElement.shadowRoot
-            .querySelector('.section.delta .diff-row');
-        assert.equal(cursorElement.diffRow, firstDeltaRow);
-
-        firstDeltaRow = diffElement.shadowRoot
-            .querySelector('.section.delta .diff-row');
-        assert.equal(cursorElement.diffRow, firstDeltaRow);
-
-        cursorElement.moveDown();
-
-        assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-        assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
-
-        cursorElement.moveUp();
-
-        assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
-        assert.equal(cursorElement.diffRow, firstDeltaRow);
+    test('adds draft for selected line on the right', done => {
+      cursorElement.moveToLineNumber(4, 'right');
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side, patchNum} = e.detail;
+        assert.equal(lineNum, 4);
+        assert.equal(range, undefined);
+        assert.equal(patchNum, 2);
+        assert.equal(side, 'right');
+        done();
       });
+      cursorElement.createCommentInPlace();
     });
 
-    test('cursor side functionality', () => {
-      // The side only applies to side-by-side mode, which should be the default
-      // mode.
-      assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
-
-      const firstDeltaSection = diffElement.shadowRoot
-          .querySelector('.section.delta');
-      const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
-
-      // Because the first delta in this diff is on the right, it should be set
-      // to the right side.
-      assert.equal(cursorElement.side, 'right');
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
-      const firstIndex = cursorElement.$.cursorManager.index;
-
-      // Move the side to the left. Because this delta only has a right side, we
-      // should be moved up to the previous line where there is content on the
-      // right. The previous row is part of the previous section.
-      cursorElement.moveLeft();
-
-      assert.equal(cursorElement.side, 'left');
-      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-      assert.equal(cursorElement.$.cursorManager.index, firstIndex - 1);
-      assert.equal(cursorElement.diffRow.parentElement,
-          firstDeltaSection.previousSibling);
-
-      // If we move down, we should skip everything in the first delta because
-      // we are on the left side and the first delta has no content on the left.
-      cursorElement.moveDown();
-
-      assert.equal(cursorElement.side, 'left');
-      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-      assert.isTrue(cursorElement.$.cursorManager.index > firstIndex);
-      assert.equal(cursorElement.diffRow.parentElement,
-          firstDeltaSection.nextSibling);
-    });
-
-    test('chunk skip functionality', () => {
-      const chunks = Polymer.dom(diffElement.root).querySelectorAll(
-          '.section.delta');
-      const indexOfChunk = function(chunk) {
-        return Array.prototype.indexOf.call(chunks, chunk);
+    test('createCommentInPlace creates comment for range if selected', done => {
+      const someRange = {
+        start_line: 2,
+        start_character: 3,
+        end_line: 6,
+        end_character: 1,
       };
-
-      // We should be initialized to the first chunk. Since this chunk only has
-      // content on the right side, our side should be right.
-      let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
-      assert.equal(currentIndex, 0);
-      assert.equal(cursorElement.side, 'right');
-
-      // Move to the next chunk.
-      cursorElement.moveToNextChunk();
-
-      // Since this chunk only has content on the left side. we should have been
-      // automatically mvoed over.
-      const previousIndex = currentIndex;
-      currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
-      assert.equal(currentIndex, previousIndex + 1);
-      assert.equal(cursorElement.side, 'left');
-    });
-
-    test('initialLineNumber not provided', done => {
-      let scrollBehaviorDuringMove;
-      const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
-      const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk',
-          () => { scrollBehaviorDuringMove = cursorElement._scrollBehavior; });
-
-      function renderHandler() {
-        diffElement.removeEventListener('render', renderHandler);
-        assert.isFalse(moveToNumStub.called);
-        assert.isTrue(moveToChunkStub.called);
-        assert.equal(scrollBehaviorDuringMove, 'never');
-        assert.equal(cursorElement._scrollBehavior, 'keep-visible');
-        done();
-      }
-      diffElement.addEventListener('render', renderHandler);
-      diffElement._diffChanged(mockDiffResponse.diffResponse);
-    });
-
-    test('initialLineNumber provided', done => {
-      let scrollBehaviorDuringMove;
-      const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber',
-          () => { scrollBehaviorDuringMove = cursorElement._scrollBehavior; });
-      const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
-      function renderHandler() {
-        diffElement.removeEventListener('render', renderHandler);
-        assert.isFalse(moveToChunkStub.called);
-        assert.isTrue(moveToNumStub.called);
-        assert.equal(moveToNumStub.lastCall.args[0], 10);
-        assert.equal(moveToNumStub.lastCall.args[1], 'right');
-        assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-        assert.equal(cursorElement._scrollBehavior, 'keep-visible');
-        done();
-      }
-      diffElement.addEventListener('render', renderHandler);
-      cursorElement.initialLineNumber = 10;
-      cursorElement.side = 'right';
-
-      diffElement._diffChanged(mockDiffResponse.diffResponse);
-    });
-
-    test('getTargetDiffElement', () => {
-      cursorElement.initialLineNumber = 1;
-      assert.isTrue(!!cursorElement.diffRow);
-      assert.equal(
-          cursorElement.getTargetDiffElement(),
-          diffElement
-      );
-    });
-
-    suite('createCommentInPlace', () => {
-      setup(() => {
-        diffElement.loggedIn = true;
-      });
-
-      test('adds new draft for selected line on the left', done => {
-        cursorElement.moveToLineNumber(2, 'left');
-        diffElement.addEventListener('create-comment', e => {
-          const {lineNum, range, side, patchNum} = e.detail;
-          assert.equal(lineNum, 2);
-          assert.equal(range, undefined);
-          assert.equal(patchNum, 1);
-          assert.equal(side, 'left');
-          done();
-        });
-        cursorElement.createCommentInPlace();
-      });
-
-      test('adds draft for selected line on the right', done => {
-        cursorElement.moveToLineNumber(4, 'right');
-        diffElement.addEventListener('create-comment', e => {
-          const {lineNum, range, side, patchNum} = e.detail;
-          assert.equal(lineNum, 4);
-          assert.equal(range, undefined);
-          assert.equal(patchNum, 2);
-          assert.equal(side, 'right');
-          done();
-        });
-        cursorElement.createCommentInPlace();
-      });
-
-      test('createCommentInPlace creates comment for range if selected', done => {
-        const someRange = {
-          start_line: 2,
-          start_character: 3,
-          end_line: 6,
-          end_character: 1,
-        };
-        diffElement.$.highlights.selectedRange = {
-          side: 'right',
-          range: someRange,
-        };
-        diffElement.addEventListener('create-comment', e => {
-          const {lineNum, range, side, patchNum} = e.detail;
-          assert.equal(lineNum, 6);
-          assert.equal(range, someRange);
-          assert.equal(patchNum, 2);
-          assert.equal(side, 'right');
-          done();
-        });
-        cursorElement.createCommentInPlace();
-      });
-
-      test('createCommentInPlace ignores call if nothing is selected', () => {
-        const createRangeCommentStub = sandbox.stub(diffElement,
-            'createRangeComment');
-        const addDraftAtLineStub = sandbox.stub(diffElement, 'addDraftAtLine');
-        cursorElement.diffRow = undefined;
-        cursorElement.createCommentInPlace();
-        assert.isFalse(createRangeCommentStub.called);
-        assert.isFalse(addDraftAtLineStub.called);
-      });
-    });
-
-    test('getAddress', () => {
-      // It should initialize to the first chunk: line 5 of the revision.
-      assert.deepEqual(cursorElement.getAddress(),
-          {leftSide: false, number: 5});
-
-      // Revision line 4 is up.
-      cursorElement.moveUp();
-      assert.deepEqual(cursorElement.getAddress(),
-          {leftSide: false, number: 4});
-
-      // Base line 4 is left.
-      cursorElement.moveLeft();
-      assert.deepEqual(cursorElement.getAddress(), {leftSide: true, number: 4});
-
-      // Moving to the next chunk takes it back to the start.
-      cursorElement.moveToNextChunk();
-      assert.deepEqual(cursorElement.getAddress(),
-          {leftSide: false, number: 5});
-
-      // The following chunk is a removal starting on line 10 of the base.
-      cursorElement.moveToNextChunk();
-      assert.deepEqual(cursorElement.getAddress(),
-          {leftSide: true, number: 10});
-
-      // Should be null if there is no selection.
-      cursorElement.$.cursorManager.unsetCursor();
-      assert.isNotOk(cursorElement.getAddress());
-    });
-
-    test('_findRowByNumberAndFile', () => {
-      // Get the first ab row after the first chunk.
-      const row = Polymer.dom(diffElement.root).querySelectorAll('tr')[8];
-
-      // It should be line 8 on the right, but line 5 on the left.
-      assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
-      assert.equal(cursorElement._findRowByNumberAndFile(5, 'left'), row);
-    });
-
-    test('expand context updates stops', done => {
-      sandbox.spy(cursorElement, 'handleDiffUpdate');
-      MockInteractions.tap(diffElement.shadowRoot
-          .querySelector('.showContext'));
-      flush(() => {
-        assert.isTrue(cursorElement.handleDiffUpdate.called);
+      diffElement.$.highlights.selectedRange = {
+        side: 'right',
+        range: someRange,
+      };
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side, patchNum} = e.detail;
+        assert.equal(lineNum, 6);
+        assert.equal(range, someRange);
+        assert.equal(patchNum, 2);
+        assert.equal(side, 'right');
         done();
       });
+      cursorElement.createCommentInPlace();
     });
 
-    suite('gr-diff-cursor event tests', () => {
-      let sandbox;
-      let someEmptyDiv;
-
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-        someEmptyDiv = fixture('empty');
-      });
-
-      teardown(() => sandbox.restore());
-
-      test('ready is fired after component is rendered', done => {
-        const cursorElement = document.createElement('gr-diff-cursor');
-        cursorElement.addEventListener('ready', () => {
-          done();
-        });
-        someEmptyDiv.appendChild(cursorElement);
-      });
+    test('createCommentInPlace ignores call if nothing is selected', () => {
+      const createRangeCommentStub = sandbox.stub(diffElement,
+          'createRangeComment');
+      const addDraftAtLineStub = sandbox.stub(diffElement, 'addDraftAtLine');
+      cursorElement.diffRow = undefined;
+      cursorElement.createCommentInPlace();
+      assert.isFalse(createRangeCommentStub.called);
+      assert.isFalse(addDraftAtLineStub.called);
     });
   });
+
+  test('getAddress', () => {
+    // It should initialize to the first chunk: line 5 of the revision.
+    assert.deepEqual(cursorElement.getAddress(),
+        {leftSide: false, number: 5});
+
+    // Revision line 4 is up.
+    cursorElement.moveUp();
+    assert.deepEqual(cursorElement.getAddress(),
+        {leftSide: false, number: 4});
+
+    // Base line 4 is left.
+    cursorElement.moveLeft();
+    assert.deepEqual(cursorElement.getAddress(), {leftSide: true, number: 4});
+
+    // Moving to the next chunk takes it back to the start.
+    cursorElement.moveToNextChunk();
+    assert.deepEqual(cursorElement.getAddress(),
+        {leftSide: false, number: 5});
+
+    // The following chunk is a removal starting on line 10 of the base.
+    cursorElement.moveToNextChunk();
+    assert.deepEqual(cursorElement.getAddress(),
+        {leftSide: true, number: 10});
+
+    // Should be null if there is no selection.
+    cursorElement.$.cursorManager.unsetCursor();
+    assert.isNotOk(cursorElement.getAddress());
+  });
+
+  test('_findRowByNumberAndFile', () => {
+    // Get the first ab row after the first chunk.
+    const row = dom(diffElement.root).querySelectorAll('tr')[8];
+
+    // It should be line 8 on the right, but line 5 on the left.
+    assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
+    assert.equal(cursorElement._findRowByNumberAndFile(5, 'left'), row);
+  });
+
+  test('expand context updates stops', done => {
+    sandbox.spy(cursorElement, 'handleDiffUpdate');
+    MockInteractions.tap(diffElement.shadowRoot
+        .querySelector('.showContext'));
+    flush(() => {
+      assert.isTrue(cursorElement.handleDiffUpdate.called);
+      done();
+    });
+  });
+
+  suite('gr-diff-cursor event tests', () => {
+    let sandbox;
+    let someEmptyDiv;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      someEmptyDiv = fixture('empty');
+    });
+
+    teardown(() => sandbox.restore());
+
+    test('ready is fired after component is rendered', done => {
+      const cursorElement = document.createElement('gr-diff-cursor');
+      cursorElement.addEventListener('ready', () => {
+        done();
+      });
+      someEmptyDiv.appendChild(cursorElement);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
index 79e4036..6db0836 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="gr-annotation.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-annotation.js"></script>
 
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-annotation.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,266 +41,269 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('annotation', async () => {
-    await readyToTest();
-    let str;
-    let parent;
-    let textNode;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-annotation.js';
+import {sanitizeDOMValue, setSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings.js';
+suite('annotation', () => {
+  let str;
+  let parent;
+  let textNode;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    parent = fixture('basic');
+    textNode = parent.childNodes[0];
+    str = textNode.textContent;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_annotateText Case 1', () => {
+    GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
+
+    assert.equal(parent.childNodes.length, 1);
+    assert.instanceOf(parent.childNodes[0], HTMLElement);
+    assert.equal(parent.childNodes[0].className, 'foobar');
+    assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
+    assert.equal(parent.childNodes[0].childNodes[0].textContent, str);
+  });
+
+  test('_annotateText Case 2', () => {
+    const length = 12;
+    const substr = str.substr(0, length);
+    const remainder = str.substr(length);
+
+    GrAnnotation._annotateText(textNode, 0, length, 'foobar');
+
+    assert.equal(parent.childNodes.length, 2);
+
+    assert.instanceOf(parent.childNodes[0], HTMLElement);
+    assert.equal(parent.childNodes[0].className, 'foobar');
+    assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
+    assert.equal(parent.childNodes[0].childNodes[0].textContent, substr);
+
+    assert.instanceOf(parent.childNodes[1], Text);
+    assert.equal(parent.childNodes[1].textContent, remainder);
+  });
+
+  test('_annotateText Case 3', () => {
+    const index = 12;
+    const length = str.length - index;
+    const remainder = str.substr(0, index);
+    const substr = str.substr(index);
+
+    GrAnnotation._annotateText(textNode, index, length, 'foobar');
+
+    assert.equal(parent.childNodes.length, 2);
+
+    assert.instanceOf(parent.childNodes[0], Text);
+    assert.equal(parent.childNodes[0].textContent, remainder);
+
+    assert.instanceOf(parent.childNodes[1], HTMLElement);
+    assert.equal(parent.childNodes[1].className, 'foobar');
+    assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
+    assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
+  });
+
+  test('_annotateText Case 4', () => {
+    const index = str.indexOf('dolor');
+    const length = 'dolor '.length;
+
+    const remainderPre = str.substr(0, index);
+    const substr = str.substr(index, length);
+    const remainderPost = str.substr(index + length);
+
+    GrAnnotation._annotateText(textNode, index, length, 'foobar');
+
+    assert.equal(parent.childNodes.length, 3);
+
+    assert.instanceOf(parent.childNodes[0], Text);
+    assert.equal(parent.childNodes[0].textContent, remainderPre);
+
+    assert.instanceOf(parent.childNodes[1], HTMLElement);
+    assert.equal(parent.childNodes[1].className, 'foobar');
+    assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
+    assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
+
+    assert.instanceOf(parent.childNodes[2], Text);
+    assert.equal(parent.childNodes[2].textContent, remainderPost);
+  });
+
+  test('_annotateElement design doc example', () => {
+    const layers = [
+      'amet, ',
+      'inceptos ',
+      'amet, ',
+      'et, suspendisse ince',
+    ];
+
+    // Apply the layers successively.
+    layers.forEach((layer, i) => {
+      GrAnnotation.annotateElement(
+          parent, str.indexOf(layer), layer.length, `layer-${i + 1}`);
+    });
+
+    assert.equal(parent.textContent, str);
+
+    // Layer 1:
+    const layer1 = parent.querySelectorAll('.layer-1');
+    assert.equal(layer1.length, 1);
+    assert.equal(layer1[0].textContent, layers[0]);
+    assert.equal(layer1[0].parentElement, parent);
+
+    // Layer 2:
+    const layer2 = parent.querySelectorAll('.layer-2');
+    assert.equal(layer2.length, 1);
+    assert.equal(layer2[0].textContent, layers[1]);
+    assert.equal(layer2[0].parentElement, parent);
+
+    // Layer 3:
+    const layer3 = parent.querySelectorAll('.layer-3');
+    assert.equal(layer3.length, 1);
+    assert.equal(layer3[0].textContent, layers[2]);
+    assert.equal(layer3[0].parentElement, layer1[0]);
+
+    // Layer 4:
+    const layer4 = parent.querySelectorAll('.layer-4');
+    assert.equal(layer4.length, 3);
+
+    assert.equal(layer4[0].textContent, 'et, ');
+    assert.equal(layer4[0].parentElement, layer3[0]);
+
+    assert.equal(layer4[1].textContent, 'suspendisse ');
+    assert.equal(layer4[1].parentElement, parent);
+
+    assert.equal(layer4[2].textContent, 'ince');
+    assert.equal(layer4[2].parentElement, layer2[0]);
+
+    assert.equal(layer4[0].textContent +
+        layer4[1].textContent +
+        layer4[2].textContent,
+    layers[3]);
+  });
+
+  test('splitTextNode', () => {
+    const helloString = 'hello';
+    const asciiString = 'ASCII';
+    const unicodeString = 'Unic💢de';
+
+    let node;
+    let tail;
+
+    // Non-unicode path:
+    node = document.createTextNode(helloString + asciiString);
+    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    assert(node.textContent, helloString);
+    assert(tail.textContent, asciiString);
+
+    // Unicdoe path:
+    node = document.createTextNode(helloString + unicodeString);
+    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    assert(node.textContent, helloString);
+    assert(tail.textContent, unicodeString);
+  });
+
+  suite('annotateWithElement', () => {
+    const fullText = '01234567890123456789';
+    let mockSanitize;
+    let originalSanitizeDOMValue;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      parent = fixture('basic');
-      textNode = parent.childNodes[0];
-      str = textNode.textContent;
+      originalSanitizeDOMValue = sanitizeDOMValue;
+      assert.isDefined(originalSanitizeDOMValue);
+      mockSanitize = sandbox.spy(originalSanitizeDOMValue);
+      setSanitizeDOMValue(mockSanitize);
     });
 
     teardown(() => {
-      sandbox.restore();
+      setSanitizeDOMValue(originalSanitizeDOMValue);
     });
 
-    test('_annotateText Case 1', () => {
-      GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
+    test('annotates when fully contained', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(
+          container, 1, length, {tagName: 'test-wrapper'});
 
-      assert.equal(parent.childNodes.length, 1);
-      assert.instanceOf(parent.childNodes[0], HTMLElement);
-      assert.equal(parent.childNodes[0].className, 'foobar');
-      assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
-      assert.equal(parent.childNodes[0].childNodes[0].textContent, str);
+      assert.equal(
+          container.innerHTML,
+          '0<test-wrapper>1234567890</test-wrapper>123456789');
     });
 
-    test('_annotateText Case 2', () => {
-      const length = 12;
-      const substr = str.substr(0, length);
-      const remainder = str.substr(length);
+    test('annotates when spanning multiple nodes', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateElement(container, 5, length, 'testclass');
+      GrAnnotation.annotateWithElement(
+          container, 1, length, {tagName: 'test-wrapper'});
 
-      GrAnnotation._annotateText(textNode, 0, length, 'foobar');
-
-      assert.equal(parent.childNodes.length, 2);
-
-      assert.instanceOf(parent.childNodes[0], HTMLElement);
-      assert.equal(parent.childNodes[0].className, 'foobar');
-      assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
-      assert.equal(parent.childNodes[0].childNodes[0].textContent, substr);
-
-      assert.instanceOf(parent.childNodes[1], Text);
-      assert.equal(parent.childNodes[1].textContent, remainder);
+      assert.equal(
+          container.innerHTML,
+          '0' +
+          '<test-wrapper>' +
+          '1234' +
+          '<hl class="testclass">567890</hl>' +
+          '</test-wrapper>' +
+          '<hl class="testclass">1234</hl>' +
+          '56789');
     });
 
-    test('_annotateText Case 3', () => {
-      const index = 12;
-      const length = str.length - index;
-      const remainder = str.substr(0, index);
-      const substr = str.substr(index);
+    test('annotates text node', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(
+          container.childNodes[0], 1, length, {tagName: 'test-wrapper'});
 
-      GrAnnotation._annotateText(textNode, index, length, 'foobar');
-
-      assert.equal(parent.childNodes.length, 2);
-
-      assert.instanceOf(parent.childNodes[0], Text);
-      assert.equal(parent.childNodes[0].textContent, remainder);
-
-      assert.instanceOf(parent.childNodes[1], HTMLElement);
-      assert.equal(parent.childNodes[1].className, 'foobar');
-      assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
-      assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
+      assert.equal(
+          container.innerHTML,
+          '0<test-wrapper>1234567890</test-wrapper>123456789');
     });
 
-    test('_annotateText Case 4', () => {
-      const index = str.indexOf('dolor');
-      const length = 'dolor '.length;
+    test('handles zero-length nodes', () => {
+      const container = document.createElement('div');
+      container.appendChild(document.createTextNode('0123456789'));
+      container.appendChild(document.createElement('span'));
+      container.appendChild(document.createTextNode('0123456789'));
+      GrAnnotation.annotateWithElement(
+          container, 1, 10, {tagName: 'test-wrapper'});
 
-      const remainderPre = str.substr(0, index);
-      const substr = str.substr(index, length);
-      const remainderPost = str.substr(index + length);
-
-      GrAnnotation._annotateText(textNode, index, length, 'foobar');
-
-      assert.equal(parent.childNodes.length, 3);
-
-      assert.instanceOf(parent.childNodes[0], Text);
-      assert.equal(parent.childNodes[0].textContent, remainderPre);
-
-      assert.instanceOf(parent.childNodes[1], HTMLElement);
-      assert.equal(parent.childNodes[1].className, 'foobar');
-      assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
-      assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
-
-      assert.instanceOf(parent.childNodes[2], Text);
-      assert.equal(parent.childNodes[2].textContent, remainderPost);
+      assert.equal(
+          container.innerHTML,
+          '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789');
     });
 
-    test('_annotateElement design doc example', () => {
-      const layers = [
-        'amet, ',
-        'inceptos ',
-        'amet, ',
-        'et, suspendisse ince',
-      ];
-
-      // Apply the layers successively.
-      layers.forEach((layer, i) => {
-        GrAnnotation.annotateElement(
-            parent, str.indexOf(layer), layer.length, `layer-${i + 1}`);
-      });
-
-      assert.equal(parent.textContent, str);
-
-      // Layer 1:
-      const layer1 = parent.querySelectorAll('.layer-1');
-      assert.equal(layer1.length, 1);
-      assert.equal(layer1[0].textContent, layers[0]);
-      assert.equal(layer1[0].parentElement, parent);
-
-      // Layer 2:
-      const layer2 = parent.querySelectorAll('.layer-2');
-      assert.equal(layer2.length, 1);
-      assert.equal(layer2[0].textContent, layers[1]);
-      assert.equal(layer2[0].parentElement, parent);
-
-      // Layer 3:
-      const layer3 = parent.querySelectorAll('.layer-3');
-      assert.equal(layer3.length, 1);
-      assert.equal(layer3[0].textContent, layers[2]);
-      assert.equal(layer3[0].parentElement, layer1[0]);
-
-      // Layer 4:
-      const layer4 = parent.querySelectorAll('.layer-4');
-      assert.equal(layer4.length, 3);
-
-      assert.equal(layer4[0].textContent, 'et, ');
-      assert.equal(layer4[0].parentElement, layer3[0]);
-
-      assert.equal(layer4[1].textContent, 'suspendisse ');
-      assert.equal(layer4[1].parentElement, parent);
-
-      assert.equal(layer4[2].textContent, 'ince');
-      assert.equal(layer4[2].parentElement, layer2[0]);
-
-      assert.equal(layer4[0].textContent +
-          layer4[1].textContent +
-          layer4[2].textContent,
-      layers[3]);
-    });
-
-    test('splitTextNode', () => {
-      const helloString = 'hello';
-      const asciiString = 'ASCII';
-      const unicodeString = 'Unic💢de';
-
-      let node;
-      let tail;
-
-      // Non-unicode path:
-      node = document.createTextNode(helloString + asciiString);
-      tail = GrAnnotation.splitTextNode(node, helloString.length);
-      assert(node.textContent, helloString);
-      assert(tail.textContent, asciiString);
-
-      // Unicdoe path:
-      node = document.createTextNode(helloString + unicodeString);
-      tail = GrAnnotation.splitTextNode(node, helloString.length);
-      assert(node.textContent, helloString);
-      assert(tail.textContent, unicodeString);
-    });
-
-    suite('annotateWithElement', () => {
-      const fullText = '01234567890123456789';
-      let mockSanitize;
-      let originalSanitizeDOMValue;
-
-      setup(() => {
-        originalSanitizeDOMValue = window.Polymer.sanitizeDOMValue;
-        assert.isDefined(originalSanitizeDOMValue);
-        mockSanitize = sandbox.spy(originalSanitizeDOMValue);
-        window.Polymer.sanitizeDOMValue = mockSanitize;
-      });
-
-      teardown(() => {
-        window.Polymer.sanitizeDOMValue = originalSanitizeDOMValue;
-      });
-
-      test('annotates when fully contained', () => {
-        const length = 10;
-        const container = document.createElement('div');
-        container.textContent = fullText;
-        GrAnnotation.annotateWithElement(
-            container, 1, length, {tagName: 'test-wrapper'});
-
-        assert.equal(
-            container.innerHTML,
-            '0<test-wrapper>1234567890</test-wrapper>123456789');
-      });
-
-      test('annotates when spanning multiple nodes', () => {
-        const length = 10;
-        const container = document.createElement('div');
-        container.textContent = fullText;
-        GrAnnotation.annotateElement(container, 5, length, 'testclass');
-        GrAnnotation.annotateWithElement(
-            container, 1, length, {tagName: 'test-wrapper'});
-
-        assert.equal(
-            container.innerHTML,
-            '0' +
-            '<test-wrapper>' +
-            '1234' +
-            '<hl class="testclass">567890</hl>' +
-            '</test-wrapper>' +
-            '<hl class="testclass">1234</hl>' +
-            '56789');
-      });
-
-      test('annotates text node', () => {
-        const length = 10;
-        const container = document.createElement('div');
-        container.textContent = fullText;
-        GrAnnotation.annotateWithElement(
-            container.childNodes[0], 1, length, {tagName: 'test-wrapper'});
-
-        assert.equal(
-            container.innerHTML,
-            '0<test-wrapper>1234567890</test-wrapper>123456789');
-      });
-
-      test('handles zero-length nodes', () => {
-        const container = document.createElement('div');
-        container.appendChild(document.createTextNode('0123456789'));
-        container.appendChild(document.createElement('span'));
-        container.appendChild(document.createTextNode('0123456789'));
-        GrAnnotation.annotateWithElement(
-            container, 1, 10, {tagName: 'test-wrapper'});
-
-        assert.equal(
-            container.innerHTML,
-            '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789');
-      });
-
-      test('sets sanitized attributes', () => {
-        const container = document.createElement('div');
-        container.textContent = fullText;
-        const attributes = {
-          'href': 'foo',
-          'data-foo': 'bar',
-          'class': 'hello world',
-        };
-        GrAnnotation.annotateWithElement(
-            container, 1, length, {tagName: 'test-wrapper', attributes});
-        assert(mockSanitize.calledWith(
-            'foo', 'href', 'attribute', sinon.match.instanceOf(Element)));
-        assert(mockSanitize.calledWith(
-            'bar', 'data-foo', 'attribute', sinon.match.instanceOf(Element)));
-        assert(mockSanitize.calledWith(
-            'hello world',
-            'class',
-            'attribute',
-            sinon.match.instanceOf(Element)));
-        const el = container.querySelector('test-wrapper');
-        assert.equal(el.getAttribute('href'), 'foo');
-        assert.equal(el.getAttribute('data-foo'), 'bar');
-        assert.equal(el.getAttribute('class'), 'hello world');
-      });
+    test('sets sanitized attributes', () => {
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      const attributes = {
+        'href': 'foo',
+        'data-foo': 'bar',
+        'class': 'hello world',
+      };
+      GrAnnotation.annotateWithElement(
+          container, 1, length, {tagName: 'test-wrapper', attributes});
+      assert(mockSanitize.calledWith(
+          'foo', 'href', 'attribute', sinon.match.instanceOf(Element)));
+      assert(mockSanitize.calledWith(
+          'bar', 'data-foo', 'attribute', sinon.match.instanceOf(Element)));
+      assert(mockSanitize.calledWith(
+          'hello world',
+          'class',
+          'attribute',
+          sinon.match.instanceOf(Element)));
+      const el = container.querySelector('test-wrapper');
+      assert.equal(el.getAttribute('href'), 'foo');
+      assert.equal(el.getAttribute('data-foo'), 'bar');
+      assert.equal(el.getAttribute('class'), 'hello world');
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index 99bf1c8..4567c9e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -14,484 +14,496 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../gr-selection-action-box/gr-selection-action-box.js';
+import './gr-annotation.js';
+import './gr-range-normalizer.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-highlight_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrDiffHighlight extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-highlight'; }
+
+  static get properties() {
+    return {
+    /** @type {!Array<!Gerrit.HoveredRange>} */
+      commentRanges: {
+        type: Array,
+        notify: true,
+      },
+      loggedIn: Boolean,
+      /**
+       * querySelector can return null, so needs to be nullable.
+       *
+       * @type {?HTMLElement}
+       * */
+      _cachedDiffBuilder: Object,
+
+      /**
+       * Which range is currently selected by the user.
+       * Stored in order to add a range-based comment
+       * later.
+       * undefined if no range is selected.
+       *
+       * @type {{side: string, range: Gerrit.Range}|undefined}
+       */
+      selectedRange: {
+        type: Object,
+        notify: true,
+      },
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('comment-thread-mouseleave',
+        e => this._handleCommentThreadMouseleave(e));
+    this.addEventListener('comment-thread-mouseenter',
+        e => this._handleCommentThreadMouseenter(e));
+    this.addEventListener('create-comment-requested',
+        e => this._handleRangeCommentRequest(e));
+  }
+
+  get diffBuilder() {
+    if (!this._cachedDiffBuilder) {
+      this._cachedDiffBuilder =
+          dom(this).querySelector('gr-diff-builder');
+    }
+    return this._cachedDiffBuilder;
+  }
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Determines side/line/range for a DOM selection and shows a tooltip.
+   *
+   * With native shadow DOM, gr-diff-highlight cannot access a selection that
+   * references the DOM elements making up the diff because they are in the
+   * shadow DOM the gr-diff element. For this reason, we listen to the
+   * selectionchange event and retrieve the selection in gr-diff, and then
+   * call this method to process the Selection.
+   *
+   * @param {Selection} selection A DOM Selection living in the shadow DOM of
+   *     the diff element.
+   * @param {boolean} isMouseUp If true, this is called due to a mouseup
+   *     event, in which case we might want to immediately create a comment,
+   *     because isMouseUp === true combined with an existing selection must
+   *     mean that this is the end of a double-click.
    */
-  class GrDiffHighlight extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-diff-highlight'; }
+  handleSelectionChange(selection, isMouseUp) {
+    // Debounce is not just nice for waiting until the selection has settled,
+    // it is also vital for being able to click on the action box before it is
+    // removed.
+    // If you wait longer than 50 ms, then you don't properly catch a very
+    // quick 'c' press after the selection change. If you wait less than 10
+    // ms, then you will have about 50 _handleSelection calls when doing a
+    // simple drag for select.
+    this.debounce(
+        'selectionChange', () => this._handleSelection(selection, isMouseUp),
+        10);
+  }
 
-    static get properties() {
-      return {
-      /** @type {!Array<!Gerrit.HoveredRange>} */
-        commentRanges: {
-          type: Array,
-          notify: true,
-        },
-        loggedIn: Boolean,
-        /**
-         * querySelector can return null, so needs to be nullable.
-         *
-         * @type {?HTMLElement}
-         * */
-        _cachedDiffBuilder: Object,
-
-        /**
-         * Which range is currently selected by the user.
-         * Stored in order to add a range-based comment
-         * later.
-         * undefined if no range is selected.
-         *
-         * @type {{side: string, range: Gerrit.Range}|undefined}
-         */
-        selectedRange: {
-          type: Object,
-          notify: true,
-        },
-      };
+  _getThreadEl(e) {
+    const path = dom(e).path || [];
+    for (const pathEl of path) {
+      if (pathEl.classList.contains('comment-thread')) return pathEl;
     }
+    return null;
+  }
 
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('comment-thread-mouseleave',
-          e => this._handleCommentThreadMouseleave(e));
-      this.addEventListener('comment-thread-mouseenter',
-          e => this._handleCommentThreadMouseenter(e));
-      this.addEventListener('create-comment-requested',
-          e => this._handleRangeCommentRequest(e));
-    }
+  _handleCommentThreadMouseenter(e) {
+    const threadEl = this._getThreadEl(e);
+    const index = this._indexForThreadEl(threadEl);
 
-    get diffBuilder() {
-      if (!this._cachedDiffBuilder) {
-        this._cachedDiffBuilder =
-            Polymer.dom(this).querySelector('gr-diff-builder');
-      }
-      return this._cachedDiffBuilder;
-    }
-
-    /**
-     * Determines side/line/range for a DOM selection and shows a tooltip.
-     *
-     * With native shadow DOM, gr-diff-highlight cannot access a selection that
-     * references the DOM elements making up the diff because they are in the
-     * shadow DOM the gr-diff element. For this reason, we listen to the
-     * selectionchange event and retrieve the selection in gr-diff, and then
-     * call this method to process the Selection.
-     *
-     * @param {Selection} selection A DOM Selection living in the shadow DOM of
-     *     the diff element.
-     * @param {boolean} isMouseUp If true, this is called due to a mouseup
-     *     event, in which case we might want to immediately create a comment,
-     *     because isMouseUp === true combined with an existing selection must
-     *     mean that this is the end of a double-click.
-     */
-    handleSelectionChange(selection, isMouseUp) {
-      // Debounce is not just nice for waiting until the selection has settled,
-      // it is also vital for being able to click on the action box before it is
-      // removed.
-      // If you wait longer than 50 ms, then you don't properly catch a very
-      // quick 'c' press after the selection change. If you wait less than 10
-      // ms, then you will have about 50 _handleSelection calls when doing a
-      // simple drag for select.
-      this.debounce(
-          'selectionChange', () => this._handleSelection(selection, isMouseUp),
-          10);
-    }
-
-    _getThreadEl(e) {
-      const path = Polymer.dom(e).path || [];
-      for (const pathEl of path) {
-        if (pathEl.classList.contains('comment-thread')) return pathEl;
-      }
-      return null;
-    }
-
-    _handleCommentThreadMouseenter(e) {
-      const threadEl = this._getThreadEl(e);
-      const index = this._indexForThreadEl(threadEl);
-
-      if (index !== undefined) {
-        this.set(['commentRanges', index, 'hovering'], true);
-      }
-    }
-
-    _handleCommentThreadMouseleave(e) {
-      const threadEl = this._getThreadEl(e);
-      const index = this._indexForThreadEl(threadEl);
-
-      if (index !== undefined) {
-        this.set(['commentRanges', index, 'hovering'], false);
-      }
-    }
-
-    _indexForThreadEl(threadEl) {
-      const side = threadEl.getAttribute('comment-side');
-      const range = JSON.parse(threadEl.getAttribute('range'));
-
-      if (!range) return undefined;
-
-      return this._indexOfCommentRange(side, range);
-    }
-
-    _indexOfCommentRange(side, range) {
-      function rangesEqual(a, b) {
-        if (!a && !b) {
-          return true;
-        }
-        if (!a || !b) {
-          return false;
-        }
-        return a.start_line === b.start_line &&
-            a.start_character === b.start_character &&
-            a.end_line === b.end_line &&
-            a.end_character === b.end_character;
-      }
-
-      return this.commentRanges.findIndex(commentRange =>
-        commentRange.side === side && rangesEqual(commentRange.range, range));
-    }
-
-    /**
-     * Get current normalized selection.
-     * Merges multiple ranges, accounts for triple click, accounts for
-     * syntax highligh, convert native DOM Range objects to Gerrit concepts
-     * (line, side, etc).
-     *
-     * @param {Selection} selection
-     * @return {({
-     *   start: {
-     *     node: Node,
-     *     side: string,
-     *     line: Number,
-     *     column: Number
-     *   },
-     *   end: {
-     *     node: Node,
-     *     side: string,
-     *     line: Number,
-     *     column: Number
-     *   }
-     * })|null|!Object}
-     */
-    _getNormalizedRange(selection) {
-      const rangeCount = selection.rangeCount;
-      if (rangeCount === 0) {
-        return null;
-      } else if (rangeCount === 1) {
-        return this._normalizeRange(selection.getRangeAt(0));
-      } else {
-        const startRange = this._normalizeRange(selection.getRangeAt(0));
-        const endRange = this._normalizeRange(
-            selection.getRangeAt(rangeCount - 1));
-        return {
-          start: startRange.start,
-          end: endRange.end,
-        };
-      }
-    }
-
-    /**
-     * Normalize a specific DOM Range.
-     *
-     * @return {!Object} fixed normalized range
-     */
-    _normalizeRange(domRange) {
-      const range = GrRangeNormalizer.normalize(domRange);
-      return this._fixTripleClickSelection({
-        start: this._normalizeSelectionSide(
-            range.startContainer, range.startOffset),
-        end: this._normalizeSelectionSide(
-            range.endContainer, range.endOffset),
-      }, domRange);
-    }
-
-    /**
-     * Adjust triple click selection for the whole line.
-     * A triple click always results in:
-     * - start.column == end.column == 0
-     * - end.line == start.line + 1
-     *
-     * @param {!Object} range Normalized range, ie column/line numbers
-     * @param {!Range} domRange DOM Range object
-     * @return {!Object} fixed normalized range
-     */
-    _fixTripleClickSelection(range, domRange) {
-      if (!range.start) {
-        // Selection outside of current diff.
-        return range;
-      }
-      const start = range.start;
-      const end = range.end;
-      // Happens when triple click in side-by-side mode with other side empty.
-      const endsAtOtherEmptySide = !end &&
-          domRange.endOffset === 0 &&
-          domRange.endContainer.nodeName === 'TD' &&
-          (domRange.endContainer.classList.contains('left') ||
-           domRange.endContainer.classList.contains('right'));
-      const endsAtBeginningOfNextLine = end &&
-          start.column === 0 &&
-          end.column === 0 &&
-          end.line === start.line + 1;
-      const content = domRange.cloneContents().querySelector('.contentText');
-      const lineLength = content && this._getLength(content) || 0;
-      if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
-        // Move the selection to the end of the previous line.
-        range.end = {
-          node: start.node,
-          column: lineLength,
-          side: start.side,
-          line: start.line,
-        };
-      }
-      return range;
-    }
-
-    /**
-     * Convert DOM Range selection to concrete numbers (line, column, side).
-     * Moves range end if it's not inside td.content.
-     * Returns null if selection end is not valid (outside of diff).
-     *
-     * @param {Node} node td.content child
-     * @param {number} offset offset within node
-     * @return {({
-     *   node: Node,
-     *   side: string,
-     *   line: Number,
-     *   column: Number
-     * }|undefined)}
-     */
-    _normalizeSelectionSide(node, offset) {
-      let column;
-      if (!this.contains(node)) {
-        return;
-      }
-      const lineEl = this.diffBuilder.getLineElByChild(node);
-      if (!lineEl) {
-        return;
-      }
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
-      if (!side) {
-        return;
-      }
-      const line = this.diffBuilder.getLineNumberByChild(lineEl);
-      if (!line) {
-        return;
-      }
-      const contentText = this.diffBuilder.getContentByLineEl(lineEl);
-      if (!contentText) {
-        return;
-      }
-      const contentTd = contentText.parentElement;
-      if (!contentTd.contains(node)) {
-        node = contentText;
-        column = 0;
-      } else {
-        const thread = contentTd.querySelector('.comment-thread');
-        if (thread && thread.contains(node)) {
-          column = this._getLength(contentText);
-          node = contentText;
-        } else {
-          column = this._convertOffsetToColumn(node, offset);
-        }
-      }
-
-      return {
-        node,
-        side,
-        line,
-        column,
-      };
-    }
-
-    /**
-     * The only line in which add a comment tooltip is cut off is the first
-     * line. Even if there is a collapsed section, The first visible line is
-     * in the position where the second line would have been, if not for the
-     * collapsed section, so don't need to worry about this case for
-     * positioning the tooltip.
-     */
-    _positionActionBox(actionBox, startLine, range) {
-      if (startLine > 1) {
-        actionBox.placeAbove(range);
-        return;
-      }
-      actionBox.positionBelow = true;
-      actionBox.placeBelow(range);
-    }
-
-    _isRangeValid(range) {
-      if (!range || !range.start || !range.end) {
-        return false;
-      }
-      const start = range.start;
-      const end = range.end;
-      if (start.side !== end.side ||
-          end.line < start.line ||
-          (start.line === end.line && start.column === end.column)) {
-        return false;
-      }
-      return true;
-    }
-
-    _handleSelection(selection, isMouseUp) {
-      const normalizedRange = this._getNormalizedRange(selection);
-      if (!this._isRangeValid(normalizedRange)) {
-        this._removeActionBox();
-        return;
-      }
-      const domRange = selection.getRangeAt(0);
-      const start = normalizedRange.start;
-      const end = normalizedRange.end;
-
-      // TODO (viktard): Drop empty first and last lines from selection.
-
-      // If the selection is from the end of one line to the start of the next
-      // line, then this must have been a double-click, or you have started
-      // dragging. Showing the action box is bad in the former case and not very
-      // useful in the latter, so never do that.
-      // If this was a mouse-up event, we create a comment immediately if
-      // the selection is from the end of a line to the start of the next line.
-      // In a perfect world we would only do this for double-click, but it is
-      // extremely rare that a user would drag from the end of one line to the
-      // start of the next and release the mouse, so we don't bother.
-      // TODO(brohlfs): This does not work, if the double-click is before a new
-      // diff chunk (start will be equal to end), and neither before an "expand
-      // the diff context" block (end line will match the first line of the new
-      // section and thus be greater than start line + 1).
-      if (start.line === end.line - 1 && end.column === 0) {
-        // Rather than trying to find the line contents (for comparing
-        // start.column with the content length), we just check if the selection
-        // is empty to see that it's at the end of a line.
-        const content = domRange.cloneContents().querySelector('.contentText');
-        if (isMouseUp && this._getLength(content) === 0) {
-          this._fireCreateRangeComment(start.side, {
-            start_line: start.line,
-            start_character: 0,
-            end_line: start.line,
-            end_character: start.column,
-          });
-        }
-        return;
-      }
-
-      let actionBox = this.shadowRoot.querySelector('gr-selection-action-box');
-      if (!actionBox) {
-        actionBox = document.createElement('gr-selection-action-box');
-        const root = Polymer.dom(this.root);
-        root.insertBefore(actionBox, root.firstElementChild);
-      }
-      this.selectedRange = {
-        range: {
-          start_line: start.line,
-          start_character: start.column,
-          end_line: end.line,
-          end_character: end.column,
-        },
-        side: start.side,
-      };
-      if (start.line === end.line) {
-        this._positionActionBox(actionBox, start.line, domRange);
-      } else if (start.node instanceof Text) {
-        if (start.column) {
-          this._positionActionBox(actionBox, start.line,
-              start.node.splitText(start.column));
-        }
-        start.node.parentElement.normalize(); // Undo splitText from above.
-      } else if (start.node.classList.contains('content') &&
-          start.node.firstChild) {
-        this._positionActionBox(actionBox, start.line, start.node.firstChild);
-      } else {
-        this._positionActionBox(actionBox, start.line, start.node);
-      }
-    }
-
-    _fireCreateRangeComment(side, range) {
-      this.fire('create-range-comment', {side, range});
-      this._removeActionBox();
-    }
-
-    _handleRangeCommentRequest(e) {
-      e.stopPropagation();
-      if (!this.selectedRange) {
-        throw Error('Selected Range is needed for new range comment!');
-      }
-      const {side, range} = this.selectedRange;
-      this._fireCreateRangeComment(side, range);
-    }
-
-    _removeActionBox() {
-      this.selectedRange = undefined;
-      const actionBox = this.shadowRoot
-          .querySelector('gr-selection-action-box');
-      if (actionBox) {
-        Polymer.dom(this.root).removeChild(actionBox);
-      }
-    }
-
-    _convertOffsetToColumn(el, offset) {
-      if (el instanceof Element && el.classList.contains('content')) {
-        return offset;
-      }
-      while (el.previousSibling ||
-          !el.parentElement.classList.contains('content')) {
-        if (el.previousSibling) {
-          el = el.previousSibling;
-          offset += this._getLength(el);
-        } else {
-          el = el.parentElement;
-        }
-      }
-      return offset;
-    }
-
-    /**
-     * Traverse Element from right to left, call callback for each node.
-     * Stops if callback returns true.
-     *
-     * @param {!Element} startNode
-     * @param {function(Node):boolean} callback
-     * @param {Object=} opt_flags If flags.left is true, traverse left.
-     */
-    _traverseContentSiblings(startNode, callback, opt_flags) {
-      const travelLeft = opt_flags && opt_flags.left;
-      let node = startNode;
-      while (node) {
-        if (node instanceof Element &&
-            node.tagName !== 'HL' &&
-            node.tagName !== 'SPAN') {
-          break;
-        }
-        const nextNode = travelLeft ? node.previousSibling : node.nextSibling;
-        if (callback(node)) {
-          break;
-        }
-        node = nextNode;
-      }
-    }
-
-    /**
-     * Get length of a node. If the node is a content node, then only give the
-     * length of its .contentText child.
-     *
-     * @param {?Element} node this is sometimes passed as null.
-     * @return {number}
-     */
-    _getLength(node) {
-      if (node instanceof Element && node.classList.contains('content')) {
-        return this._getLength(node.querySelector('.contentText'));
-      } else {
-        return GrAnnotation.getLength(node);
-      }
+    if (index !== undefined) {
+      this.set(['commentRanges', index, 'hovering'], true);
     }
   }
 
-  customElements.define(GrDiffHighlight.is, GrDiffHighlight);
-})();
+  _handleCommentThreadMouseleave(e) {
+    const threadEl = this._getThreadEl(e);
+    const index = this._indexForThreadEl(threadEl);
+
+    if (index !== undefined) {
+      this.set(['commentRanges', index, 'hovering'], false);
+    }
+  }
+
+  _indexForThreadEl(threadEl) {
+    const side = threadEl.getAttribute('comment-side');
+    const range = JSON.parse(threadEl.getAttribute('range'));
+
+    if (!range) return undefined;
+
+    return this._indexOfCommentRange(side, range);
+  }
+
+  _indexOfCommentRange(side, range) {
+    function rangesEqual(a, b) {
+      if (!a && !b) {
+        return true;
+      }
+      if (!a || !b) {
+        return false;
+      }
+      return a.start_line === b.start_line &&
+          a.start_character === b.start_character &&
+          a.end_line === b.end_line &&
+          a.end_character === b.end_character;
+    }
+
+    return this.commentRanges.findIndex(commentRange =>
+      commentRange.side === side && rangesEqual(commentRange.range, range));
+  }
+
+  /**
+   * Get current normalized selection.
+   * Merges multiple ranges, accounts for triple click, accounts for
+   * syntax highligh, convert native DOM Range objects to Gerrit concepts
+   * (line, side, etc).
+   *
+   * @param {Selection} selection
+   * @return {({
+   *   start: {
+   *     node: Node,
+   *     side: string,
+   *     line: Number,
+   *     column: Number
+   *   },
+   *   end: {
+   *     node: Node,
+   *     side: string,
+   *     line: Number,
+   *     column: Number
+   *   }
+   * })|null|!Object}
+   */
+  _getNormalizedRange(selection) {
+    const rangeCount = selection.rangeCount;
+    if (rangeCount === 0) {
+      return null;
+    } else if (rangeCount === 1) {
+      return this._normalizeRange(selection.getRangeAt(0));
+    } else {
+      const startRange = this._normalizeRange(selection.getRangeAt(0));
+      const endRange = this._normalizeRange(
+          selection.getRangeAt(rangeCount - 1));
+      return {
+        start: startRange.start,
+        end: endRange.end,
+      };
+    }
+  }
+
+  /**
+   * Normalize a specific DOM Range.
+   *
+   * @return {!Object} fixed normalized range
+   */
+  _normalizeRange(domRange) {
+    const range = GrRangeNormalizer.normalize(domRange);
+    return this._fixTripleClickSelection({
+      start: this._normalizeSelectionSide(
+          range.startContainer, range.startOffset),
+      end: this._normalizeSelectionSide(
+          range.endContainer, range.endOffset),
+    }, domRange);
+  }
+
+  /**
+   * Adjust triple click selection for the whole line.
+   * A triple click always results in:
+   * - start.column == end.column == 0
+   * - end.line == start.line + 1
+   *
+   * @param {!Object} range Normalized range, ie column/line numbers
+   * @param {!Range} domRange DOM Range object
+   * @return {!Object} fixed normalized range
+   */
+  _fixTripleClickSelection(range, domRange) {
+    if (!range.start) {
+      // Selection outside of current diff.
+      return range;
+    }
+    const start = range.start;
+    const end = range.end;
+    // Happens when triple click in side-by-side mode with other side empty.
+    const endsAtOtherEmptySide = !end &&
+        domRange.endOffset === 0 &&
+        domRange.endContainer.nodeName === 'TD' &&
+        (domRange.endContainer.classList.contains('left') ||
+         domRange.endContainer.classList.contains('right'));
+    const endsAtBeginningOfNextLine = end &&
+        start.column === 0 &&
+        end.column === 0 &&
+        end.line === start.line + 1;
+    const content = domRange.cloneContents().querySelector('.contentText');
+    const lineLength = content && this._getLength(content) || 0;
+    if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
+      // Move the selection to the end of the previous line.
+      range.end = {
+        node: start.node,
+        column: lineLength,
+        side: start.side,
+        line: start.line,
+      };
+    }
+    return range;
+  }
+
+  /**
+   * Convert DOM Range selection to concrete numbers (line, column, side).
+   * Moves range end if it's not inside td.content.
+   * Returns null if selection end is not valid (outside of diff).
+   *
+   * @param {Node} node td.content child
+   * @param {number} offset offset within node
+   * @return {({
+   *   node: Node,
+   *   side: string,
+   *   line: Number,
+   *   column: Number
+   * }|undefined)}
+   */
+  _normalizeSelectionSide(node, offset) {
+    let column;
+    if (!this.contains(node)) {
+      return;
+    }
+    const lineEl = this.diffBuilder.getLineElByChild(node);
+    if (!lineEl) {
+      return;
+    }
+    const side = this.diffBuilder.getSideByLineEl(lineEl);
+    if (!side) {
+      return;
+    }
+    const line = this.diffBuilder.getLineNumberByChild(lineEl);
+    if (!line) {
+      return;
+    }
+    const contentText = this.diffBuilder.getContentByLineEl(lineEl);
+    if (!contentText) {
+      return;
+    }
+    const contentTd = contentText.parentElement;
+    if (!contentTd.contains(node)) {
+      node = contentText;
+      column = 0;
+    } else {
+      const thread = contentTd.querySelector('.comment-thread');
+      if (thread && thread.contains(node)) {
+        column = this._getLength(contentText);
+        node = contentText;
+      } else {
+        column = this._convertOffsetToColumn(node, offset);
+      }
+    }
+
+    return {
+      node,
+      side,
+      line,
+      column,
+    };
+  }
+
+  /**
+   * The only line in which add a comment tooltip is cut off is the first
+   * line. Even if there is a collapsed section, The first visible line is
+   * in the position where the second line would have been, if not for the
+   * collapsed section, so don't need to worry about this case for
+   * positioning the tooltip.
+   */
+  _positionActionBox(actionBox, startLine, range) {
+    if (startLine > 1) {
+      actionBox.placeAbove(range);
+      return;
+    }
+    actionBox.positionBelow = true;
+    actionBox.placeBelow(range);
+  }
+
+  _isRangeValid(range) {
+    if (!range || !range.start || !range.end) {
+      return false;
+    }
+    const start = range.start;
+    const end = range.end;
+    if (start.side !== end.side ||
+        end.line < start.line ||
+        (start.line === end.line && start.column === end.column)) {
+      return false;
+    }
+    return true;
+  }
+
+  _handleSelection(selection, isMouseUp) {
+    const normalizedRange = this._getNormalizedRange(selection);
+    if (!this._isRangeValid(normalizedRange)) {
+      this._removeActionBox();
+      return;
+    }
+    const domRange = selection.getRangeAt(0);
+    const start = normalizedRange.start;
+    const end = normalizedRange.end;
+
+    // TODO (viktard): Drop empty first and last lines from selection.
+
+    // If the selection is from the end of one line to the start of the next
+    // line, then this must have been a double-click, or you have started
+    // dragging. Showing the action box is bad in the former case and not very
+    // useful in the latter, so never do that.
+    // If this was a mouse-up event, we create a comment immediately if
+    // the selection is from the end of a line to the start of the next line.
+    // In a perfect world we would only do this for double-click, but it is
+    // extremely rare that a user would drag from the end of one line to the
+    // start of the next and release the mouse, so we don't bother.
+    // TODO(brohlfs): This does not work, if the double-click is before a new
+    // diff chunk (start will be equal to end), and neither before an "expand
+    // the diff context" block (end line will match the first line of the new
+    // section and thus be greater than start line + 1).
+    if (start.line === end.line - 1 && end.column === 0) {
+      // Rather than trying to find the line contents (for comparing
+      // start.column with the content length), we just check if the selection
+      // is empty to see that it's at the end of a line.
+      const content = domRange.cloneContents().querySelector('.contentText');
+      if (isMouseUp && this._getLength(content) === 0) {
+        this._fireCreateRangeComment(start.side, {
+          start_line: start.line,
+          start_character: 0,
+          end_line: start.line,
+          end_character: start.column,
+        });
+      }
+      return;
+    }
+
+    let actionBox = this.shadowRoot.querySelector('gr-selection-action-box');
+    if (!actionBox) {
+      actionBox = document.createElement('gr-selection-action-box');
+      const root = dom(this.root);
+      root.insertBefore(actionBox, root.firstElementChild);
+    }
+    this.selectedRange = {
+      range: {
+        start_line: start.line,
+        start_character: start.column,
+        end_line: end.line,
+        end_character: end.column,
+      },
+      side: start.side,
+    };
+    if (start.line === end.line) {
+      this._positionActionBox(actionBox, start.line, domRange);
+    } else if (start.node instanceof Text) {
+      if (start.column) {
+        this._positionActionBox(actionBox, start.line,
+            start.node.splitText(start.column));
+      }
+      start.node.parentElement.normalize(); // Undo splitText from above.
+    } else if (start.node.classList.contains('content') &&
+        start.node.firstChild) {
+      this._positionActionBox(actionBox, start.line, start.node.firstChild);
+    } else {
+      this._positionActionBox(actionBox, start.line, start.node);
+    }
+  }
+
+  _fireCreateRangeComment(side, range) {
+    this.fire('create-range-comment', {side, range});
+    this._removeActionBox();
+  }
+
+  _handleRangeCommentRequest(e) {
+    e.stopPropagation();
+    if (!this.selectedRange) {
+      throw Error('Selected Range is needed for new range comment!');
+    }
+    const {side, range} = this.selectedRange;
+    this._fireCreateRangeComment(side, range);
+  }
+
+  _removeActionBox() {
+    this.selectedRange = undefined;
+    const actionBox = this.shadowRoot
+        .querySelector('gr-selection-action-box');
+    if (actionBox) {
+      dom(this.root).removeChild(actionBox);
+    }
+  }
+
+  _convertOffsetToColumn(el, offset) {
+    if (el instanceof Element && el.classList.contains('content')) {
+      return offset;
+    }
+    while (el.previousSibling ||
+        !el.parentElement.classList.contains('content')) {
+      if (el.previousSibling) {
+        el = el.previousSibling;
+        offset += this._getLength(el);
+      } else {
+        el = el.parentElement;
+      }
+    }
+    return offset;
+  }
+
+  /**
+   * Traverse Element from right to left, call callback for each node.
+   * Stops if callback returns true.
+   *
+   * @param {!Element} startNode
+   * @param {function(Node):boolean} callback
+   * @param {Object=} opt_flags If flags.left is true, traverse left.
+   */
+  _traverseContentSiblings(startNode, callback, opt_flags) {
+    const travelLeft = opt_flags && opt_flags.left;
+    let node = startNode;
+    while (node) {
+      if (node instanceof Element &&
+          node.tagName !== 'HL' &&
+          node.tagName !== 'SPAN') {
+        break;
+      }
+      const nextNode = travelLeft ? node.previousSibling : node.nextSibling;
+      if (callback(node)) {
+        break;
+      }
+      node = nextNode;
+    }
+  }
+
+  /**
+   * Get length of a node. If the node is a content node, then only give the
+   * length of its .contentText child.
+   *
+   * @param {?Element} node this is sometimes passed as null.
+   * @return {number}
+   */
+  _getLength(node) {
+    if (node instanceof Element && node.classList.contains('content')) {
+      return this._getLength(node.querySelector('.contentText'));
+    } else {
+      return GrAnnotation.getLength(node);
+    }
+  }
+}
+
+customElements.define(GrDiffHighlight.is, GrDiffHighlight);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.js
index be72b05..10b4f2d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-selection-action-box/gr-selection-action-box.html">
-<script src="gr-annotation.js"></script>
-<script src="gr-range-normalizer.js"></script>
-
-<dom-module id="gr-diff-highlight">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         position: relative;
@@ -39,6 +32,4 @@
     <div class="contentWrapper">
       <slot></slot>
     </div>
-  </template>
-  <script src="gr-diff-highlight.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index 02c2033..ca1e2e2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-highlight</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-diff-highlight.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-diff-highlight.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-diff-highlight.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -147,486 +152,488 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-highlight', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-diff-highlight.js';
+suite('gr-diff-highlight', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic')[1];
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('comment events', () => {
+    let builder;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic')[1];
+      builder = {
+        getContentsByLineRange: sandbox.stub().returns([]),
+        getLineElByChild: sandbox.stub().returns({}),
+        getSideByLineEl: sandbox.stub().returns('other-side'),
+      };
+      element._cachedDiffBuilder = builder;
+    });
+
+    test('comment-thread-mouseenter from line comments is ignored', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('line-num', 3);
+      element.appendChild(threadEl);
+      element.commentRanges = [{side: 'right'}];
+
+      sandbox.stub(element, 'set');
+      threadEl.dispatchEvent(new CustomEvent(
+          'comment-thread-mouseenter', {bubbles: true, composed: true}));
+      assert.isFalse(element.set.called);
+    });
+
+    test('comment-thread-mouseenter from ranged comment causes set', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('line-num', 3);
+      threadEl.setAttribute('range', JSON.stringify({
+        start_line: 3,
+        start_character: 4,
+        end_line: 5,
+        end_character: 6,
+      }));
+      element.appendChild(threadEl);
+      element.commentRanges = [{side: 'right', range: {
+        start_line: 3,
+        start_character: 4,
+        end_line: 5,
+        end_character: 6,
+      }}];
+
+      sandbox.stub(element, 'set');
+      threadEl.dispatchEvent(new CustomEvent(
+          'comment-thread-mouseenter', {bubbles: true, composed: true}));
+      assert.isTrue(element.set.called);
+      const args = element.set.lastCall.args;
+      assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
+      assert.deepEqual(args[1], true);
+    });
+
+    test('comment-thread-mouseleave from line comments is ignored', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('line-num', 3);
+      element.appendChild(threadEl);
+      element.commentRanges = [{side: 'right'}];
+
+      sandbox.stub(element, 'set');
+      threadEl.dispatchEvent(new CustomEvent(
+          'comment-thread-mouseleave', {bubbles: true, composed: true}));
+      assert.isFalse(element.set.called);
+    });
+
+    test(`create-range-comment for range when create-comment-requested
+          is fired`, () => {
+      sandbox.stub(element, '_removeActionBox');
+      element.selectedRange = {
+        side: 'left',
+        range: {
+          start_line: 7,
+          start_character: 11,
+          end_line: 24,
+          end_character: 42,
+        },
+      };
+      const requestEvent = new CustomEvent('create-comment-requested');
+      let createRangeEvent;
+      element.addEventListener('create-range-comment', e => {
+        createRangeEvent = e;
+      });
+      element.dispatchEvent(requestEvent);
+      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
+      assert.isTrue(element._removeActionBox.called);
+    });
+  });
+
+  suite('selection', () => {
+    let diff;
+    let builder;
+    let contentStubs;
+
+    const stubContent = (line, side, opt_child) => {
+      const contentTd = diff.querySelector(
+          `.${side}.lineNum[data-value="${line}"] ~ .content`);
+      const contentText = contentTd.querySelector('.contentText');
+      const lineEl = diff.querySelector(
+          `.${side}.lineNum[data-value="${line}"]`);
+      contentStubs.push({
+        lineEl,
+        contentTd,
+        contentText,
+      });
+      builder.getContentByLineEl.withArgs(lineEl).returns(contentText);
+      builder.getLineNumberByChild.withArgs(lineEl).returns(line);
+      builder.getContentByLine.withArgs(line, side).returns(contentText);
+      builder.getSideByLineEl.withArgs(lineEl).returns(side);
+      return contentText;
+    };
+
+    const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
+      const selection = window.getSelection();
+      const range = document.createRange();
+      range.setStart(startNode, startOffset);
+      range.setEnd(endNode, endOffset);
+      selection.addRange(range);
+      element._handleSelection(selection);
+    };
+
+    const getLineElByChild = node => {
+      const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
+      return stubs && stubs.lineEl;
+    };
+
+    setup(() => {
+      contentStubs = [];
+      stub('gr-selection-action-box', {
+        placeAbove: sandbox.stub(),
+        placeBelow: sandbox.stub(),
+      });
+      diff = element.querySelector('#diffTable');
+      builder = {
+        getContentByLine: sandbox.stub(),
+        getContentByLineEl: sandbox.stub(),
+        getLineElByChild,
+        getLineNumberByChild: sandbox.stub(),
+        getSideByLineEl: sandbox.stub(),
+      };
+      element._cachedDiffBuilder = builder;
     });
 
     teardown(() => {
-      sandbox.restore();
+      contentStubs = null;
+      window.getSelection().removeAllRanges();
     });
 
-    suite('comment events', () => {
-      let builder;
+    test('single first line', () => {
+      const content = stubContent(1, 'right');
+      sandbox.spy(element, '_positionActionBox');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      assert.isTrue(actionBox.positionBelow);
+    });
 
-      setup(() => {
-        builder = {
-          getContentsByLineRange: sandbox.stub().returns([]),
-          getLineElByChild: sandbox.stub().returns({}),
-          getSideByLineEl: sandbox.stub().returns('other-side'),
-        };
-        element._cachedDiffBuilder = builder;
+    test('multiline starting on first line', () => {
+      const startContent = stubContent(1, 'right');
+      const endContent = stubContent(2, 'right');
+      sandbox.spy(element, '_positionActionBox');
+      emulateSelection(
+          startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('single line', () => {
+      const content = stubContent(138, 'left');
+      sandbox.spy(element, '_positionActionBox');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 138,
+        start_character: 5,
+        end_line: 138,
+        end_character: 12,
       });
+      assert.equal(side, 'left');
+      assert.notOk(actionBox.positionBelow);
+    });
 
-      test('comment-thread-mouseenter from line comments is ignored', () => {
-        const threadEl = document.createElement('div');
-        threadEl.className = 'comment-thread';
-        threadEl.setAttribute('comment-side', 'right');
-        threadEl.setAttribute('line-num', 3);
-        element.appendChild(threadEl);
-        element.commentRanges = [{side: 'right'}];
-
-        sandbox.stub(element, 'set');
-        threadEl.dispatchEvent(new CustomEvent(
-            'comment-thread-mouseenter', {bubbles: true, composed: true}));
-        assert.isFalse(element.set.called);
+    test('multiline', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
+      sandbox.spy(element, '_positionActionBox');
+      emulateSelection(
+          startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
       });
+      assert.equal(side, 'right');
+      assert.notOk(actionBox.positionBelow);
+    });
 
-      test('comment-thread-mouseenter from ranged comment causes set', () => {
-        const threadEl = document.createElement('div');
-        threadEl.className = 'comment-thread';
-        threadEl.setAttribute('comment-side', 'right');
-        threadEl.setAttribute('line-num', 3);
-        threadEl.setAttribute('range', JSON.stringify({
-          start_line: 3,
-          start_character: 4,
-          end_line: 5,
-          end_character: 6,
-        }));
-        element.appendChild(threadEl);
-        element.commentRanges = [{side: 'right', range: {
-          start_line: 3,
-          start_character: 4,
-          end_line: 5,
-          end_character: 6,
-        }}];
+    test('multiple ranges aka firefox implementation', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
 
-        sandbox.stub(element, 'set');
-        threadEl.dispatchEvent(new CustomEvent(
-            'comment-thread-mouseenter', {bubbles: true, composed: true}));
-        assert.isTrue(element.set.called);
-        const args = element.set.lastCall.args;
-        assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
-        assert.deepEqual(args[1], true);
-      });
+      const startRange = document.createRange();
+      startRange.setStart(startContent.firstChild, 10);
+      startRange.setEnd(startContent.firstChild, 11);
 
-      test('comment-thread-mouseleave from line comments is ignored', () => {
-        const threadEl = document.createElement('div');
-        threadEl.className = 'comment-thread';
-        threadEl.setAttribute('comment-side', 'right');
-        threadEl.setAttribute('line-num', 3);
-        element.appendChild(threadEl);
-        element.commentRanges = [{side: 'right'}];
+      const endRange = document.createRange();
+      endRange.setStart(endContent.lastChild, 6);
+      endRange.setEnd(endContent.lastChild, 7);
 
-        sandbox.stub(element, 'set');
-        threadEl.dispatchEvent(new CustomEvent(
-            'comment-thread-mouseleave', {bubbles: true, composed: true}));
-        assert.isFalse(element.set.called);
-      });
-
-      test(`create-range-comment for range when create-comment-requested
-            is fired`, () => {
-        sandbox.stub(element, '_removeActionBox');
-        element.selectedRange = {
-          side: 'left',
-          range: {
-            start_line: 7,
-            start_character: 11,
-            end_line: 24,
-            end_character: 42,
-          },
-        };
-        const requestEvent = new CustomEvent('create-comment-requested');
-        let createRangeEvent;
-        element.addEventListener('create-range-comment', e => {
-          createRangeEvent = e;
-        });
-        element.dispatchEvent(requestEvent);
-        assert.deepEqual(element.selectedRange, createRangeEvent.detail);
-        assert.isTrue(element._removeActionBox.called);
+      const getRangeAtStub = sandbox.stub();
+      getRangeAtStub
+          .onFirstCall().returns(startRange)
+          .onSecondCall()
+          .returns(endRange);
+      const selection = {
+        rangeCount: 2,
+        getRangeAt: getRangeAtStub,
+        removeAllRanges: sandbox.stub(),
+      };
+      element._handleSelection(selection);
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
       });
     });
 
-    suite('selection', () => {
-      let diff;
-      let builder;
-      let contentStubs;
-
-      const stubContent = (line, side, opt_child) => {
-        const contentTd = diff.querySelector(
-            `.${side}.lineNum[data-value="${line}"] ~ .content`);
-        const contentText = contentTd.querySelector('.contentText');
-        const lineEl = diff.querySelector(
-            `.${side}.lineNum[data-value="${line}"]`);
-        contentStubs.push({
-          lineEl,
-          contentTd,
-          contentText,
-        });
-        builder.getContentByLineEl.withArgs(lineEl).returns(contentText);
-        builder.getLineNumberByChild.withArgs(lineEl).returns(line);
-        builder.getContentByLine.withArgs(line, side).returns(contentText);
-        builder.getSideByLineEl.withArgs(lineEl).returns(side);
-        return contentText;
-      };
-
-      const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
-        const selection = window.getSelection();
-        const range = document.createRange();
-        range.setStart(startNode, startOffset);
-        range.setEnd(endNode, endOffset);
-        selection.addRange(range);
-        element._handleSelection(selection);
-      };
-
-      const getLineElByChild = node => {
-        const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
-        return stubs && stubs.lineEl;
-      };
-
-      setup(() => {
-        contentStubs = [];
-        stub('gr-selection-action-box', {
-          placeAbove: sandbox.stub(),
-          placeBelow: sandbox.stub(),
-        });
-        diff = element.querySelector('#diffTable');
-        builder = {
-          getContentByLine: sandbox.stub(),
-          getContentByLineEl: sandbox.stub(),
-          getLineElByChild,
-          getLineNumberByChild: sandbox.stub(),
-          getSideByLineEl: sandbox.stub(),
-        };
-        element._cachedDiffBuilder = builder;
+    test('multiline grow end highlight over tabs', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
+      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 2,
       });
+      assert.equal(side, 'right');
+    });
 
-      teardown(() => {
-        contentStubs = null;
-        window.getSelection().removeAllRanges();
+    test('collapsed', () => {
+      const content = stubContent(138, 'left');
+      emulateSelection(content.firstChild, 5, content.firstChild, 5);
+      assert.isOk(window.getSelection().getRangeAt(0).startContainer);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts inside hl', () => {
+      const content = stubContent(140, 'left');
+      const hl = content.querySelector('.foo');
+      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 8,
+        end_line: 140,
+        end_character: 23,
       });
+      assert.equal(side, 'left');
+    });
 
-      test('single first line', () => {
-        const content = stubContent(1, 'right');
-        sandbox.spy(element, '_positionActionBox');
-        emulateSelection(content.firstChild, 5, content.firstChild, 12);
-        const actionBox = element.shadowRoot
-            .querySelector('gr-selection-action-box');
-        assert.isTrue(actionBox.positionBelow);
+    test('ends inside hl', () => {
+      const content = stubContent(140, 'left');
+      const hl = content.querySelector('.bar');
+      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 18,
+        end_line: 140,
+        end_character: 27,
       });
+    });
 
-      test('multiline starting on first line', () => {
-        const startContent = stubContent(1, 'right');
-        const endContent = stubContent(2, 'right');
-        sandbox.spy(element, '_positionActionBox');
-        emulateSelection(
-            startContent.firstChild, 10, endContent.lastChild, 7);
-        const actionBox = element.shadowRoot
-            .querySelector('gr-selection-action-box');
-        assert.isTrue(actionBox.positionBelow);
+    test('multiple hl', () => {
+      const content = stubContent(140, 'left');
+      const hl = content.querySelectorAll('hl')[4];
+      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 2,
+        end_line: 140,
+        end_character: 61,
       });
+      assert.equal(side, 'left');
+    });
 
-      test('single line', () => {
-        const content = stubContent(138, 'left');
-        sandbox.spy(element, '_positionActionBox');
-        emulateSelection(content.firstChild, 5, content.firstChild, 12);
-        const actionBox = element.shadowRoot
-            .querySelector('gr-selection-action-box');
-        const {range, side} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 138,
-          start_character: 5,
-          end_line: 138,
-          end_character: 12,
-        });
-        assert.equal(side, 'left');
-        assert.notOk(actionBox.positionBelow);
+    test('starts outside of diff', () => {
+      const contentText = stubContent(140, 'left');
+      const contentTd = contentText.parentElement;
+
+      emulateSelection(contentTd.previousElementSibling, 0,
+          contentText.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends outside of diff', () => {
+      const content = stubContent(140, 'left');
+      emulateSelection(content.nextElementSibling.firstChild, 2,
+          content.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts and ends on different sides', () => {
+      const startContent = stubContent(140, 'left');
+      const endContent = stubContent(130, 'right');
+      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts in comment thread element', () => {
+      const startContent = stubContent(140, 'left');
+      const comment = startContent.parentElement.querySelector(
+          '.comment-thread');
+      const endContent = stubContent(141, 'left');
+      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 83,
+        end_line: 141,
+        end_character: 4,
       });
+      assert.equal(side, 'left');
+    });
 
-      test('multiline', () => {
-        const startContent = stubContent(119, 'right');
-        const endContent = stubContent(120, 'right');
-        sandbox.spy(element, '_positionActionBox');
-        emulateSelection(
-            startContent.firstChild, 10, endContent.lastChild, 7);
-        const actionBox = element.shadowRoot
-            .querySelector('gr-selection-action-box');
-        const {range, side} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 119,
-          start_character: 10,
-          end_line: 120,
-          end_character: 36,
-        });
-        assert.equal(side, 'right');
-        assert.notOk(actionBox.positionBelow);
+    test('ends in comment thread element', () => {
+      const content = stubContent(140, 'left');
+      const comment = content.parentElement.querySelector(
+          '.comment-thread');
+      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 4,
+        end_line: 140,
+        end_character: 83,
       });
+      assert.equal(side, 'left');
+    });
 
-      test('multiple ranges aka firefox implementation', () => {
-        const startContent = stubContent(119, 'right');
-        const endContent = stubContent(120, 'right');
+    test('starts in context element', () => {
+      const contextControl =
+          diff.querySelector('.contextControl').querySelector('gr-button');
+      const content = stubContent(146, 'right');
+      emulateSelection(contextControl, 0, content.firstChild, 7);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
 
-        const startRange = document.createRange();
-        startRange.setStart(startContent.firstChild, 10);
-        startRange.setEnd(startContent.firstChild, 11);
+    test('ends in context element', () => {
+      const contextControl =
+          diff.querySelector('.contextControl').querySelector('gr-button');
+      const content = stubContent(141, 'left');
+      emulateSelection(content.firstChild, 2, contextControl, 1);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
 
-        const endRange = document.createRange();
-        endRange.setStart(endContent.lastChild, 6);
-        endRange.setEnd(endContent.lastChild, 7);
-
-        const getRangeAtStub = sandbox.stub();
-        getRangeAtStub
-            .onFirstCall().returns(startRange)
-            .onSecondCall()
-            .returns(endRange);
-        const selection = {
-          rangeCount: 2,
-          getRangeAt: getRangeAtStub,
-          removeAllRanges: sandbox.stub(),
-        };
-        element._handleSelection(selection);
-        const {range} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 119,
-          start_character: 10,
-          end_line: 120,
-          end_character: 36,
-        });
+    test('selection containing context element', () => {
+      const startContent = stubContent(130, 'right');
+      const endContent = stubContent(146, 'right');
+      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 130,
+        start_character: 3,
+        end_line: 146,
+        end_character: 14,
       });
+      assert.equal(side, 'right');
+    });
 
-      test('multiline grow end highlight over tabs', () => {
-        const startContent = stubContent(119, 'right');
-        const endContent = stubContent(120, 'right');
-        emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
-        const {range, side} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 119,
-          start_character: 10,
-          end_line: 120,
-          end_character: 2,
-        });
-        assert.equal(side, 'right');
+    test('ends at a tab', () => {
+      const content = stubContent(140, 'left');
+      emulateSelection(
+          content.firstChild, 1, content.querySelector('span'), 0);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 1,
+        end_line: 140,
+        end_character: 51,
       });
+      assert.equal(side, 'left');
+    });
 
-      test('collapsed', () => {
-        const content = stubContent(138, 'left');
-        emulateSelection(content.firstChild, 5, content.firstChild, 5);
-        assert.isOk(window.getSelection().getRangeAt(0).startContainer);
-        assert.isFalse(!!element.selectedRange);
+    test('starts at a tab', () => {
+      const content = stubContent(140, 'left');
+      emulateSelection(
+          content.querySelectorAll('hl')[3], 0,
+          content.querySelectorAll('span')[1].nextSibling, 1);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 71,
       });
+      assert.equal(side, 'left');
+    });
 
-      test('starts inside hl', () => {
-        const content = stubContent(140, 'left');
-        const hl = content.querySelector('.foo');
-        emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
-        const {range, side} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 140,
-          start_character: 8,
-          end_line: 140,
-          end_character: 23,
-        });
-        assert.equal(side, 'left');
+    test('properly accounts for syntax highlighting', () => {
+      const content = stubContent(140, 'left');
+      const spy = sinon.spy(element, '_normalizeRange');
+      emulateSelection(
+          content.querySelectorAll('hl')[3], 0,
+          content.querySelectorAll('span')[1], 0);
+      const spyCall = spy.getCall(0);
+      const range = window.getSelection().getRangeAt(0);
+      assert.notDeepEqual(spyCall.returnValue, range);
+    });
+
+    test('GrRangeNormalizer._getTextOffset computes text offset', () => {
+      let content = stubContent(140, 'left');
+      let child = content.lastChild.lastChild;
+      let result = GrRangeNormalizer._getTextOffset(content, child);
+      assert.equal(result, 75);
+      content = stubContent(146, 'right');
+      child = content.lastChild;
+      result = GrRangeNormalizer._getTextOffset(content, child);
+      assert.equal(result, 0);
+    });
+
+    test('_fixTripleClickSelection', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
+      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 0,
+        end_line: 119,
+        end_character: element._getLength(startContent),
       });
+      assert.equal(side, 'right');
+    });
 
-      test('ends inside hl', () => {
-        const content = stubContent(140, 'left');
-        const hl = content.querySelector('.bar');
-        emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
-        const {range} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 140,
-          start_character: 18,
-          end_line: 140,
-          end_character: 27,
-        });
+    test('_fixTripleClickSelection empty line', () => {
+      const startContent = stubContent(146, 'right');
+      const endContent = stubContent(165, 'left');
+      emulateSelection(startContent.firstChild, 0,
+          endContent.parentElement.previousElementSibling, 0);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 146,
+        start_character: 0,
+        end_line: 146,
+        end_character: 84,
       });
-
-      test('multiple hl', () => {
-        const content = stubContent(140, 'left');
-        const hl = content.querySelectorAll('hl')[4];
-        emulateSelection(content.firstChild, 2, hl.firstChild, 2);
-        const {range, side} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 140,
-          start_character: 2,
-          end_line: 140,
-          end_character: 61,
-        });
-        assert.equal(side, 'left');
-      });
-
-      test('starts outside of diff', () => {
-        const contentText = stubContent(140, 'left');
-        const contentTd = contentText.parentElement;
-
-        emulateSelection(contentTd.previousElementSibling, 0,
-            contentText.firstChild, 2);
-        assert.isFalse(!!element.selectedRange);
-      });
-
-      test('ends outside of diff', () => {
-        const content = stubContent(140, 'left');
-        emulateSelection(content.nextElementSibling.firstChild, 2,
-            content.firstChild, 2);
-        assert.isFalse(!!element.selectedRange);
-      });
-
-      test('starts and ends on different sides', () => {
-        const startContent = stubContent(140, 'left');
-        const endContent = stubContent(130, 'right');
-        emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
-        assert.isFalse(!!element.selectedRange);
-      });
-
-      test('starts in comment thread element', () => {
-        const startContent = stubContent(140, 'left');
-        const comment = startContent.parentElement.querySelector(
-            '.comment-thread');
-        const endContent = stubContent(141, 'left');
-        emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
-        const {range, side} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 140,
-          start_character: 83,
-          end_line: 141,
-          end_character: 4,
-        });
-        assert.equal(side, 'left');
-      });
-
-      test('ends in comment thread element', () => {
-        const content = stubContent(140, 'left');
-        const comment = content.parentElement.querySelector(
-            '.comment-thread');
-        emulateSelection(content.firstChild, 4, comment.firstChild, 1);
-        const {range, side} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 140,
-          start_character: 4,
-          end_line: 140,
-          end_character: 83,
-        });
-        assert.equal(side, 'left');
-      });
-
-      test('starts in context element', () => {
-        const contextControl =
-            diff.querySelector('.contextControl').querySelector('gr-button');
-        const content = stubContent(146, 'right');
-        emulateSelection(contextControl, 0, content.firstChild, 7);
-        // TODO (viktard): Select nearest line.
-        assert.isFalse(!!element.selectedRange);
-      });
-
-      test('ends in context element', () => {
-        const contextControl =
-            diff.querySelector('.contextControl').querySelector('gr-button');
-        const content = stubContent(141, 'left');
-        emulateSelection(content.firstChild, 2, contextControl, 1);
-        // TODO (viktard): Select nearest line.
-        assert.isFalse(!!element.selectedRange);
-      });
-
-      test('selection containing context element', () => {
-        const startContent = stubContent(130, 'right');
-        const endContent = stubContent(146, 'right');
-        emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
-        const {range, side} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 130,
-          start_character: 3,
-          end_line: 146,
-          end_character: 14,
-        });
-        assert.equal(side, 'right');
-      });
-
-      test('ends at a tab', () => {
-        const content = stubContent(140, 'left');
-        emulateSelection(
-            content.firstChild, 1, content.querySelector('span'), 0);
-        const {range, side} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 140,
-          start_character: 1,
-          end_line: 140,
-          end_character: 51,
-        });
-        assert.equal(side, 'left');
-      });
-
-      test('starts at a tab', () => {
-        const content = stubContent(140, 'left');
-        emulateSelection(
-            content.querySelectorAll('hl')[3], 0,
-            content.querySelectorAll('span')[1].nextSibling, 1);
-        const {range, side} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 140,
-          start_character: 51,
-          end_line: 140,
-          end_character: 71,
-        });
-        assert.equal(side, 'left');
-      });
-
-      test('properly accounts for syntax highlighting', () => {
-        const content = stubContent(140, 'left');
-        const spy = sinon.spy(element, '_normalizeRange');
-        emulateSelection(
-            content.querySelectorAll('hl')[3], 0,
-            content.querySelectorAll('span')[1], 0);
-        const spyCall = spy.getCall(0);
-        const range = window.getSelection().getRangeAt(0);
-        assert.notDeepEqual(spyCall.returnValue, range);
-      });
-
-      test('GrRangeNormalizer._getTextOffset computes text offset', () => {
-        let content = stubContent(140, 'left');
-        let child = content.lastChild.lastChild;
-        let result = GrRangeNormalizer._getTextOffset(content, child);
-        assert.equal(result, 75);
-        content = stubContent(146, 'right');
-        child = content.lastChild;
-        result = GrRangeNormalizer._getTextOffset(content, child);
-        assert.equal(result, 0);
-      });
-
-      test('_fixTripleClickSelection', () => {
-        const startContent = stubContent(119, 'right');
-        const endContent = stubContent(120, 'right');
-        emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
-        const {range, side} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 119,
-          start_character: 0,
-          end_line: 119,
-          end_character: element._getLength(startContent),
-        });
-        assert.equal(side, 'right');
-      });
-
-      test('_fixTripleClickSelection empty line', () => {
-        const startContent = stubContent(146, 'right');
-        const endContent = stubContent(165, 'left');
-        emulateSelection(startContent.firstChild, 0,
-            endContent.parentElement.previousElementSibling, 0);
-        const {range, side} = element.selectedRange;
-        assert.deepEqual(range, {
-          start_line: 146,
-          start_character: 0,
-          end_line: 146,
-          end_character: 84,
-        });
-        assert.equal(side, 'right');
-      });
+      assert.equal(side, 'right');
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index a44e366..26a3a40 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -14,1097 +14,1112 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const MSG_EMPTY_BLAME = 'No blame information for this diff.';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-comment-thread/gr-comment-thread.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import '../gr-diff/gr-diff.js';
+import '../gr-syntax-layer/gr-syntax-layer.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-host_html.js';
 
-  const EVENT_AGAINST_PARENT = 'diff-against-parent';
-  const EVENT_ZERO_REBASE = 'rebase-percent-zero';
-  const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
+const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+const EVENT_AGAINST_PARENT = 'diff-against-parent';
+const EVENT_ZERO_REBASE = 'rebase-percent-zero';
+const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
 
-  /** @enum {string} */
-  const TimingLabel = {
-    TOTAL: 'Diff Total Render',
-    CONTENT: 'Diff Content Render',
-    SYNTAX: 'Diff Syntax Render',
-  };
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
 
-  // Disable syntax highlighting if the overall diff is too large.
-  const SYNTAX_MAX_DIFF_LENGTH = 20000;
+/** @enum {string} */
+const TimingLabel = {
+  TOTAL: 'Diff Total Render',
+  CONTENT: 'Diff Content Render',
+  SYNTAX: 'Diff Syntax Render',
+};
 
-  // If any line of the diff is more than the character limit, then disable
-  // syntax highlighting for the entire file.
-  const SYNTAX_MAX_LINE_LENGTH = 500;
+// Disable syntax highlighting if the overall diff is too large.
+const SYNTAX_MAX_DIFF_LENGTH = 20000;
 
-  // 120 lines is good enough threshold for full-sized window viewport
-  const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
+// If any line of the diff is more than the character limit, then disable
+// syntax highlighting for the entire file.
+const SYNTAX_MAX_LINE_LENGTH = 500;
 
-  const WHITESPACE_IGNORE_NONE = 'IGNORE_NONE';
+// 120 lines is good enough threshold for full-sized window viewport
+const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
+
+const WHITESPACE_IGNORE_NONE = 'IGNORE_NONE';
+
+/**
+ * @param {Object} diff
+ * @return {boolean}
+ */
+function isImageDiff(diff) {
+  if (!diff) { return false; }
+
+  const isA = diff.meta_a &&
+      diff.meta_a.content_type.startsWith('image/');
+  const isB = diff.meta_b &&
+      diff.meta_b.content_type.startsWith('image/');
+
+  return !!(diff.binary && (isA || isB));
+}
+
+/** @enum {string} */
+Gerrit.DiffSide = {
+  LEFT: 'left',
+  RIGHT: 'right',
+};
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ */
+/**
+ * Wrapper around gr-diff.
+ *
+ * Webcomponent fetching diffs and related data from restAPI and passing them
+ * to the presentational gr-diff for rendering.
+ *
+ * @extends Polymer.Element
+ */
+class GrDiffHost extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.PatchSetBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-host'; }
+  /**
+   * Fired when the user selects a line.
+   *
+   * @event line-selected
+   */
+
+  /**
+   * Fired if being logged in is required.
+   *
+   * @event show-auth-required
+   */
+
+  /**
+   * Fired when a comment is saved or discarded
+   *
+   * @event diff-comments-modified
+   */
+
+  static get properties() {
+    return {
+      changeNum: String,
+      noAutoRender: {
+        type: Boolean,
+        value: false,
+      },
+      /** @type {?} */
+      patchRange: Object,
+      path: String,
+      prefs: {
+        type: Object,
+      },
+      projectName: String,
+      displayLine: {
+        type: Boolean,
+        value: false,
+      },
+      isImageDiff: {
+        type: Boolean,
+        computed: '_computeIsImageDiff(diff)',
+        notify: true,
+      },
+      commitRange: Object,
+      filesWeblinks: {
+        type: Object,
+        value() {
+          return {};
+        },
+        notify: true,
+      },
+      hidden: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
+      noRenderOnPrefsChange: {
+        type: Boolean,
+        value: false,
+      },
+      comments: {
+        type: Object,
+        observer: '_commentsChanged',
+      },
+      lineWrapping: {
+        type: Boolean,
+        value: false,
+      },
+      viewMode: {
+        type: String,
+        value: DiffViewMode.SIDE_BY_SIDE,
+      },
+
+      /**
+       * Special line number which should not be collapsed into a shared region.
+       *
+       * @type {{
+       *  number: number,
+       *  leftSide: {boolean}
+       * }|null}
+       */
+      lineOfInterest: Object,
+
+      /**
+       * If the diff fails to load, show the failure message in the diff rather
+       * than bubbling the error up to the whole page. This is useful for when
+       * loading inline diffs because one diff failing need not mark the whole
+       * page with a failure.
+       */
+      showLoadFailure: Boolean,
+
+      isBlameLoaded: {
+        type: Boolean,
+        notify: true,
+        computed: '_computeIsBlameLoaded(_blame)',
+      },
+
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+
+      _loading: {
+        type: Boolean,
+        value: false,
+      },
+
+      /** @type {?string} */
+      _errorMessage: {
+        type: String,
+        value: null,
+      },
+
+      /** @type {?Object} */
+      _baseImage: Object,
+      /** @type {?Object} */
+      _revisionImage: Object,
+      /**
+       * This is a DiffInfo object.
+       */
+      diff: {
+        type: Object,
+        notify: true,
+      },
+
+      /** @type {?Object} */
+      _blame: {
+        type: Object,
+        value: null,
+      },
+
+      /**
+       * @type {!Array<!Gerrit.CoverageRange>}
+       */
+      _coverageRanges: {
+        type: Array,
+        value: () => [],
+      },
+
+      _loadedWhitespaceLevel: String,
+
+      _parentIndex: {
+        type: Number,
+        computed: '_computeParentIndex(patchRange.*)',
+      },
+
+      _syntaxHighlightingEnabled: {
+        type: Boolean,
+        computed:
+        '_isSyntaxHighlightingEnabled(prefs.*, diff)',
+      },
+
+      _layers: {
+        type: Array,
+        value: [],
+      },
+    };
+  }
+
+  static get observers() {
+    return [
+      '_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' +
+        ' noRenderOnPrefsChange)',
+      '_syntaxHighlightingChanged(noRenderOnPrefsChange, prefs.*)',
+    ];
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener(
+        // These are named inconsistently for a reason:
+        // The create-comment event is fired to indicate that we should
+        // create a comment.
+        // The comment-* events are just notifying that the comments did already
+        // change in some way, and that we should update any models we may want
+        // to keep in sync.
+        'create-comment',
+        e => this._handleCreateComment(e));
+    this.addEventListener('comment-discard',
+        e => this._handleCommentDiscard(e));
+    this.addEventListener('comment-update',
+        e => this._handleCommentUpdate(e));
+    this.addEventListener('comment-save',
+        e => this._handleCommentSave(e));
+    this.addEventListener('render-start',
+        () => this._handleRenderStart());
+    this.addEventListener('render-content',
+        () => this._handleRenderContent());
+    this.addEventListener('normalize-range',
+        event => this._handleNormalizeRange(event));
+    this.addEventListener('diff-context-expanded',
+        event => this._handleDiffContextExpanded(event));
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    if (this._canReload()) {
+      this.reload();
+    }
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+  }
+
+  /**
+   * @param {boolean=} shouldReportMetric indicate a new Diff Page. This is a
+   * signal to report metrics event that started on location change.
+   * @return {!Promise}
+   **/
+  reload(shouldReportMetric) {
+    this._loading = true;
+    this._errorMessage = null;
+    const whitespaceLevel = this._getIgnoreWhitespace();
+
+    const layers = [this.$.syntaxLayer];
+    // Get layers from plugins (if any).
+    for (const pluginLayer of this.$.jsAPI.getDiffLayers(
+        this.path, this.changeNum, this.patchNum)) {
+      layers.push(pluginLayer);
+    }
+    this._layers = layers;
+
+    if (shouldReportMetric) {
+      // We listen on render viewport only on DiffPage (on paramsChanged)
+      this._listenToViewportRender();
+    }
+
+    this._coverageRanges = [];
+    this._getCoverageData();
+    const diffRequest = this._getDiff()
+        .then(diff => {
+          this._loadedWhitespaceLevel = whitespaceLevel;
+          this._reportDiff(diff);
+          return diff;
+        })
+        .catch(e => {
+          this._handleGetDiffError(e);
+          return null;
+        });
+
+    const assetRequest = diffRequest.then(diff => {
+      // If the diff is null, then it's failed to load.
+      if (!diff) { return null; }
+
+      return this._loadDiffAssets(diff);
+    });
+
+    // Not waiting for coverage ranges intentionally as
+    // plugin loading should not block the content rendering
+    return Promise.all([diffRequest, assetRequest])
+        .then(results => {
+          const diff = results[0];
+          if (!diff) {
+            return Promise.resolve();
+          }
+          this.filesWeblinks = this._getFilesWeblinks(diff);
+          return new Promise(resolve => {
+            const callback = event => {
+              const needsSyntaxHighlighting = event.detail &&
+                    event.detail.contentRendered;
+              if (needsSyntaxHighlighting) {
+                this.$.reporting.time(TimingLabel.SYNTAX);
+                this.$.syntaxLayer.process().then(() => {
+                  this.$.reporting.timeEnd(TimingLabel.SYNTAX);
+                  this.$.reporting.timeEnd(TimingLabel.TOTAL);
+                  resolve();
+                });
+              } else {
+                this.$.reporting.timeEnd(TimingLabel.TOTAL);
+                resolve();
+              }
+              this.removeEventListener('render', callback);
+              if (shouldReportMetric) {
+                // We report diffViewContentDisplayed only on reload caused
+                // by params changed - expected only on Diff Page.
+                this.$.reporting.diffViewContentDisplayed();
+              }
+            };
+            this.addEventListener('render', callback);
+            this.diff = diff;
+          });
+        })
+        .catch(err => {
+          console.warn('Error encountered loading diff:', err);
+        })
+        .then(() => { this._loading = false; });
+  }
+
+  _getCoverageData() {
+    const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
+    this.$.jsAPI.getCoverageAnnotationApi().
+        then(coverageAnnotationApi => {
+          if (!coverageAnnotationApi) return;
+          const provider = coverageAnnotationApi.getCoverageProvider();
+          return provider(changeNum, path, basePatchNum, patchNum)
+              .then(coverageRanges => {
+                if (!coverageRanges ||
+                  changeNum !== this.changeNum ||
+                  path !== this.path ||
+                  basePatchNum !== this.patchRange.basePatchNum ||
+                  patchNum !== this.patchRange.patchNum) {
+                  return;
+                }
+
+                const existingCoverageRanges = this._coverageRanges;
+                this._coverageRanges = coverageRanges;
+
+                // Notify with existing coverage ranges
+                // in case there is some existing coverage data that needs to be removed
+                existingCoverageRanges.forEach(range => {
+                  coverageAnnotationApi.notify(
+                      path,
+                      range.code_range.start_line,
+                      range.code_range.end_line,
+                      range.side);
+                });
+
+                // Notify with new coverage data
+                coverageRanges.forEach(range => {
+                  coverageAnnotationApi.notify(
+                      path,
+                      range.code_range.start_line,
+                      range.code_range.end_line,
+                      range.side);
+                });
+              });
+        })
+        .catch(err => {
+          console.warn('Loading coverage ranges failed: ', err);
+        });
+  }
+
+  _getFilesWeblinks(diff) {
+    if (!this.commitRange) {
+      return {};
+    }
+    return {
+      meta_a: Gerrit.Nav.getFileWebLinks(
+          this.projectName, this.commitRange.baseCommit, this.path,
+          {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
+      meta_b: Gerrit.Nav.getFileWebLinks(
+          this.projectName, this.commitRange.commit, this.path,
+          {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
+    };
+  }
+
+  /** Cancel any remaining diff builder rendering work. */
+  cancel() {
+    this.$.diff.cancel();
+  }
+
+  /** @return {!Array<!HTMLElement>} */
+  getCursorStops() {
+    return this.$.diff.getCursorStops();
+  }
+
+  /** @return {boolean} */
+  isRangeSelected() {
+    return this.$.diff.isRangeSelected();
+  }
+
+  createRangeComment() {
+    return this.$.diff.createRangeComment();
+  }
+
+  toggleLeftDiff() {
+    this.$.diff.toggleLeftDiff();
+  }
+
+  /**
+   * Load and display blame information for the base of the diff.
+   *
+   * @return {Promise} A promise that resolves when blame finishes rendering.
+   */
+  loadBlame() {
+    return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
+        this.path, true)
+        .then(blame => {
+          if (!blame.length) {
+            this.fire('show-alert', {message: MSG_EMPTY_BLAME});
+            return Promise.reject(MSG_EMPTY_BLAME);
+          }
+
+          this._blame = blame;
+        });
+  }
+
+  /** Unload blame information for the diff. */
+  clearBlame() {
+    this._blame = null;
+  }
+
+  /**
+   * The thread elements in this diff, in no particular order.
+   *
+   * @return {!Array<!HTMLElement>}
+   */
+  getThreadEls() {
+    return Array.from(
+        dom(this.$.diff).querySelectorAll('.comment-thread'));
+  }
+
+  /** @param {HTMLElement} el */
+  addDraftAtLine(el) {
+    this.$.diff.addDraftAtLine(el);
+  }
+
+  clearDiffContent() {
+    this.$.diff.clearDiffContent();
+  }
+
+  expandAllContext() {
+    this.$.diff.expandAllContext();
+  }
+
+  /** @return {!Promise} */
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  /** @return {boolean}} */
+  _canReload() {
+    return !!this.changeNum && !!this.patchRange && !!this.path &&
+        !this.noAutoRender;
+  }
+
+  /** @return {!Promise<!Object>} */
+  _getDiff() {
+    // Wrap the diff request in a new promise so that the error handler
+    // rejects the promise, allowing the error to be handled in the .catch.
+    return new Promise((resolve, reject) => {
+      this.$.restAPI.getDiff(
+          this.changeNum,
+          this.patchRange.basePatchNum,
+          this.patchRange.patchNum,
+          this.path,
+          this._getIgnoreWhitespace(),
+          reject)
+          .then(resolve);
+    });
+  }
+
+  _handleGetDiffError(response) {
+    // Loading the diff may respond with 409 if the file is too large. In this
+    // case, use a toast error..
+    if (response.status === 409) {
+      this.fire('server-error', {response});
+      return;
+    }
+
+    if (this.showLoadFailure) {
+      this._errorMessage = [
+        'Encountered error when loading the diff:',
+        response.status,
+        response.statusText,
+      ].join(' ');
+      return;
+    }
+
+    this.fire('page-error', {response});
+  }
+
+  /**
+   * Report info about the diff response.
+   */
+  _reportDiff(diff) {
+    if (!diff || !diff.content) {
+      return;
+    }
+
+    // Count the delta lines stemming from normal deltas, and from
+    // due_to_rebase deltas.
+    let nonRebaseDelta = 0;
+    let rebaseDelta = 0;
+    diff.content.forEach(chunk => {
+      if (chunk.ab) { return; }
+      const deltaSize = Math.max(
+          chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
+      if (chunk.due_to_rebase) {
+        rebaseDelta += deltaSize;
+      } else {
+        nonRebaseDelta += deltaSize;
+      }
+    });
+
+    // Find the percent of the delta from due_to_rebase chunks rounded to two
+    // digits. Diffs with no delta are considered 0%.
+    const totalDelta = rebaseDelta + nonRebaseDelta;
+    const percentRebaseDelta = !totalDelta ? 0 :
+      Math.round(100 * rebaseDelta / totalDelta);
+
+    // Report the due_to_rebase percentage in the "diff" category when
+    // applicable.
+    if (this.patchRange.basePatchNum === 'PARENT') {
+      this.$.reporting.reportInteraction(EVENT_AGAINST_PARENT);
+    } else if (percentRebaseDelta === 0) {
+      this.$.reporting.reportInteraction(EVENT_ZERO_REBASE);
+    } else {
+      this.$.reporting.reportInteraction(EVENT_NONZERO_REBASE,
+          {percentRebaseDelta});
+    }
+  }
+
+  /**
+   * @param {Object} diff
+   * @return {!Promise}
+   */
+  _loadDiffAssets(diff) {
+    if (isImageDiff(diff)) {
+      return this._getImages(diff).then(images => {
+        this._baseImage = images.baseImage;
+        this._revisionImage = images.revisionImage;
+      });
+    } else {
+      this._baseImage = null;
+      this._revisionImage = null;
+      return Promise.resolve();
+    }
+  }
 
   /**
    * @param {Object} diff
    * @return {boolean}
    */
-  function isImageDiff(diff) {
-    if (!diff) { return false; }
-
-    const isA = diff.meta_a &&
-        diff.meta_a.content_type.startsWith('image/');
-    const isB = diff.meta_b &&
-        diff.meta_b.content_type.startsWith('image/');
-
-    return !!(diff.binary && (isA || isB));
+  _computeIsImageDiff(diff) {
+    return isImageDiff(diff);
   }
 
-  /** @enum {string} */
-  Gerrit.DiffSide = {
-    LEFT: 'left',
-    RIGHT: 'right',
-  };
+  _commentsChanged(newComments) {
+    const allComments = [];
+    for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
+      // This is needed by the threading.
+      for (const comment of newComments[side]) {
+        comment.__commentSide = side;
+      }
+      allComments.push(...newComments[side]);
+    }
+    // Currently, the only way this is ever changed here is when the initial
+    // comments are loaded, so it's okay performance wise to clear the threads
+    // and recreate them. If this changes in future, we might want to reuse
+    // some DOM nodes here.
+    this._clearThreads();
+    const threads = this._createThreads(allComments);
+    for (const thread of threads) {
+      const threadEl = this._createThreadElement(thread);
+      this._attachThreadElement(threadEl);
+    }
+  }
+
+  _sortComments(comments) {
+    return comments.slice(0).sort((a, b) => {
+      if (b.__draft && !a.__draft ) { return -1; }
+      if (a.__draft && !b.__draft ) { return 1; }
+      return util.parseDate(a.updated) - util.parseDate(b.updated);
+    });
+  }
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.PatchSetMixin
+   * @param {!Array<!Object>} comments
+   * @return {!Array<!Object>} Threads for the given comments.
    */
+  _createThreads(comments) {
+    const sortedComments = this._sortComments(comments);
+    const threads = [];
+    for (const comment of sortedComments) {
+      // If the comment is in reply to another comment, find that comment's
+      // thread and append to it.
+      if (comment.in_reply_to) {
+        const thread = threads.find(thread =>
+          thread.comments.some(c => c.id === comment.in_reply_to));
+        if (thread) {
+          thread.comments.push(comment);
+          continue;
+        }
+      }
+
+      // Otherwise, this comment starts its own thread.
+      const newThread = {
+        start_datetime: comment.updated,
+        comments: [comment],
+        commentSide: comment.__commentSide,
+        patchNum: comment.patch_set,
+        rootId: comment.id || comment.__draftID,
+        lineNum: comment.line,
+        isOnParent: comment.side === 'PARENT',
+      };
+      if (comment.range) {
+        newThread.range = Object.assign({}, comment.range);
+      }
+      threads.push(newThread);
+    }
+    return threads;
+  }
+
   /**
-   * Wrapper around gr-diff.
-   *
-   * Webcomponent fetching diffs and related data from restAPI and passing them
-   * to the presentational gr-diff for rendering.
-   *
-   * @extends Polymer.Element
+   * @param {Object} blame
+   * @return {boolean}
    */
-  class GrDiffHost extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.PatchSetBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-diff-host'; }
-    /**
-     * Fired when the user selects a line.
-     *
-     * @event line-selected
-     */
+  _computeIsBlameLoaded(blame) {
+    return !!blame;
+  }
 
-    /**
-     * Fired if being logged in is required.
-     *
-     * @event show-auth-required
-     */
+  /**
+   * @param {Object} diff
+   * @return {!Promise}
+   */
+  _getImages(diff) {
+    return this.$.restAPI.getImagesForDiff(this.changeNum, diff,
+        this.patchRange);
+  }
 
-    /**
-     * Fired when a comment is saved or discarded
-     *
-     * @event diff-comments-modified
-     */
+  /** @param {CustomEvent} e */
+  _handleCreateComment(e) {
+    const {lineNum, side, patchNum, isOnParent, range} = e.detail;
+    const threadEl = this._getOrCreateThread(patchNum, lineNum, side, range,
+        isOnParent);
+    threadEl.addOrEditDraft(lineNum, range);
 
-    static get properties() {
-      return {
-        changeNum: String,
-        noAutoRender: {
-          type: Boolean,
-          value: false,
-        },
-        /** @type {?} */
-        patchRange: Object,
-        path: String,
-        prefs: {
-          type: Object,
-        },
-        projectName: String,
-        displayLine: {
-          type: Boolean,
-          value: false,
-        },
-        isImageDiff: {
-          type: Boolean,
-          computed: '_computeIsImageDiff(diff)',
-          notify: true,
-        },
-        commitRange: Object,
-        filesWeblinks: {
-          type: Object,
-          value() {
-            return {};
-          },
-          notify: true,
-        },
-        hidden: {
-          type: Boolean,
-          reflectToAttribute: true,
-        },
-        noRenderOnPrefsChange: {
-          type: Boolean,
-          value: false,
-        },
-        comments: {
-          type: Object,
-          observer: '_commentsChanged',
-        },
-        lineWrapping: {
-          type: Boolean,
-          value: false,
-        },
-        viewMode: {
-          type: String,
-          value: DiffViewMode.SIDE_BY_SIDE,
-        },
+    this.$.reporting.recordDraftInteraction();
+  }
 
-        /**
-         * Special line number which should not be collapsed into a shared region.
-         *
-         * @type {{
-         *  number: number,
-         *  leftSide: {boolean}
-         * }|null}
-         */
-        lineOfInterest: Object,
-
-        /**
-         * If the diff fails to load, show the failure message in the diff rather
-         * than bubbling the error up to the whole page. This is useful for when
-         * loading inline diffs because one diff failing need not mark the whole
-         * page with a failure.
-         */
-        showLoadFailure: Boolean,
-
-        isBlameLoaded: {
-          type: Boolean,
-          notify: true,
-          computed: '_computeIsBlameLoaded(_blame)',
-        },
-
-        _loggedIn: {
-          type: Boolean,
-          value: false,
-        },
-
-        _loading: {
-          type: Boolean,
-          value: false,
-        },
-
-        /** @type {?string} */
-        _errorMessage: {
-          type: String,
-          value: null,
-        },
-
-        /** @type {?Object} */
-        _baseImage: Object,
-        /** @type {?Object} */
-        _revisionImage: Object,
-        /**
-         * This is a DiffInfo object.
-         */
-        diff: {
-          type: Object,
-          notify: true,
-        },
-
-        /** @type {?Object} */
-        _blame: {
-          type: Object,
-          value: null,
-        },
-
-        /**
-         * @type {!Array<!Gerrit.CoverageRange>}
-         */
-        _coverageRanges: {
-          type: Array,
-          value: () => [],
-        },
-
-        _loadedWhitespaceLevel: String,
-
-        _parentIndex: {
-          type: Number,
-          computed: '_computeParentIndex(patchRange.*)',
-        },
-
-        _syntaxHighlightingEnabled: {
-          type: Boolean,
-          computed:
-          '_isSyntaxHighlightingEnabled(prefs.*, diff)',
-        },
-
-        _layers: {
-          type: Array,
-          value: [],
-        },
-      };
-    }
-
-    static get observers() {
-      return [
-        '_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' +
-          ' noRenderOnPrefsChange)',
-        '_syntaxHighlightingChanged(noRenderOnPrefsChange, prefs.*)',
-      ];
-    }
-
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener(
-          // These are named inconsistently for a reason:
-          // The create-comment event is fired to indicate that we should
-          // create a comment.
-          // The comment-* events are just notifying that the comments did already
-          // change in some way, and that we should update any models we may want
-          // to keep in sync.
-          'create-comment',
-          e => this._handleCreateComment(e));
-      this.addEventListener('comment-discard',
-          e => this._handleCommentDiscard(e));
-      this.addEventListener('comment-update',
-          e => this._handleCommentUpdate(e));
-      this.addEventListener('comment-save',
-          e => this._handleCommentSave(e));
-      this.addEventListener('render-start',
-          () => this._handleRenderStart());
-      this.addEventListener('render-content',
-          () => this._handleRenderContent());
-      this.addEventListener('normalize-range',
-          event => this._handleNormalizeRange(event));
-      this.addEventListener('diff-context-expanded',
-          event => this._handleDiffContextExpanded(event));
-    }
-
-    /** @override */
-    ready() {
-      super.ready();
-      if (this._canReload()) {
-        this.reload();
-      }
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
+  /**
+   * Gets or creates a comment thread at a given location.
+   * May provide a range, to get/create a range comment.
+   *
+   * @param {string} patchNum
+   * @param {?number} lineNum
+   * @param {string} commentSide
+   * @param {Gerrit.Range|undefined} range
+   * @param {boolean} isOnParent
+   * @return {!Object}
+   */
+  _getOrCreateThread(patchNum, lineNum, commentSide, range, isOnParent) {
+    let threadEl = this._getThreadEl(lineNum, commentSide, range);
+    if (!threadEl) {
+      threadEl = this._createThreadElement({
+        comments: [],
+        commentSide,
+        patchNum,
+        lineNum,
+        range,
+        isOnParent,
       });
+      this._attachThreadElement(threadEl);
+    }
+    return threadEl;
+  }
+
+  _attachThreadElement(threadEl) {
+    dom(this.$.diff).appendChild(threadEl);
+  }
+
+  _clearThreads() {
+    for (const threadEl of this.getThreadEls()) {
+      const parent = dom(threadEl).parentNode;
+      dom(parent).removeChild(threadEl);
+    }
+  }
+
+  _createThreadElement(thread) {
+    const threadEl = document.createElement('gr-comment-thread');
+    threadEl.className = 'comment-thread';
+    threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
+    threadEl.comments = thread.comments;
+    threadEl.commentSide = thread.commentSide;
+    threadEl.isOnParent = !!thread.isOnParent;
+    threadEl.parentIndex = this._parentIndex;
+    threadEl.changeNum = this.changeNum;
+    threadEl.patchNum = thread.patchNum;
+    threadEl.lineNum = thread.lineNum;
+    const rootIdChangedListener = changeEvent => {
+      thread.rootId = changeEvent.detail.value;
+    };
+    threadEl.addEventListener('root-id-changed', rootIdChangedListener);
+    threadEl.path = this.path;
+    threadEl.projectName = this.projectName;
+    threadEl.range = thread.range;
+    const threadDiscardListener = e => {
+      const threadEl = /** @type {!Node} */ (e.currentTarget);
+
+      const parent = dom(threadEl).parentNode;
+      dom(parent).removeChild(threadEl);
+
+      threadEl.removeEventListener('root-id-changed', rootIdChangedListener);
+      threadEl.removeEventListener('thread-discard', threadDiscardListener);
+    };
+    threadEl.addEventListener('thread-discard', threadDiscardListener);
+    return threadEl;
+  }
+
+  /**
+   * Gets a comment thread element at a given location.
+   * May provide a range, to get a range comment.
+   *
+   * @param {?number} lineNum
+   * @param {string} commentSide
+   * @param {!Gerrit.Range=} range
+   * @return {?Node}
+   */
+  _getThreadEl(lineNum, commentSide, range = undefined) {
+    let line;
+    if (commentSide === GrDiffBuilder.Side.LEFT) {
+      line = {beforeNumber: lineNum};
+    } else if (commentSide === GrDiffBuilder.Side.RIGHT) {
+      line = {afterNumber: lineNum};
+    } else {
+      throw new Error(`Unknown side: ${commentSide}`);
+    }
+    function matchesRange(threadEl) {
+      const threadRange = /** @type {!Gerrit.Range} */(
+        JSON.parse(threadEl.getAttribute('range')));
+      return Gerrit.rangesEqual(threadRange, range);
     }
 
-    /**
-     * @param {boolean=} shouldReportMetric indicate a new Diff Page. This is a
-     * signal to report metrics event that started on location change.
-     * @return {!Promise}
-     **/
-    reload(shouldReportMetric) {
-      this._loading = true;
-      this._errorMessage = null;
-      const whitespaceLevel = this._getIgnoreWhitespace();
+    const filteredThreadEls = this._filterThreadElsForLocation(
+        this.getThreadEls(), line, commentSide).filter(matchesRange);
+    return filteredThreadEls.length ? filteredThreadEls[0] : null;
+  }
 
-      const layers = [this.$.syntaxLayer];
-      // Get layers from plugins (if any).
-      for (const pluginLayer of this.$.jsAPI.getDiffLayers(
-          this.path, this.changeNum, this.patchNum)) {
-        layers.push(pluginLayer);
-      }
-      this._layers = layers;
-
-      if (shouldReportMetric) {
-        // We listen on render viewport only on DiffPage (on paramsChanged)
-        this._listenToViewportRender();
-      }
-
-      this._coverageRanges = [];
-      this._getCoverageData();
-      const diffRequest = this._getDiff()
-          .then(diff => {
-            this._loadedWhitespaceLevel = whitespaceLevel;
-            this._reportDiff(diff);
-            return diff;
-          })
-          .catch(e => {
-            this._handleGetDiffError(e);
-            return null;
-          });
-
-      const assetRequest = diffRequest.then(diff => {
-        // If the diff is null, then it's failed to load.
-        if (!diff) { return null; }
-
-        return this._loadDiffAssets(diff);
-      });
-
-      // Not waiting for coverage ranges intentionally as
-      // plugin loading should not block the content rendering
-      return Promise.all([diffRequest, assetRequest])
-          .then(results => {
-            const diff = results[0];
-            if (!diff) {
-              return Promise.resolve();
-            }
-            this.filesWeblinks = this._getFilesWeblinks(diff);
-            return new Promise(resolve => {
-              const callback = event => {
-                const needsSyntaxHighlighting = event.detail &&
-                      event.detail.contentRendered;
-                if (needsSyntaxHighlighting) {
-                  this.$.reporting.time(TimingLabel.SYNTAX);
-                  this.$.syntaxLayer.process().then(() => {
-                    this.$.reporting.timeEnd(TimingLabel.SYNTAX);
-                    this.$.reporting.timeEnd(TimingLabel.TOTAL);
-                    resolve();
-                  });
-                } else {
-                  this.$.reporting.timeEnd(TimingLabel.TOTAL);
-                  resolve();
-                }
-                this.removeEventListener('render', callback);
-                if (shouldReportMetric) {
-                  // We report diffViewContentDisplayed only on reload caused
-                  // by params changed - expected only on Diff Page.
-                  this.$.reporting.diffViewContentDisplayed();
-                }
-              };
-              this.addEventListener('render', callback);
-              this.diff = diff;
-            });
-          })
-          .catch(err => {
-            console.warn('Error encountered loading diff:', err);
-          })
-          .then(() => { this._loading = false; });
+  /**
+   * @param {!Array<!HTMLElement>} threadEls
+   * @param {!{beforeNumber: (number|string|undefined|null),
+   *           afterNumber: (number|string|undefined|null)}}
+   *     lineInfo
+   * @param {!Gerrit.DiffSide=} side The side (LEFT, RIGHT) for
+   *     which to return the threads.
+   * @return {!Array<!HTMLElement>} The thread elements matching the given
+   *     location.
+   */
+  _filterThreadElsForLocation(threadEls, lineInfo, side) {
+    function matchesLeftLine(threadEl) {
+      return threadEl.getAttribute('comment-side') ==
+          Gerrit.DiffSide.LEFT &&
+          threadEl.getAttribute('line-num') == lineInfo.beforeNumber;
+    }
+    function matchesRightLine(threadEl) {
+      return threadEl.getAttribute('comment-side') ==
+          Gerrit.DiffSide.RIGHT &&
+          threadEl.getAttribute('line-num') == lineInfo.afterNumber;
+    }
+    function matchesFileComment(threadEl) {
+      return threadEl.getAttribute('comment-side') == side &&
+            // line/range comments have 1-based line set, if line is falsy it's
+            // a file comment
+            !threadEl.getAttribute('line-num');
     }
 
-    _getCoverageData() {
-      const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
-      this.$.jsAPI.getCoverageAnnotationApi().
-          then(coverageAnnotationApi => {
-            if (!coverageAnnotationApi) return;
-            const provider = coverageAnnotationApi.getCoverageProvider();
-            return provider(changeNum, path, basePatchNum, patchNum)
-                .then(coverageRanges => {
-                  if (!coverageRanges ||
-                    changeNum !== this.changeNum ||
-                    path !== this.path ||
-                    basePatchNum !== this.patchRange.basePatchNum ||
-                    patchNum !== this.patchRange.patchNum) {
-                    return;
-                  }
+    // Select the appropriate matchers for the desired side and line
+    // If side is BOTH, we want both the left and right matcher.
+    const matchers = [];
+    if (side !== Gerrit.DiffSide.RIGHT) {
+      matchers.push(matchesLeftLine);
+    }
+    if (side !== Gerrit.DiffSide.LEFT) {
+      matchers.push(matchesRightLine);
+    }
+    if (lineInfo.afterNumber === 'FILE' ||
+        lineInfo.beforeNumber === 'FILE') {
+      matchers.push(matchesFileComment);
+    }
+    return threadEls.filter(threadEl =>
+      matchers.some(matcher => matcher(threadEl)));
+  }
 
-                  const existingCoverageRanges = this._coverageRanges;
-                  this._coverageRanges = coverageRanges;
+  _getIgnoreWhitespace() {
+    if (!this.prefs || !this.prefs.ignore_whitespace) {
+      return WHITESPACE_IGNORE_NONE;
+    }
+    return this.prefs.ignore_whitespace;
+  }
 
-                  // Notify with existing coverage ranges
-                  // in case there is some existing coverage data that needs to be removed
-                  existingCoverageRanges.forEach(range => {
-                    coverageAnnotationApi.notify(
-                        path,
-                        range.code_range.start_line,
-                        range.code_range.end_line,
-                        range.side);
-                  });
-
-                  // Notify with new coverage data
-                  coverageRanges.forEach(range => {
-                    coverageAnnotationApi.notify(
-                        path,
-                        range.code_range.start_line,
-                        range.code_range.end_line,
-                        range.side);
-                  });
-                });
-          })
-          .catch(err => {
-            console.warn('Loading coverage ranges failed: ', err);
-          });
+  _whitespaceChanged(
+      preferredWhitespaceLevel, loadedWhitespaceLevel,
+      noRenderOnPrefsChange) {
+    // Polymer 2: check for undefined
+    if ([
+      preferredWhitespaceLevel,
+      loadedWhitespaceLevel,
+      noRenderOnPrefsChange,
+    ].some(arg => arg === undefined)) {
+      return;
     }
 
-    _getFilesWeblinks(diff) {
-      if (!this.commitRange) {
-        return {};
-      }
-      return {
-        meta_a: Gerrit.Nav.getFileWebLinks(
-            this.projectName, this.commitRange.baseCommit, this.path,
-            {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
-        meta_b: Gerrit.Nav.getFileWebLinks(
-            this.projectName, this.commitRange.commit, this.path,
-            {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
-      };
+    if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
+        !noRenderOnPrefsChange) {
+      this.reload();
+    }
+  }
+
+  _syntaxHighlightingChanged(noRenderOnPrefsChange, prefsChangeRecord) {
+    // Polymer 2: check for undefined
+    if ([
+      noRenderOnPrefsChange,
+      prefsChangeRecord,
+    ].some(arg => arg === undefined)) {
+      return;
     }
 
-    /** Cancel any remaining diff builder rendering work. */
-    cancel() {
-      this.$.diff.cancel();
+    if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') {
+      return;
     }
 
-    /** @return {!Array<!HTMLElement>} */
-    getCursorStops() {
-      return this.$.diff.getCursorStops();
+    if (!noRenderOnPrefsChange) {
+      this.reload();
     }
+  }
 
-    /** @return {boolean} */
-    isRangeSelected() {
-      return this.$.diff.isRangeSelected();
+  /**
+   * @param {Object} patchRangeRecord
+   * @return {number|null}
+   */
+  _computeParentIndex(patchRangeRecord) {
+    return this.isMergeParent(patchRangeRecord.base.basePatchNum) ?
+      this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
+  }
+
+  _handleCommentSave(e) {
+    const comment = e.detail.comment;
+    const side = e.detail.comment.__commentSide;
+    const idx = this._findDraftIndex(comment, side);
+    this.set(['comments', side, idx], comment);
+    this._handleCommentSaveOrDiscard();
+  }
+
+  _handleCommentDiscard(e) {
+    const comment = e.detail.comment;
+    this._removeComment(comment);
+    this._handleCommentSaveOrDiscard();
+  }
+
+  /**
+   * Closure annotation for Polymer.prototype.push is off. Submitted PR:
+   * https://github.com/Polymer/polymer/pull/4776
+   * but for not supressing annotations.
+   *
+   * @suppress {checkTypes}
+   */
+  _handleCommentUpdate(e) {
+    const comment = e.detail.comment;
+    const side = e.detail.comment.__commentSide;
+    let idx = this._findCommentIndex(comment, side);
+    if (idx === -1) {
+      idx = this._findDraftIndex(comment, side);
     }
-
-    createRangeComment() {
-      return this.$.diff.createRangeComment();
-    }
-
-    toggleLeftDiff() {
-      this.$.diff.toggleLeftDiff();
-    }
-
-    /**
-     * Load and display blame information for the base of the diff.
-     *
-     * @return {Promise} A promise that resolves when blame finishes rendering.
-     */
-    loadBlame() {
-      return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
-          this.path, true)
-          .then(blame => {
-            if (!blame.length) {
-              this.fire('show-alert', {message: MSG_EMPTY_BLAME});
-              return Promise.reject(MSG_EMPTY_BLAME);
-            }
-
-            this._blame = blame;
-          });
-    }
-
-    /** Unload blame information for the diff. */
-    clearBlame() {
-      this._blame = null;
-    }
-
-    /**
-     * The thread elements in this diff, in no particular order.
-     *
-     * @return {!Array<!HTMLElement>}
-     */
-    getThreadEls() {
-      return Array.from(
-          Polymer.dom(this.$.diff).querySelectorAll('.comment-thread'));
-    }
-
-    /** @param {HTMLElement} el */
-    addDraftAtLine(el) {
-      this.$.diff.addDraftAtLine(el);
-    }
-
-    clearDiffContent() {
-      this.$.diff.clearDiffContent();
-    }
-
-    expandAllContext() {
-      this.$.diff.expandAllContext();
-    }
-
-    /** @return {!Promise} */
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    }
-
-    /** @return {boolean}} */
-    _canReload() {
-      return !!this.changeNum && !!this.patchRange && !!this.path &&
-          !this.noAutoRender;
-    }
-
-    /** @return {!Promise<!Object>} */
-    _getDiff() {
-      // Wrap the diff request in a new promise so that the error handler
-      // rejects the promise, allowing the error to be handled in the .catch.
-      return new Promise((resolve, reject) => {
-        this.$.restAPI.getDiff(
-            this.changeNum,
-            this.patchRange.basePatchNum,
-            this.patchRange.patchNum,
-            this.path,
-            this._getIgnoreWhitespace(),
-            reject)
-            .then(resolve);
-      });
-    }
-
-    _handleGetDiffError(response) {
-      // Loading the diff may respond with 409 if the file is too large. In this
-      // case, use a toast error..
-      if (response.status === 409) {
-        this.fire('server-error', {response});
-        return;
-      }
-
-      if (this.showLoadFailure) {
-        this._errorMessage = [
-          'Encountered error when loading the diff:',
-          response.status,
-          response.statusText,
-        ].join(' ');
-        return;
-      }
-
-      this.fire('page-error', {response});
-    }
-
-    /**
-     * Report info about the diff response.
-     */
-    _reportDiff(diff) {
-      if (!diff || !diff.content) {
-        return;
-      }
-
-      // Count the delta lines stemming from normal deltas, and from
-      // due_to_rebase deltas.
-      let nonRebaseDelta = 0;
-      let rebaseDelta = 0;
-      diff.content.forEach(chunk => {
-        if (chunk.ab) { return; }
-        const deltaSize = Math.max(
-            chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
-        if (chunk.due_to_rebase) {
-          rebaseDelta += deltaSize;
-        } else {
-          nonRebaseDelta += deltaSize;
-        }
-      });
-
-      // Find the percent of the delta from due_to_rebase chunks rounded to two
-      // digits. Diffs with no delta are considered 0%.
-      const totalDelta = rebaseDelta + nonRebaseDelta;
-      const percentRebaseDelta = !totalDelta ? 0 :
-        Math.round(100 * rebaseDelta / totalDelta);
-
-      // Report the due_to_rebase percentage in the "diff" category when
-      // applicable.
-      if (this.patchRange.basePatchNum === 'PARENT') {
-        this.$.reporting.reportInteraction(EVENT_AGAINST_PARENT);
-      } else if (percentRebaseDelta === 0) {
-        this.$.reporting.reportInteraction(EVENT_ZERO_REBASE);
-      } else {
-        this.$.reporting.reportInteraction(EVENT_NONZERO_REBASE,
-            {percentRebaseDelta});
-      }
-    }
-
-    /**
-     * @param {Object} diff
-     * @return {!Promise}
-     */
-    _loadDiffAssets(diff) {
-      if (isImageDiff(diff)) {
-        return this._getImages(diff).then(images => {
-          this._baseImage = images.baseImage;
-          this._revisionImage = images.revisionImage;
-        });
-      } else {
-        this._baseImage = null;
-        this._revisionImage = null;
-        return Promise.resolve();
-      }
-    }
-
-    /**
-     * @param {Object} diff
-     * @return {boolean}
-     */
-    _computeIsImageDiff(diff) {
-      return isImageDiff(diff);
-    }
-
-    _commentsChanged(newComments) {
-      const allComments = [];
-      for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
-        // This is needed by the threading.
-        for (const comment of newComments[side]) {
-          comment.__commentSide = side;
-        }
-        allComments.push(...newComments[side]);
-      }
-      // Currently, the only way this is ever changed here is when the initial
-      // comments are loaded, so it's okay performance wise to clear the threads
-      // and recreate them. If this changes in future, we might want to reuse
-      // some DOM nodes here.
-      this._clearThreads();
-      const threads = this._createThreads(allComments);
-      for (const thread of threads) {
-        const threadEl = this._createThreadElement(thread);
-        this._attachThreadElement(threadEl);
-      }
-    }
-
-    _sortComments(comments) {
-      return comments.slice(0).sort((a, b) => {
-        if (b.__draft && !a.__draft ) { return -1; }
-        if (a.__draft && !b.__draft ) { return 1; }
-        return util.parseDate(a.updated) - util.parseDate(b.updated);
-      });
-    }
-
-    /**
-     * @param {!Array<!Object>} comments
-     * @return {!Array<!Object>} Threads for the given comments.
-     */
-    _createThreads(comments) {
-      const sortedComments = this._sortComments(comments);
-      const threads = [];
-      for (const comment of sortedComments) {
-        // If the comment is in reply to another comment, find that comment's
-        // thread and append to it.
-        if (comment.in_reply_to) {
-          const thread = threads.find(thread =>
-            thread.comments.some(c => c.id === comment.in_reply_to));
-          if (thread) {
-            thread.comments.push(comment);
-            continue;
-          }
-        }
-
-        // Otherwise, this comment starts its own thread.
-        const newThread = {
-          start_datetime: comment.updated,
-          comments: [comment],
-          commentSide: comment.__commentSide,
-          patchNum: comment.patch_set,
-          rootId: comment.id || comment.__draftID,
-          lineNum: comment.line,
-          isOnParent: comment.side === 'PARENT',
-        };
-        if (comment.range) {
-          newThread.range = Object.assign({}, comment.range);
-        }
-        threads.push(newThread);
-      }
-      return threads;
-    }
-
-    /**
-     * @param {Object} blame
-     * @return {boolean}
-     */
-    _computeIsBlameLoaded(blame) {
-      return !!blame;
-    }
-
-    /**
-     * @param {Object} diff
-     * @return {!Promise}
-     */
-    _getImages(diff) {
-      return this.$.restAPI.getImagesForDiff(this.changeNum, diff,
-          this.patchRange);
-    }
-
-    /** @param {CustomEvent} e */
-    _handleCreateComment(e) {
-      const {lineNum, side, patchNum, isOnParent, range} = e.detail;
-      const threadEl = this._getOrCreateThread(patchNum, lineNum, side, range,
-          isOnParent);
-      threadEl.addOrEditDraft(lineNum, range);
-
-      this.$.reporting.recordDraftInteraction();
-    }
-
-    /**
-     * Gets or creates a comment thread at a given location.
-     * May provide a range, to get/create a range comment.
-     *
-     * @param {string} patchNum
-     * @param {?number} lineNum
-     * @param {string} commentSide
-     * @param {Gerrit.Range|undefined} range
-     * @param {boolean} isOnParent
-     * @return {!Object}
-     */
-    _getOrCreateThread(patchNum, lineNum, commentSide, range, isOnParent) {
-      let threadEl = this._getThreadEl(lineNum, commentSide, range);
-      if (!threadEl) {
-        threadEl = this._createThreadElement({
-          comments: [],
-          commentSide,
-          patchNum,
-          lineNum,
-          range,
-          isOnParent,
-        });
-        this._attachThreadElement(threadEl);
-      }
-      return threadEl;
-    }
-
-    _attachThreadElement(threadEl) {
-      Polymer.dom(this.$.diff).appendChild(threadEl);
-    }
-
-    _clearThreads() {
-      for (const threadEl of this.getThreadEls()) {
-        const parent = Polymer.dom(threadEl).parentNode;
-        Polymer.dom(parent).removeChild(threadEl);
-      }
-    }
-
-    _createThreadElement(thread) {
-      const threadEl = document.createElement('gr-comment-thread');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
-      threadEl.comments = thread.comments;
-      threadEl.commentSide = thread.commentSide;
-      threadEl.isOnParent = !!thread.isOnParent;
-      threadEl.parentIndex = this._parentIndex;
-      threadEl.changeNum = this.changeNum;
-      threadEl.patchNum = thread.patchNum;
-      threadEl.lineNum = thread.lineNum;
-      const rootIdChangedListener = changeEvent => {
-        thread.rootId = changeEvent.detail.value;
-      };
-      threadEl.addEventListener('root-id-changed', rootIdChangedListener);
-      threadEl.path = this.path;
-      threadEl.projectName = this.projectName;
-      threadEl.range = thread.range;
-      const threadDiscardListener = e => {
-        const threadEl = /** @type {!Node} */ (e.currentTarget);
-
-        const parent = Polymer.dom(threadEl).parentNode;
-        Polymer.dom(parent).removeChild(threadEl);
-
-        threadEl.removeEventListener('root-id-changed', rootIdChangedListener);
-        threadEl.removeEventListener('thread-discard', threadDiscardListener);
-      };
-      threadEl.addEventListener('thread-discard', threadDiscardListener);
-      return threadEl;
-    }
-
-    /**
-     * Gets a comment thread element at a given location.
-     * May provide a range, to get a range comment.
-     *
-     * @param {?number} lineNum
-     * @param {string} commentSide
-     * @param {!Gerrit.Range=} range
-     * @return {?Node}
-     */
-    _getThreadEl(lineNum, commentSide, range = undefined) {
-      let line;
-      if (commentSide === GrDiffBuilder.Side.LEFT) {
-        line = {beforeNumber: lineNum};
-      } else if (commentSide === GrDiffBuilder.Side.RIGHT) {
-        line = {afterNumber: lineNum};
-      } else {
-        throw new Error(`Unknown side: ${commentSide}`);
-      }
-      function matchesRange(threadEl) {
-        const threadRange = /** @type {!Gerrit.Range} */(
-          JSON.parse(threadEl.getAttribute('range')));
-        return Gerrit.rangesEqual(threadRange, range);
-      }
-
-      const filteredThreadEls = this._filterThreadElsForLocation(
-          this.getThreadEls(), line, commentSide).filter(matchesRange);
-      return filteredThreadEls.length ? filteredThreadEls[0] : null;
-    }
-
-    /**
-     * @param {!Array<!HTMLElement>} threadEls
-     * @param {!{beforeNumber: (number|string|undefined|null),
-     *           afterNumber: (number|string|undefined|null)}}
-     *     lineInfo
-     * @param {!Gerrit.DiffSide=} side The side (LEFT, RIGHT) for
-     *     which to return the threads.
-     * @return {!Array<!HTMLElement>} The thread elements matching the given
-     *     location.
-     */
-    _filterThreadElsForLocation(threadEls, lineInfo, side) {
-      function matchesLeftLine(threadEl) {
-        return threadEl.getAttribute('comment-side') ==
-            Gerrit.DiffSide.LEFT &&
-            threadEl.getAttribute('line-num') == lineInfo.beforeNumber;
-      }
-      function matchesRightLine(threadEl) {
-        return threadEl.getAttribute('comment-side') ==
-            Gerrit.DiffSide.RIGHT &&
-            threadEl.getAttribute('line-num') == lineInfo.afterNumber;
-      }
-      function matchesFileComment(threadEl) {
-        return threadEl.getAttribute('comment-side') == side &&
-              // line/range comments have 1-based line set, if line is falsy it's
-              // a file comment
-              !threadEl.getAttribute('line-num');
-      }
-
-      // Select the appropriate matchers for the desired side and line
-      // If side is BOTH, we want both the left and right matcher.
-      const matchers = [];
-      if (side !== Gerrit.DiffSide.RIGHT) {
-        matchers.push(matchesLeftLine);
-      }
-      if (side !== Gerrit.DiffSide.LEFT) {
-        matchers.push(matchesRightLine);
-      }
-      if (lineInfo.afterNumber === 'FILE' ||
-          lineInfo.beforeNumber === 'FILE') {
-        matchers.push(matchesFileComment);
-      }
-      return threadEls.filter(threadEl =>
-        matchers.some(matcher => matcher(threadEl)));
-    }
-
-    _getIgnoreWhitespace() {
-      if (!this.prefs || !this.prefs.ignore_whitespace) {
-        return WHITESPACE_IGNORE_NONE;
-      }
-      return this.prefs.ignore_whitespace;
-    }
-
-    _whitespaceChanged(
-        preferredWhitespaceLevel, loadedWhitespaceLevel,
-        noRenderOnPrefsChange) {
-      // Polymer 2: check for undefined
-      if ([
-        preferredWhitespaceLevel,
-        loadedWhitespaceLevel,
-        noRenderOnPrefsChange,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
-          !noRenderOnPrefsChange) {
-        this.reload();
-      }
-    }
-
-    _syntaxHighlightingChanged(noRenderOnPrefsChange, prefsChangeRecord) {
-      // Polymer 2: check for undefined
-      if ([
-        noRenderOnPrefsChange,
-        prefsChangeRecord,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') {
-        return;
-      }
-
-      if (!noRenderOnPrefsChange) {
-        this.reload();
-      }
-    }
-
-    /**
-     * @param {Object} patchRangeRecord
-     * @return {number|null}
-     */
-    _computeParentIndex(patchRangeRecord) {
-      return this.isMergeParent(patchRangeRecord.base.basePatchNum) ?
-        this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
-    }
-
-    _handleCommentSave(e) {
-      const comment = e.detail.comment;
-      const side = e.detail.comment.__commentSide;
-      const idx = this._findDraftIndex(comment, side);
+    if (idx !== -1) { // Update draft or comment.
       this.set(['comments', side, idx], comment);
-      this._handleCommentSaveOrDiscard();
-    }
-
-    _handleCommentDiscard(e) {
-      const comment = e.detail.comment;
-      this._removeComment(comment);
-      this._handleCommentSaveOrDiscard();
-    }
-
-    /**
-     * Closure annotation for Polymer.prototype.push is off. Submitted PR:
-     * https://github.com/Polymer/polymer/pull/4776
-     * but for not supressing annotations.
-     *
-     * @suppress {checkTypes}
-     */
-    _handleCommentUpdate(e) {
-      const comment = e.detail.comment;
-      const side = e.detail.comment.__commentSide;
-      let idx = this._findCommentIndex(comment, side);
-      if (idx === -1) {
-        idx = this._findDraftIndex(comment, side);
-      }
-      if (idx !== -1) { // Update draft or comment.
-        this.set(['comments', side, idx], comment);
-      } else { // Create new draft.
-        this.push(['comments', side], comment);
-      }
-    }
-
-    _handleCommentSaveOrDiscard() {
-      this.dispatchEvent(new CustomEvent(
-          'diff-comments-modified', {bubbles: true, composed: true}));
-    }
-
-    _removeComment(comment) {
-      const side = comment.__commentSide;
-      this._removeCommentFromSide(comment, side);
-    }
-
-    _removeCommentFromSide(comment, side) {
-      let idx = this._findCommentIndex(comment, side);
-      if (idx === -1) {
-        idx = this._findDraftIndex(comment, side);
-      }
-      if (idx !== -1) {
-        this.splice('comments.' + side, idx, 1);
-      }
-    }
-
-    /** @return {number} */
-    _findCommentIndex(comment, side) {
-      if (!comment.id || !this.comments[side]) {
-        return -1;
-      }
-      return this.comments[side].findIndex(item => item.id === comment.id);
-    }
-
-    /** @return {number} */
-    _findDraftIndex(comment, side) {
-      if (!comment.__draftID || !this.comments[side]) {
-        return -1;
-      }
-      return this.comments[side].findIndex(
-          item => item.__draftID === comment.__draftID);
-    }
-
-    _isSyntaxHighlightingEnabled(preferenceChangeRecord, diff) {
-      if (!preferenceChangeRecord ||
-          !preferenceChangeRecord.base ||
-          !preferenceChangeRecord.base.syntax_highlighting ||
-          !diff) {
-        return false;
-      }
-      return !this._anyLineTooLong(diff) &&
-          this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH;
-    }
-
-    /**
-     * @return {boolean} whether any of the lines in diff are longer
-     * than SYNTAX_MAX_LINE_LENGTH.
-     */
-    _anyLineTooLong(diff) {
-      if (!diff) return false;
-      return diff.content.some(section => {
-        const lines = section.ab ?
-          section.ab :
-          (section.a || []).concat(section.b || []);
-        return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
-      });
-    }
-
-    _listenToViewportRender() {
-      const renderUpdateListener = start => {
-        if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
-          this.$.reporting.diffViewDisplayed();
-          this.$.syntaxLayer.removeListener(renderUpdateListener);
-        }
-      };
-
-      this.$.syntaxLayer.addListener(renderUpdateListener);
-    }
-
-    _handleRenderStart() {
-      this.$.reporting.time(TimingLabel.TOTAL);
-      this.$.reporting.time(TimingLabel.CONTENT);
-    }
-
-    _handleRenderContent() {
-      this.$.reporting.timeEnd(TimingLabel.CONTENT);
-    }
-
-    _handleNormalizeRange(event) {
-      this.$.reporting.reportInteraction('normalize-range',
-          {
-            side: event.detail.side,
-            lineNum: event.detail.lineNum,
-          });
-    }
-
-    _handleDiffContextExpanded(event) {
-      this.$.reporting.reportInteraction(
-          'diff-context-expanded', {numLines: event.detail.numLines}
-      );
-    }
-
-    /**
-     * Find the last chunk for the given side.
-     *
-     * @param {!Object} diff
-     * @param {boolean} leftSide true if checking the base of the diff,
-     *     false if testing the revision.
-     * @return {Object|null} returns the chunk object or null if there was
-     *     no chunk for that side.
-     */
-    _lastChunkForSide(diff, leftSide) {
-      if (!diff.content.length) { return null; }
-
-      let chunkIndex = diff.content.length;
-      let chunk;
-
-      // Walk backwards until we find a chunk for the given side.
-      do {
-        chunkIndex--;
-        chunk = diff.content[chunkIndex];
-      } while (
-      // We haven't reached the beginning.
-        chunkIndex >= 0 &&
-
-          // The chunk doesn't have both sides.
-          !chunk.ab &&
-
-          // The chunk doesn't have the given side.
-          ((leftSide && (!chunk.a || !chunk.a.length)) ||
-           (!leftSide && (!chunk.b || !chunk.b.length))));
-
-      // If we reached the beginning of the diff and failed to find a chunk
-      // with the given side, return null.
-      if (chunkIndex === -1) { return null; }
-
-      return chunk;
-    }
-
-    /**
-     * Check whether the specified side of the diff has a trailing newline.
-     *
-     * @param {!Object} diff
-     * @param {boolean} leftSide true if checking the base of the diff,
-     *     false if testing the revision.
-     * @return {boolean|null} Return true if the side has a trailing newline.
-     *     Return false if it doesn't. Return null if not applicable (for
-     *     example, if the diff has no content on the specified side).
-     */
-    _hasTrailingNewlines(diff, leftSide) {
-      const chunk = this._lastChunkForSide(diff, leftSide);
-      if (!chunk) { return null; }
-      let lines;
-      if (chunk.ab) {
-        lines = chunk.ab;
-      } else {
-        lines = leftSide ? chunk.a : chunk.b;
-      }
-      return lines[lines.length - 1] === '';
-    }
-
-    _showNewlineWarningLeft(diff) {
-      return this._hasTrailingNewlines(diff, true) === false;
-    }
-
-    _showNewlineWarningRight(diff) {
-      return this._hasTrailingNewlines(diff, false) === false;
+    } else { // Create new draft.
+      this.push(['comments', side], comment);
     }
   }
 
-  customElements.define(GrDiffHost.is, GrDiffHost);
-})();
+  _handleCommentSaveOrDiscard() {
+    this.dispatchEvent(new CustomEvent(
+        'diff-comments-modified', {bubbles: true, composed: true}));
+  }
+
+  _removeComment(comment) {
+    const side = comment.__commentSide;
+    this._removeCommentFromSide(comment, side);
+  }
+
+  _removeCommentFromSide(comment, side) {
+    let idx = this._findCommentIndex(comment, side);
+    if (idx === -1) {
+      idx = this._findDraftIndex(comment, side);
+    }
+    if (idx !== -1) {
+      this.splice('comments.' + side, idx, 1);
+    }
+  }
+
+  /** @return {number} */
+  _findCommentIndex(comment, side) {
+    if (!comment.id || !this.comments[side]) {
+      return -1;
+    }
+    return this.comments[side].findIndex(item => item.id === comment.id);
+  }
+
+  /** @return {number} */
+  _findDraftIndex(comment, side) {
+    if (!comment.__draftID || !this.comments[side]) {
+      return -1;
+    }
+    return this.comments[side].findIndex(
+        item => item.__draftID === comment.__draftID);
+  }
+
+  _isSyntaxHighlightingEnabled(preferenceChangeRecord, diff) {
+    if (!preferenceChangeRecord ||
+        !preferenceChangeRecord.base ||
+        !preferenceChangeRecord.base.syntax_highlighting ||
+        !diff) {
+      return false;
+    }
+    return !this._anyLineTooLong(diff) &&
+        this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH;
+  }
+
+  /**
+   * @return {boolean} whether any of the lines in diff are longer
+   * than SYNTAX_MAX_LINE_LENGTH.
+   */
+  _anyLineTooLong(diff) {
+    if (!diff) return false;
+    return diff.content.some(section => {
+      const lines = section.ab ?
+        section.ab :
+        (section.a || []).concat(section.b || []);
+      return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
+    });
+  }
+
+  _listenToViewportRender() {
+    const renderUpdateListener = start => {
+      if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
+        this.$.reporting.diffViewDisplayed();
+        this.$.syntaxLayer.removeListener(renderUpdateListener);
+      }
+    };
+
+    this.$.syntaxLayer.addListener(renderUpdateListener);
+  }
+
+  _handleRenderStart() {
+    this.$.reporting.time(TimingLabel.TOTAL);
+    this.$.reporting.time(TimingLabel.CONTENT);
+  }
+
+  _handleRenderContent() {
+    this.$.reporting.timeEnd(TimingLabel.CONTENT);
+  }
+
+  _handleNormalizeRange(event) {
+    this.$.reporting.reportInteraction('normalize-range',
+        {
+          side: event.detail.side,
+          lineNum: event.detail.lineNum,
+        });
+  }
+
+  _handleDiffContextExpanded(event) {
+    this.$.reporting.reportInteraction(
+        'diff-context-expanded', {numLines: event.detail.numLines}
+    );
+  }
+
+  /**
+   * Find the last chunk for the given side.
+   *
+   * @param {!Object} diff
+   * @param {boolean} leftSide true if checking the base of the diff,
+   *     false if testing the revision.
+   * @return {Object|null} returns the chunk object or null if there was
+   *     no chunk for that side.
+   */
+  _lastChunkForSide(diff, leftSide) {
+    if (!diff.content.length) { return null; }
+
+    let chunkIndex = diff.content.length;
+    let chunk;
+
+    // Walk backwards until we find a chunk for the given side.
+    do {
+      chunkIndex--;
+      chunk = diff.content[chunkIndex];
+    } while (
+    // We haven't reached the beginning.
+      chunkIndex >= 0 &&
+
+        // The chunk doesn't have both sides.
+        !chunk.ab &&
+
+        // The chunk doesn't have the given side.
+        ((leftSide && (!chunk.a || !chunk.a.length)) ||
+         (!leftSide && (!chunk.b || !chunk.b.length))));
+
+    // If we reached the beginning of the diff and failed to find a chunk
+    // with the given side, return null.
+    if (chunkIndex === -1) { return null; }
+
+    return chunk;
+  }
+
+  /**
+   * Check whether the specified side of the diff has a trailing newline.
+   *
+   * @param {!Object} diff
+   * @param {boolean} leftSide true if checking the base of the diff,
+   *     false if testing the revision.
+   * @return {boolean|null} Return true if the side has a trailing newline.
+   *     Return false if it doesn't. Return null if not applicable (for
+   *     example, if the diff has no content on the specified side).
+   */
+  _hasTrailingNewlines(diff, leftSide) {
+    const chunk = this._lastChunkForSide(diff, leftSide);
+    if (!chunk) { return null; }
+    let lines;
+    if (chunk.ab) {
+      lines = chunk.ab;
+    } else {
+      lines = leftSide ? chunk.a : chunk.b;
+    }
+    return lines[lines.length - 1] === '';
+  }
+
+  _showNewlineWarningLeft(diff) {
+    return this._hasTrailingNewlines(diff, true) === false;
+  }
+
+  _showNewlineWarningRight(diff) {
+    return this._hasTrailingNewlines(diff, false) === false;
+  }
+}
+
+customElements.define(GrDiffHost.is, GrDiffHost);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.js
index 2d9369f..d48531b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.js
@@ -1,67 +1,26 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-comment-thread/gr-comment-thread.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../gr-diff/gr-diff.html">
-<link rel="import" href="../gr-syntax-layer/gr-syntax-layer.html">
-
-<dom-module id="gr-diff-host">
-  <template>
-    <gr-diff
-        id="diff"
-        change-num="[[changeNum]]"
-        no-auto-render=[[noAutoRender]]
-        patch-range="[[patchRange]]"
-        path="[[path]]"
-        prefs="[[prefs]]"
-        project-name="[[projectName]]"
-        display-line="[[displayLine]]"
-        is-image-diff="[[isImageDiff]]"
-        commit-range="[[commitRange]]"
-        hidden$="[[hidden]]"
-        no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
-        line-wrapping="[[lineWrapping]]"
-        view-mode="[[viewMode]]"
-        line-of-interest="[[lineOfInterest]]"
-        logged-in="[[_loggedIn]]"
-        loading="[[_loading]]"
-        error-message="[[_errorMessage]]"
-        base-image="[[_baseImage]]"
-        revision-image=[[_revisionImage]]
-        coverage-ranges="[[_coverageRanges]]"
-        blame="[[_blame]]"
-        layers="[[_layers]]"
-        diff="[[diff]]"
-        show-newline-warning-left="[[_showNewlineWarningLeft(diff)]]"
-        show-newline-warning-right="[[_showNewlineWarningRight(diff)]]">
+export const htmlTemplate = html`
+    <gr-diff id="diff" change-num="[[changeNum]]" no-auto-render="[[noAutoRender]]" patch-range="[[patchRange]]" path="[[path]]" prefs="[[prefs]]" project-name="[[projectName]]" display-line="[[displayLine]]" is-image-diff="[[isImageDiff]]" commit-range="[[commitRange]]" hidden\$="[[hidden]]" no-render-on-prefs-change="[[noRenderOnPrefsChange]]" line-wrapping="[[lineWrapping]]" view-mode="[[viewMode]]" line-of-interest="[[lineOfInterest]]" logged-in="[[_loggedIn]]" loading="[[_loading]]" error-message="[[_errorMessage]]" base-image="[[_baseImage]]" revision-image="[[_revisionImage]]" coverage-ranges="[[_coverageRanges]]" blame="[[_blame]]" layers="[[_layers]]" diff="[[diff]]" show-newline-warning-left="[[_showNewlineWarningLeft(diff)]]" show-newline-warning-right="[[_showNewlineWarningRight(diff)]]">
     </gr-diff>
-    <gr-syntax-layer
-        id="syntaxLayer"
-        enabled="[[_syntaxHighlightingEnabled]]"
-        diff="[[diff]]"></gr-syntax-layer>
+    <gr-syntax-layer id="syntaxLayer" enabled="[[_syntaxHighlightingEnabled]]" diff="[[diff]]"></gr-syntax-layer>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting" category="diff"></gr-reporting>
-  </template>
-  <script src="gr-diff-host.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
index be2101c..d2097d3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel="import" href="gr-diff-host.html">
+<script type="module" src="./gr-diff-host.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-diff-host.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,125 +41,50 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-host tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let getLoggedIn;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-diff-host.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-diff-host tests', () => {
+  let element;
+  let sandbox;
+  let getLoggedIn;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    getLoggedIn = false;
+    stub('gr-rest-api-interface', {
+      async getLoggedIn() { return getLoggedIn; },
+    });
+    stub('gr-reporting', {
+      time: sandbox.stub(),
+      timeEnd: sandbox.stub(),
+    });
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('plugin layers', () => {
+    const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      getLoggedIn = false;
-      stub('gr-rest-api-interface', {
-        async getLoggedIn() { return getLoggedIn; },
-      });
-      stub('gr-reporting', {
-        time: sandbox.stub(),
-        timeEnd: sandbox.stub(),
+      stub('gr-js-api-interface', {
+        getDiffLayers() { return pluginLayers; },
       });
       element = fixture('basic');
     });
-
-    teardown(() => {
-      sandbox.restore();
+    test('plugin layers requested', () => {
+      element.patchRange = {};
+      element.reload();
+      assert(element.$.jsAPI.getDiffLayers.called);
     });
+  });
 
-    suite('plugin layers', () => {
-      const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
-      setup(() => {
-        stub('gr-js-api-interface', {
-          getDiffLayers() { return pluginLayers; },
-        });
-        element = fixture('basic');
-      });
-      test('plugin layers requested', () => {
-        element.patchRange = {};
-        element.reload();
-        assert(element.$.jsAPI.getDiffLayers.called);
-      });
-    });
-
-    suite('handle comment-update', () => {
-      setup(() => {
-        sandbox.stub(element, '_commentsChanged');
-        element.comments = {
-          meta: {
-            changeNum: '42',
-            patchRange: {
-              basePatchNum: 'PARENT',
-              patchNum: 3,
-            },
-            path: '/path/to/foo',
-            projectConfig: {foo: 'bar'},
-          },
-          left: [
-            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          ],
-          right: [
-            {id: 'c1', __commentSide: 'right'},
-            {id: 'c2', __commentSide: 'right'},
-            {id: 'd1', __draft: true, __commentSide: 'right'},
-            {id: 'd2', __draft: true, __commentSide: 'right'},
-          ],
-        };
-      });
-
-      test('creating a draft', () => {
-        const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
-          __commentSide: 'left'};
-        element.fire('comment-update', {comment});
-        assert.include(element.comments.left, comment);
-      });
-
-      test('discarding a draft', () => {
-        const draftID = 'tempID';
-        const id = 'savedID';
-        const comment = {
-          __draft: true,
-          __draftID: draftID,
-          side: 'PARENT',
-          __commentSide: 'left',
-        };
-        const diffCommentsModifiedStub = sandbox.stub();
-        element.addEventListener('diff-comments-modified',
-            diffCommentsModifiedStub);
-        element.comments.left.push(comment);
-        comment.id = id;
-        element.fire('comment-discard', {comment});
-        const drafts = element.comments.left
-            .filter(item => item.__draftID === draftID);
-        assert.equal(drafts.length, 0);
-        assert.isTrue(diffCommentsModifiedStub.called);
-      });
-
-      test('saving a draft', () => {
-        const draftID = 'tempID';
-        const id = 'savedID';
-        const comment = {
-          __draft: true,
-          __draftID: draftID,
-          side: 'PARENT',
-          __commentSide: 'left',
-        };
-        const diffCommentsModifiedStub = sandbox.stub();
-        element.addEventListener('diff-comments-modified',
-            diffCommentsModifiedStub);
-        element.comments.left.push(comment);
-        comment.id = id;
-        element.fire('comment-save', {comment});
-        const drafts = element.comments.left
-            .filter(item => item.__draftID === draftID);
-        assert.equal(drafts.length, 1);
-        assert.equal(drafts[0].id, id);
-        assert.isTrue(diffCommentsModifiedStub.called);
-      });
-    });
-
-    test('remove comment', () => {
+  suite('handle comment-update', () => {
+    setup(() => {
       sandbox.stub(element, '_commentsChanged');
       element.comments = {
         meta: {
@@ -179,1453 +109,1531 @@
           {id: 'd2', __draft: true, __commentSide: 'right'},
         ],
       };
-
-      element._removeComment({});
-      // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
-      // to believe that one object deepEquals another even when they do :-/.
-      assert.equal(JSON.stringify(element.comments), JSON.stringify({
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 3,
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [
-          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        ],
-        right: [
-          {id: 'c1', __commentSide: 'right'},
-          {id: 'c2', __commentSide: 'right'},
-          {id: 'd1', __draft: true, __commentSide: 'right'},
-          {id: 'd2', __draft: true, __commentSide: 'right'},
-        ],
-      }));
-
-      element._removeComment({id: 'bc2', side: 'PARENT',
-        __commentSide: 'left'});
-      assert.deepEqual(element.comments, {
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 3,
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [
-          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        ],
-        right: [
-          {id: 'c1', __commentSide: 'right'},
-          {id: 'c2', __commentSide: 'right'},
-          {id: 'd1', __draft: true, __commentSide: 'right'},
-          {id: 'd2', __draft: true, __commentSide: 'right'},
-        ],
-      });
-
-      element._removeComment({id: 'd2', __commentSide: 'right'});
-      assert.deepEqual(element.comments, {
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 3,
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [
-          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        ],
-        right: [
-          {id: 'c1', __commentSide: 'right'},
-          {id: 'c2', __commentSide: 'right'},
-          {id: 'd1', __draft: true, __commentSide: 'right'},
-        ],
-      });
     });
 
-    test('thread-discard handling', () => {
-      const threads = [
-        {comments: [{id: 4711}]},
-        {comments: [{id: 42}]},
-      ];
-      element._parentIndex = 1;
-      element.changeNum = '2';
-      element.path = 'some/path';
-      element.projectName = 'Some project';
-      const threadEls = threads.map(
-          thread => {
-            const threadEl = element._createThreadElement(thread);
-            // Polymer 2 doesn't fire ready events and doesn't execute
-            // observers if element is not added to the Dom.
-            // See https://github.com/Polymer/old-docs-site/issues/2322
-            // and https://github.com/Polymer/polymer/issues/4526
-            element._attachThreadElement(threadEl);
-            return threadEl;
-          });
-      assert.equal(threadEls.length, 2);
-      assert.equal(threadEls[0].rootId, 4711);
-      assert.equal(threadEls[1].rootId, 42);
-      for (const threadEl of threadEls) {
-        Polymer.dom(element).appendChild(threadEl);
-      }
-
-      threadEls[0].dispatchEvent(
-          new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
-      const attachedThreads = element.queryAllEffectiveChildren(
-          'gr-comment-thread');
-      assert.equal(attachedThreads.length, 1);
-      assert.equal(attachedThreads[0].rootId, 42);
+    test('creating a draft', () => {
+      const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
+        __commentSide: 'left'};
+      element.fire('comment-update', {comment});
+      assert.include(element.comments.left, comment);
     });
 
-    suite('render reporting', () => {
-      test('starts total and content timer on render-start', done => {
-        element.dispatchEvent(
-            new CustomEvent('render-start', {bubbles: true, composed: true}));
-        assert.isTrue(element.$.reporting.time.calledWithExactly(
-            'Diff Total Render'));
-        assert.isTrue(element.$.reporting.time.calledWithExactly(
-            'Diff Content Render'));
-        done();
-      });
+    test('discarding a draft', () => {
+      const draftID = 'tempID';
+      const id = 'savedID';
+      const comment = {
+        __draft: true,
+        __draftID: draftID,
+        side: 'PARENT',
+        __commentSide: 'left',
+      };
+      const diffCommentsModifiedStub = sandbox.stub();
+      element.addEventListener('diff-comments-modified',
+          diffCommentsModifiedStub);
+      element.comments.left.push(comment);
+      comment.id = id;
+      element.fire('comment-discard', {comment});
+      const drafts = element.comments.left
+          .filter(item => item.__draftID === draftID);
+      assert.equal(drafts.length, 0);
+      assert.isTrue(diffCommentsModifiedStub.called);
+    });
 
-      test('ends content timer on render-content', () => {
-        element.dispatchEvent(
-            new CustomEvent('render-content', {bubbles: true, composed: true}));
-        assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-            'Diff Content Render'));
-      });
+    test('saving a draft', () => {
+      const draftID = 'tempID';
+      const id = 'savedID';
+      const comment = {
+        __draft: true,
+        __draftID: draftID,
+        side: 'PARENT',
+        __commentSide: 'left',
+      };
+      const diffCommentsModifiedStub = sandbox.stub();
+      element.addEventListener('diff-comments-modified',
+          diffCommentsModifiedStub);
+      element.comments.left.push(comment);
+      comment.id = id;
+      element.fire('comment-save', {comment});
+      const drafts = element.comments.left
+          .filter(item => item.__draftID === draftID);
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].id, id);
+      assert.isTrue(diffCommentsModifiedStub.called);
+    });
+  });
 
-      test('ends total and syntax timer after syntax layer processing', done => {
-        let notifySyntaxProcessed;
-        sandbox.stub(element.$.syntaxLayer, 'process').returns(new Promise(
-            resolve => {
-              notifySyntaxProcessed = resolve;
-            }));
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(
-            Promise.resolve({content: []}));
-        element.patchRange = {};
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          return element.reload(true);
+  test('remove comment', () => {
+    sandbox.stub(element, '_commentsChanged');
+    element.comments = {
+      meta: {
+        changeNum: '42',
+        patchRange: {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        },
+        path: '/path/to/foo',
+        projectConfig: {foo: 'bar'},
+      },
+      left: [
+        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+      ],
+      right: [
+        {id: 'c1', __commentSide: 'right'},
+        {id: 'c2', __commentSide: 'right'},
+        {id: 'd1', __draft: true, __commentSide: 'right'},
+        {id: 'd2', __draft: true, __commentSide: 'right'},
+      ],
+    };
+
+    element._removeComment({});
+    // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
+    // to believe that one object deepEquals another even when they do :-/.
+    assert.equal(JSON.stringify(element.comments), JSON.stringify({
+      meta: {
+        changeNum: '42',
+        patchRange: {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        },
+        path: '/path/to/foo',
+        projectConfig: {foo: 'bar'},
+      },
+      left: [
+        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+      ],
+      right: [
+        {id: 'c1', __commentSide: 'right'},
+        {id: 'c2', __commentSide: 'right'},
+        {id: 'd1', __draft: true, __commentSide: 'right'},
+        {id: 'd2', __draft: true, __commentSide: 'right'},
+      ],
+    }));
+
+    element._removeComment({id: 'bc2', side: 'PARENT',
+      __commentSide: 'left'});
+    assert.deepEqual(element.comments, {
+      meta: {
+        changeNum: '42',
+        patchRange: {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        },
+        path: '/path/to/foo',
+        projectConfig: {foo: 'bar'},
+      },
+      left: [
+        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+      ],
+      right: [
+        {id: 'c1', __commentSide: 'right'},
+        {id: 'c2', __commentSide: 'right'},
+        {id: 'd1', __draft: true, __commentSide: 'right'},
+        {id: 'd2', __draft: true, __commentSide: 'right'},
+      ],
+    });
+
+    element._removeComment({id: 'd2', __commentSide: 'right'});
+    assert.deepEqual(element.comments, {
+      meta: {
+        changeNum: '42',
+        patchRange: {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        },
+        path: '/path/to/foo',
+        projectConfig: {foo: 'bar'},
+      },
+      left: [
+        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+      ],
+      right: [
+        {id: 'c1', __commentSide: 'right'},
+        {id: 'c2', __commentSide: 'right'},
+        {id: 'd1', __draft: true, __commentSide: 'right'},
+      ],
+    });
+  });
+
+  test('thread-discard handling', () => {
+    const threads = [
+      {comments: [{id: 4711}]},
+      {comments: [{id: 42}]},
+    ];
+    element._parentIndex = 1;
+    element.changeNum = '2';
+    element.path = 'some/path';
+    element.projectName = 'Some project';
+    const threadEls = threads.map(
+        thread => {
+          const threadEl = element._createThreadElement(thread);
+          // Polymer 2 doesn't fire ready events and doesn't execute
+          // observers if element is not added to the Dom.
+          // See https://github.com/Polymer/old-docs-site/issues/2322
+          // and https://github.com/Polymer/polymer/issues/4526
+          element._attachThreadElement(threadEl);
+          return threadEl;
         });
-        // Multiple cascading microtasks are scheduled.
-        setTimeout(() => {
-          notifySyntaxProcessed();
-          // Assert after the notification task is processed.
-          Promise.resolve().then(() => {
-            assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-                'Diff Total Render'));
-            assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-                'Diff Syntax Render'));
-            assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-                'StartupDiffViewOnlyContent'));
-            done();
-          });
-        });
-      });
+    assert.equal(threadEls.length, 2);
+    assert.equal(threadEls[0].rootId, 4711);
+    assert.equal(threadEls[1].rootId, 42);
+    for (const threadEl of threadEls) {
+      dom(element).appendChild(threadEl);
+    }
 
-      test('ends total timer w/ no syntax layer processing', done => {
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(
-            Promise.resolve({content: []}));
-        element.patchRange = {};
-        element.reload();
-        // Multiple cascading microtasks are scheduled.
-        setTimeout(() => {
-          assert.isTrue(element.$.reporting.timeEnd.calledOnce);
+    threadEls[0].dispatchEvent(
+        new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
+    const attachedThreads = element.queryAllEffectiveChildren(
+        'gr-comment-thread');
+    assert.equal(attachedThreads.length, 1);
+    assert.equal(attachedThreads[0].rootId, 42);
+  });
+
+  suite('render reporting', () => {
+    test('starts total and content timer on render-start', done => {
+      element.dispatchEvent(
+          new CustomEvent('render-start', {bubbles: true, composed: true}));
+      assert.isTrue(element.$.reporting.time.calledWithExactly(
+          'Diff Total Render'));
+      assert.isTrue(element.$.reporting.time.calledWithExactly(
+          'Diff Content Render'));
+      done();
+    });
+
+    test('ends content timer on render-content', () => {
+      element.dispatchEvent(
+          new CustomEvent('render-content', {bubbles: true, composed: true}));
+      assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+          'Diff Content Render'));
+    });
+
+    test('ends total and syntax timer after syntax layer processing', done => {
+      let notifySyntaxProcessed;
+      sandbox.stub(element.$.syntaxLayer, 'process').returns(new Promise(
+          resolve => {
+            notifySyntaxProcessed = resolve;
+          }));
+      sandbox.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.patchRange = {};
+      element.$.restAPI.getDiffPreferences().then(prefs => {
+        element.prefs = prefs;
+        return element.reload(true);
+      });
+      // Multiple cascading microtasks are scheduled.
+      setTimeout(() => {
+        notifySyntaxProcessed();
+        // Assert after the notification task is processed.
+        Promise.resolve().then(() => {
           assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
               'Diff Total Render'));
+          assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+              'Diff Syntax Render'));
+          assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+              'StartupDiffViewOnlyContent'));
           done();
         });
       });
+    });
 
-      test('completes reload promise after syntax layer processing', done => {
-        let notifySyntaxProcessed;
-        sandbox.stub(element.$.syntaxLayer, 'process').returns(new Promise(
-            resolve => {
-              notifySyntaxProcessed = resolve;
-            }));
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(
-            Promise.resolve({content: []}));
-        element.patchRange = {};
-        let reloadComplete = false;
-        element.$.restAPI.getDiffPreferences()
-            .then(prefs => {
-              element.prefs = prefs;
-              return element.reload();
-            })
-            .then(() => {
-              reloadComplete = true;
-            });
-        // Multiple cascading microtasks are scheduled.
+    test('ends total timer w/ no syntax layer processing', done => {
+      sandbox.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.patchRange = {};
+      element.reload();
+      // Multiple cascading microtasks are scheduled.
+      setTimeout(() => {
+        assert.isTrue(element.$.reporting.timeEnd.calledOnce);
+        assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+            'Diff Total Render'));
+        done();
+      });
+    });
+
+    test('completes reload promise after syntax layer processing', done => {
+      let notifySyntaxProcessed;
+      sandbox.stub(element.$.syntaxLayer, 'process').returns(new Promise(
+          resolve => {
+            notifySyntaxProcessed = resolve;
+          }));
+      sandbox.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.patchRange = {};
+      let reloadComplete = false;
+      element.$.restAPI.getDiffPreferences()
+          .then(prefs => {
+            element.prefs = prefs;
+            return element.reload();
+          })
+          .then(() => {
+            reloadComplete = true;
+          });
+      // Multiple cascading microtasks are scheduled.
+      setTimeout(() => {
+        assert.isFalse(reloadComplete);
+        notifySyntaxProcessed();
+        // Assert after the notification task is processed.
         setTimeout(() => {
-          assert.isFalse(reloadComplete);
-          notifySyntaxProcessed();
-          // Assert after the notification task is processed.
-          setTimeout(() => {
-            assert.isTrue(reloadComplete);
-            done();
-          });
+          assert.isTrue(reloadComplete);
+          done();
         });
       });
     });
+  });
 
-    test('reload() cancels before network resolves', () => {
-      const cancelStub = sandbox.stub(element.$.diff, 'cancel');
+  test('reload() cancels before network resolves', () => {
+    const cancelStub = sandbox.stub(element.$.diff, 'cancel');
 
-      // Stub the network calls into requests that never resolve.
-      sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
+    // Stub the network calls into requests that never resolve.
+    sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
+    element.patchRange = {};
+
+    element.reload();
+    assert.isTrue(cancelStub.called);
+  });
+
+  suite('not logged in', () => {
+    setup(() => {
+      getLoggedIn = false;
+      element = fixture('basic');
+    });
+
+    test('reload() loads files weblinks', () => {
+      const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
+          .returns({name: 'stubb', url: '#s'});
+      sandbox.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({
+        content: [],
+      }));
+      element.projectName = 'test-project';
+      element.path = 'test-path';
+      element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
       element.patchRange = {};
-
-      element.reload();
-      assert.isTrue(cancelStub.called);
+      return element.reload().then(() => {
+        assert.isTrue(weblinksStub.calledTwice);
+        assert.isTrue(weblinksStub.firstCall.calledWith({
+          commit: 'test-base',
+          file: 'test-path',
+          options: {
+            weblinks: undefined,
+          },
+          repo: 'test-project',
+          type: Gerrit.Nav.WeblinkType.FILE}));
+        assert.isTrue(weblinksStub.secondCall.calledWith({
+          commit: 'test-commit',
+          file: 'test-path',
+          options: {
+            weblinks: undefined,
+          },
+          repo: 'test-project',
+          type: Gerrit.Nav.WeblinkType.FILE}));
+        assert.deepEqual(element.filesWeblinks, {
+          meta_a: [{name: 'stubb', url: '#s'}],
+          meta_b: [{name: 'stubb', url: '#s'}],
+        });
+      });
     });
 
-    suite('not logged in', () => {
+    test('_getDiff handles null diff responses', done => {
+      stub('gr-rest-api-interface', {
+        getDiff() { return Promise.resolve(null); },
+      });
+      element.changeNum = 123;
+      element.patchRange = {basePatchNum: 1, patchNum: 2};
+      element.path = 'file.txt';
+      element._getDiff().then(done);
+    });
+
+    test('reload resolves on error', () => {
+      const onErrStub = sandbox.stub(element, '_handleGetDiffError');
+      const error = {ok: false, status: 500};
+      sandbox.stub(element.$.restAPI, 'getDiff',
+          (changeNum, basePatchNum, patchNum, path, onErr) => {
+            onErr(error);
+          });
+      element.patchRange = {};
+      return element.reload().then(() => {
+        assert.isTrue(onErrStub.calledOnce);
+      });
+    });
+
+    suite('_handleGetDiffError', () => {
+      let serverErrorStub;
+      let pageErrorStub;
+
       setup(() => {
-        getLoggedIn = false;
-        element = fixture('basic');
+        serverErrorStub = sinon.stub();
+        element.addEventListener('server-error', serverErrorStub);
+        pageErrorStub = sinon.stub();
+        element.addEventListener('page-error', pageErrorStub);
       });
 
-      test('reload() loads files weblinks', () => {
-        const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
-            .returns({name: 'stubb', url: '#s'});
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({
-          content: [],
-        }));
-        element.projectName = 'test-project';
-        element.path = 'test-path';
-        element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
-        element.patchRange = {};
-        return element.reload().then(() => {
-          assert.isTrue(weblinksStub.calledTwice);
-          assert.isTrue(weblinksStub.firstCall.calledWith({
-            commit: 'test-base',
-            file: 'test-path',
-            options: {
-              weblinks: undefined,
-            },
-            repo: 'test-project',
-            type: Gerrit.Nav.WeblinkType.FILE}));
-          assert.isTrue(weblinksStub.secondCall.calledWith({
-            commit: 'test-commit',
-            file: 'test-path',
-            options: {
-              weblinks: undefined,
-            },
-            repo: 'test-project',
-            type: Gerrit.Nav.WeblinkType.FILE}));
-          assert.deepEqual(element.filesWeblinks, {
-            meta_a: [{name: 'stubb', url: '#s'}],
-            meta_b: [{name: 'stubb', url: '#s'}],
-          });
-        });
+      test('page error on HTTP-409', () => {
+        element._handleGetDiffError({status: 409});
+        assert.isTrue(serverErrorStub.calledOnce);
+        assert.isFalse(pageErrorStub.called);
+        assert.isNotOk(element._errorMessage);
       });
 
-      test('_getDiff handles null diff responses', done => {
-        stub('gr-rest-api-interface', {
-          getDiff() { return Promise.resolve(null); },
-        });
-        element.changeNum = 123;
-        element.patchRange = {basePatchNum: 1, patchNum: 2};
-        element.path = 'file.txt';
-        element._getDiff().then(done);
+      test('server error on non-HTTP-409', () => {
+        element._handleGetDiffError({status: 500});
+        assert.isFalse(serverErrorStub.called);
+        assert.isTrue(pageErrorStub.calledOnce);
+        assert.isNotOk(element._errorMessage);
       });
 
-      test('reload resolves on error', () => {
-        const onErrStub = sandbox.stub(element, '_handleGetDiffError');
-        const error = {ok: false, status: 500};
-        sandbox.stub(element.$.restAPI, 'getDiff',
-            (changeNum, basePatchNum, patchNum, path, onErr) => {
-              onErr(error);
-            });
-        element.patchRange = {};
-        return element.reload().then(() => {
-          assert.isTrue(onErrStub.calledOnce);
-        });
+      test('error message if showLoadFailure', () => {
+        element.showLoadFailure = true;
+        element._handleGetDiffError({status: 500, statusText: 'Failure!'});
+        assert.isFalse(serverErrorStub.called);
+        assert.isFalse(pageErrorStub.called);
+        assert.equal(element._errorMessage,
+            'Encountered error when loading the diff: 500 Failure!');
+      });
+    });
+
+    suite('image diffs', () => {
+      let mockFile1;
+      let mockFile2;
+      setup(() => {
+        mockFile1 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAAAAAA/w==',
+          type: 'image/bmp',
+        };
+        mockFile2 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAA/////w==',
+          type: 'image/bmp',
+        };
+        sandbox.stub(element.$.restAPI,
+            'getB64FileContents',
+            (changeId, patchNum, path, opt_parentIndex) => Promise.resolve(
+                opt_parentIndex === 1 ? mockFile1 :
+                  mockFile2)
+        );
+
+        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+        element.comments = {
+          left: [],
+          right: [],
+          meta: {patchRange: element.patchRange},
+        };
       });
 
-      suite('_handleGetDiffError', () => {
-        let serverErrorStub;
-        let pageErrorStub;
+      test('renders image diffs with same file name', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        sandbox.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
 
-        setup(() => {
-          serverErrorStub = sinon.stub();
-          element.addEventListener('server-error', serverErrorStub);
-          pageErrorStub = sinon.stub();
-          element.addEventListener('page-error', pageErrorStub);
-        });
+        const rendered = () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
 
-        test('page error on HTTP-409', () => {
-          element._handleGetDiffError({status: 409});
-          assert.isTrue(serverErrorStub.calledOnce);
-          assert.isFalse(pageErrorStub.called);
-          assert.isNotOk(element._errorMessage);
-        });
+          // Left image rendered with the parent commit's version of the file.
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          const leftLabel =
+              element.$.diff.$.diffTable.querySelector('td.left label');
+          const leftLabelContent = leftLabel.querySelector('.label');
+          const leftLabelName = leftLabel.querySelector('.name');
 
-        test('server error on non-HTTP-409', () => {
-          element._handleGetDiffError({status: 500});
-          assert.isFalse(serverErrorStub.called);
-          assert.isTrue(pageErrorStub.calledOnce);
-          assert.isNotOk(element._errorMessage);
-        });
+          const rightImage =
+              element.$.diff.$.diffTable.querySelector('td.right img');
+          const rightLabel = element.$.diff.$.diffTable.querySelector(
+              'td.right label');
+          const rightLabelContent = rightLabel.querySelector('.label');
+          const rightLabelName = rightLabel.querySelector('.name');
 
-        test('error message if showLoadFailure', () => {
-          element.showLoadFailure = true;
-          element._handleGetDiffError({status: 500, statusText: 'Failure!'});
-          assert.isFalse(serverErrorStub.called);
-          assert.isFalse(pageErrorStub.called);
-          assert.equal(element._errorMessage,
-              'Encountered error when loading the diff: 500 Failure!');
-        });
-      });
+          assert.isNotOk(rightLabelName);
+          assert.isNotOk(leftLabelName);
 
-      suite('image diffs', () => {
-        let mockFile1;
-        let mockFile2;
-        setup(() => {
-          mockFile1 = {
-            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-            'wsAAAAAAAAAAAAAAAAA/w==',
-            type: 'image/bmp',
-          };
-          mockFile2 = {
-            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-            'wsAAAAAAAAAAAAA/////w==',
-            type: 'image/bmp',
-          };
-          sandbox.stub(element.$.restAPI,
-              'getB64FileContents',
-              (changeId, patchNum, path, opt_parentIndex) => Promise.resolve(
-                  opt_parentIndex === 1 ? mockFile1 :
-                    mockFile2)
-          );
+          let leftLoaded = false;
+          let rightLoaded = false;
 
-          element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-          element.comments = {
-            left: [],
-            right: [],
-            meta: {patchRange: element.patchRange},
-          };
-        });
-
-        test('renders image diffs with same file name', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'MODIFIED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index 2adc47d..f9c2f2c 100644',
-              '--- a/carrot.jpg',
-              '+++ b/carrot.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          sandbox.stub(element.$.restAPI, 'getDiff')
-              .returns(Promise.resolve(mockDiff));
-
-          const rendered = () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            // Left image rendered with the parent commit's version of the file.
-            const leftImage =
-                element.$.diff.$.diffTable.querySelector('td.left img');
-            const leftLabel =
-                element.$.diff.$.diffTable.querySelector('td.left label');
-            const leftLabelContent = leftLabel.querySelector('.label');
-            const leftLabelName = leftLabel.querySelector('.name');
-
-            const rightImage =
-                element.$.diff.$.diffTable.querySelector('td.right img');
-            const rightLabel = element.$.diff.$.diffTable.querySelector(
-                'td.right label');
-            const rightLabelContent = rightLabel.querySelector('.label');
-            const rightLabelName = rightLabel.querySelector('.name');
-
-            assert.isNotOk(rightLabelName);
-            assert.isNotOk(leftLabelName);
-
-            let leftLoaded = false;
-            let rightLoaded = false;
-
-            leftImage.addEventListener('load', () => {
-              assert.isOk(leftImage);
-              assert.equal(leftImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile1.body);
-              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-              leftLoaded = true;
-              if (rightLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-
-            rightImage.addEventListener('load', () => {
-              assert.isOk(rightImage);
-              assert.equal(rightImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile2.body);
-              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-              rightLoaded = true;
-              if (leftLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-          };
-
-          element.addEventListener('render', rendered);
-
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
-        });
-
-        test('renders image diffs with a different file name', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-            meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'MODIFIED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot2.jpg',
-              'index 2adc47d..f9c2f2c 100644',
-              '--- a/carrot.jpg',
-              '+++ b/carrot2.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          sandbox.stub(element.$.restAPI, 'getDiff')
-              .returns(Promise.resolve(mockDiff));
-
-          const rendered = () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            // Left image rendered with the parent commit's version of the file.
-            const leftImage =
-                element.$.diff.$.diffTable.querySelector('td.left img');
-            const leftLabel =
-                element.$.diff.$.diffTable.querySelector('td.left label');
-            const leftLabelContent = leftLabel.querySelector('.label');
-            const leftLabelName = leftLabel.querySelector('.name');
-
-            const rightImage =
-                element.$.diff.$.diffTable.querySelector('td.right img');
-            const rightLabel = element.$.diff.$.diffTable.querySelector(
-                'td.right label');
-            const rightLabelContent = rightLabel.querySelector('.label');
-            const rightLabelName = rightLabel.querySelector('.name');
-
-            assert.isOk(rightLabelName);
-            assert.isOk(leftLabelName);
-            assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-            assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-            let leftLoaded = false;
-            let rightLoaded = false;
-
-            leftImage.addEventListener('load', () => {
-              assert.isOk(leftImage);
-              assert.equal(leftImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile1.body);
-              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-              leftLoaded = true;
-              if (rightLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-
-            rightImage.addEventListener('load', () => {
-              assert.isOk(rightImage);
-              assert.equal(rightImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile2.body);
-              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-              rightLoaded = true;
-              if (leftLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-          };
-
-          element.addEventListener('render', rendered);
-
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
-        });
-
-        test('renders added image', done => {
-          const mockDiff = {
-            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'ADDED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index 0000000..f9c2f2c 100644',
-              '--- /dev/null',
-              '+++ b/carrot.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          sandbox.stub(element.$.restAPI, 'getDiff')
-              .returns(Promise.resolve(mockDiff));
-
-          element.addEventListener('render', () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            const leftImage =
-                element.$.diff.$.diffTable.querySelector('td.left img');
-            const rightImage =
-                element.$.diff.$.diffTable.querySelector('td.right img');
-
-            assert.isNotOk(leftImage);
-            assert.isOk(rightImage);
-            done();
-          });
-
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
-        });
-
-        test('renders removed image', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'DELETED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index f9c2f2c..0000000 100644',
-              '--- a/carrot.jpg',
-              '+++ /dev/null',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          sandbox.stub(element.$.restAPI, 'getDiff')
-              .returns(Promise.resolve(mockDiff));
-
-          element.addEventListener('render', () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            const leftImage =
-                element.$.diff.$.diffTable.querySelector('td.left img');
-            const rightImage =
-                element.$.diff.$.diffTable.querySelector('td.right img');
-
+          leftImage.addEventListener('load', () => {
             assert.isOk(leftImage);
-            assert.isNotOk(rightImage);
-            done();
+            assert.equal(leftImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile1.body);
+            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+            leftLoaded = true;
+            if (rightLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
           });
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
+          rightImage.addEventListener('load', () => {
+            assert.isOk(rightImage);
+            assert.equal(rightImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile2.body);
+            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+            rightLoaded = true;
+            if (leftLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
           });
-        });
+        };
 
-        test('does not render disallowed image type', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'DELETED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index f9c2f2c..0000000 100644',
-              '--- a/carrot.jpg',
-              '+++ /dev/null',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          mockFile1.type = 'image/jpeg-evil';
+        element.addEventListener('render', rendered);
 
-          sandbox.stub(element.$.restAPI, 'getDiff')
-              .returns(Promise.resolve(mockDiff));
-
-          element.addEventListener('render', () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-            const leftImage =
-                element.$.diff.$.diffTable.querySelector('td.left img');
-            assert.isNotOk(leftImage);
-            done();
-          });
-
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
-        });
-      });
-    });
-
-    test('delegates cancel()', () => {
-      const stub = sandbox.stub(element.$.diff, 'cancel');
-      element.patchRange = {};
-      element.reload();
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
-    });
-
-    test('delegates getCursorStops()', () => {
-      const returnValue = [document.createElement('b')];
-      const stub = sandbox.stub(element.$.diff, 'getCursorStops')
-          .returns(returnValue);
-      assert.equal(element.getCursorStops(), returnValue);
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
-    });
-
-    test('delegates isRangeSelected()', () => {
-      const returnValue = true;
-      const stub = sandbox.stub(element.$.diff, 'isRangeSelected')
-          .returns(returnValue);
-      assert.equal(element.isRangeSelected(), returnValue);
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
-    });
-
-    test('delegates toggleLeftDiff()', () => {
-      const stub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
-      element.toggleLeftDiff();
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
-    });
-
-    suite('blame', () => {
-      setup(() => {
-        element = fixture('basic');
-      });
-
-      test('clearBlame', () => {
-        element._blame = [];
-        const setBlameSpy = sandbox.spy(element.$.diff.$.diffBuilder, 'setBlame');
-        element.clearBlame();
-        assert.isNull(element._blame);
-        assert.isTrue(setBlameSpy.calledWithExactly(null));
-        assert.equal(element.isBlameLoaded, false);
-      });
-
-      test('loadBlame', () => {
-        const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-        const showAlertStub = sinon.stub();
-        element.addEventListener('show-alert', showAlertStub);
-        const getBlameStub = sandbox.stub(element.$.restAPI, 'getBlame')
-            .returns(Promise.resolve(mockBlame));
-        element.changeNum = 42;
-        element.patchRange = {patchNum: 5, basePatchNum: 4};
-        element.path = 'foo/bar.baz';
-        return element.loadBlame().then(() => {
-          assert.isTrue(getBlameStub.calledWithExactly(
-              42, 5, 'foo/bar.baz', true));
-          assert.isFalse(showAlertStub.called);
-          assert.equal(element._blame, mockBlame);
-          assert.equal(element.isBlameLoaded, true);
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
         });
       });
 
-      test('loadBlame empty', () => {
-        const mockBlame = [];
-        const showAlertStub = sinon.stub();
-        element.addEventListener('show-alert', showAlertStub);
-        sandbox.stub(element.$.restAPI, 'getBlame')
-            .returns(Promise.resolve(mockBlame));
-        element.changeNum = 42;
-        element.patchRange = {patchNum: 5, basePatchNum: 4};
-        element.path = 'foo/bar.baz';
-        return element.loadBlame()
-            .then(() => {
-              assert.isTrue(false, 'Promise should not resolve');
-            })
-            .catch(() => {
-              assert.isTrue(showAlertStub.calledOnce);
-              assert.isNull(element._blame);
-              assert.equal(element.isBlameLoaded, false);
-            });
-      });
-    });
-
-    test('getThreadEls() returns .comment-threads', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      Polymer.dom(element.$.diff).appendChild(threadEl);
-      assert.deepEqual(element.getThreadEls(), [threadEl]);
-    });
-
-    test('delegates addDraftAtLine(el)', () => {
-      const param0 = document.createElement('b');
-      const stub = sandbox.stub(element.$.diff, 'addDraftAtLine');
-      element.addDraftAtLine(param0);
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 1);
-      assert.equal(stub.lastCall.args[0], param0);
-    });
-
-    test('delegates clearDiffContent()', () => {
-      const stub = sandbox.stub(element.$.diff, 'clearDiffContent');
-      element.clearDiffContent();
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
-    });
-
-    test('delegates expandAllContext()', () => {
-      const stub = sandbox.stub(element.$.diff, 'expandAllContext');
-      element.expandAllContext();
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
-    });
-
-    test('passes in changeNum', () => {
-      const value = '12345';
-      element.changeNum = value;
-      assert.equal(element.$.diff.changeNum, value);
-    });
-
-    test('passes in noAutoRender', () => {
-      const value = true;
-      element.noAutoRender = value;
-      assert.equal(element.$.diff.noAutoRender, value);
-    });
-
-    test('passes in patchRange', () => {
-      const value = {patchNum: 'foo', basePatchNum: 'bar'};
-      element.patchRange = value;
-      assert.equal(element.$.diff.patchRange, value);
-    });
-
-    test('passes in path', () => {
-      const value = 'some/file/path';
-      element.path = value;
-      assert.equal(element.$.diff.path, value);
-    });
-
-    test('passes in prefs', () => {
-      const value = {};
-      element.prefs = value;
-      assert.equal(element.$.diff.prefs, value);
-    });
-
-    test('passes in changeNum', () => {
-      const value = '12345';
-      element.changeNum = value;
-      assert.equal(element.$.diff.changeNum, value);
-    });
-
-    test('passes in projectName', () => {
-      const value = 'Gerrit';
-      element.projectName = value;
-      assert.equal(element.$.diff.projectName, value);
-    });
-
-    test('passes in displayLine', () => {
-      const value = true;
-      element.displayLine = value;
-      assert.equal(element.$.diff.displayLine, value);
-    });
-
-    test('passes in commitRange', () => {
-      const value = {};
-      element.commitRange = value;
-      assert.equal(element.$.diff.commitRange, value);
-    });
-
-    test('passes in hidden', () => {
-      const value = true;
-      element.hidden = value;
-      assert.equal(element.$.diff.hidden, value);
-      assert.isNotNull(element.getAttribute('hidden'));
-    });
-
-    test('passes in noRenderOnPrefsChange', () => {
-      const value = true;
-      element.noRenderOnPrefsChange = value;
-      assert.equal(element.$.diff.noRenderOnPrefsChange, value);
-    });
-
-    test('passes in lineWrapping', () => {
-      const value = true;
-      element.lineWrapping = value;
-      assert.equal(element.$.diff.lineWrapping, value);
-    });
-
-    test('passes in viewMode', () => {
-      const value = 'SIDE_BY_SIDE';
-      element.viewMode = value;
-      assert.equal(element.$.diff.viewMode, value);
-    });
-
-    test('passes in lineOfInterest', () => {
-      const value = {number: 123, leftSide: true};
-      element.lineOfInterest = value;
-      assert.equal(element.$.diff.lineOfInterest, value);
-    });
-
-    suite('_reportDiff', () => {
-      let reportStub;
-
-      setup(() => {
-        element = fixture('basic');
-        element.patchRange = {basePatchNum: 1};
-        reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
-      });
-
-      test('null and content-less', () => {
-        element._reportDiff(null);
-        assert.isFalse(reportStub.called);
-
-        element._reportDiff({});
-        assert.isFalse(reportStub.called);
-      });
-
-      test('diff w/ no delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {ab: ['baz', 'foo']},
+      test('renders image diffs with a different file name', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot2.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot2.jpg',
+            'Binary files differ',
           ],
+          content: [{skip: 66}],
+          binary: true,
         };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-        assert.isUndefined(reportStub.lastCall.args[1]);
+        sandbox.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
+
+        const rendered = () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          // Left image rendered with the parent commit's version of the file.
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          const leftLabel =
+              element.$.diff.$.diffTable.querySelector('td.left label');
+          const leftLabelContent = leftLabel.querySelector('.label');
+          const leftLabelName = leftLabel.querySelector('.name');
+
+          const rightImage =
+              element.$.diff.$.diffTable.querySelector('td.right img');
+          const rightLabel = element.$.diff.$.diffTable.querySelector(
+              'td.right label');
+          const rightLabelContent = rightLabel.querySelector('.label');
+          const rightLabelName = rightLabel.querySelector('.name');
+
+          assert.isOk(rightLabelName);
+          assert.isOk(leftLabelName);
+          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+          let leftLoaded = false;
+          let rightLoaded = false;
+
+          leftImage.addEventListener('load', () => {
+            assert.isOk(leftImage);
+            assert.equal(leftImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile1.body);
+            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+            leftLoaded = true;
+            if (rightLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+
+          rightImage.addEventListener('load', () => {
+            assert.isOk(rightImage);
+            assert.equal(rightImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile2.body);
+            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+            rightLoaded = true;
+            if (leftLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+        };
+
+        element.addEventListener('render', rendered);
+
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
+        });
       });
 
-      test('diff w/ no rebase delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo']},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], b: ['bar', 'baz']},
-            {ab: ['foo', 'bar']},
-            {b: ['baz', 'foo']},
-            {ab: ['foo', 'bar']},
+      test('renders added image', done => {
+        const mockDiff = {
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'ADDED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 0000000..f9c2f2c 100644',
+            '--- /dev/null',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
           ],
+          content: [{skip: 66}],
+          binary: true,
         };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-        assert.isUndefined(reportStub.lastCall.args[1]);
+        sandbox.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
+
+        element.addEventListener('render', () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          const rightImage =
+              element.$.diff.$.diffTable.querySelector('td.right img');
+
+          assert.isNotOk(leftImage);
+          assert.isOk(rightImage);
+          done();
+        });
+
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
+        });
       });
 
-      test('diff w/ some rebase delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], due_to_rebase: true},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], b: ['bar', 'baz']},
-            {ab: ['foo', 'bar']},
-            {b: ['baz', 'foo'], due_to_rebase: true},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo']},
+      test('renders removed image', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
           ],
+          content: [{skip: 66}],
+          binary: true,
         };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.isTrue(reportStub.calledWith(
-            'rebase-percent-nonzero',
-            {percentRebaseDelta: 50}
-        ));
+        sandbox.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
+
+        element.addEventListener('render', () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          const rightImage =
+              element.$.diff.$.diffTable.querySelector('td.right img');
+
+          assert.isOk(leftImage);
+          assert.isNotOk(rightImage);
+          done();
+        });
+
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
+        });
       });
 
-      test('diff w/ all rebase delta', () => {
-        const diff = {content: [{
-          a: ['foo', 'bar'],
-          b: ['baz', 'foo'],
-          due_to_rebase: true,
-        }]};
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.isTrue(reportStub.calledWith(
-            'rebase-percent-nonzero',
-            {percentRebaseDelta: 100}
-        ));
-      });
+      test('does not render disallowed image type', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        mockFile1.type = 'image/jpeg-evil';
 
-      test('diff against parent event', () => {
-        element.patchRange.basePatchNum = 'PARENT';
-        const diff = {content: [{
-          a: ['foo', 'bar'],
-          b: ['baz', 'foo'],
-        }]};
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
-        assert.isUndefined(reportStub.lastCall.args[1]);
+        sandbox.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
+
+        element.addEventListener('render', () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          assert.isNotOk(leftImage);
+          done();
+        });
+
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
+        });
+      });
+    });
+  });
+
+  test('delegates cancel()', () => {
+    const stub = sandbox.stub(element.$.diff, 'cancel');
+    element.patchRange = {};
+    element.reload();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates getCursorStops()', () => {
+    const returnValue = [document.createElement('b')];
+    const stub = sandbox.stub(element.$.diff, 'getCursorStops')
+        .returns(returnValue);
+    assert.equal(element.getCursorStops(), returnValue);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates isRangeSelected()', () => {
+    const returnValue = true;
+    const stub = sandbox.stub(element.$.diff, 'isRangeSelected')
+        .returns(returnValue);
+    assert.equal(element.isRangeSelected(), returnValue);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates toggleLeftDiff()', () => {
+    const stub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
+    element.toggleLeftDiff();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  suite('blame', () => {
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('clearBlame', () => {
+      element._blame = [];
+      const setBlameSpy = sandbox.spy(element.$.diff.$.diffBuilder, 'setBlame');
+      element.clearBlame();
+      assert.isNull(element._blame);
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.equal(element.isBlameLoaded, false);
+    });
+
+    test('loadBlame', () => {
+      const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+      const showAlertStub = sinon.stub();
+      element.addEventListener('show-alert', showAlertStub);
+      const getBlameStub = sandbox.stub(element.$.restAPI, 'getBlame')
+          .returns(Promise.resolve(mockBlame));
+      element.changeNum = 42;
+      element.patchRange = {patchNum: 5, basePatchNum: 4};
+      element.path = 'foo/bar.baz';
+      return element.loadBlame().then(() => {
+        assert.isTrue(getBlameStub.calledWithExactly(
+            42, 5, 'foo/bar.baz', true));
+        assert.isFalse(showAlertStub.called);
+        assert.equal(element._blame, mockBlame);
+        assert.equal(element.isBlameLoaded, true);
       });
     });
 
-    test('comments sorting', () => {
-      const comments = [
-        {
-          id: 'new_draft',
-          message: 'i do not like either of you',
-          __commentSide: 'left',
-          __draft: true,
-          updated: '2015-12-20 15:01:20.396000000',
-        },
-        {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-23 15:00:20.396000000',
-          line: 1,
-          __commentSide: 'left',
-        }, {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          updated: '2015-12-24 15:01:20.396000000',
-          __commentSide: 'left',
-          line: 1,
-          in_reply_to: 'sallys_confession',
-        },
-      ];
-      const sortedComments = element._sortComments(comments);
-      assert.equal(sortedComments[0], comments[1]);
-      assert.equal(sortedComments[1], comments[2]);
-      assert.equal(sortedComments[2], comments[0]);
+    test('loadBlame empty', () => {
+      const mockBlame = [];
+      const showAlertStub = sinon.stub();
+      element.addEventListener('show-alert', showAlertStub);
+      sandbox.stub(element.$.restAPI, 'getBlame')
+          .returns(Promise.resolve(mockBlame));
+      element.changeNum = 42;
+      element.patchRange = {patchNum: 5, basePatchNum: 4};
+      element.path = 'foo/bar.baz';
+      return element.loadBlame()
+          .then(() => {
+            assert.isTrue(false, 'Promise should not resolve');
+          })
+          .catch(() => {
+            assert.isTrue(showAlertStub.calledOnce);
+            assert.isNull(element._blame);
+            assert.equal(element.isBlameLoaded, false);
+          });
+    });
+  });
+
+  test('getThreadEls() returns .comment-threads', () => {
+    const threadEl = document.createElement('div');
+    threadEl.className = 'comment-thread';
+    dom(element.$.diff).appendChild(threadEl);
+    assert.deepEqual(element.getThreadEls(), [threadEl]);
+  });
+
+  test('delegates addDraftAtLine(el)', () => {
+    const param0 = document.createElement('b');
+    const stub = sandbox.stub(element.$.diff, 'addDraftAtLine');
+    element.addDraftAtLine(param0);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 1);
+    assert.equal(stub.lastCall.args[0], param0);
+  });
+
+  test('delegates clearDiffContent()', () => {
+    const stub = sandbox.stub(element.$.diff, 'clearDiffContent');
+    element.clearDiffContent();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates expandAllContext()', () => {
+    const stub = sandbox.stub(element.$.diff, 'expandAllContext');
+    element.expandAllContext();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('passes in changeNum', () => {
+    const value = '12345';
+    element.changeNum = value;
+    assert.equal(element.$.diff.changeNum, value);
+  });
+
+  test('passes in noAutoRender', () => {
+    const value = true;
+    element.noAutoRender = value;
+    assert.equal(element.$.diff.noAutoRender, value);
+  });
+
+  test('passes in patchRange', () => {
+    const value = {patchNum: 'foo', basePatchNum: 'bar'};
+    element.patchRange = value;
+    assert.equal(element.$.diff.patchRange, value);
+  });
+
+  test('passes in path', () => {
+    const value = 'some/file/path';
+    element.path = value;
+    assert.equal(element.$.diff.path, value);
+  });
+
+  test('passes in prefs', () => {
+    const value = {};
+    element.prefs = value;
+    assert.equal(element.$.diff.prefs, value);
+  });
+
+  test('passes in changeNum', () => {
+    const value = '12345';
+    element.changeNum = value;
+    assert.equal(element.$.diff.changeNum, value);
+  });
+
+  test('passes in projectName', () => {
+    const value = 'Gerrit';
+    element.projectName = value;
+    assert.equal(element.$.diff.projectName, value);
+  });
+
+  test('passes in displayLine', () => {
+    const value = true;
+    element.displayLine = value;
+    assert.equal(element.$.diff.displayLine, value);
+  });
+
+  test('passes in commitRange', () => {
+    const value = {};
+    element.commitRange = value;
+    assert.equal(element.$.diff.commitRange, value);
+  });
+
+  test('passes in hidden', () => {
+    const value = true;
+    element.hidden = value;
+    assert.equal(element.$.diff.hidden, value);
+    assert.isNotNull(element.getAttribute('hidden'));
+  });
+
+  test('passes in noRenderOnPrefsChange', () => {
+    const value = true;
+    element.noRenderOnPrefsChange = value;
+    assert.equal(element.$.diff.noRenderOnPrefsChange, value);
+  });
+
+  test('passes in lineWrapping', () => {
+    const value = true;
+    element.lineWrapping = value;
+    assert.equal(element.$.diff.lineWrapping, value);
+  });
+
+  test('passes in viewMode', () => {
+    const value = 'SIDE_BY_SIDE';
+    element.viewMode = value;
+    assert.equal(element.$.diff.viewMode, value);
+  });
+
+  test('passes in lineOfInterest', () => {
+    const value = {number: 123, leftSide: true};
+    element.lineOfInterest = value;
+    assert.equal(element.$.diff.lineOfInterest, value);
+  });
+
+  suite('_reportDiff', () => {
+    let reportStub;
+
+    setup(() => {
+      element = fixture('basic');
+      element.patchRange = {basePatchNum: 1};
+      reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
     });
 
-    test('_createThreads', () => {
-      const comments = [
-        {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-23 15:00:20.396000000',
-          line: 1,
-          __commentSide: 'left',
-        }, {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          updated: '2015-12-24 15:01:20.396000000',
-          __commentSide: 'left',
-          line: 1,
-          in_reply_to: 'sallys_confession',
-        },
-        {
-          id: 'new_draft',
-          message: 'i do not like either of you',
-          __commentSide: 'left',
-          __draft: true,
-          updated: '2015-12-20 15:01:20.396000000',
-        },
-      ];
+    test('null and content-less', () => {
+      element._reportDiff(null);
+      assert.isFalse(reportStub.called);
 
-      const actualThreads = element._createThreads(comments);
-
-      assert.equal(actualThreads.length, 2);
-
-      assert.equal(
-          actualThreads[0].start_datetime, '2015-12-23 15:00:20.396000000');
-      assert.equal(actualThreads[0].commentSide, 'left');
-      assert.equal(actualThreads[0].comments.length, 2);
-      assert.deepEqual(actualThreads[0].comments[0], comments[0]);
-      assert.deepEqual(actualThreads[0].comments[1], comments[1]);
-      assert.equal(actualThreads[0].patchNum, undefined);
-      assert.equal(actualThreads[0].rootId, 'sallys_confession');
-      assert.equal(actualThreads[0].lineNum, 1);
-
-      assert.equal(
-          actualThreads[1].start_datetime, '2015-12-20 15:01:20.396000000');
-      assert.equal(actualThreads[1].commentSide, 'left');
-      assert.equal(actualThreads[1].comments.length, 1);
-      assert.deepEqual(actualThreads[1].comments[0], comments[2]);
-      assert.equal(actualThreads[1].patchNum, undefined);
-      assert.equal(actualThreads[1].rootId, 'new_draft');
-      assert.equal(actualThreads[1].lineNum, undefined);
+      element._reportDiff({});
+      assert.isFalse(reportStub.called);
     });
 
-    test('_createThreads inherits patchNum and range', () => {
-      const comments = [{
-        id: 'betsys_confession',
+    test('diff w/ no delta', () => {
+      const diff = {
+        content: [
+          {ab: ['foo', 'bar']},
+          {ab: ['baz', 'foo']},
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+
+    test('diff w/ no rebase delta', () => {
+      const diff = {
+        content: [
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo']},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], b: ['bar', 'baz']},
+          {ab: ['foo', 'bar']},
+          {b: ['baz', 'foo']},
+          {ab: ['foo', 'bar']},
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+
+    test('diff w/ some rebase delta', () => {
+      const diff = {
+        content: [
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], due_to_rebase: true},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], b: ['bar', 'baz']},
+          {ab: ['foo', 'bar']},
+          {b: ['baz', 'foo'], due_to_rebase: true},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo']},
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.isTrue(reportStub.calledWith(
+          'rebase-percent-nonzero',
+          {percentRebaseDelta: 50}
+      ));
+    });
+
+    test('diff w/ all rebase delta', () => {
+      const diff = {content: [{
+        a: ['foo', 'bar'],
+        b: ['baz', 'foo'],
+        due_to_rebase: true,
+      }]};
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.isTrue(reportStub.calledWith(
+          'rebase-percent-nonzero',
+          {percentRebaseDelta: 100}
+      ));
+    });
+
+    test('diff against parent event', () => {
+      element.patchRange.basePatchNum = 'PARENT';
+      const diff = {content: [{
+        a: ['foo', 'bar'],
+        b: ['baz', 'foo'],
+      }]};
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+  });
+
+  test('comments sorting', () => {
+    const comments = [
+      {
+        id: 'new_draft',
+        message: 'i do not like either of you',
+        __commentSide: 'left',
+        __draft: true,
+        updated: '2015-12-20 15:01:20.396000000',
+      },
+      {
+        id: 'sallys_confession',
         message: 'i like you, jack',
-        updated: '2015-12-24 15:00:10.396000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 1,
-          end_character: 2,
-        },
-        patch_set: 5,
+        updated: '2015-12-23 15:00:20.396000000',
+        line: 1,
+        __commentSide: 'left',
+      }, {
+        id: 'jacks_reply',
+        message: 'i like you, too',
+        updated: '2015-12-24 15:01:20.396000000',
         __commentSide: 'left',
         line: 1,
-      }];
+        in_reply_to: 'sallys_confession',
+      },
+    ];
+    const sortedComments = element._sortComments(comments);
+    assert.equal(sortedComments[0], comments[1]);
+    assert.equal(sortedComments[1], comments[2]);
+    assert.equal(sortedComments[2], comments[0]);
+  });
 
-      const expectedThreads = [
-        {
-          start_datetime: '2015-12-24 15:00:10.396000000',
-          commentSide: 'left',
-          comments: [{
-            id: 'betsys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-24 15:00:10.396000000',
-            range: {
-              start_line: 1,
-              start_character: 1,
-              end_line: 1,
-              end_character: 2,
-            },
-            patch_set: 5,
-            __commentSide: 'left',
-            line: 1,
-          }],
-          patchNum: 5,
-          rootId: 'betsys_confession',
+  test('_createThreads', () => {
+    const comments = [
+      {
+        id: 'sallys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-23 15:00:20.396000000',
+        line: 1,
+        __commentSide: 'left',
+      }, {
+        id: 'jacks_reply',
+        message: 'i like you, too',
+        updated: '2015-12-24 15:01:20.396000000',
+        __commentSide: 'left',
+        line: 1,
+        in_reply_to: 'sallys_confession',
+      },
+      {
+        id: 'new_draft',
+        message: 'i do not like either of you',
+        __commentSide: 'left',
+        __draft: true,
+        updated: '2015-12-20 15:01:20.396000000',
+      },
+    ];
+
+    const actualThreads = element._createThreads(comments);
+
+    assert.equal(actualThreads.length, 2);
+
+    assert.equal(
+        actualThreads[0].start_datetime, '2015-12-23 15:00:20.396000000');
+    assert.equal(actualThreads[0].commentSide, 'left');
+    assert.equal(actualThreads[0].comments.length, 2);
+    assert.deepEqual(actualThreads[0].comments[0], comments[0]);
+    assert.deepEqual(actualThreads[0].comments[1], comments[1]);
+    assert.equal(actualThreads[0].patchNum, undefined);
+    assert.equal(actualThreads[0].rootId, 'sallys_confession');
+    assert.equal(actualThreads[0].lineNum, 1);
+
+    assert.equal(
+        actualThreads[1].start_datetime, '2015-12-20 15:01:20.396000000');
+    assert.equal(actualThreads[1].commentSide, 'left');
+    assert.equal(actualThreads[1].comments.length, 1);
+    assert.deepEqual(actualThreads[1].comments[0], comments[2]);
+    assert.equal(actualThreads[1].patchNum, undefined);
+    assert.equal(actualThreads[1].rootId, 'new_draft');
+    assert.equal(actualThreads[1].lineNum, undefined);
+  });
+
+  test('_createThreads inherits patchNum and range', () => {
+    const comments = [{
+      id: 'betsys_confession',
+      message: 'i like you, jack',
+      updated: '2015-12-24 15:00:10.396000000',
+      range: {
+        start_line: 1,
+        start_character: 1,
+        end_line: 1,
+        end_character: 2,
+      },
+      patch_set: 5,
+      __commentSide: 'left',
+      line: 1,
+    }];
+
+    const expectedThreads = [
+      {
+        start_datetime: '2015-12-24 15:00:10.396000000',
+        commentSide: 'left',
+        comments: [{
+          id: 'betsys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:10.396000000',
           range: {
             start_line: 1,
             start_character: 1,
             end_line: 1,
             end_character: 2,
           },
-          lineNum: 1,
-          isOnParent: false,
+          patch_set: 5,
+          __commentSide: 'left',
+          line: 1,
+        }],
+        patchNum: 5,
+        rootId: 'betsys_confession',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 2,
         },
-      ];
+        lineNum: 1,
+        isOnParent: false,
+      },
+    ];
 
-      assert.deepEqual(
-          element._createThreads(comments),
-          expectedThreads);
-    });
+    assert.deepEqual(
+        element._createThreads(comments),
+        expectedThreads);
+  });
 
-    test('_createThreads does not thread unrelated comments at same location',
-        () => {
-          const comments = [
-            {
-              id: 'sallys_confession',
-              message: 'i like you, jack',
-              updated: '2015-12-23 15:00:20.396000000',
-              __commentSide: 'left',
-            }, {
-              id: 'jacks_reply',
-              message: 'i like you, too',
-              updated: '2015-12-24 15:01:20.396000000',
-              __commentSide: 'left',
-            },
-          ];
-          assert.equal(element._createThreads(comments).length, 2);
-        });
-
-    test('_createThreads derives isOnParent using  side from first comment',
-        () => {
-          const comments = [
-            {
-              id: 'sallys_confession',
-              message: 'i like you, jack',
-              updated: '2015-12-23 15:00:20.396000000',
-              // line: 1,
-              // __commentSide: 'left',
-            }, {
-              id: 'jacks_reply',
-              message: 'i like you, too',
-              updated: '2015-12-24 15:01:20.396000000',
-              // __commentSide: 'left',
-              // line: 1,
-              in_reply_to: 'sallys_confession',
-            },
-          ];
-
-          assert.equal(element._createThreads(comments)[0].isOnParent, false);
-
-          comments[0].side = 'REVISION';
-          assert.equal(element._createThreads(comments)[0].isOnParent, false);
-
-          comments[0].side = 'PARENT';
-          assert.equal(element._createThreads(comments)[0].isOnParent, true);
-        });
-
-    test('_getOrCreateThread', () => {
-      const commentSide = 'left';
-
-      assert.isOk(element._getOrCreateThread('2', 3,
-          commentSide, undefined, false));
-
-      let threads = Polymer.dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      assert.equal(threads[0].commentSide, commentSide);
-      assert.equal(threads[0].range, undefined);
-      assert.equal(threads[0].isOnParent, false);
-      assert.equal(threads[0].patchNum, 2);
-
-      // Try to fetch a thread with a different range.
-      const range = {
-        start_line: 1,
-        start_character: 1,
-        end_line: 1,
-        end_character: 3,
-      };
-
-      assert.isOk(element._getOrCreateThread(
-          '3', 1, commentSide, range, true));
-
-      threads = Polymer.dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 2);
-      assert.equal(threads[1].commentSide, commentSide);
-      assert.equal(threads[1].range, range);
-      assert.equal(threads[1].isOnParent, true);
-      assert.equal(threads[1].patchNum, 3);
-    });
-
-    test('_filterThreadElsForLocation with no threads', () => {
-      const line = {beforeNumber: 3, afterNumber: 5};
-
-      const threads = [];
-      assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
-      assert.deepEqual(element._filterThreadElsForLocation(threads, line,
-          Gerrit.DiffSide.LEFT), []);
-      assert.deepEqual(element._filterThreadElsForLocation(threads, line,
-          Gerrit.DiffSide.RIGHT), []);
-    });
-
-    test('_filterThreadElsForLocation for line comments', () => {
-      const line = {beforeNumber: 3, afterNumber: 5};
-
-      const l3 = document.createElement('div');
-      l3.setAttribute('line-num', 3);
-      l3.setAttribute('comment-side', 'left');
-
-      const l5 = document.createElement('div');
-      l5.setAttribute('line-num', 5);
-      l5.setAttribute('comment-side', 'left');
-
-      const r3 = document.createElement('div');
-      r3.setAttribute('line-num', 3);
-      r3.setAttribute('comment-side', 'right');
-
-      const r5 = document.createElement('div');
-      r5.setAttribute('line-num', 5);
-      r5.setAttribute('comment-side', 'right');
-
-      const threadEls = [l3, l5, r3, r5];
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
-          [l3, r5]);
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.LEFT), [l3]);
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.RIGHT), [r5]);
-    });
-
-    test('_filterThreadElsForLocation for file comments', () => {
-      const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
-
-      const l = document.createElement('div');
-      l.setAttribute('comment-side', 'left');
-      l.setAttribute('line-num', 'FILE');
-
-      const r = document.createElement('div');
-      r.setAttribute('comment-side', 'right');
-      r.setAttribute('line-num', 'FILE');
-
-      const threadEls = [l, r];
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
-          [l, r]);
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.BOTH), [l, r]);
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.LEFT), [l]);
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.RIGHT), [r]);
-    });
-
-    suite('syntax layer with syntax_highlighting on', () => {
-      setup(() => {
-        const prefs = {
-          line_length: 10,
-          show_tabs: true,
-          tab_size: 4,
-          context: -1,
-          syntax_highlighting: true,
-        };
-        element.patchRange = {};
-        element.prefs = prefs;
-      });
-
-      test('gr-diff-host provides syntax highlighting layer to gr-diff', () => {
-        element.reload();
-        assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
-      });
-
-      test('rendering normal-sized diff does not disable syntax', () => {
-        element.diff = {
-          content: [{
-            a: ['foo'],
-          }],
-        };
-        assert.isTrue(element.$.syntaxLayer.enabled);
-      });
-
-      test('rendering large diff disables syntax', () => {
-        // Before it renders, set the first diff line to 500 '*' characters.
-        element.diff = {
-          content: [{
-            a: [new Array(501).join('*')],
-          }],
-        };
-        assert.isFalse(element.$.syntaxLayer.enabled);
-      });
-
-      test('starts syntax layer processing on render event', done => {
-        sandbox.stub(element.$.syntaxLayer, 'process')
-            .returns(Promise.resolve());
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(
-            Promise.resolve({content: []}));
-        element.reload();
-        setTimeout(() => {
-          element.dispatchEvent(
-              new CustomEvent('render', {bubbles: true, composed: true}));
-          assert.isTrue(element.$.syntaxLayer.process.called);
-          done();
-        });
-      });
-    });
-
-    suite('syntax layer with syntax_highlgihting off', () => {
-      setup(() => {
-        const prefs = {
-          line_length: 10,
-          show_tabs: true,
-          tab_size: 4,
-          context: -1,
-        };
-        element.diff = {
-          content: [{
-            a: ['foo'],
-          }],
-        };
-        element.patchRange = {};
-        element.prefs = prefs;
-      });
-
-      test('gr-diff-host provides syntax highlighting layer', () => {
-        element.reload();
-        assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
-      });
-
-      test('syntax layer should be disabled', () => {
-        assert.isFalse(element.$.syntaxLayer.enabled);
-      });
-
-      test('still disabled for large diff', () => {
-        // Before it renders, set the first diff line to 500 '*' characters.
-        element.diff = {
-          content: [{
-            a: [new Array(501).join('*')],
-          }],
-        };
-        assert.isFalse(element.$.syntaxLayer.enabled);
-      });
-    });
-
-    suite('coverage layer', () => {
-      let notifyStub;
-      setup(() => {
-        notifyStub = sinon.stub();
-        stub('gr-js-api-interface', {
-          getCoverageAnnotationApi() {
-            return Promise.resolve({
-              notify: notifyStub,
-              getCoverageProvider() {
-                return () => Promise.resolve([
-                  {
-                    type: 'COVERED',
-                    side: 'right',
-                    code_range: {
-                      start_line: 1,
-                      end_line: 2,
-                    },
-                  },
-                  {
-                    type: 'NOT_COVERED',
-                    side: 'right',
-                    code_range: {
-                      start_line: 3,
-                      end_line: 4,
-                    },
-                  },
-                ]);
-              },
-            });
+  test('_createThreads does not thread unrelated comments at same location',
+      () => {
+        const comments = [
+          {
+            id: 'sallys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-23 15:00:20.396000000',
+            __commentSide: 'left',
+          }, {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            updated: '2015-12-24 15:01:20.396000000',
+            __commentSide: 'left',
           },
-        });
-        element = fixture('basic');
-        const prefs = {
-          line_length: 10,
-          show_tabs: true,
-          tab_size: 4,
-          context: -1,
-        };
-        element.diff = {
-          content: [{
-            a: ['foo'],
-          }],
-        };
-        element.patchRange = {};
-        element.prefs = prefs;
+        ];
+        assert.equal(element._createThreads(comments).length, 2);
       });
 
-      test('getCoverageAnnotationApi should be called', done => {
-        element.reload();
-        flush(() => {
-          assert.isTrue(element.$.jsAPI.getCoverageAnnotationApi.calledOnce);
-          done();
-        });
+  test('_createThreads derives isOnParent using  side from first comment',
+      () => {
+        const comments = [
+          {
+            id: 'sallys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-23 15:00:20.396000000',
+            // line: 1,
+            // __commentSide: 'left',
+          }, {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            updated: '2015-12-24 15:01:20.396000000',
+            // __commentSide: 'left',
+            // line: 1,
+            in_reply_to: 'sallys_confession',
+          },
+        ];
+
+        assert.equal(element._createThreads(comments)[0].isOnParent, false);
+
+        comments[0].side = 'REVISION';
+        assert.equal(element._createThreads(comments)[0].isOnParent, false);
+
+        comments[0].side = 'PARENT';
+        assert.equal(element._createThreads(comments)[0].isOnParent, true);
       });
 
-      test('coverageRangeChanged should be called', done => {
-        element.reload();
-        flush(() => {
-          assert.equal(notifyStub.callCount, 2);
-          done();
-        });
-      });
+  test('_getOrCreateThread', () => {
+    const commentSide = 'left';
+
+    assert.isOk(element._getOrCreateThread('2', 3,
+        commentSide, undefined, false));
+
+    let threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
+
+    assert.equal(threads.length, 1);
+    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].range, undefined);
+    assert.equal(threads[0].isOnParent, false);
+    assert.equal(threads[0].patchNum, 2);
+
+    // Try to fetch a thread with a different range.
+    const range = {
+      start_line: 1,
+      start_character: 1,
+      end_line: 1,
+      end_character: 3,
+    };
+
+    assert.isOk(element._getOrCreateThread(
+        '3', 1, commentSide, range, true));
+
+    threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
+
+    assert.equal(threads.length, 2);
+    assert.equal(threads[1].commentSide, commentSide);
+    assert.equal(threads[1].range, range);
+    assert.equal(threads[1].isOnParent, true);
+    assert.equal(threads[1].patchNum, 3);
+  });
+
+  test('_filterThreadElsForLocation with no threads', () => {
+    const line = {beforeNumber: 3, afterNumber: 5};
+
+    const threads = [];
+    assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
+    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
+        Gerrit.DiffSide.LEFT), []);
+    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
+        Gerrit.DiffSide.RIGHT), []);
+  });
+
+  test('_filterThreadElsForLocation for line comments', () => {
+    const line = {beforeNumber: 3, afterNumber: 5};
+
+    const l3 = document.createElement('div');
+    l3.setAttribute('line-num', 3);
+    l3.setAttribute('comment-side', 'left');
+
+    const l5 = document.createElement('div');
+    l5.setAttribute('line-num', 5);
+    l5.setAttribute('comment-side', 'left');
+
+    const r3 = document.createElement('div');
+    r3.setAttribute('line-num', 3);
+    r3.setAttribute('comment-side', 'right');
+
+    const r5 = document.createElement('div');
+    r5.setAttribute('line-num', 5);
+    r5.setAttribute('comment-side', 'right');
+
+    const threadEls = [l3, l5, r3, r5];
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
+        [l3, r5]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        Gerrit.DiffSide.LEFT), [l3]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        Gerrit.DiffSide.RIGHT), [r5]);
+  });
+
+  test('_filterThreadElsForLocation for file comments', () => {
+    const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
+
+    const l = document.createElement('div');
+    l.setAttribute('comment-side', 'left');
+    l.setAttribute('line-num', 'FILE');
+
+    const r = document.createElement('div');
+    r.setAttribute('comment-side', 'right');
+    r.setAttribute('line-num', 'FILE');
+
+    const threadEls = [l, r];
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
+        [l, r]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        Gerrit.DiffSide.BOTH), [l, r]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        Gerrit.DiffSide.LEFT), [l]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        Gerrit.DiffSide.RIGHT), [r]);
+  });
+
+  suite('syntax layer with syntax_highlighting on', () => {
+    setup(() => {
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      element.patchRange = {};
+      element.prefs = prefs;
     });
 
-    suite('trailing newlines', () => {
-      setup(() => {
-      });
+    test('gr-diff-host provides syntax highlighting layer to gr-diff', () => {
+      element.reload();
+      assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
+    });
 
-      suite('_lastChunkForSide', () => {
-        test('deltas', () => {
-          const diff = {content: [
-            {a: ['foo', 'bar'], b: ['baz']},
-            {ab: ['foo', 'bar', 'baz']},
-            {b: ['foo']},
-          ]};
-          assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
-          assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
+    test('rendering normal-sized diff does not disable syntax', () => {
+      element.diff = {
+        content: [{
+          a: ['foo'],
+        }],
+      };
+      assert.isTrue(element.$.syntaxLayer.enabled);
+    });
 
-          diff.content.push({a: ['foo'], b: ['bar']});
-          assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
-          assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
-        });
+    test('rendering large diff disables syntax', () => {
+      // Before it renders, set the first diff line to 500 '*' characters.
+      element.diff = {
+        content: [{
+          a: [new Array(501).join('*')],
+        }],
+      };
+      assert.isFalse(element.$.syntaxLayer.enabled);
+    });
 
-        test('addition with a undefined', () => {
-          const diff = {content: [
-            {b: ['foo', 'bar', 'baz']},
-          ]};
-          assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
-          assert.isNull(element._lastChunkForSide(diff, true));
-        });
-
-        test('addition with a empty', () => {
-          const diff = {content: [
-            {a: [], b: ['foo', 'bar', 'baz']},
-          ]};
-          assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
-          assert.isNull(element._lastChunkForSide(diff, true));
-        });
-
-        test('deletion with b undefined', () => {
-          const diff = {content: [
-            {a: ['foo', 'bar', 'baz']},
-          ]};
-          assert.isNull(element._lastChunkForSide(diff, false));
-          assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
-        });
-
-        test('deletion with b empty', () => {
-          const diff = {content: [
-            {a: ['foo', 'bar', 'baz'], b: []},
-          ]};
-          assert.isNull(element._lastChunkForSide(diff, false));
-          assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
-        });
-
-        test('empty', () => {
-          const diff = {content: []};
-          assert.isNull(element._lastChunkForSide(diff, false));
-          assert.isNull(element._lastChunkForSide(diff, true));
-        });
-      });
-
-      suite('_hasTrailingNewlines', () => {
-        test('shared no trailing', () => {
-          const diff = undefined;
-          sandbox.stub(element, '_lastChunkForSide')
-              .returns({ab: ['foo', 'bar']});
-          assert.isFalse(element._hasTrailingNewlines(diff, false));
-          assert.isFalse(element._hasTrailingNewlines(diff, true));
-        });
-
-        test('delta trailing in right', () => {
-          const diff = undefined;
-          sandbox.stub(element, '_lastChunkForSide')
-              .returns({a: ['foo', 'bar'], b: ['baz', '']});
-          assert.isTrue(element._hasTrailingNewlines(diff, false));
-          assert.isFalse(element._hasTrailingNewlines(diff, true));
-        });
-
-        test('addition', () => {
-          const diff = undefined;
-          sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
-            if (leftSide) { return null; }
-            return {b: ['foo', '']};
-          });
-          assert.isTrue(element._hasTrailingNewlines(diff, false));
-          assert.isNull(element._hasTrailingNewlines(diff, true));
-        });
-
-        test('deletion', () => {
-          const diff = undefined;
-          sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
-            if (!leftSide) { return null; }
-            return {a: ['foo']};
-          });
-          assert.isNull(element._hasTrailingNewlines(diff, false));
-          assert.isFalse(element._hasTrailingNewlines(diff, true));
-        });
+    test('starts syntax layer processing on render event', done => {
+      sandbox.stub(element.$.syntaxLayer, 'process')
+          .returns(Promise.resolve());
+      sandbox.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.reload();
+      setTimeout(() => {
+        element.dispatchEvent(
+            new CustomEvent('render', {bubbles: true, composed: true}));
+        assert.isTrue(element.$.syntaxLayer.process.called);
+        done();
       });
     });
   });
+
+  suite('syntax layer with syntax_highlgihting off', () => {
+    setup(() => {
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+      };
+      element.diff = {
+        content: [{
+          a: ['foo'],
+        }],
+      };
+      element.patchRange = {};
+      element.prefs = prefs;
+    });
+
+    test('gr-diff-host provides syntax highlighting layer', () => {
+      element.reload();
+      assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
+    });
+
+    test('syntax layer should be disabled', () => {
+      assert.isFalse(element.$.syntaxLayer.enabled);
+    });
+
+    test('still disabled for large diff', () => {
+      // Before it renders, set the first diff line to 500 '*' characters.
+      element.diff = {
+        content: [{
+          a: [new Array(501).join('*')],
+        }],
+      };
+      assert.isFalse(element.$.syntaxLayer.enabled);
+    });
+  });
+
+  suite('coverage layer', () => {
+    let notifyStub;
+    setup(() => {
+      notifyStub = sinon.stub();
+      stub('gr-js-api-interface', {
+        getCoverageAnnotationApi() {
+          return Promise.resolve({
+            notify: notifyStub,
+            getCoverageProvider() {
+              return () => Promise.resolve([
+                {
+                  type: 'COVERED',
+                  side: 'right',
+                  code_range: {
+                    start_line: 1,
+                    end_line: 2,
+                  },
+                },
+                {
+                  type: 'NOT_COVERED',
+                  side: 'right',
+                  code_range: {
+                    start_line: 3,
+                    end_line: 4,
+                  },
+                },
+              ]);
+            },
+          });
+        },
+      });
+      element = fixture('basic');
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+      };
+      element.diff = {
+        content: [{
+          a: ['foo'],
+        }],
+      };
+      element.patchRange = {};
+      element.prefs = prefs;
+    });
+
+    test('getCoverageAnnotationApi should be called', done => {
+      element.reload();
+      flush(() => {
+        assert.isTrue(element.$.jsAPI.getCoverageAnnotationApi.calledOnce);
+        done();
+      });
+    });
+
+    test('coverageRangeChanged should be called', done => {
+      element.reload();
+      flush(() => {
+        assert.equal(notifyStub.callCount, 2);
+        done();
+      });
+    });
+  });
+
+  suite('trailing newlines', () => {
+    setup(() => {
+    });
+
+    suite('_lastChunkForSide', () => {
+      test('deltas', () => {
+        const diff = {content: [
+          {a: ['foo', 'bar'], b: ['baz']},
+          {ab: ['foo', 'bar', 'baz']},
+          {b: ['foo']},
+        ]};
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
+
+        diff.content.push({a: ['foo'], b: ['bar']});
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
+      });
+
+      test('addition with a undefined', () => {
+        const diff = {content: [
+          {b: ['foo', 'bar', 'baz']},
+        ]};
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
+        assert.isNull(element._lastChunkForSide(diff, true));
+      });
+
+      test('addition with a empty', () => {
+        const diff = {content: [
+          {a: [], b: ['foo', 'bar', 'baz']},
+        ]};
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
+        assert.isNull(element._lastChunkForSide(diff, true));
+      });
+
+      test('deletion with b undefined', () => {
+        const diff = {content: [
+          {a: ['foo', 'bar', 'baz']},
+        ]};
+        assert.isNull(element._lastChunkForSide(diff, false));
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
+      });
+
+      test('deletion with b empty', () => {
+        const diff = {content: [
+          {a: ['foo', 'bar', 'baz'], b: []},
+        ]};
+        assert.isNull(element._lastChunkForSide(diff, false));
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
+      });
+
+      test('empty', () => {
+        const diff = {content: []};
+        assert.isNull(element._lastChunkForSide(diff, false));
+        assert.isNull(element._lastChunkForSide(diff, true));
+      });
+    });
+
+    suite('_hasTrailingNewlines', () => {
+      test('shared no trailing', () => {
+        const diff = undefined;
+        sandbox.stub(element, '_lastChunkForSide')
+            .returns({ab: ['foo', 'bar']});
+        assert.isFalse(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+
+      test('delta trailing in right', () => {
+        const diff = undefined;
+        sandbox.stub(element, '_lastChunkForSide')
+            .returns({a: ['foo', 'bar'], b: ['baz', '']});
+        assert.isTrue(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+
+      test('addition', () => {
+        const diff = undefined;
+        sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
+          if (leftSide) { return null; }
+          return {b: ['foo', '']};
+        });
+        assert.isTrue(element._hasTrailingNewlines(diff, false));
+        assert.isNull(element._hasTrailingNewlines(diff, true));
+      });
+
+      test('deletion', () => {
+        const diff = undefined;
+        sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
+          if (!leftSide) { return null; }
+          return {a: ['foo']};
+        });
+        assert.isNull(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
index 68bca23..acd9457 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
@@ -14,65 +14,74 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrDiffModeSelector extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-diff-mode-selector'; }
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-mode-selector_html.js';
 
-    static get properties() {
-      return {
-        mode: {
-          type: String,
-          notify: true,
+/** @extends Polymer.Element */
+class GrDiffModeSelector extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-mode-selector'; }
+
+  static get properties() {
+    return {
+      mode: {
+        type: String,
+        notify: true,
+      },
+
+      /**
+       * If set to true, the user's preference will be updated every time a
+       * button is tapped. Don't set to true if there is no user.
+       */
+      saveOnChange: {
+        type: Boolean,
+        value: false,
+      },
+
+      /** @type {?} */
+      _VIEW_MODES: {
+        type: Object,
+        readOnly: true,
+        value: {
+          SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+          UNIFIED: 'UNIFIED_DIFF',
         },
-
-        /**
-         * If set to true, the user's preference will be updated every time a
-         * button is tapped. Don't set to true if there is no user.
-         */
-        saveOnChange: {
-          type: Boolean,
-          value: false,
-        },
-
-        /** @type {?} */
-        _VIEW_MODES: {
-          type: Object,
-          readOnly: true,
-          value: {
-            SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-            UNIFIED: 'UNIFIED_DIFF',
-          },
-        },
-      };
-    }
-
-    /**
-     * Set the mode. If save on change is enabled also update the preference.
-     */
-    setMode(newMode) {
-      if (this.saveOnChange && this.mode && this.mode !== newMode) {
-        this.$.restAPI.savePreferences({diff_view: newMode});
-      }
-      this.mode = newMode;
-    }
-
-    _computeSelectedClass(diffViewMode, buttonViewMode) {
-      return buttonViewMode === diffViewMode ? 'selected' : '';
-    }
-
-    _handleSideBySideTap() {
-      this.setMode(this._VIEW_MODES.SIDE_BY_SIDE);
-    }
-
-    _handleUnifiedTap() {
-      this.setMode(this._VIEW_MODES.UNIFIED);
-    }
+      },
+    };
   }
 
-  customElements.define(GrDiffModeSelector.is, GrDiffModeSelector);
-})();
+  /**
+   * Set the mode. If save on change is enabled also update the preference.
+   */
+  setMode(newMode) {
+    if (this.saveOnChange && this.mode && this.mode !== newMode) {
+      this.$.restAPI.savePreferences({diff_view: newMode});
+    }
+    this.mode = newMode;
+  }
+
+  _computeSelectedClass(diffViewMode, buttonViewMode) {
+    return buttonViewMode === diffViewMode ? 'selected' : '';
+  }
+
+  _handleSideBySideTap() {
+    this.setMode(this._VIEW_MODES.SIDE_BY_SIDE);
+  }
+
+  _handleUnifiedTap() {
+    this.setMode(this._VIEW_MODES.UNIFIED);
+  }
+}
+
+customElements.define(GrDiffModeSelector.is, GrDiffModeSelector);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.js
index 47cf771..5fe516c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-diff-mode-selector">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         /* Used to remove horizontal whitespace between the icons. */
@@ -36,25 +30,11 @@
         width: 1.3rem;
       }
     </style>
-    <gr-button
-        id="sideBySideBtn"
-        link
-        has-tooltip
-        class$="[[_computeSelectedClass(mode, _VIEW_MODES.SIDE_BY_SIDE)]]"
-        title="Side-by-side diff"
-        on-click="_handleSideBySideTap">
+    <gr-button id="sideBySideBtn" link="" has-tooltip="" class\$="[[_computeSelectedClass(mode, _VIEW_MODES.SIDE_BY_SIDE)]]" title="Side-by-side diff" on-click="_handleSideBySideTap">
       <iron-icon icon="gr-icons:side-by-side"></iron-icon>
     </gr-button>
-    <gr-button
-        id="unifiedBtn"
-        link
-        has-tooltip
-        title="Unified diff"
-        class$="[[_computeSelectedClass(mode, _VIEW_MODES.UNIFIED)]]"
-        on-click="_handleUnifiedTap">
+    <gr-button id="unifiedBtn" link="" has-tooltip="" title="Unified diff" class\$="[[_computeSelectedClass(mode, _VIEW_MODES.UNIFIED)]]" on-click="_handleUnifiedTap">
       <iron-icon icon="gr-icons:unified"></iron-icon>
     </gr-button>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-diff-mode-selector.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
index 2f3d262..921bc74 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
@@ -19,18 +19,24 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-mode-selector</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script src="/node_modules/page/page.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-diff-mode-selector.html">
+<script type="module" src="./gr-diff-mode-selector.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-diff-mode-selector.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -38,52 +44,55 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-mode-selector tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-diff-mode-selector.js';
+suite('gr-diff-mode-selector tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_computeSelectedClass', () => {
-      assert.equal(
-          element._computeSelectedClass('SIDE_BY_SIDE', 'SIDE_BY_SIDE'),
-          'selected');
-      assert.equal(
-          element._computeSelectedClass('SIDE_BY_SIDE', 'UNIFIED_DIFF'), '');
-    });
-
-    test('setMode', () => {
-      const saveStub = sandbox.stub(element.$.restAPI, 'savePreferences');
-
-      // Setting the mode initially does not save prefs.
-      element.saveOnChange = true;
-      element.setMode('SIDE_BY_SIDE');
-      assert.isFalse(saveStub.called);
-
-      // Setting the mode to itself does not save prefs.
-      element.setMode('SIDE_BY_SIDE');
-      assert.isFalse(saveStub.called);
-
-      // Setting the mode to something else does not save prefs if saveOnChange
-      // is false.
-      element.saveOnChange = false;
-      element.setMode('UNIFIED_DIFF');
-      assert.isFalse(saveStub.called);
-
-      // Setting the mode to something else does not save prefs if saveOnChange
-      // is false.
-      element.saveOnChange = true;
-      element.setMode('SIDE_BY_SIDE');
-      assert.isTrue(saveStub.calledOnce);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_computeSelectedClass', () => {
+    assert.equal(
+        element._computeSelectedClass('SIDE_BY_SIDE', 'SIDE_BY_SIDE'),
+        'selected');
+    assert.equal(
+        element._computeSelectedClass('SIDE_BY_SIDE', 'UNIFIED_DIFF'), '');
+  });
+
+  test('setMode', () => {
+    const saveStub = sandbox.stub(element.$.restAPI, 'savePreferences');
+
+    // Setting the mode initially does not save prefs.
+    element.saveOnChange = true;
+    element.setMode('SIDE_BY_SIDE');
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to itself does not save prefs.
+    element.setMode('SIDE_BY_SIDE');
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to something else does not save prefs if saveOnChange
+    // is false.
+    element.saveOnChange = false;
+    element.setMode('UNIFIED_DIFF');
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to something else does not save prefs if saveOnChange
+    // is false.
+    element.saveOnChange = true;
+    element.setMode('SIDE_BY_SIDE');
+    assert.isTrue(saveStub.calledOnce);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
index 6aad66c..fa79e49 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
@@ -14,65 +14,76 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
-   */
-  class GrDiffPreferencesDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-diff-preferences-dialog'; }
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-diff-preferences/gr-diff-preferences.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-preferences-dialog_html.js';
 
-    static get properties() {
-      return {
-      /** @type {?} */
-        diffPrefs: Object,
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrDiffPreferencesDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-        _diffPrefsChanged: Boolean,
-      };
-    }
+  static get is() { return 'gr-diff-preferences-dialog'; }
 
-    getFocusStops() {
-      return {
-        start: this.$.diffPreferences.$.contextSelect,
-        end: this.$.saveButton,
-      };
-    }
+  static get properties() {
+    return {
+    /** @type {?} */
+      diffPrefs: Object,
 
-    resetFocus() {
-      this.$.diffPreferences.$.contextSelect.focus();
-    }
-
-    _computeHeaderClass(changed) {
-      return changed ? 'edited' : '';
-    }
-
-    _handleCancelDiff(e) {
-      e.stopPropagation();
-      this.$.diffPrefsOverlay.close();
-    }
-
-    open() {
-      this.$.diffPrefsOverlay.open().then(() => {
-        const focusStops = this.getFocusStops();
-        this.$.diffPrefsOverlay.setFocusStops(focusStops);
-        this.resetFocus();
-      });
-    }
-
-    _handleSaveDiffPreferences() {
-      this.$.diffPreferences.save().then(() => {
-        this.fire('reload-diff-preference', null, {bubbles: false});
-
-        this.$.diffPrefsOverlay.close();
-      });
-    }
+      _diffPrefsChanged: Boolean,
+    };
   }
 
-  customElements.define(GrDiffPreferencesDialog.is, GrDiffPreferencesDialog);
-})();
+  getFocusStops() {
+    return {
+      start: this.$.diffPreferences.$.contextSelect,
+      end: this.$.saveButton,
+    };
+  }
+
+  resetFocus() {
+    this.$.diffPreferences.$.contextSelect.focus();
+  }
+
+  _computeHeaderClass(changed) {
+    return changed ? 'edited' : '';
+  }
+
+  _handleCancelDiff(e) {
+    e.stopPropagation();
+    this.$.diffPrefsOverlay.close();
+  }
+
+  open() {
+    this.$.diffPrefsOverlay.open().then(() => {
+      const focusStops = this.getFocusStops();
+      this.$.diffPrefsOverlay.setFocusStops(focusStops);
+      this.resetFocus();
+    });
+  }
+
+  _handleSaveDiffPreferences() {
+    this.$.diffPreferences.save().then(() => {
+      this.fire('reload-diff-preference', null, {bubbles: false});
+
+      this.$.diffPrefsOverlay.close();
+    });
+  }
+}
+
+customElements.define(GrDiffPreferencesDialog.is, GrDiffPreferencesDialog);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.js
index 21f6282..cd26a0b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-diff-preferences/gr-diff-preferences.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-
-<dom-module id="gr-diff-preferences-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .diffHeader,
       .diffActions {
@@ -54,28 +47,16 @@
         padding: var(--spacing-s) var(--spacing-xl);
       }
     </style>
-    <gr-overlay id="diffPrefsOverlay" with-backdrop>
-      <div class$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]">Diff Preferences</div>
-      <gr-diff-preferences
-          id="diffPreferences"
-          diff-prefs="{{diffPrefs}}"
-          has-unsaved-changes="{{_diffPrefsChanged}}"></gr-diff-preferences>
+    <gr-overlay id="diffPrefsOverlay" with-backdrop="">
+      <div class\$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]">Diff Preferences</div>
+      <gr-diff-preferences id="diffPreferences" diff-prefs="{{diffPrefs}}" has-unsaved-changes="{{_diffPrefsChanged}}"></gr-diff-preferences>
       <div class="diffActions">
-        <gr-button
-            id="cancelButton"
-            link
-            on-click="_handleCancelDiff">
+        <gr-button id="cancelButton" link="" on-click="_handleCancelDiff">
             Cancel
         </gr-button>
-        <gr-button
-            id="saveButton"
-            link primary
-            on-click="_handleSaveDiffPreferences"
-            disabled$="[[!_diffPrefsChanged]]">
+        <gr-button id="saveButton" link="" primary="" on-click="_handleSaveDiffPreferences" disabled\$="[[!_diffPrefsChanged]]">
             Save
         </gr-button>
       </div>
     </gr-overlay>
-  </template>
-  <script src="gr-diff-preferences-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
deleted file mode 100644
index 7a0bce1..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
+++ /dev/null
@@ -1,25 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<script src="../gr-diff/gr-diff-line.js"></script>
-<script src="../gr-diff/gr-diff-group.js"></script>
-<script src="../../../scripts/util.js"></script>
-
-<dom-module id="gr-diff-processor">
-  <script src="gr-diff-processor.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index dcda64d..adcb375 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -14,653 +14,658 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const WHOLE_FILE = -1;
+import '../gr-diff/gr-diff-line.js';
+import '../gr-diff/gr-diff-group.js';
+import '../../../scripts/util.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 
-  const DiffSide = {
-    LEFT: 'left',
-    RIGHT: 'right',
-  };
+const WHOLE_FILE = -1;
 
-  const DiffHighlights = {
-    ADDED: 'edit_b',
-    REMOVED: 'edit_a',
-  };
+const DiffSide = {
+  LEFT: 'left',
+  RIGHT: 'right',
+};
+
+const DiffHighlights = {
+  ADDED: 'edit_b',
+  REMOVED: 'edit_a',
+};
+
+/**
+ * The maximum size for an addition or removal chunk before it is broken down
+ * into a series of chunks that are this size at most.
+ *
+ * Note: The value of 120 is chosen so that it is larger than the default
+ * _asyncThreshold of 64, but feel free to tune this constant to your
+ * performance needs.
+ */
+const MAX_GROUP_SIZE = 120;
+
+/**
+ * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
+ *
+ * Glossary:
+ * - "chunk": A single `DiffContent` as returned by the API.
+ * - "group": A single `GrDiffGroup` as used for rendering.
+ * - "common" chunk/group: A chunk/group that should be considered unchanged
+ *   for diffing purposes. This can mean its either actually unchanged, or it
+ *   has only whitespace changes.
+ * - "key location": A line number and side of the diff that should not be
+ *   collapsed e.g. because a comment is attached to it, or because it was
+ *   provided in the URL and thus should be visible
+ * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
+ *   or cannot be collapsed because it contains a key location
+ *
+ * Here a a number of tasks this processor performs:
+ *  - splitting large chunks to allow more granular async rendering
+ *  - adding a group for the "File" pseudo line that file-level comments can
+ *    be attached to
+ *  - replacing common parts of the diff that are outside the user's
+ *    context setting and do not have comments with a group representing the
+ *    "expand context" widget. This may require splitting a chunk/group so
+ *    that the part that is within the context or has comments is shown, while
+ *    the rest is not.
+ *
+ * @extends Polymer.Element
+ */
+class GrDiffProcessor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get is() { return 'gr-diff-processor'; }
+
+  static get properties() {
+    return {
+
+      /**
+       * The amount of context around collapsed groups.
+       */
+      context: Number,
+
+      /**
+       * The array of groups output by the processor.
+       */
+      groups: {
+        type: Array,
+        notify: true,
+      },
+
+      /**
+       * Locations that should not be collapsed, including the locations of
+       * comments.
+       */
+      keyLocations: {
+        type: Object,
+        value() { return {left: {}, right: {}}; },
+      },
+
+      /**
+       * The maximum number of lines to process synchronously.
+       */
+      _asyncThreshold: {
+        type: Number,
+        value: 64,
+      },
+
+      /** @type {?number} */
+      _nextStepHandle: Number,
+      /**
+       * The promise last returned from `process()` while the asynchronous
+       * processing is running - `null` otherwise. Provides a `cancel()`
+       * method that rejects it with `{isCancelled: true}`.
+       *
+       * @type {?Object}
+       */
+      _processPromise: {
+        type: Object,
+        value: null,
+      },
+      _isScrolling: Boolean,
+    };
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.listen(window, 'scroll', '_handleWindowScroll');
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.cancel();
+    this.unlisten(window, 'scroll', '_handleWindowScroll');
+  }
+
+  _handleWindowScroll() {
+    this._isScrolling = true;
+    this.debounce('resetIsScrolling', () => {
+      this._isScrolling = false;
+    }, 50);
+  }
 
   /**
-   * The maximum size for an addition or removal chunk before it is broken down
-   * into a series of chunks that are this size at most.
+   * Asynchronously process the diff chunks into groups. As it processes, it
+   * will splice groups into the `groups` property of the component.
    *
-   * Note: The value of 120 is chosen so that it is larger than the default
-   * _asyncThreshold of 64, but feel free to tune this constant to your
-   * performance needs.
+   * @param {!Array<!Gerrit.DiffChunk>} chunks
+   * @param {boolean} isBinary
+   *
+   * @return {!Promise<!Array<!Object>>} A promise that resolves with an
+   *     array of GrDiffGroups when the diff is completely processed.
    */
-  const MAX_GROUP_SIZE = 120;
+  process(chunks, isBinary) {
+    // Cancel any still running process() calls, because they append to the
+    // same groups field.
+    this.cancel();
 
-  /**
-   * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
-   *
-   * Glossary:
-   * - "chunk": A single `DiffContent` as returned by the API.
-   * - "group": A single `GrDiffGroup` as used for rendering.
-   * - "common" chunk/group: A chunk/group that should be considered unchanged
-   *   for diffing purposes. This can mean its either actually unchanged, or it
-   *   has only whitespace changes.
-   * - "key location": A line number and side of the diff that should not be
-   *   collapsed e.g. because a comment is attached to it, or because it was
-   *   provided in the URL and thus should be visible
-   * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
-   *   or cannot be collapsed because it contains a key location
-   *
-   * Here a a number of tasks this processor performs:
-   *  - splitting large chunks to allow more granular async rendering
-   *  - adding a group for the "File" pseudo line that file-level comments can
-   *    be attached to
-   *  - replacing common parts of the diff that are outside the user's
-   *    context setting and do not have comments with a group representing the
-   *    "expand context" widget. This may require splitting a chunk/group so
-   *    that the part that is within the context or has comments is shown, while
-   *    the rest is not.
-   *
-   * @extends Polymer.Element
-   */
-  class GrDiffProcessor extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-diff-processor'; }
+    this.groups = [];
+    this.push('groups', this._makeFileComments());
 
-    static get properties() {
-      return {
+    // If it's a binary diff, we won't be rendering hunks of text differences
+    // so finish processing.
+    if (isBinary) { return Promise.resolve(); }
 
-        /**
-         * The amount of context around collapsed groups.
-         */
-        context: Number,
+    this._processPromise = util.makeCancelable(
+        new Promise(resolve => {
+          const state = {
+            lineNums: {left: 0, right: 0},
+            chunkIndex: 0,
+          };
 
-        /**
-         * The array of groups output by the processor.
-         */
-        groups: {
-          type: Array,
-          notify: true,
-        },
+          chunks = this._splitLargeChunks(chunks);
+          chunks = this._splitCommonChunksWithKeyLocations(chunks);
 
-        /**
-         * Locations that should not be collapsed, including the locations of
-         * comments.
-         */
-        keyLocations: {
-          type: Object,
-          value() { return {left: {}, right: {}}; },
-        },
-
-        /**
-         * The maximum number of lines to process synchronously.
-         */
-        _asyncThreshold: {
-          type: Number,
-          value: 64,
-        },
-
-        /** @type {?number} */
-        _nextStepHandle: Number,
-        /**
-         * The promise last returned from `process()` while the asynchronous
-         * processing is running - `null` otherwise. Provides a `cancel()`
-         * method that rejects it with `{isCancelled: true}`.
-         *
-         * @type {?Object}
-         */
-        _processPromise: {
-          type: Object,
-          value: null,
-        },
-        _isScrolling: Boolean,
-      };
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this.listen(window, 'scroll', '_handleWindowScroll');
-    }
-
-    /** @override */
-    detached() {
-      super.detached();
-      this.cancel();
-      this.unlisten(window, 'scroll', '_handleWindowScroll');
-    }
-
-    _handleWindowScroll() {
-      this._isScrolling = true;
-      this.debounce('resetIsScrolling', () => {
-        this._isScrolling = false;
-      }, 50);
-    }
-
-    /**
-     * Asynchronously process the diff chunks into groups. As it processes, it
-     * will splice groups into the `groups` property of the component.
-     *
-     * @param {!Array<!Gerrit.DiffChunk>} chunks
-     * @param {boolean} isBinary
-     *
-     * @return {!Promise<!Array<!Object>>} A promise that resolves with an
-     *     array of GrDiffGroups when the diff is completely processed.
-     */
-    process(chunks, isBinary) {
-      // Cancel any still running process() calls, because they append to the
-      // same groups field.
-      this.cancel();
-
-      this.groups = [];
-      this.push('groups', this._makeFileComments());
-
-      // If it's a binary diff, we won't be rendering hunks of text differences
-      // so finish processing.
-      if (isBinary) { return Promise.resolve(); }
-
-      this._processPromise = util.makeCancelable(
-          new Promise(resolve => {
-            const state = {
-              lineNums: {left: 0, right: 0},
-              chunkIndex: 0,
-            };
-
-            chunks = this._splitLargeChunks(chunks);
-            chunks = this._splitCommonChunksWithKeyLocations(chunks);
-
-            let currentBatch = 0;
-            const nextStep = () => {
-              if (this._isScrolling) {
-                this._nextStepHandle = this.async(nextStep, 100);
-                return;
-              }
-              // If we are done, resolve the promise.
-              if (state.chunkIndex >= chunks.length) {
-                resolve();
-                this._nextStepHandle = null;
-                return;
-              }
-
-              // Process the next chunk and incorporate the result.
-              const stateUpdate = this._processNext(state, chunks);
-              for (const group of stateUpdate.groups) {
-                this.push('groups', group);
-                currentBatch += group.lines.length;
-              }
-              state.lineNums.left += stateUpdate.lineDelta.left;
-              state.lineNums.right += stateUpdate.lineDelta.right;
-
-              // Increment the index and recurse.
-              state.chunkIndex = stateUpdate.newChunkIndex;
-              if (currentBatch >= this._asyncThreshold) {
-                currentBatch = 0;
-                this._nextStepHandle = this.async(nextStep, 1);
-              } else {
-                nextStep.call(this);
-              }
-            };
-
-            nextStep.call(this);
-          }));
-      return this._processPromise
-          .finally(() => { this._processPromise = null; });
-    }
-
-    /**
-     * Cancel any jobs that are running.
-     */
-    cancel() {
-      if (this._nextStepHandle != null) {
-        this.cancelAsync(this._nextStepHandle);
-        this._nextStepHandle = null;
-      }
-      if (this._processPromise) {
-        this._processPromise.cancel();
-      }
-    }
-
-    /**
-     * Process the next uncollapsible chunk, or the next collapsible chunks.
-     *
-     * @param {!Object} state
-     * @param {!Array<!Object>} chunks
-     * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
-     */
-    _processNext(state, chunks) {
-      const firstUncollapsibleChunkIndex =
-          this._firstUncollapsibleChunkIndex(chunks, state.chunkIndex);
-      if (firstUncollapsibleChunkIndex === state.chunkIndex) {
-        const chunk = chunks[state.chunkIndex];
-        return {
-          lineDelta: {
-            left: this._linesLeft(chunk).length,
-            right: this._linesRight(chunk).length,
-          },
-          groups: [this._chunkToGroup(
-              chunk, state.lineNums.left + 1, state.lineNums.right + 1)],
-          newChunkIndex: state.chunkIndex + 1,
-        };
-      }
-
-      return this._processCollapsibleChunks(
-          state, chunks, firstUncollapsibleChunkIndex);
-    }
-
-    _linesLeft(chunk) {
-      return chunk.ab || chunk.a || [];
-    }
-
-    _linesRight(chunk) {
-      return chunk.ab || chunk.b || [];
-    }
-
-    _firstUncollapsibleChunkIndex(chunks, offset) {
-      let chunkIndex = offset;
-      while (chunkIndex < chunks.length &&
-          this._isCollapsibleChunk(chunks[chunkIndex])) {
-        chunkIndex++;
-      }
-      return chunkIndex;
-    }
-
-    _isCollapsibleChunk(chunk) {
-      return (chunk.ab || chunk.common) && !chunk.keyLocation;
-    }
-
-    /**
-     * Process a stretch of collapsible chunks.
-     *
-     * Outputs up to three groups:
-     *  1) Visible context before the hidden common code, unless it's the
-     *     very beginning of the file.
-     *  2) Context hidden behind a context bar, unless empty.
-     *  3) Visible context after the hidden common code, unless it's the very
-     *     end of the file.
-     *
-     * @param {!Object} state
-     * @param {!Array<Object>} chunks
-     * @param {number} firstUncollapsibleChunkIndex
-     * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
-     */
-    _processCollapsibleChunks(
-        state, chunks, firstUncollapsibleChunkIndex) {
-      const collapsibleChunks = chunks.slice(
-          state.chunkIndex, firstUncollapsibleChunkIndex);
-      const lineCount = collapsibleChunks.reduce(
-          (sum, chunk) => sum + this._commonChunkLength(chunk), 0);
-
-      let groups = this._chunksToGroups(
-          collapsibleChunks,
-          state.lineNums.left + 1,
-          state.lineNums.right + 1);
-
-      if (this.context !== WHOLE_FILE) {
-        const hiddenStart = state.chunkIndex === 0 ? 0 : this.context;
-        const hiddenEnd = lineCount - (
-          firstUncollapsibleChunkIndex === chunks.length ?
-            0 : this.context);
-        groups = GrDiffGroup.hideInContextControl(
-            groups, hiddenStart, hiddenEnd);
-      }
-
-      return {
-        lineDelta: {
-          left: lineCount,
-          right: lineCount,
-        },
-        groups,
-        newChunkIndex: firstUncollapsibleChunkIndex,
-      };
-    }
-
-    _commonChunkLength(chunk) {
-      console.assert(chunk.ab || chunk.common);
-      console.assert(
-          !chunk.a || (chunk.b && chunk.a.length === chunk.b.length),
-          `common chunk needs same number of a and b lines: `, chunk);
-      return this._linesLeft(chunk).length;
-    }
-
-    /**
-     * @param {!Array<!Object>} chunks
-     * @param {number} offsetLeft
-     * @param {number} offsetRight
-     * @return {!Array<!Object>} (GrDiffGroup)
-     */
-    _chunksToGroups(chunks, offsetLeft, offsetRight) {
-      return chunks.map(chunk => {
-        const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
-        const chunkLength = this._commonChunkLength(chunk);
-        offsetLeft += chunkLength;
-        offsetRight += chunkLength;
-        return group;
-      });
-    }
-
-    /**
-     * @param {!Object} chunk
-     * @param {number} offsetLeft
-     * @param {number} offsetRight
-     * @return {!Object} (GrDiffGroup)
-     */
-    _chunkToGroup(chunk, offsetLeft, offsetRight) {
-      const type = chunk.ab ? GrDiffGroup.Type.BOTH : GrDiffGroup.Type.DELTA;
-      const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
-      const group = new GrDiffGroup(type, lines);
-      group.keyLocation = chunk.keyLocation;
-      group.dueToRebase = chunk.due_to_rebase;
-      group.ignoredWhitespaceOnly = chunk.common;
-      return group;
-    }
-
-    _linesFromChunk(chunk, offsetLeft, offsetRight) {
-      if (chunk.ab) {
-        return chunk.ab.map((row, i) => this._lineFromRow(
-            GrDiffLine.Type.BOTH, offsetLeft, offsetRight, row, i));
-      }
-      let lines = [];
-      if (chunk.a) {
-        // Avoiding a.push(...b) because that causes callstack overflows for
-        // large b, which can occur when large files are added removed.
-        lines = lines.concat(this._linesFromRows(
-            GrDiffLine.Type.REMOVE, chunk.a, offsetLeft,
-            chunk[DiffHighlights.REMOVED]));
-      }
-      if (chunk.b) {
-        // Avoiding a.push(...b) because that causes callstack overflows for
-        // large b, which can occur when large files are added removed.
-        lines = lines.concat(this._linesFromRows(
-            GrDiffLine.Type.ADD, chunk.b, offsetRight,
-            chunk[DiffHighlights.ADDED]));
-      }
-      return lines;
-    }
-
-    /**
-     * @param {string} lineType (GrDiffLine.Type)
-     * @param {!Array<string>} rows
-     * @param {number} offset
-     * @param {?Array<!Gerrit.IntralineInfo>=} opt_intralineInfos
-     * @return {!Array<!Object>} (GrDiffLine)
-     */
-    _linesFromRows(lineType, rows, offset, opt_intralineInfos) {
-      const grDiffHighlights = opt_intralineInfos ?
-        this._convertIntralineInfos(rows, opt_intralineInfos) : undefined;
-      return rows.map((row, i) => this._lineFromRow(
-          lineType, offset, offset, row, i, grDiffHighlights));
-    }
-
-    /**
-     * @param {string} type (GrDiffLine.Type)
-     * @param {number} offsetLeft
-     * @param {number} offsetRight
-     * @param {string} row
-     * @param {number} i
-     * @param {!Array<!Object>=} opt_highlights
-     * @return {!Object} (GrDiffLine)
-     */
-    _lineFromRow(type, offsetLeft, offsetRight, row, i, opt_highlights) {
-      const line = new GrDiffLine(type);
-      line.text = row;
-      if (type !== GrDiffLine.Type.ADD) line.beforeNumber = offsetLeft + i;
-      if (type !== GrDiffLine.Type.REMOVE) line.afterNumber = offsetRight + i;
-      if (opt_highlights) {
-        line.hasIntralineInfo = true;
-        line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
-      } else {
-        line.hasIntralineInfo = false;
-      }
-      return line;
-    }
-
-    _makeFileComments() {
-      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.beforeNumber = GrDiffLine.FILE;
-      line.afterNumber = GrDiffLine.FILE;
-      return new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]);
-    }
-
-    /**
-     * Split chunks into smaller chunks of the same kind.
-     *
-     * This is done to prevent doing too much work on the main thread in one
-     * uninterrupted rendering step, which would make the browser unresponsive.
-     *
-     * Note that in the case of unmodified chunks, we only split chunks if the
-     * context is set to file (because otherwise they are split up further down
-     * the processing into the visible and hidden context), and only split it
-     * into 2 chunks, one max sized one and the rest (for reasons that are
-     * unclear to me).
-     *
-     * @param {!Array<!Gerrit.DiffChunk>} chunks Chunks as returned from the server
-     * @return {!Array<!Gerrit.DiffChunk>} Finer grained chunks.
-     */
-    _splitLargeChunks(chunks) {
-      const newChunks = [];
-
-      for (const chunk of chunks) {
-        if (!chunk.ab) {
-          for (const subChunk of this._breakdownChunk(chunk)) {
-            newChunks.push(subChunk);
-          }
-          continue;
-        }
-
-        // If the context is set to "whole file", then break down the shared
-        // chunks so they can be rendered incrementally. Note: this is not
-        // enabled for any other context preference because manipulating the
-        // chunks in this way violates assumptions by the context grouper logic.
-        if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
-          // Split large shared chunks in two, where the first is the maximum
-          // group size.
-          newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
-          newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
-        } else {
-          newChunks.push(chunk);
-        }
-      }
-      return newChunks;
-    }
-
-    /**
-     * In order to show key locations, such as comments, out of the bounds of
-     * the selected context, treat them as separate chunks within the model so
-     * that the content (and context surrounding it) renders correctly.
-     *
-     * @param {!Array<!Object>} chunks DiffContents as returned from server.
-     * @return {!Array<!Object>} Finer grained DiffContents.
-     */
-    _splitCommonChunksWithKeyLocations(chunks) {
-      const result = [];
-      let leftLineNum = 1;
-      let rightLineNum = 1;
-
-      for (const chunk of chunks) {
-        // If it isn't a common chunk, append it as-is and update line numbers.
-        if (!chunk.ab && !chunk.common) {
-          if (chunk.a) {
-            leftLineNum += chunk.a.length;
-          }
-          if (chunk.b) {
-            rightLineNum += chunk.b.length;
-          }
-          result.push(chunk);
-          continue;
-        }
-
-        if (chunk.common && chunk.a.length != chunk.b.length) {
-          throw new Error(
-              'DiffContent with common=true must always have equal length');
-        }
-        const numLines = this._commonChunkLength(chunk);
-        const chunkEnds = this._findChunkEndsAtKeyLocations(
-            numLines, leftLineNum, rightLineNum);
-        leftLineNum += numLines;
-        rightLineNum += numLines;
-
-        if (chunk.ab) {
-          result.push(...this._splitAtChunkEnds(chunk.ab, chunkEnds)
-              .map(({lines, keyLocation}) =>
-                Object.assign({}, chunk, {ab: lines, keyLocation})));
-        } else if (chunk.common) {
-          const aChunks = this._splitAtChunkEnds(chunk.a, chunkEnds);
-          const bChunks = this._splitAtChunkEnds(chunk.b, chunkEnds);
-          result.push(...aChunks.map(({lines, keyLocation}, i) =>
-            Object.assign(
-                {}, chunk, {a: lines, b: bChunks[i].lines, keyLocation})));
-        }
-      }
-
-      return result;
-    }
-
-    /**
-     * @return {!Array<{offset: number, keyLocation: boolean}>} Offsets of the
-     *   new chunk ends, including whether it's a key location.
-     */
-    _findChunkEndsAtKeyLocations(numLines, leftOffset, rightOffset) {
-      const result = [];
-      let lastChunkEnd = 0;
-      for (let i=0; i<numLines; i++) {
-        // If this line should not be collapsed.
-        if (this.keyLocations[DiffSide.LEFT][leftOffset + i] ||
-            this.keyLocations[DiffSide.RIGHT][rightOffset + i]) {
-          // If any lines have been accumulated into the chunk leading up to
-          // this non-collapse line, then add them as a chunk and start a new
-          // one.
-          if (i > lastChunkEnd) {
-            result.push({offset: i, keyLocation: false});
-            lastChunkEnd = i;
-          }
-
-          // Add the non-collapse line as its own chunk.
-          result.push({offset: i + 1, keyLocation: true});
-        }
-      }
-
-      if (numLines > lastChunkEnd) {
-        result.push({offset: numLines, keyLocation: false});
-      }
-
-      return result;
-    }
-
-    _splitAtChunkEnds(lines, chunkEnds) {
-      const result = [];
-      let lastChunkEndOffset = 0;
-      for (const {offset, keyLocation} of chunkEnds) {
-        result.push(
-            {lines: lines.slice(lastChunkEndOffset, offset), keyLocation});
-        lastChunkEndOffset = offset;
-      }
-      return result;
-    }
-
-    /**
-     * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
-     * for rendering.
-     *
-     * @param {!Array<string>} rows
-     * @param {!Array<!Gerrit.IntralineInfo>} intralineInfos
-     * @return {!Array<!Object>} (GrDiffLine.Highlight)
-     */
-    _convertIntralineInfos(rows, intralineInfos) {
-      let rowIndex = 0;
-      let idx = 0;
-      const normalized = [];
-      for (const [skipLength, markLength] of intralineInfos) {
-        let line = rows[rowIndex] + '\n';
-        let j = 0;
-        while (j < skipLength) {
-          if (idx === line.length) {
-            idx = 0;
-            line = rows[++rowIndex] + '\n';
-            continue;
-          }
-          idx++;
-          j++;
-        }
-        let lineHighlight = {
-          contentIndex: rowIndex,
-          startIndex: idx,
-        };
-
-        j = 0;
-        while (line && j < markLength) {
-          if (idx === line.length) {
-            idx = 0;
-            line = rows[++rowIndex] + '\n';
-            normalized.push(lineHighlight);
-            lineHighlight = {
-              contentIndex: rowIndex,
-              startIndex: idx,
-            };
-            continue;
-          }
-          idx++;
-          j++;
-        }
-        lineHighlight.endIndex = idx;
-        normalized.push(lineHighlight);
-      }
-      return normalized;
-    }
-
-    /**
-     * If a group is an addition or a removal, break it down into smaller groups
-     * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
-     * or a delta it is returned as the single element of the result array.
-     *
-     * @param {!Gerrit.DiffChunk} chunk A raw chunk from a diff response.
-     * @return {!Array<!Array<!Object>>}
-     */
-    _breakdownChunk(chunk) {
-      let key = null;
-      if (chunk.a && !chunk.b) {
-        key = 'a';
-      } else if (chunk.b && !chunk.a) {
-        key = 'b';
-      } else if (chunk.ab) {
-        key = 'ab';
-      }
-
-      if (!key) { return [chunk]; }
-
-      return this._breakdown(chunk[key], MAX_GROUP_SIZE)
-          .map(subChunkLines => {
-            const subChunk = {};
-            subChunk[key] = subChunkLines;
-            if (chunk.due_to_rebase) {
-              subChunk.due_to_rebase = true;
+          let currentBatch = 0;
+          const nextStep = () => {
+            if (this._isScrolling) {
+              this._nextStepHandle = this.async(nextStep, 100);
+              return;
             }
-            return subChunk;
-          });
+            // If we are done, resolve the promise.
+            if (state.chunkIndex >= chunks.length) {
+              resolve();
+              this._nextStepHandle = null;
+              return;
+            }
+
+            // Process the next chunk and incorporate the result.
+            const stateUpdate = this._processNext(state, chunks);
+            for (const group of stateUpdate.groups) {
+              this.push('groups', group);
+              currentBatch += group.lines.length;
+            }
+            state.lineNums.left += stateUpdate.lineDelta.left;
+            state.lineNums.right += stateUpdate.lineDelta.right;
+
+            // Increment the index and recurse.
+            state.chunkIndex = stateUpdate.newChunkIndex;
+            if (currentBatch >= this._asyncThreshold) {
+              currentBatch = 0;
+              this._nextStepHandle = this.async(nextStep, 1);
+            } else {
+              nextStep.call(this);
+            }
+          };
+
+          nextStep.call(this);
+        }));
+    return this._processPromise
+        .finally(() => { this._processPromise = null; });
+  }
+
+  /**
+   * Cancel any jobs that are running.
+   */
+  cancel() {
+    if (this._nextStepHandle != null) {
+      this.cancelAsync(this._nextStepHandle);
+      this._nextStepHandle = null;
     }
-
-    /**
-     * Given an array and a size, return an array of arrays where no inner array
-     * is larger than that size, preserving the original order.
-     *
-     * @param {!Array<T>} array
-     * @param {number} size
-     * @return {!Array<!Array<T>>}
-     * @template T
-     */
-    _breakdown(array, size) {
-      if (!array.length) { return []; }
-      if (array.length < size) { return [array]; }
-
-      const head = array.slice(0, array.length - size);
-      const tail = array.slice(array.length - size);
-
-      return this._breakdown(head, size).concat([tail]);
+    if (this._processPromise) {
+      this._processPromise.cancel();
     }
   }
 
-  customElements.define(GrDiffProcessor.is, GrDiffProcessor);
-})();
+  /**
+   * Process the next uncollapsible chunk, or the next collapsible chunks.
+   *
+   * @param {!Object} state
+   * @param {!Array<!Object>} chunks
+   * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
+   */
+  _processNext(state, chunks) {
+    const firstUncollapsibleChunkIndex =
+        this._firstUncollapsibleChunkIndex(chunks, state.chunkIndex);
+    if (firstUncollapsibleChunkIndex === state.chunkIndex) {
+      const chunk = chunks[state.chunkIndex];
+      return {
+        lineDelta: {
+          left: this._linesLeft(chunk).length,
+          right: this._linesRight(chunk).length,
+        },
+        groups: [this._chunkToGroup(
+            chunk, state.lineNums.left + 1, state.lineNums.right + 1)],
+        newChunkIndex: state.chunkIndex + 1,
+      };
+    }
+
+    return this._processCollapsibleChunks(
+        state, chunks, firstUncollapsibleChunkIndex);
+  }
+
+  _linesLeft(chunk) {
+    return chunk.ab || chunk.a || [];
+  }
+
+  _linesRight(chunk) {
+    return chunk.ab || chunk.b || [];
+  }
+
+  _firstUncollapsibleChunkIndex(chunks, offset) {
+    let chunkIndex = offset;
+    while (chunkIndex < chunks.length &&
+        this._isCollapsibleChunk(chunks[chunkIndex])) {
+      chunkIndex++;
+    }
+    return chunkIndex;
+  }
+
+  _isCollapsibleChunk(chunk) {
+    return (chunk.ab || chunk.common) && !chunk.keyLocation;
+  }
+
+  /**
+   * Process a stretch of collapsible chunks.
+   *
+   * Outputs up to three groups:
+   *  1) Visible context before the hidden common code, unless it's the
+   *     very beginning of the file.
+   *  2) Context hidden behind a context bar, unless empty.
+   *  3) Visible context after the hidden common code, unless it's the very
+   *     end of the file.
+   *
+   * @param {!Object} state
+   * @param {!Array<Object>} chunks
+   * @param {number} firstUncollapsibleChunkIndex
+   * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
+   */
+  _processCollapsibleChunks(
+      state, chunks, firstUncollapsibleChunkIndex) {
+    const collapsibleChunks = chunks.slice(
+        state.chunkIndex, firstUncollapsibleChunkIndex);
+    const lineCount = collapsibleChunks.reduce(
+        (sum, chunk) => sum + this._commonChunkLength(chunk), 0);
+
+    let groups = this._chunksToGroups(
+        collapsibleChunks,
+        state.lineNums.left + 1,
+        state.lineNums.right + 1);
+
+    if (this.context !== WHOLE_FILE) {
+      const hiddenStart = state.chunkIndex === 0 ? 0 : this.context;
+      const hiddenEnd = lineCount - (
+        firstUncollapsibleChunkIndex === chunks.length ?
+          0 : this.context);
+      groups = GrDiffGroup.hideInContextControl(
+          groups, hiddenStart, hiddenEnd);
+    }
+
+    return {
+      lineDelta: {
+        left: lineCount,
+        right: lineCount,
+      },
+      groups,
+      newChunkIndex: firstUncollapsibleChunkIndex,
+    };
+  }
+
+  _commonChunkLength(chunk) {
+    console.assert(chunk.ab || chunk.common);
+    console.assert(
+        !chunk.a || (chunk.b && chunk.a.length === chunk.b.length),
+        `common chunk needs same number of a and b lines: `, chunk);
+    return this._linesLeft(chunk).length;
+  }
+
+  /**
+   * @param {!Array<!Object>} chunks
+   * @param {number} offsetLeft
+   * @param {number} offsetRight
+   * @return {!Array<!Object>} (GrDiffGroup)
+   */
+  _chunksToGroups(chunks, offsetLeft, offsetRight) {
+    return chunks.map(chunk => {
+      const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
+      const chunkLength = this._commonChunkLength(chunk);
+      offsetLeft += chunkLength;
+      offsetRight += chunkLength;
+      return group;
+    });
+  }
+
+  /**
+   * @param {!Object} chunk
+   * @param {number} offsetLeft
+   * @param {number} offsetRight
+   * @return {!Object} (GrDiffGroup)
+   */
+  _chunkToGroup(chunk, offsetLeft, offsetRight) {
+    const type = chunk.ab ? GrDiffGroup.Type.BOTH : GrDiffGroup.Type.DELTA;
+    const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
+    const group = new GrDiffGroup(type, lines);
+    group.keyLocation = chunk.keyLocation;
+    group.dueToRebase = chunk.due_to_rebase;
+    group.ignoredWhitespaceOnly = chunk.common;
+    return group;
+  }
+
+  _linesFromChunk(chunk, offsetLeft, offsetRight) {
+    if (chunk.ab) {
+      return chunk.ab.map((row, i) => this._lineFromRow(
+          GrDiffLine.Type.BOTH, offsetLeft, offsetRight, row, i));
+    }
+    let lines = [];
+    if (chunk.a) {
+      // Avoiding a.push(...b) because that causes callstack overflows for
+      // large b, which can occur when large files are added removed.
+      lines = lines.concat(this._linesFromRows(
+          GrDiffLine.Type.REMOVE, chunk.a, offsetLeft,
+          chunk[DiffHighlights.REMOVED]));
+    }
+    if (chunk.b) {
+      // Avoiding a.push(...b) because that causes callstack overflows for
+      // large b, which can occur when large files are added removed.
+      lines = lines.concat(this._linesFromRows(
+          GrDiffLine.Type.ADD, chunk.b, offsetRight,
+          chunk[DiffHighlights.ADDED]));
+    }
+    return lines;
+  }
+
+  /**
+   * @param {string} lineType (GrDiffLine.Type)
+   * @param {!Array<string>} rows
+   * @param {number} offset
+   * @param {?Array<!Gerrit.IntralineInfo>=} opt_intralineInfos
+   * @return {!Array<!Object>} (GrDiffLine)
+   */
+  _linesFromRows(lineType, rows, offset, opt_intralineInfos) {
+    const grDiffHighlights = opt_intralineInfos ?
+      this._convertIntralineInfos(rows, opt_intralineInfos) : undefined;
+    return rows.map((row, i) => this._lineFromRow(
+        lineType, offset, offset, row, i, grDiffHighlights));
+  }
+
+  /**
+   * @param {string} type (GrDiffLine.Type)
+   * @param {number} offsetLeft
+   * @param {number} offsetRight
+   * @param {string} row
+   * @param {number} i
+   * @param {!Array<!Object>=} opt_highlights
+   * @return {!Object} (GrDiffLine)
+   */
+  _lineFromRow(type, offsetLeft, offsetRight, row, i, opt_highlights) {
+    const line = new GrDiffLine(type);
+    line.text = row;
+    if (type !== GrDiffLine.Type.ADD) line.beforeNumber = offsetLeft + i;
+    if (type !== GrDiffLine.Type.REMOVE) line.afterNumber = offsetRight + i;
+    if (opt_highlights) {
+      line.hasIntralineInfo = true;
+      line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
+    } else {
+      line.hasIntralineInfo = false;
+    }
+    return line;
+  }
+
+  _makeFileComments() {
+    const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+    line.beforeNumber = GrDiffLine.FILE;
+    line.afterNumber = GrDiffLine.FILE;
+    return new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]);
+  }
+
+  /**
+   * Split chunks into smaller chunks of the same kind.
+   *
+   * This is done to prevent doing too much work on the main thread in one
+   * uninterrupted rendering step, which would make the browser unresponsive.
+   *
+   * Note that in the case of unmodified chunks, we only split chunks if the
+   * context is set to file (because otherwise they are split up further down
+   * the processing into the visible and hidden context), and only split it
+   * into 2 chunks, one max sized one and the rest (for reasons that are
+   * unclear to me).
+   *
+   * @param {!Array<!Gerrit.DiffChunk>} chunks Chunks as returned from the server
+   * @return {!Array<!Gerrit.DiffChunk>} Finer grained chunks.
+   */
+  _splitLargeChunks(chunks) {
+    const newChunks = [];
+
+    for (const chunk of chunks) {
+      if (!chunk.ab) {
+        for (const subChunk of this._breakdownChunk(chunk)) {
+          newChunks.push(subChunk);
+        }
+        continue;
+      }
+
+      // If the context is set to "whole file", then break down the shared
+      // chunks so they can be rendered incrementally. Note: this is not
+      // enabled for any other context preference because manipulating the
+      // chunks in this way violates assumptions by the context grouper logic.
+      if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
+        // Split large shared chunks in two, where the first is the maximum
+        // group size.
+        newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
+        newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
+      } else {
+        newChunks.push(chunk);
+      }
+    }
+    return newChunks;
+  }
+
+  /**
+   * In order to show key locations, such as comments, out of the bounds of
+   * the selected context, treat them as separate chunks within the model so
+   * that the content (and context surrounding it) renders correctly.
+   *
+   * @param {!Array<!Object>} chunks DiffContents as returned from server.
+   * @return {!Array<!Object>} Finer grained DiffContents.
+   */
+  _splitCommonChunksWithKeyLocations(chunks) {
+    const result = [];
+    let leftLineNum = 1;
+    let rightLineNum = 1;
+
+    for (const chunk of chunks) {
+      // If it isn't a common chunk, append it as-is and update line numbers.
+      if (!chunk.ab && !chunk.common) {
+        if (chunk.a) {
+          leftLineNum += chunk.a.length;
+        }
+        if (chunk.b) {
+          rightLineNum += chunk.b.length;
+        }
+        result.push(chunk);
+        continue;
+      }
+
+      if (chunk.common && chunk.a.length != chunk.b.length) {
+        throw new Error(
+            'DiffContent with common=true must always have equal length');
+      }
+      const numLines = this._commonChunkLength(chunk);
+      const chunkEnds = this._findChunkEndsAtKeyLocations(
+          numLines, leftLineNum, rightLineNum);
+      leftLineNum += numLines;
+      rightLineNum += numLines;
+
+      if (chunk.ab) {
+        result.push(...this._splitAtChunkEnds(chunk.ab, chunkEnds)
+            .map(({lines, keyLocation}) =>
+              Object.assign({}, chunk, {ab: lines, keyLocation})));
+      } else if (chunk.common) {
+        const aChunks = this._splitAtChunkEnds(chunk.a, chunkEnds);
+        const bChunks = this._splitAtChunkEnds(chunk.b, chunkEnds);
+        result.push(...aChunks.map(({lines, keyLocation}, i) =>
+          Object.assign(
+              {}, chunk, {a: lines, b: bChunks[i].lines, keyLocation})));
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * @return {!Array<{offset: number, keyLocation: boolean}>} Offsets of the
+   *   new chunk ends, including whether it's a key location.
+   */
+  _findChunkEndsAtKeyLocations(numLines, leftOffset, rightOffset) {
+    const result = [];
+    let lastChunkEnd = 0;
+    for (let i=0; i<numLines; i++) {
+      // If this line should not be collapsed.
+      if (this.keyLocations[DiffSide.LEFT][leftOffset + i] ||
+          this.keyLocations[DiffSide.RIGHT][rightOffset + i]) {
+        // If any lines have been accumulated into the chunk leading up to
+        // this non-collapse line, then add them as a chunk and start a new
+        // one.
+        if (i > lastChunkEnd) {
+          result.push({offset: i, keyLocation: false});
+          lastChunkEnd = i;
+        }
+
+        // Add the non-collapse line as its own chunk.
+        result.push({offset: i + 1, keyLocation: true});
+      }
+    }
+
+    if (numLines > lastChunkEnd) {
+      result.push({offset: numLines, keyLocation: false});
+    }
+
+    return result;
+  }
+
+  _splitAtChunkEnds(lines, chunkEnds) {
+    const result = [];
+    let lastChunkEndOffset = 0;
+    for (const {offset, keyLocation} of chunkEnds) {
+      result.push(
+          {lines: lines.slice(lastChunkEndOffset, offset), keyLocation});
+      lastChunkEndOffset = offset;
+    }
+    return result;
+  }
+
+  /**
+   * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
+   * for rendering.
+   *
+   * @param {!Array<string>} rows
+   * @param {!Array<!Gerrit.IntralineInfo>} intralineInfos
+   * @return {!Array<!Object>} (GrDiffLine.Highlight)
+   */
+  _convertIntralineInfos(rows, intralineInfos) {
+    let rowIndex = 0;
+    let idx = 0;
+    const normalized = [];
+    for (const [skipLength, markLength] of intralineInfos) {
+      let line = rows[rowIndex] + '\n';
+      let j = 0;
+      while (j < skipLength) {
+        if (idx === line.length) {
+          idx = 0;
+          line = rows[++rowIndex] + '\n';
+          continue;
+        }
+        idx++;
+        j++;
+      }
+      let lineHighlight = {
+        contentIndex: rowIndex,
+        startIndex: idx,
+      };
+
+      j = 0;
+      while (line && j < markLength) {
+        if (idx === line.length) {
+          idx = 0;
+          line = rows[++rowIndex] + '\n';
+          normalized.push(lineHighlight);
+          lineHighlight = {
+            contentIndex: rowIndex,
+            startIndex: idx,
+          };
+          continue;
+        }
+        idx++;
+        j++;
+      }
+      lineHighlight.endIndex = idx;
+      normalized.push(lineHighlight);
+    }
+    return normalized;
+  }
+
+  /**
+   * If a group is an addition or a removal, break it down into smaller groups
+   * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
+   * or a delta it is returned as the single element of the result array.
+   *
+   * @param {!Gerrit.DiffChunk} chunk A raw chunk from a diff response.
+   * @return {!Array<!Array<!Object>>}
+   */
+  _breakdownChunk(chunk) {
+    let key = null;
+    if (chunk.a && !chunk.b) {
+      key = 'a';
+    } else if (chunk.b && !chunk.a) {
+      key = 'b';
+    } else if (chunk.ab) {
+      key = 'ab';
+    }
+
+    if (!key) { return [chunk]; }
+
+    return this._breakdown(chunk[key], MAX_GROUP_SIZE)
+        .map(subChunkLines => {
+          const subChunk = {};
+          subChunk[key] = subChunkLines;
+          if (chunk.due_to_rebase) {
+            subChunk.due_to_rebase = true;
+          }
+          return subChunk;
+        });
+  }
+
+  /**
+   * Given an array and a size, return an array of arrays where no inner array
+   * is larger than that size, preserving the original order.
+   *
+   * @param {!Array<T>} array
+   * @param {number} size
+   * @return {!Array<!Array<T>>}
+   * @template T
+   */
+  _breakdown(array, size) {
+    if (!array.length) { return []; }
+    if (array.length < size) { return [array]; }
+
+    const head = array.slice(0, array.length - size);
+    const tail = array.slice(array.length - size);
+
+    return this._breakdown(head, size).concat([tail]);
+  }
+}
+
+customElements.define(GrDiffProcessor.is, GrDiffProcessor);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
index 9b3f3b2..e62abe5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-processor test</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-diff-processor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-diff-processor.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-diff-processor.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,902 +40,904 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-processor tests', async () => {
-    await readyToTest();
-    const WHOLE_FILE = -1;
-    const loremIpsum =
-        'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
-        'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
-        'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
-        'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
-        'fugit assum per.';
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-diff-processor.js';
+suite('gr-diff-processor tests', () => {
+  const WHOLE_FILE = -1;
+  const loremIpsum =
+      'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+      'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
+      'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
+      'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
+      'fugit assum per.';
 
-    let element;
-    let sandbox;
+  let element;
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('not logged in', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+
+      element.context = 4;
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('not logged in', () => {
-      setup(() => {
-        element = fixture('basic');
-
-        element.context = 4;
-      });
-
-      test('process loaded content', () => {
-        const content = [
-          {
-            ab: [
-              '<!DOCTYPE html>',
-              '<meta charset="utf-8">',
-            ],
-          },
-          {
-            a: [
-              '  Welcome ',
-              '  to the wooorld of tomorrow!',
-            ],
-            b: [
-              '  Hello, world!',
-            ],
-          },
-          {
-            ab: [
-              'Leela: This is the only place the ship can’t hear us, so ',
-              'everyone pretend to shower.',
-              'Fry: Same as every day. Got it.',
-            ],
-          },
-        ];
-
-        return element.process(content).then(() => {
-          const groups = element.groups;
-
-          assert.equal(groups.length, 4);
-
-          let group = groups[0];
-          assert.equal(group.type, GrDiffGroup.Type.BOTH);
-          assert.equal(group.lines.length, 1);
-          assert.equal(group.lines[0].text, '');
-          assert.equal(group.lines[0].beforeNumber, GrDiffLine.FILE);
-          assert.equal(group.lines[0].afterNumber, GrDiffLine.FILE);
-
-          group = groups[1];
-          assert.equal(group.type, GrDiffGroup.Type.BOTH);
-          assert.equal(group.lines.length, 2);
-          assert.equal(group.lines.length, 2);
-
-          function beforeNumberFn(l) { return l.beforeNumber; }
-          function afterNumberFn(l) { return l.afterNumber; }
-          function textFn(l) { return l.text; }
-
-          assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
-          assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
-          assert.deepEqual(group.lines.map(textFn), [
+    test('process loaded content', () => {
+      const content = [
+        {
+          ab: [
             '<!DOCTYPE html>',
             '<meta charset="utf-8">',
-          ]);
-
-          group = groups[2];
-          assert.equal(group.type, GrDiffGroup.Type.DELTA);
-          assert.equal(group.lines.length, 3);
-          assert.equal(group.adds.length, 1);
-          assert.equal(group.removes.length, 2);
-          assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
-          assert.deepEqual(group.adds.map(afterNumberFn), [3]);
-          assert.deepEqual(group.removes.map(textFn), [
+          ],
+        },
+        {
+          a: [
             '  Welcome ',
             '  to the wooorld of tomorrow!',
-          ]);
-          assert.deepEqual(group.adds.map(textFn), [
+          ],
+          b: [
             '  Hello, world!',
-          ]);
-
-          group = groups[3];
-          assert.equal(group.type, GrDiffGroup.Type.BOTH);
-          assert.equal(group.lines.length, 3);
-          assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
-          assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
-          assert.deepEqual(group.lines.map(textFn), [
+          ],
+        },
+        {
+          ab: [
             'Leela: This is the only place the ship can’t hear us, so ',
             'everyone pretend to shower.',
             'Fry: Same as every day. Got it.',
-          ]);
-        });
-      });
+          ],
+        },
+      ];
 
-      test('first group is for file', () => {
+      return element.process(content).then(() => {
+        const groups = element.groups;
+
+        assert.equal(groups.length, 4);
+
+        let group = groups[0];
+        assert.equal(group.type, GrDiffGroup.Type.BOTH);
+        assert.equal(group.lines.length, 1);
+        assert.equal(group.lines[0].text, '');
+        assert.equal(group.lines[0].beforeNumber, GrDiffLine.FILE);
+        assert.equal(group.lines[0].afterNumber, GrDiffLine.FILE);
+
+        group = groups[1];
+        assert.equal(group.type, GrDiffGroup.Type.BOTH);
+        assert.equal(group.lines.length, 2);
+        assert.equal(group.lines.length, 2);
+
+        function beforeNumberFn(l) { return l.beforeNumber; }
+        function afterNumberFn(l) { return l.afterNumber; }
+        function textFn(l) { return l.text; }
+
+        assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
+        assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
+        assert.deepEqual(group.lines.map(textFn), [
+          '<!DOCTYPE html>',
+          '<meta charset="utf-8">',
+        ]);
+
+        group = groups[2];
+        assert.equal(group.type, GrDiffGroup.Type.DELTA);
+        assert.equal(group.lines.length, 3);
+        assert.equal(group.adds.length, 1);
+        assert.equal(group.removes.length, 2);
+        assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
+        assert.deepEqual(group.adds.map(afterNumberFn), [3]);
+        assert.deepEqual(group.removes.map(textFn), [
+          '  Welcome ',
+          '  to the wooorld of tomorrow!',
+        ]);
+        assert.deepEqual(group.adds.map(textFn), [
+          '  Hello, world!',
+        ]);
+
+        group = groups[3];
+        assert.equal(group.type, GrDiffGroup.Type.BOTH);
+        assert.equal(group.lines.length, 3);
+        assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
+        assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
+        assert.deepEqual(group.lines.map(textFn), [
+          'Leela: This is the only place the ship can’t hear us, so ',
+          'everyone pretend to shower.',
+          'Fry: Same as every day. Got it.',
+        ]);
+      });
+    });
+
+    test('first group is for file', () => {
+      const content = [
+        {b: ['foo']},
+      ];
+
+      return element.process(content).then(() => {
+        const groups = element.groups;
+
+        assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
+        assert.equal(groups[0].lines.length, 1);
+        assert.equal(groups[0].lines[0].text, '');
+        assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
+        assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+      });
+    });
+
+    suite('context groups', () => {
+      test('at the beginning, larger than context', () => {
+        element.context = 10;
         const content = [
-          {b: ['foo']},
+          {ab: new Array(100)
+              .fill('all work and no play make jack a dull boy')},
+          {a: ['all work and no play make andybons a dull boy']},
         ];
 
         return element.process(content).then(() => {
           const groups = element.groups;
 
-          assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[0].lines.length, 1);
-          assert.equal(groups[0].lines[0].text, '');
-          assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
-          assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+          // group[0] is the file group
+
+          assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[1].lines[0].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[1].lines[0].contextGroups[0].lines.length, 90);
+          for (const l of groups[1].lines[0].contextGroups[0].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
         });
       });
 
-      suite('context groups', () => {
-        test('at the beginning, larger than context', () => {
-          element.context = 10;
-          const content = [
-            {ab: new Array(100)
-                .fill('all work and no play make jack a dull boy')},
-            {a: ['all work and no play make andybons a dull boy']},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-
-            assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-            assert.instanceOf(groups[1].lines[0].contextGroups[0], GrDiffGroup);
-            assert.equal(groups[1].lines[0].contextGroups[0].lines.length, 90);
-            for (const l of groups[1].lines[0].contextGroups[0].lines) {
-              assert.equal(l.text, 'all work and no play make jack a dull boy');
-            }
-
-            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[2].lines.length, 10);
-            for (const l of groups[2].lines) {
-              assert.equal(l.text, 'all work and no play make jack a dull boy');
-            }
-          });
-        });
-
-        test('at the beginning, smaller than context', () => {
-          element.context = 10;
-          const content = [
-            {ab: new Array(5)
-                .fill('all work and no play make jack a dull boy')},
-            {a: ['all work and no play make andybons a dull boy']},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-
-            assert.equal(groups[1].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[1].lines.length, 5);
-            for (const l of groups[1].lines) {
-              assert.equal(l.text, 'all work and no play make jack a dull boy');
-            }
-          });
-        });
-
-        test('at the end, larger than context', () => {
-          element.context = 10;
-          const content = [
-            {a: ['all work and no play make andybons a dull boy']},
-            {ab: new Array(100)
-                .fill('all work and no play make jill a dull girl')},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-            // group[1] is the "a" group
-
-            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[2].lines.length, 10);
-            for (const l of groups[2].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-
-            assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-            assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
-            assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 90);
-            for (const l of groups[3].lines[0].contextGroups[0].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-          });
-        });
-
-        test('at the end, smaller than context', () => {
-          element.context = 10;
-          const content = [
-            {a: ['all work and no play make andybons a dull boy']},
-            {ab: new Array(5)
-                .fill('all work and no play make jill a dull girl')},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-            // group[1] is the "a" group
-
-            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[2].lines.length, 5);
-            for (const l of groups[2].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-          });
-        });
-
-        test('for interleaved ab and common: true chunks', () => {
-          element.context = 10;
-          const content = [
-            {a: ['all work and no play make andybons a dull boy']},
-            {ab: new Array(3)
-                .fill('all work and no play make jill a dull girl')},
-            {
-              a: new Array(3).fill(
-                  'all work and no play make jill a dull girl'),
-              b: new Array(3).fill(
-                  '  all work and no play make jill a dull girl'),
-              common: true,
-            },
-            {ab: new Array(3)
-                .fill('all work and no play make jill a dull girl')},
-            {
-              a: new Array(3).fill(
-                  'all work and no play make jill a dull girl'),
-              b: new Array(3).fill(
-                  '  all work and no play make jill a dull girl'),
-              common: true,
-            },
-            {ab: new Array(3)
-                .fill('all work and no play make jill a dull girl')},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-            // group[1] is the "a" group
-
-            // The first three interleaved chunks are completely shown because
-            // they are part of the context (3 * 3 <= 10)
-
-            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[2].lines.length, 3);
-            for (const l of groups[2].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-
-            assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
-            assert.equal(groups[3].lines.length, 6);
-            assert.equal(groups[3].adds.length, 3);
-            assert.equal(groups[3].removes.length, 3);
-            for (const l of groups[3].removes) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-            for (const l of groups[3].adds) {
-              assert.equal(
-                  l.text, '  all work and no play make jill a dull girl');
-            }
-
-            assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[4].lines.length, 3);
-            for (const l of groups[4].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-
-            // The next chunk is partially shown, so it results in two groups
-
-            assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
-            assert.equal(groups[5].lines.length, 2);
-            assert.equal(groups[5].adds.length, 1);
-            assert.equal(groups[5].removes.length, 1);
-            for (const l of groups[5].removes) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-            for (const l of groups[5].adds) {
-              assert.equal(
-                  l.text, '  all work and no play make jill a dull girl');
-            }
-
-            assert.equal(groups[6].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-            assert.equal(groups[6].lines[0].contextGroups.length, 2);
-
-            assert.equal(groups[6].lines[0].contextGroups[0].lines.length, 4);
-            assert.equal(groups[6].lines[0].contextGroups[0].removes.length, 2);
-            assert.equal(groups[6].lines[0].contextGroups[0].adds.length, 2);
-            for (const l of groups[6].lines[0].contextGroups[0].removes) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-            for (const l of groups[6].lines[0].contextGroups[0].adds) {
-              assert.equal(
-                  l.text, '  all work and no play make jill a dull girl');
-            }
-
-            // The final chunk is completely hidden
-            assert.equal(
-                groups[6].lines[0].contextGroups[1].type,
-                GrDiffGroup.Type.BOTH);
-            assert.equal(groups[6].lines[0].contextGroups[1].lines.length, 3);
-            for (const l of groups[6].lines[0].contextGroups[1].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-          });
-        });
-
-        test('in the middle, larger than context', () => {
-          element.context = 10;
-          const content = [
-            {a: ['all work and no play make andybons a dull boy']},
-            {ab: new Array(100)
-                .fill('all work and no play make jill a dull girl')},
-            {a: ['all work and no play make andybons a dull boy']},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-            // group[1] is the "a" group
-
-            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[2].lines.length, 10);
-            for (const l of groups[2].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-
-            assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-            assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
-            assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 80);
-            for (const l of groups[3].lines[0].contextGroups[0].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-
-            assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[4].lines.length, 10);
-            for (const l of groups[4].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-          });
-        });
-
-        test('in the middle, smaller than context', () => {
-          element.context = 10;
-          const content = [
-            {a: ['all work and no play make andybons a dull boy']},
-            {ab: new Array(5)
-                .fill('all work and no play make jill a dull girl')},
-            {a: ['all work and no play make andybons a dull boy']},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-            // group[1] is the "a" group
-
-            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[2].lines.length, 5);
-            for (const l of groups[2].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-          });
-        });
-      });
-
-      test('break up common diff chunks', () => {
-        element.keyLocations = {
-          left: {1: true},
-          right: {10: true},
-        };
-
+      test('at the beginning, smaller than context', () => {
+        element.context = 10;
         const content = [
-          {
-            ab: [
-              'Copyright (C) 2015 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.',
-            ],
-          },
+          {ab: new Array(5)
+              .fill('all work and no play make jack a dull boy')},
+          {a: ['all work and no play make andybons a dull boy']},
         ];
-        const result =
-            element._splitCommonChunksWithKeyLocations(content);
-        assert.deepEqual(result, [
-          {
-            ab: ['Copyright (C) 2015 The Android Open Source Project'],
-            keyLocation: true,
-          },
-          {
-            ab: [
-              '',
-              '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, ',
-            ],
-            keyLocation: false,
-          },
-          {
-            ab: [
-              'software distributed under the License is distributed on an '],
-            keyLocation: true,
-          },
-          {
-            ab: [
-              '"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.',
-            ],
-            keyLocation: false,
-          },
-        ]);
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+
+          assert.equal(groups[1].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[1].lines.length, 5);
+          for (const l of groups[1].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+        });
       });
 
-      test('breaks down shared chunks w/ whole-file', () => {
-        const size = 120 * 2 + 5;
-        const content = [{
-          ab: _.times(size, () => `${Math.random()}`),
-        }];
-        element.context = -1;
-        const result = element._splitLargeChunks(content);
-        assert.equal(result.length, 2);
-        assert.deepEqual(result[0].ab, content[0].ab.slice(0, 120));
-        assert.deepEqual(result[1].ab, content[0].ab.slice(120));
+      test('at the end, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(100)
+              .fill('all work and no play make jill a dull girl')},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 90);
+          for (const l of groups[3].lines[0].contextGroups[0].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
       });
 
-      test('does not break-down common chunks w/ context', () => {
-        const content = [{
-          ab: _.times(75, () => `${Math.random()}`),
-        }];
-        element.context = 4;
-        const result =
-            element._splitCommonChunksWithKeyLocations(content);
-        assert.equal(result.length, 1);
-        assert.deepEqual(result[0].ab, content[0].ab);
-        assert.isFalse(result[0].keyLocation);
+      test('at the end, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(5)
+              .fill('all work and no play make jill a dull girl')},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 5);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
       });
 
-      test('intraline normalization', () => {
-        // The content and highlights are in the format returned by the Gerrit
-        // REST API.
-        let content = [
-          '      <section class="summary">',
-          '        <gr-linked-text content="' +
-              '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
-          '      </section>',
-        ];
-        let highlights = [
-          [31, 34], [42, 26],
+      test('for interleaved ab and common: true chunks', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(3)
+              .fill('all work and no play make jill a dull girl')},
+          {
+            a: new Array(3).fill(
+                'all work and no play make jill a dull girl'),
+            b: new Array(3).fill(
+                '  all work and no play make jill a dull girl'),
+            common: true,
+          },
+          {ab: new Array(3)
+              .fill('all work and no play make jill a dull girl')},
+          {
+            a: new Array(3).fill(
+                'all work and no play make jill a dull girl'),
+            b: new Array(3).fill(
+                '  all work and no play make jill a dull girl'),
+            common: true,
+          },
+          {ab: new Array(3)
+              .fill('all work and no play make jill a dull girl')},
         ];
 
-        let results = element._convertIntralineInfos(content,
-            highlights);
-        assert.deepEqual(results, [
-          {
-            contentIndex: 0,
-            startIndex: 31,
-          },
-          {
-            contentIndex: 1,
-            startIndex: 0,
-            endIndex: 33,
-          },
-          {
-            contentIndex: 1,
-            startIndex: 75,
-          },
-          {
-            contentIndex: 2,
-            startIndex: 0,
-            endIndex: 6,
-          },
-        ]);
+        return element.process(content).then(() => {
+          const groups = element.groups;
 
-        content = [
-          '        this._path = value.path;',
-          '',
-          '        // When navigating away from the page, there is a ' +
-            'possibility that the',
-          '        // patch number is no longer a part of the URL ' +
-            '(say when navigating to',
-          '        // the top-level change info view) and therefore ' +
-            'undefined in `params`.',
-          '        if (!this._patchRange.patchNum) {',
-        ];
-        highlights = [
-          [14, 17],
-          [11, 70],
-          [12, 67],
-          [12, 67],
-          [14, 29],
-        ];
-        results = element._convertIntralineInfos(content, highlights);
-        assert.deepEqual(results, [
-          {
-            contentIndex: 0,
-            startIndex: 14,
-            endIndex: 31,
-          },
-          {
-            contentIndex: 2,
-            startIndex: 8,
-            endIndex: 78,
-          },
-          {
-            contentIndex: 3,
-            startIndex: 11,
-            endIndex: 78,
-          },
-          {
-            contentIndex: 4,
-            startIndex: 11,
-            endIndex: 78,
-          },
-          {
-            contentIndex: 5,
-            startIndex: 12,
-            endIndex: 41,
-          },
-        ]);
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          // The first three interleaved chunks are completely shown because
+          // they are part of the context (3 * 3 <= 10)
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 3);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[3].lines.length, 6);
+          assert.equal(groups[3].adds.length, 3);
+          assert.equal(groups[3].removes.length, 3);
+          for (const l of groups[3].removes) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[3].adds) {
+            assert.equal(
+                l.text, '  all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[4].lines.length, 3);
+          for (const l of groups[4].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          // The next chunk is partially shown, so it results in two groups
+
+          assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[5].lines.length, 2);
+          assert.equal(groups[5].adds.length, 1);
+          assert.equal(groups[5].removes.length, 1);
+          for (const l of groups[5].removes) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[5].adds) {
+            assert.equal(
+                l.text, '  all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[6].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.equal(groups[6].lines[0].contextGroups.length, 2);
+
+          assert.equal(groups[6].lines[0].contextGroups[0].lines.length, 4);
+          assert.equal(groups[6].lines[0].contextGroups[0].removes.length, 2);
+          assert.equal(groups[6].lines[0].contextGroups[0].adds.length, 2);
+          for (const l of groups[6].lines[0].contextGroups[0].removes) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[6].lines[0].contextGroups[0].adds) {
+            assert.equal(
+                l.text, '  all work and no play make jill a dull girl');
+          }
+
+          // The final chunk is completely hidden
+          assert.equal(
+              groups[6].lines[0].contextGroups[1].type,
+              GrDiffGroup.Type.BOTH);
+          assert.equal(groups[6].lines[0].contextGroups[1].lines.length, 3);
+          for (const l of groups[6].lines[0].contextGroups[1].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
       });
 
-      test('scrolling pauses rendering', () => {
-        const contentRow = {
+      test('in the middle, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(100)
+              .fill('all work and no play make jill a dull girl')},
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 80);
+          for (const l of groups[3].lines[0].contextGroups[0].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[4].lines.length, 10);
+          for (const l of groups[4].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('in the middle, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(5)
+              .fill('all work and no play make jill a dull girl')},
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 5);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+    });
+
+    test('break up common diff chunks', () => {
+      element.keyLocations = {
+        left: {1: true},
+        right: {10: true},
+      };
+
+      const content = [
+        {
           ab: [
-            '<!DOCTYPE html>',
-            '<meta charset="utf-8">',
+            'Copyright (C) 2015 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.',
           ],
-        };
-        const content = _.times(200, _.constant(contentRow));
-        sandbox.stub(element, 'async');
-        element._isScrolling = true;
-        element.process(content);
-        // Just the files group - no more processing during scrolling.
-        assert.equal(element.groups.length, 1);
-
-        element._isScrolling = false;
-        element.process(content);
-        // More groups have been processed. How many does not matter here.
-        assert.isAtLeast(element.groups.length, 2);
-      });
-
-      test('image diffs', () => {
-        const contentRow = {
+        },
+      ];
+      const result =
+          element._splitCommonChunksWithKeyLocations(content);
+      assert.deepEqual(result, [
+        {
+          ab: ['Copyright (C) 2015 The Android Open Source Project'],
+          keyLocation: true,
+        },
+        {
           ab: [
-            '<!DOCTYPE html>',
-            '<meta charset="utf-8">',
+            '',
+            '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, ',
           ],
-        };
-        const content = _.times(200, _.constant(contentRow));
-        sandbox.stub(element, 'async');
-        element.process(content, true);
-        assert.equal(element.groups.length, 1);
+          keyLocation: false,
+        },
+        {
+          ab: [
+            'software distributed under the License is distributed on an '],
+          keyLocation: true,
+        },
+        {
+          ab: [
+            '"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.',
+          ],
+          keyLocation: false,
+        },
+      ]);
+    });
 
-        // Image diffs don't process content, just the 'FILE' line.
-        assert.equal(element.groups[0].lines.length, 1);
+    test('breaks down shared chunks w/ whole-file', () => {
+      const size = 120 * 2 + 5;
+      const content = [{
+        ab: _.times(size, () => `${Math.random()}`),
+      }];
+      element.context = -1;
+      const result = element._splitLargeChunks(content);
+      assert.equal(result.length, 2);
+      assert.deepEqual(result[0].ab, content[0].ab.slice(0, 120));
+      assert.deepEqual(result[1].ab, content[0].ab.slice(120));
+    });
+
+    test('does not break-down common chunks w/ context', () => {
+      const content = [{
+        ab: _.times(75, () => `${Math.random()}`),
+      }];
+      element.context = 4;
+      const result =
+          element._splitCommonChunksWithKeyLocations(content);
+      assert.equal(result.length, 1);
+      assert.deepEqual(result[0].ab, content[0].ab);
+      assert.isFalse(result[0].keyLocation);
+    });
+
+    test('intraline normalization', () => {
+      // The content and highlights are in the format returned by the Gerrit
+      // REST API.
+      let content = [
+        '      <section class="summary">',
+        '        <gr-linked-text content="' +
+            '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
+        '      </section>',
+      ];
+      let highlights = [
+        [31, 34], [42, 26],
+      ];
+
+      let results = element._convertIntralineInfos(content,
+          highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 31,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+          endIndex: 33,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 75,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 0,
+          endIndex: 6,
+        },
+      ]);
+
+      content = [
+        '        this._path = value.path;',
+        '',
+        '        // When navigating away from the page, there is a ' +
+          'possibility that the',
+        '        // patch number is no longer a part of the URL ' +
+          '(say when navigating to',
+        '        // the top-level change info view) and therefore ' +
+          'undefined in `params`.',
+        '        if (!this._patchRange.patchNum) {',
+      ];
+      highlights = [
+        [14, 17],
+        [11, 70],
+        [12, 67],
+        [12, 67],
+        [14, 29],
+      ];
+      results = element._convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 14,
+          endIndex: 31,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 8,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 3,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 4,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 5,
+          startIndex: 12,
+          endIndex: 41,
+        },
+      ]);
+    });
+
+    test('scrolling pauses rendering', () => {
+      const contentRow = {
+        ab: [
+          '<!DOCTYPE html>',
+          '<meta charset="utf-8">',
+        ],
+      };
+      const content = _.times(200, _.constant(contentRow));
+      sandbox.stub(element, 'async');
+      element._isScrolling = true;
+      element.process(content);
+      // Just the files group - no more processing during scrolling.
+      assert.equal(element.groups.length, 1);
+
+      element._isScrolling = false;
+      element.process(content);
+      // More groups have been processed. How many does not matter here.
+      assert.isAtLeast(element.groups.length, 2);
+    });
+
+    test('image diffs', () => {
+      const contentRow = {
+        ab: [
+          '<!DOCTYPE html>',
+          '<meta charset="utf-8">',
+        ],
+      };
+      const content = _.times(200, _.constant(contentRow));
+      sandbox.stub(element, 'async');
+      element.process(content, true);
+      assert.equal(element.groups.length, 1);
+
+      // Image diffs don't process content, just the 'FILE' line.
+      assert.equal(element.groups[0].lines.length, 1);
+    });
+
+    suite('_processNext', () => {
+      let rows;
+
+      setup(() => {
+        rows = loremIpsum.split(' ');
       });
 
-      suite('_processNext', () => {
-        let rows;
+      test('WHOLE_FILE', () => {
+        element.context = WHOLE_FILE;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [
+          {a: ['foo']},
+          {ab: rows},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+
+        // Results in one, uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1);
+        assert.equal(result.groups[0].type, GrDiffGroup.Type.BOTH);
+        assert.equal(result.groups[0].lines.length, rows.length);
+
+        // Line numbers are set correctly.
+        assert.equal(
+            result.groups[0].lines[0].beforeNumber,
+            state.lineNums.left + 1);
+        assert.equal(
+            result.groups[0].lines[0].afterNumber,
+            state.lineNums.right + 1);
+
+        assert.equal(result.groups[0].lines[rows.length - 1].beforeNumber,
+            state.lineNums.left + rows.length);
+        assert.equal(result.groups[0].lines[rows.length - 1].afterNumber,
+            state.lineNums.right + rows.length);
+      });
+
+      test('with context', () => {
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [
+          {a: ['foo']},
+          {ab: rows},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+        const expectedCollapseSize = rows.length - 2 * element.context;
+
+        assert.equal(result.groups.length, 3, 'Results in three groups');
+
+        // The first and last are uncollapsed context, whereas the middle has
+        // a single context-control line.
+        assert.equal(result.groups[0].lines.length, element.context);
+        assert.equal(result.groups[1].lines.length, 1);
+        assert.equal(result.groups[2].lines.length, element.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
+            expectedCollapseSize);
+      });
+
+      test('first', () => {
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 0,
+        };
+        const chunks = [
+          {ab: rows},
+          {a: ['foo']},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+        const expectedCollapseSize = rows.length - element.context;
+
+        assert.equal(result.groups.length, 2, 'Results in two groups');
+
+        // Only the first group is collapsed.
+        assert.equal(result.groups[0].lines.length, 1);
+        assert.equal(result.groups[1].lines.length, element.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
+            expectedCollapseSize);
+      });
+
+      test('few-rows', () => {
+        // Only ten rows.
+        rows = rows.slice(0, 10);
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 0,
+        };
+        const chunks = [
+          {ab: rows},
+          {a: ['foo']},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+
+        // Results in one uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1, 'Results in one group');
+        assert.equal(result.groups[0].lines.length, rows.length);
+      });
+
+      test('no single line collapse', () => {
+        rows = rows.slice(0, 7);
+        element.context = 3;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [
+          {a: ['foo']},
+          {ab: rows},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+
+        // Results in one uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1, 'Results in one group');
+        assert.equal(result.groups[0].lines.length, rows.length);
+      });
+
+      suite('with key location', () => {
+        let state;
+        let chunks;
 
         setup(() => {
-          rows = loremIpsum.split(' ');
-        });
-
-        test('WHOLE_FILE', () => {
-          element.context = WHOLE_FILE;
-          const state = {
+          state = {
             lineNums: {left: 10, right: 100},
-            chunkIndex: 1,
           };
-          const chunks = [
-            {a: ['foo']},
-            {ab: rows},
-            {a: ['bar']},
-          ];
-          const result = element._processNext(state, chunks);
-
-          // Results in one, uncollapsed group with all rows.
-          assert.equal(result.groups.length, 1);
-          assert.equal(result.groups[0].type, GrDiffGroup.Type.BOTH);
-          assert.equal(result.groups[0].lines.length, rows.length);
-
-          // Line numbers are set correctly.
-          assert.equal(
-              result.groups[0].lines[0].beforeNumber,
-              state.lineNums.left + 1);
-          assert.equal(
-              result.groups[0].lines[0].afterNumber,
-              state.lineNums.right + 1);
-
-          assert.equal(result.groups[0].lines[rows.length - 1].beforeNumber,
-              state.lineNums.left + rows.length);
-          assert.equal(result.groups[0].lines[rows.length - 1].afterNumber,
-              state.lineNums.right + rows.length);
-        });
-
-        test('with context', () => {
           element.context = 10;
-          const state = {
-            lineNums: {left: 10, right: 100},
-            chunkIndex: 1,
-          };
-          const chunks = [
-            {a: ['foo']},
+          chunks = [
             {ab: rows},
-            {a: ['bar']},
+            {ab: ['foo'], keyLocation: true},
+            {ab: rows},
           ];
-          const result = element._processNext(state, chunks);
-          const expectedCollapseSize = rows.length - 2 * element.context;
-
-          assert.equal(result.groups.length, 3, 'Results in three groups');
-
-          // The first and last are uncollapsed context, whereas the middle has
-          // a single context-control line.
-          assert.equal(result.groups[0].lines.length, element.context);
-          assert.equal(result.groups[1].lines.length, 1);
-          assert.equal(result.groups[2].lines.length, element.context);
-
-          // The collapsed group has the hidden lines as its context group.
-          assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
-              expectedCollapseSize);
         });
 
-        test('first', () => {
-          element.context = 10;
-          const state = {
-            lineNums: {left: 10, right: 100},
-            chunkIndex: 0,
-          };
-          const chunks = [
-            {ab: rows},
-            {a: ['foo']},
-            {a: ['bar']},
-          ];
+        test('context before', () => {
+          state.chunkIndex = 0;
           const result = element._processNext(state, chunks);
-          const expectedCollapseSize = rows.length - element.context;
 
-          assert.equal(result.groups.length, 2, 'Results in two groups');
-
-          // Only the first group is collapsed.
+          // The first chunk is split into two groups:
+          // 1) A context-control, hiding everything but the context before
+          //    the key location.
+          // 2) The context before the key location.
+          // The key location is not processed in this call to _processNext
+          assert.equal(result.groups.length, 2);
           assert.equal(result.groups[0].lines.length, 1);
-          assert.equal(result.groups[1].lines.length, element.context);
-
           // The collapsed group has the hidden lines as its context group.
           assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
-              expectedCollapseSize);
+              rows.length - element.context);
+          assert.equal(result.groups[1].lines.length, element.context);
         });
 
-        test('few-rows', () => {
-          // Only ten rows.
-          rows = rows.slice(0, 10);
-          element.context = 10;
-          const state = {
-            lineNums: {left: 10, right: 100},
-            chunkIndex: 0,
-          };
-          const chunks = [
-            {ab: rows},
-            {a: ['foo']},
-            {a: ['bar']},
-          ];
+        test('key location itself', () => {
+          state.chunkIndex = 1;
           const result = element._processNext(state, chunks);
 
-          // Results in one uncollapsed group with all rows.
-          assert.equal(result.groups.length, 1, 'Results in one group');
-          assert.equal(result.groups[0].lines.length, rows.length);
+          // The second chunk results in a single group, that is just the
+          // line with the key location
+          assert.equal(result.groups.length, 1);
+          assert.equal(result.groups[0].lines.length, 1);
+          assert.equal(result.lineDelta.left, 1);
+          assert.equal(result.lineDelta.right, 1);
         });
 
-        test('no single line collapse', () => {
-          rows = rows.slice(0, 7);
-          element.context = 3;
-          const state = {
-            lineNums: {left: 10, right: 100},
-            chunkIndex: 1,
-          };
-          const chunks = [
-            {a: ['foo']},
-            {ab: rows},
-            {a: ['bar']},
-          ];
+        test('context after', () => {
+          state.chunkIndex = 2;
           const result = element._processNext(state, chunks);
 
-          // Results in one uncollapsed group with all rows.
-          assert.equal(result.groups.length, 1, 'Results in one group');
-          assert.equal(result.groups[0].lines.length, rows.length);
-        });
-
-        suite('with key location', () => {
-          let state;
-          let chunks;
-
-          setup(() => {
-            state = {
-              lineNums: {left: 10, right: 100},
-            };
-            element.context = 10;
-            chunks = [
-              {ab: rows},
-              {ab: ['foo'], keyLocation: true},
-              {ab: rows},
-            ];
-          });
-
-          test('context before', () => {
-            state.chunkIndex = 0;
-            const result = element._processNext(state, chunks);
-
-            // The first chunk is split into two groups:
-            // 1) A context-control, hiding everything but the context before
-            //    the key location.
-            // 2) The context before the key location.
-            // The key location is not processed in this call to _processNext
-            assert.equal(result.groups.length, 2);
-            assert.equal(result.groups[0].lines.length, 1);
-            // The collapsed group has the hidden lines as its context group.
-            assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
-                rows.length - element.context);
-            assert.equal(result.groups[1].lines.length, element.context);
-          });
-
-          test('key location itself', () => {
-            state.chunkIndex = 1;
-            const result = element._processNext(state, chunks);
-
-            // The second chunk results in a single group, that is just the
-            // line with the key location
-            assert.equal(result.groups.length, 1);
-            assert.equal(result.groups[0].lines.length, 1);
-            assert.equal(result.lineDelta.left, 1);
-            assert.equal(result.lineDelta.right, 1);
-          });
-
-          test('context after', () => {
-            state.chunkIndex = 2;
-            const result = element._processNext(state, chunks);
-
-            // The last chunk is split into two groups:
-            // 1) The context after the key location.
-            // 1) A context-control, hiding everything but the context after the
-            //    key location.
-            assert.equal(result.groups.length, 2);
-            assert.equal(result.groups[0].lines.length, element.context);
-            assert.equal(result.groups[1].lines.length, 1);
-            // The collapsed group has the hidden lines as its context group.
-            assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
-                rows.length - element.context);
-          });
-        });
-      });
-
-      suite('gr-diff-processor helpers', () => {
-        let rows;
-
-        setup(() => {
-          rows = loremIpsum.split(' ');
-        });
-
-        test('_linesFromRows', () => {
-          const startLineNum = 10;
-          let result = element._linesFromRows(GrDiffLine.Type.ADD, rows,
-              startLineNum + 1);
-
-          assert.equal(result.length, rows.length);
-          assert.equal(result[0].type, GrDiffLine.Type.ADD);
-          assert.equal(result[0].afterNumber, startLineNum + 1);
-          assert.notOk(result[0].beforeNumber);
-          assert.equal(result[result.length - 1].afterNumber,
-              startLineNum + rows.length);
-          assert.notOk(result[result.length - 1].beforeNumber);
-
-          result = element._linesFromRows(GrDiffLine.Type.REMOVE, rows,
-              startLineNum + 1);
-
-          assert.equal(result.length, rows.length);
-          assert.equal(result[0].type, GrDiffLine.Type.REMOVE);
-          assert.equal(result[0].beforeNumber, startLineNum + 1);
-          assert.notOk(result[0].afterNumber);
-          assert.equal(result[result.length - 1].beforeNumber,
-              startLineNum + rows.length);
-          assert.notOk(result[result.length - 1].afterNumber);
-        });
-      });
-
-      suite('_breakdown*', () => {
-        test('_breakdownChunk breaks down additions', () => {
-          sandbox.spy(element, '_breakdown');
-          const chunk = {b: ['blah', 'blah', 'blah']};
-          const result = element._breakdownChunk(chunk);
-          assert.deepEqual(result, [chunk]);
-          assert.isTrue(element._breakdown.called);
-        });
-
-        test('_breakdownChunk keeps due_to_rebase for broken down additions',
-            () => {
-              sandbox.spy(element, '_breakdown');
-              const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
-              const result = element._breakdownChunk(chunk);
-              for (const subResult of result) {
-                assert.isTrue(subResult.due_to_rebase);
-              }
-            });
-
-        test('_breakdown common case', () => {
-          const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
-              .split(' ');
-          const size = 3;
-
-          const result = element._breakdown(array, size);
-
-          for (const subResult of result) {
-            assert.isAtMost(subResult.length, size);
-          }
-          const flattened = result
-              .reduce((a, b) => a.concat(b), []);
-          assert.deepEqual(flattened, array);
-        });
-
-        test('_breakdown smaller than size', () => {
-          const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
-              .split(' ');
-          const size = 10;
-          const expected = [array];
-
-          const result = element._breakdown(array, size);
-
-          assert.deepEqual(result, expected);
-        });
-
-        test('_breakdown empty', () => {
-          const array = [];
-          const size = 10;
-          const expected = [];
-
-          const result = element._breakdown(array, size);
-
-          assert.deepEqual(result, expected);
+          // The last chunk is split into two groups:
+          // 1) The context after the key location.
+          // 1) A context-control, hiding everything but the context after the
+          //    key location.
+          assert.equal(result.groups.length, 2);
+          assert.equal(result.groups[0].lines.length, element.context);
+          assert.equal(result.groups[1].lines.length, 1);
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
+              rows.length - element.context);
         });
       });
     });
 
-    test('detaching cancels', () => {
-      element = fixture('basic');
-      sandbox.stub(element, 'cancel');
-      element.detached();
-      assert(element.cancel.called);
+    suite('gr-diff-processor helpers', () => {
+      let rows;
+
+      setup(() => {
+        rows = loremIpsum.split(' ');
+      });
+
+      test('_linesFromRows', () => {
+        const startLineNum = 10;
+        let result = element._linesFromRows(GrDiffLine.Type.ADD, rows,
+            startLineNum + 1);
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLine.Type.ADD);
+        assert.equal(result[0].afterNumber, startLineNum + 1);
+        assert.notOk(result[0].beforeNumber);
+        assert.equal(result[result.length - 1].afterNumber,
+            startLineNum + rows.length);
+        assert.notOk(result[result.length - 1].beforeNumber);
+
+        result = element._linesFromRows(GrDiffLine.Type.REMOVE, rows,
+            startLineNum + 1);
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLine.Type.REMOVE);
+        assert.equal(result[0].beforeNumber, startLineNum + 1);
+        assert.notOk(result[0].afterNumber);
+        assert.equal(result[result.length - 1].beforeNumber,
+            startLineNum + rows.length);
+        assert.notOk(result[result.length - 1].afterNumber);
+      });
+    });
+
+    suite('_breakdown*', () => {
+      test('_breakdownChunk breaks down additions', () => {
+        sandbox.spy(element, '_breakdown');
+        const chunk = {b: ['blah', 'blah', 'blah']};
+        const result = element._breakdownChunk(chunk);
+        assert.deepEqual(result, [chunk]);
+        assert.isTrue(element._breakdown.called);
+      });
+
+      test('_breakdownChunk keeps due_to_rebase for broken down additions',
+          () => {
+            sandbox.spy(element, '_breakdown');
+            const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
+            const result = element._breakdownChunk(chunk);
+            for (const subResult of result) {
+              assert.isTrue(subResult.due_to_rebase);
+            }
+          });
+
+      test('_breakdown common case', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
+            .split(' ');
+        const size = 3;
+
+        const result = element._breakdown(array, size);
+
+        for (const subResult of result) {
+          assert.isAtMost(subResult.length, size);
+        }
+        const flattened = result
+            .reduce((a, b) => a.concat(b), []);
+        assert.deepEqual(flattened, array);
+      });
+
+      test('_breakdown smaller than size', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
+            .split(' ');
+        const size = 10;
+        const expected = [array];
+
+        const result = element._breakdown(array, size);
+
+        assert.deepEqual(result, expected);
+      });
+
+      test('_breakdown empty', () => {
+        const array = [];
+        const size = 10;
+        const expected = [];
+
+        const result = element._breakdown(array, size);
+
+        assert.deepEqual(result, expected);
+      });
     });
   });
+
+  test('detaching cancels', () => {
+    element = fixture('basic');
+    sandbox.stub(element, 'cancel');
+    element.detached();
+    assert(element.cancel.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index e46f959..e59b0c2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -14,352 +14,364 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * Possible CSS classes indicating the state of selection. Dynamically added/
-   * removed based on where the user clicks within the diff.
-   */
-  const SelectionClass = {
-    COMMENT: 'selected-comment',
-    LEFT: 'selected-left',
-    RIGHT: 'selected-right',
-    BLAME: 'selected-blame',
-  };
+import '../../../behaviors/dom-util-behavior/dom-util-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../../scripts/util.js';
+import '../gr-diff-highlight/gr-range-normalizer.js';
+import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-selection_html.js';
 
-  const getNewCache = () => { return {left: null, right: null}; };
+/**
+ * Possible CSS classes indicating the state of selection. Dynamically added/
+ * removed based on where the user clicks within the diff.
+ */
+const SelectionClass = {
+  COMMENT: 'selected-comment',
+  LEFT: 'selected-left',
+  RIGHT: 'selected-right',
+  BLAME: 'selected-blame',
+};
 
-  /**
-   * @appliesMixin Gerrit.DomUtilMixin
-   * @extends Polymer.Element
-   */
-  class GrDiffSelection extends Polymer.mixinBehaviors( [
-    Gerrit.DomUtilBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-diff-selection'; }
+const getNewCache = () => { return {left: null, right: null}; };
 
-    static get properties() {
-      return {
-        diff: Object,
-        /** @type {?Object} */
-        _cachedDiffBuilder: Object,
-        _linesCache: {
-          type: Object,
-          value: getNewCache(),
-        },
-      };
+/**
+ * @appliesMixin Gerrit.DomUtilMixin
+ * @extends Polymer.Element
+ */
+class GrDiffSelection extends mixinBehaviors( [
+  Gerrit.DomUtilBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-selection'; }
+
+  static get properties() {
+    return {
+      diff: Object,
+      /** @type {?Object} */
+      _cachedDiffBuilder: Object,
+      _linesCache: {
+        type: Object,
+        value: getNewCache(),
+      },
+    };
+  }
+
+  static get observers() {
+    return [
+      '_diffChanged(diff)',
+    ];
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('copy',
+        e => this._handleCopy(e));
+    addListener(this, 'down',
+        e => this._handleDown(e));
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.classList.add(SelectionClass.RIGHT);
+  }
+
+  get diffBuilder() {
+    if (!this._cachedDiffBuilder) {
+      this._cachedDiffBuilder =
+          dom(this).querySelector('gr-diff-builder');
     }
+    return this._cachedDiffBuilder;
+  }
 
-    static get observers() {
-      return [
-        '_diffChanged(diff)',
-      ];
-    }
+  _diffChanged() {
+    this._linesCache = getNewCache();
+  }
 
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('copy',
-          e => this._handleCopy(e));
-      Polymer.Gestures.addListener(this, 'down',
-          e => this._handleDown(e));
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this.classList.add(SelectionClass.RIGHT);
-    }
-
-    get diffBuilder() {
-      if (!this._cachedDiffBuilder) {
-        this._cachedDiffBuilder =
-            Polymer.dom(this).querySelector('gr-diff-builder');
-      }
-      return this._cachedDiffBuilder;
-    }
-
-    _diffChanged() {
-      this._linesCache = getNewCache();
-    }
-
-    _handleDownOnRangeComment(node) {
-      if (node &&
-          node.nodeName &&
-          node.nodeName.toLowerCase() === 'gr-comment-thread') {
-        this._setClasses([
-          SelectionClass.COMMENT,
-          node.commentSide === 'left' ?
-            SelectionClass.LEFT :
-            SelectionClass.RIGHT,
-        ]);
-        return true;
-      }
-      return false;
-    }
-
-    _handleDown(e) {
-      // Handle the down event on comment thread in Polymer 2
-      const handled = this._handleDownOnRangeComment(e.target);
-      if (handled) return;
-
-      const lineEl = this.diffBuilder.getLineElByChild(e.target);
-      const blameSelected = this._elementDescendedFromClass(e.target, 'blame');
-      if (!lineEl && !blameSelected) { return; }
-
-      const targetClasses = [];
-
-      if (blameSelected) {
-        targetClasses.push(SelectionClass.BLAME);
-      } else {
-        const commentSelected =
-            this._elementDescendedFromClass(e.target, 'gr-comment');
-        const side = this.diffBuilder.getSideByLineEl(lineEl);
-
-        targetClasses.push(side === 'left' ?
+  _handleDownOnRangeComment(node) {
+    if (node &&
+        node.nodeName &&
+        node.nodeName.toLowerCase() === 'gr-comment-thread') {
+      this._setClasses([
+        SelectionClass.COMMENT,
+        node.commentSide === 'left' ?
           SelectionClass.LEFT :
-          SelectionClass.RIGHT);
-
-        if (commentSelected) {
-          targetClasses.push(SelectionClass.COMMENT);
-        }
-      }
-
-      this._setClasses(targetClasses);
+          SelectionClass.RIGHT,
+      ]);
+      return true;
     }
+    return false;
+  }
 
-    /**
-     * Set the provided list of classes on the element, to the exclusion of all
-     * other SelectionClass values.
-     *
-     * @param {!Array<!string>} targetClasses
-     */
-    _setClasses(targetClasses) {
-      // Remove any selection classes that do not belong.
-      for (const key in SelectionClass) {
-        if (SelectionClass.hasOwnProperty(key)) {
-          const className = SelectionClass[key];
-          if (!targetClasses.includes(className)) {
-            this.classList.remove(SelectionClass[key]);
-          }
-        }
-      }
-      // Add new selection classes iff they are not already present.
-      for (const _class of targetClasses) {
-        if (!this.classList.contains(_class)) {
-          this.classList.add(_class);
-        }
-      }
-    }
+  _handleDown(e) {
+    // Handle the down event on comment thread in Polymer 2
+    const handled = this._handleDownOnRangeComment(e.target);
+    if (handled) return;
 
-    _getCopyEventTarget(e) {
-      return Polymer.dom(e).rootTarget;
-    }
+    const lineEl = this.diffBuilder.getLineElByChild(e.target);
+    const blameSelected = this._elementDescendedFromClass(e.target, 'blame');
+    if (!lineEl && !blameSelected) { return; }
 
-    /**
-     * Utility function to determine whether an element is a descendant of
-     * another element with the particular className.
-     *
-     * @param {!Element} element
-     * @param {!string} className
-     * @return {boolean}
-     */
-    _elementDescendedFromClass(element, className) {
-      return this.descendedFromClass(element, className,
-          this.diffBuilder.diffElement);
-    }
+    const targetClasses = [];
 
-    _handleCopy(e) {
-      let commentSelected = false;
-      const target = this._getCopyEventTarget(e);
-      if (target.type === 'textarea') { return; }
-      if (!this._elementDescendedFromClass(target, 'diff-row')) { return; }
-      if (this.classList.contains(SelectionClass.COMMENT)) {
-        commentSelected = true;
-      }
-      const lineEl = this.diffBuilder.getLineElByChild(target);
-      if (!lineEl) {
-        return;
-      }
+    if (blameSelected) {
+      targetClasses.push(SelectionClass.BLAME);
+    } else {
+      const commentSelected =
+          this._elementDescendedFromClass(e.target, 'gr-comment');
       const side = this.diffBuilder.getSideByLineEl(lineEl);
-      const text = this._getSelectedText(side, commentSelected);
-      if (text) {
-        e.clipboardData.setData('Text', text);
-        e.preventDefault();
-      }
-    }
 
-    _getSelection() {
-      const diffHosts = util.querySelectorAll(document.body, 'gr-diff');
-      if (!diffHosts.length) return window.getSelection();
+      targetClasses.push(side === 'left' ?
+        SelectionClass.LEFT :
+        SelectionClass.RIGHT);
 
-      const curDiffHost = diffHosts.find(diffHost => {
-        if (!diffHost || !diffHost.shadowRoot) return false;
-        const selection = diffHost.shadowRoot.getSelection();
-        // Pick the one with valid selection:
-        // https://developer.mozilla.org/en-US/docs/Web/API/Selection/type
-        return selection && selection.type !== 'None';
-      });
-
-      return curDiffHost ?
-        curDiffHost.shadowRoot.getSelection(): window.getSelection();
-    }
-
-    /**
-     * Get the text of the current selection. If commentSelected is
-     * true, it returns only the text of comments within the selection.
-     * Otherwise it returns the text of the selected diff region.
-     *
-     * @param {!string} side The side that is selected.
-     * @param {boolean} commentSelected Whether or not a comment is selected.
-     * @return {string} The selected text.
-     */
-    _getSelectedText(side, commentSelected) {
-      const sel = this._getSelection();
-      if (sel.rangeCount != 1) {
-        return ''; // No multi-select support yet.
-      }
       if (commentSelected) {
-        return this._getCommentLines(sel, side);
+        targetClasses.push(SelectionClass.COMMENT);
       }
-      const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
-      const startLineEl =
-          this.diffBuilder.getLineElByChild(range.startContainer);
-      const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
-      // Happens when triple click in side-by-side mode with other side empty.
-      const endsAtOtherEmptySide = !endLineEl &&
-          range.endOffset === 0 &&
-          range.endContainer.nodeName === 'TD' &&
-          (range.endContainer.classList.contains('left') ||
-           range.endContainer.classList.contains('right'));
-      const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
-      let endLineNum;
-      if (endsAtOtherEmptySide) {
-        endLineNum = startLineNum + 1;
-      } else if (endLineEl) {
-        endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
-      }
-
-      return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
-          range.endOffset, side);
     }
 
-    /**
-     * Query the diff object for the selected lines.
-     *
-     * @param {number} startLineNum
-     * @param {number} startOffset
-     * @param {number|undefined} endLineNum Use undefined to get the range
-     *     extending to the end of the file.
-     * @param {number} endOffset
-     * @param {!string} side The side that is currently selected.
-     * @return {string} The selected diff text.
-     */
-    _getRangeFromDiff(startLineNum, startOffset, endLineNum, endOffset, side) {
-      const lines =
-          this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
-      if (lines.length) {
-        lines[lines.length - 1] = lines[lines.length - 1]
-            .substring(0, endOffset);
-        lines[0] = lines[0].substring(startOffset);
+    this._setClasses(targetClasses);
+  }
+
+  /**
+   * Set the provided list of classes on the element, to the exclusion of all
+   * other SelectionClass values.
+   *
+   * @param {!Array<!string>} targetClasses
+   */
+  _setClasses(targetClasses) {
+    // Remove any selection classes that do not belong.
+    for (const key in SelectionClass) {
+      if (SelectionClass.hasOwnProperty(key)) {
+        const className = SelectionClass[key];
+        if (!targetClasses.includes(className)) {
+          this.classList.remove(SelectionClass[key]);
+        }
       }
-      return lines.join('\n');
     }
-
-    /**
-     * Query the diff object for the lines from a particular side.
-     *
-     * @param {!string} side The side that is currently selected.
-     * @return {!Array<string>} An array of strings indexed by line number.
-     */
-    _getDiffLines(side) {
-      if (this._linesCache[side]) {
-        return this._linesCache[side];
+    // Add new selection classes iff they are not already present.
+    for (const _class of targetClasses) {
+      if (!this.classList.contains(_class)) {
+        this.classList.add(_class);
       }
-      let lines = [];
-      const key = side === 'left' ? 'a' : 'b';
-      for (const chunk of this.diff.content) {
-        if (chunk.ab) {
-          lines = lines.concat(chunk.ab);
-        } else if (chunk[key]) {
-          lines = lines.concat(chunk[key]);
-        }
-      }
-      this._linesCache[side] = lines;
-      return lines;
-    }
-
-    /**
-     * Query the diffElement for comments and check whether they lie inside the
-     * selection range.
-     *
-     * @param {!Selection} sel The selection of the window.
-     * @param {!string} side The side that is currently selected.
-     * @return {string} The selected comment text.
-     */
-    _getCommentLines(sel, side) {
-      const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
-      const content = [];
-      // Query the diffElement for comments.
-      const messages = this.diffBuilder.diffElement.querySelectorAll(
-          `.side-by-side [data-side="${side
-          }"] .message *, .unified .message *`);
-
-      for (let i = 0; i < messages.length; i++) {
-        const el = messages[i];
-        // Check if the comment element exists inside the selection.
-        if (sel.containsNode(el, true)) {
-          // Padded elements require newlines for accurate spacing.
-          if (el.parentElement.id === 'container' ||
-              el.parentElement.nodeName === 'BLOCKQUOTE') {
-            if (content.length && content[content.length - 1] !== '') {
-              content.push('');
-            }
-          }
-
-          if (el.id === 'output' &&
-              !this._elementDescendedFromClass(el, 'collapsed')) {
-            content.push(this._getTextContentForRange(el, sel, range));
-          }
-        }
-      }
-
-      return content.join('\n');
-    }
-
-    /**
-     * Given a DOM node, a selection, and a selection range, recursively get all
-     * of the text content within that selection.
-     * Using a domNode that isn't in the selection returns an empty string.
-     *
-     * @param {!Node} domNode The root DOM node.
-     * @param {!Selection} sel The selection.
-     * @param {!Range} range The normalized selection range.
-     * @return {string} The text within the selection.
-     */
-    _getTextContentForRange(domNode, sel, range) {
-      if (!sel.containsNode(domNode, true)) { return ''; }
-
-      let text = '';
-      if (domNode instanceof Text) {
-        text = domNode.textContent;
-        if (domNode === range.endContainer) {
-          text = text.substring(0, range.endOffset);
-        }
-        if (domNode === range.startContainer) {
-          text = text.substring(range.startOffset);
-        }
-      } else {
-        for (const childNode of domNode.childNodes) {
-          text += this._getTextContentForRange(childNode, sel, range);
-        }
-      }
-      return text;
     }
   }
 
-  customElements.define(GrDiffSelection.is, GrDiffSelection);
-})();
+  _getCopyEventTarget(e) {
+    return dom(e).rootTarget;
+  }
+
+  /**
+   * Utility function to determine whether an element is a descendant of
+   * another element with the particular className.
+   *
+   * @param {!Element} element
+   * @param {!string} className
+   * @return {boolean}
+   */
+  _elementDescendedFromClass(element, className) {
+    return this.descendedFromClass(element, className,
+        this.diffBuilder.diffElement);
+  }
+
+  _handleCopy(e) {
+    let commentSelected = false;
+    const target = this._getCopyEventTarget(e);
+    if (target.type === 'textarea') { return; }
+    if (!this._elementDescendedFromClass(target, 'diff-row')) { return; }
+    if (this.classList.contains(SelectionClass.COMMENT)) {
+      commentSelected = true;
+    }
+    const lineEl = this.diffBuilder.getLineElByChild(target);
+    if (!lineEl) {
+      return;
+    }
+    const side = this.diffBuilder.getSideByLineEl(lineEl);
+    const text = this._getSelectedText(side, commentSelected);
+    if (text) {
+      e.clipboardData.setData('Text', text);
+      e.preventDefault();
+    }
+  }
+
+  _getSelection() {
+    const diffHosts = util.querySelectorAll(document.body, 'gr-diff');
+    if (!diffHosts.length) return window.getSelection();
+
+    const curDiffHost = diffHosts.find(diffHost => {
+      if (!diffHost || !diffHost.shadowRoot) return false;
+      const selection = diffHost.shadowRoot.getSelection();
+      // Pick the one with valid selection:
+      // https://developer.mozilla.org/en-US/docs/Web/API/Selection/type
+      return selection && selection.type !== 'None';
+    });
+
+    return curDiffHost ?
+      curDiffHost.shadowRoot.getSelection(): window.getSelection();
+  }
+
+  /**
+   * Get the text of the current selection. If commentSelected is
+   * true, it returns only the text of comments within the selection.
+   * Otherwise it returns the text of the selected diff region.
+   *
+   * @param {!string} side The side that is selected.
+   * @param {boolean} commentSelected Whether or not a comment is selected.
+   * @return {string} The selected text.
+   */
+  _getSelectedText(side, commentSelected) {
+    const sel = this._getSelection();
+    if (sel.rangeCount != 1) {
+      return ''; // No multi-select support yet.
+    }
+    if (commentSelected) {
+      return this._getCommentLines(sel, side);
+    }
+    const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
+    const startLineEl =
+        this.diffBuilder.getLineElByChild(range.startContainer);
+    const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
+    // Happens when triple click in side-by-side mode with other side empty.
+    const endsAtOtherEmptySide = !endLineEl &&
+        range.endOffset === 0 &&
+        range.endContainer.nodeName === 'TD' &&
+        (range.endContainer.classList.contains('left') ||
+         range.endContainer.classList.contains('right'));
+    const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
+    let endLineNum;
+    if (endsAtOtherEmptySide) {
+      endLineNum = startLineNum + 1;
+    } else if (endLineEl) {
+      endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
+    }
+
+    return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
+        range.endOffset, side);
+  }
+
+  /**
+   * Query the diff object for the selected lines.
+   *
+   * @param {number} startLineNum
+   * @param {number} startOffset
+   * @param {number|undefined} endLineNum Use undefined to get the range
+   *     extending to the end of the file.
+   * @param {number} endOffset
+   * @param {!string} side The side that is currently selected.
+   * @return {string} The selected diff text.
+   */
+  _getRangeFromDiff(startLineNum, startOffset, endLineNum, endOffset, side) {
+    const lines =
+        this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
+    if (lines.length) {
+      lines[lines.length - 1] = lines[lines.length - 1]
+          .substring(0, endOffset);
+      lines[0] = lines[0].substring(startOffset);
+    }
+    return lines.join('\n');
+  }
+
+  /**
+   * Query the diff object for the lines from a particular side.
+   *
+   * @param {!string} side The side that is currently selected.
+   * @return {!Array<string>} An array of strings indexed by line number.
+   */
+  _getDiffLines(side) {
+    if (this._linesCache[side]) {
+      return this._linesCache[side];
+    }
+    let lines = [];
+    const key = side === 'left' ? 'a' : 'b';
+    for (const chunk of this.diff.content) {
+      if (chunk.ab) {
+        lines = lines.concat(chunk.ab);
+      } else if (chunk[key]) {
+        lines = lines.concat(chunk[key]);
+      }
+    }
+    this._linesCache[side] = lines;
+    return lines;
+  }
+
+  /**
+   * Query the diffElement for comments and check whether they lie inside the
+   * selection range.
+   *
+   * @param {!Selection} sel The selection of the window.
+   * @param {!string} side The side that is currently selected.
+   * @return {string} The selected comment text.
+   */
+  _getCommentLines(sel, side) {
+    const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
+    const content = [];
+    // Query the diffElement for comments.
+    const messages = this.diffBuilder.diffElement.querySelectorAll(
+        `.side-by-side [data-side="${side
+        }"] .message *, .unified .message *`);
+
+    for (let i = 0; i < messages.length; i++) {
+      const el = messages[i];
+      // Check if the comment element exists inside the selection.
+      if (sel.containsNode(el, true)) {
+        // Padded elements require newlines for accurate spacing.
+        if (el.parentElement.id === 'container' ||
+            el.parentElement.nodeName === 'BLOCKQUOTE') {
+          if (content.length && content[content.length - 1] !== '') {
+            content.push('');
+          }
+        }
+
+        if (el.id === 'output' &&
+            !this._elementDescendedFromClass(el, 'collapsed')) {
+          content.push(this._getTextContentForRange(el, sel, range));
+        }
+      }
+    }
+
+    return content.join('\n');
+  }
+
+  /**
+   * Given a DOM node, a selection, and a selection range, recursively get all
+   * of the text content within that selection.
+   * Using a domNode that isn't in the selection returns an empty string.
+   *
+   * @param {!Node} domNode The root DOM node.
+   * @param {!Selection} sel The selection.
+   * @param {!Range} range The normalized selection range.
+   * @return {string} The text within the selection.
+   */
+  _getTextContentForRange(domNode, sel, range) {
+    if (!sel.containsNode(domNode, true)) { return ''; }
+
+    let text = '';
+    if (domNode instanceof Text) {
+      text = domNode.textContent;
+      if (domNode === range.endContainer) {
+        text = text.substring(0, range.endOffset);
+      }
+      if (domNode === range.startContainer) {
+        text = text.substring(range.startOffset);
+      }
+    } else {
+      for (const childNode of domNode.childNodes) {
+        text += this._getTextContentForRange(childNode, sel, range);
+      }
+    }
+    return text;
+  }
+}
+
+customElements.define(GrDiffSelection.is, GrDiffSelection);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.js
index 016305c..ce6008e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.js
@@ -1,30 +1,23 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/dom-util-behavior/dom-util-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<script src="../../../scripts/util.js"></script>
-<script src="../gr-diff-highlight/gr-range-normalizer.js"></script>
-
-<dom-module id="gr-diff-selection">
-  <template>
+export const htmlTemplate = html`
     <div class="contentWrapper">
       <slot></slot>
     </div>
-  </template>
-  <script src="gr-diff-selection.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index a8e85e2..ac3a87f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-selection</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-diff-selection.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-diff-selection.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-diff-selection.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -107,298 +112,300 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-selection', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-diff-selection.js';
+suite('gr-diff-selection', () => {
+  let element;
+  let sandbox;
 
-    const emulateCopyOn = function(target) {
-      const fakeEvent = {
-        target,
-        preventDefault: sandbox.stub(),
-        clipboardData: {
-          setData: sandbox.stub(),
+  const emulateCopyOn = function(target) {
+    const fakeEvent = {
+      target,
+      preventDefault: sandbox.stub(),
+      clipboardData: {
+        setData: sandbox.stub(),
+      },
+    };
+    element._getCopyEventTarget.returns(target);
+    element._handleCopy(fakeEvent);
+    return fakeEvent;
+  };
+
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(element, '_getCopyEventTarget');
+    element._cachedDiffBuilder = {
+      getLineElByChild: sandbox.stub().returns({}),
+      getSideByLineEl: sandbox.stub(),
+      diffElement: element.querySelector('#diffTable'),
+    };
+    element.diff = {
+      content: [
+        {
+          a: ['ba ba'],
+          b: ['some other text'],
         },
-      };
-      element._getCopyEventTarget.returns(target);
-      element._handleCopy(fakeEvent);
-      return fakeEvent;
+        {
+          a: ['zin'],
+          b: ['more more more'],
+        },
+        {
+          a: ['ga ga'],
+          b: ['some other text'],
+        },
+      ],
+    };
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('applies selected-left on left side click', () => {
+    element.classList.add('selected-right');
+    element._cachedDiffBuilder.getSideByLineEl.returns('left');
+    MockInteractions.down(element);
+    assert.isTrue(
+        element.classList.contains('selected-left'), 'adds selected-left');
+    assert.isFalse(
+        element.classList.contains('selected-right'),
+        'removes selected-right');
+  });
+
+  test('applies selected-right on right side click', () => {
+    element.classList.add('selected-left');
+    element._cachedDiffBuilder.getSideByLineEl.returns('right');
+    MockInteractions.down(element);
+    assert.isTrue(
+        element.classList.contains('selected-right'), 'adds selected-right');
+    assert.isFalse(
+        element.classList.contains('selected-left'), 'removes selected-left');
+  });
+
+  test('applies selected-blame on blame click', () => {
+    element.classList.add('selected-left');
+    element.diffBuilder.getLineElByChild.returns(null);
+    sandbox.stub(element, '_elementDescendedFromClass',
+        (el, className) => className === 'blame');
+    MockInteractions.down(element);
+    assert.isTrue(
+        element.classList.contains('selected-blame'), 'adds selected-right');
+    assert.isFalse(
+        element.classList.contains('selected-left'), 'removes selected-left');
+  });
+
+  test('ignores copy for non-content Element', () => {
+    sandbox.stub(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('.not-diff-row'));
+    assert.isFalse(element._getSelectedText.called);
+  });
+
+  test('asks for text for left side Elements', () => {
+    element._cachedDiffBuilder.getSideByLineEl.returns('left');
+    sandbox.stub(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('div.contentText'));
+    assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
+  });
+
+  test('reacts to copy for content Elements', () => {
+    sandbox.stub(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('div.contentText'));
+    assert.isTrue(element._getSelectedText.called);
+  });
+
+  test('copy event is prevented for content Elements', () => {
+    sandbox.stub(element, '_getSelectedText');
+    element._cachedDiffBuilder.getSideByLineEl.returns('left');
+    element._getSelectedText.returns('test');
+    const event = emulateCopyOn(element.querySelector('div.contentText'));
+    assert.isTrue(event.preventDefault.called);
+  });
+
+  test('inserts text into clipboard on copy', () => {
+    sandbox.stub(element, '_getSelectedText').returns('the text');
+    const event = emulateCopyOn(element.querySelector('div.contentText'));
+    assert.deepEqual(
+        ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
+  });
+
+  test('_setClasses adds given SelectionClass values, removes others', () => {
+    element.classList.add('selected-right');
+    element._setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(element.classList.contains('selected-comment'));
+    assert.isTrue(element.classList.contains('selected-left'));
+    assert.isFalse(element.classList.contains('selected-right'));
+    assert.isFalse(element.classList.contains('selected-blame'));
+
+    element._setClasses(['selected-blame']);
+    assert.isFalse(element.classList.contains('selected-comment'));
+    assert.isFalse(element.classList.contains('selected-left'));
+    assert.isFalse(element.classList.contains('selected-right'));
+    assert.isTrue(element.classList.contains('selected-blame'));
+  });
+
+  test('_setClasses removes before it ads', () => {
+    element.classList.add('selected-right');
+    const addStub = sandbox.stub(element.classList, 'add');
+    const removeStub = sandbox.stub(element.classList, 'remove', () => {
+      assert.isFalse(addStub.called);
+    });
+    element._setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(addStub.called);
+    assert.isTrue(removeStub.called);
+  });
+
+  test('copies content correctly', () => {
+    // Fetch the line number.
+    element._cachedDiffBuilder.getLineElByChild = function(child) {
+      while (!child.classList.contains('content') && child.parentElement) {
+        child = child.parentElement;
+      }
+      return child.previousElementSibling;
     };
 
+    element.classList.add('selected-left');
+    element.classList.remove('selected-right');
+
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(element.querySelector('div.contentText').firstChild, 3);
+    range.setEnd(
+        element.querySelectorAll('div.contentText')[4].firstChild, 2);
+    selection.addRange(range);
+    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
+  });
+
+  test('copies comments', () => {
+    element.classList.add('selected-left');
+    element.classList.add('selected-comment');
+    element.classList.remove('selected-right');
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(
+        element.querySelector('.gr-formatted-text *').firstChild, 3);
+    range.setEnd(
+        element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2], 7);
+    selection.addRange(range);
+    assert.equal('s is a comment\nThis is a differ',
+        element._getSelectedText('left', true));
+  });
+
+  test('respects astral chars in comments', () => {
+    element.classList.add('selected-left');
+    element.classList.add('selected-comment');
+    element.classList.remove('selected-right');
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    const nodes = element.querySelectorAll('.gr-formatted-text *');
+    range.setStart(nodes[2].childNodes[2], 13);
+    range.setEnd(nodes[2].childNodes[2], 23);
+    selection.addRange(range);
+    assert.equal('mment 💩 u',
+        element._getSelectedText('left', true));
+  });
+
+  test('defers to default behavior for textarea', () => {
+    element.classList.add('selected-left');
+    element.classList.remove('selected-right');
+    const selectedTextSpy = sandbox.spy(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('textarea'));
+    assert.isFalse(selectedTextSpy.called);
+  });
+
+  test('regression test for 4794', () => {
+    element._cachedDiffBuilder.getLineElByChild = function(child) {
+      while (!child.classList.contains('content') && child.parentElement) {
+        child = child.parentElement;
+      }
+      return child.previousElementSibling;
+    };
+
+    element.classList.add('selected-right');
+    element.classList.remove('selected-left');
+
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(
+        element.querySelectorAll('div.contentText')[1].firstChild, 4);
+    range.setEnd(
+        element.querySelectorAll('div.contentText')[1].firstChild, 10);
+    selection.addRange(range);
+    assert.equal(element._getSelectedText('right'), ' other');
+  });
+
+  test('copies to end of side (issue 7895)', () => {
+    element._cachedDiffBuilder.getLineElByChild = function(child) {
+      // Return null for the end container.
+      if (child.textContent === 'ga ga') { return null; }
+      while (!child.classList.contains('content') && child.parentElement) {
+        child = child.parentElement;
+      }
+      return child.previousElementSibling;
+    };
+    element.classList.add('selected-left');
+    element.classList.remove('selected-right');
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(element.querySelector('div.contentText').firstChild, 3);
+    range.setEnd(
+        element.querySelectorAll('div.contentText')[4].firstChild, 2);
+    selection.addRange(range);
+    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
+  });
+
+  suite('_getTextContentForRange', () => {
+    let selection;
+    let range;
+    let nodes;
+
     setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(element, '_getCopyEventTarget');
-      element._cachedDiffBuilder = {
-        getLineElByChild: sandbox.stub().returns({}),
-        getSideByLineEl: sandbox.stub(),
-        diffElement: element.querySelector('#diffTable'),
-      };
-      element.diff = {
-        content: [
-          {
-            a: ['ba ba'],
-            b: ['some other text'],
-          },
-          {
-            a: ['zin'],
-            b: ['more more more'],
-          },
-          {
-            a: ['ga ga'],
-            b: ['some other text'],
-          },
-        ],
-      };
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('applies selected-left on left side click', () => {
-      element.classList.add('selected-right');
-      element._cachedDiffBuilder.getSideByLineEl.returns('left');
-      MockInteractions.down(element);
-      assert.isTrue(
-          element.classList.contains('selected-left'), 'adds selected-left');
-      assert.isFalse(
-          element.classList.contains('selected-right'),
-          'removes selected-right');
-    });
-
-    test('applies selected-right on right side click', () => {
-      element.classList.add('selected-left');
-      element._cachedDiffBuilder.getSideByLineEl.returns('right');
-      MockInteractions.down(element);
-      assert.isTrue(
-          element.classList.contains('selected-right'), 'adds selected-right');
-      assert.isFalse(
-          element.classList.contains('selected-left'), 'removes selected-left');
-    });
-
-    test('applies selected-blame on blame click', () => {
-      element.classList.add('selected-left');
-      element.diffBuilder.getLineElByChild.returns(null);
-      sandbox.stub(element, '_elementDescendedFromClass',
-          (el, className) => className === 'blame');
-      MockInteractions.down(element);
-      assert.isTrue(
-          element.classList.contains('selected-blame'), 'adds selected-right');
-      assert.isFalse(
-          element.classList.contains('selected-left'), 'removes selected-left');
-    });
-
-    test('ignores copy for non-content Element', () => {
-      sandbox.stub(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('.not-diff-row'));
-      assert.isFalse(element._getSelectedText.called);
-    });
-
-    test('asks for text for left side Elements', () => {
-      element._cachedDiffBuilder.getSideByLineEl.returns('left');
-      sandbox.stub(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('div.contentText'));
-      assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
-    });
-
-    test('reacts to copy for content Elements', () => {
-      sandbox.stub(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('div.contentText'));
-      assert.isTrue(element._getSelectedText.called);
-    });
-
-    test('copy event is prevented for content Elements', () => {
-      sandbox.stub(element, '_getSelectedText');
-      element._cachedDiffBuilder.getSideByLineEl.returns('left');
-      element._getSelectedText.returns('test');
-      const event = emulateCopyOn(element.querySelector('div.contentText'));
-      assert.isTrue(event.preventDefault.called);
-    });
-
-    test('inserts text into clipboard on copy', () => {
-      sandbox.stub(element, '_getSelectedText').returns('the text');
-      const event = emulateCopyOn(element.querySelector('div.contentText'));
-      assert.deepEqual(
-          ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
-    });
-
-    test('_setClasses adds given SelectionClass values, removes others', () => {
-      element.classList.add('selected-right');
-      element._setClasses(['selected-comment', 'selected-left']);
-      assert.isTrue(element.classList.contains('selected-comment'));
-      assert.isTrue(element.classList.contains('selected-left'));
-      assert.isFalse(element.classList.contains('selected-right'));
-      assert.isFalse(element.classList.contains('selected-blame'));
-
-      element._setClasses(['selected-blame']);
-      assert.isFalse(element.classList.contains('selected-comment'));
-      assert.isFalse(element.classList.contains('selected-left'));
-      assert.isFalse(element.classList.contains('selected-right'));
-      assert.isTrue(element.classList.contains('selected-blame'));
-    });
-
-    test('_setClasses removes before it ads', () => {
-      element.classList.add('selected-right');
-      const addStub = sandbox.stub(element.classList, 'add');
-      const removeStub = sandbox.stub(element.classList, 'remove', () => {
-        assert.isFalse(addStub.called);
-      });
-      element._setClasses(['selected-comment', 'selected-left']);
-      assert.isTrue(addStub.called);
-      assert.isTrue(removeStub.called);
-    });
-
-    test('copies content correctly', () => {
-      // Fetch the line number.
-      element._cachedDiffBuilder.getLineElByChild = function(child) {
-        while (!child.classList.contains('content') && child.parentElement) {
-          child = child.parentElement;
-        }
-        return child.previousElementSibling;
-      };
-
-      element.classList.add('selected-left');
-      element.classList.remove('selected-right');
-
-      const selection = window.getSelection();
-      selection.removeAllRanges();
-      const range = document.createRange();
-      range.setStart(element.querySelector('div.contentText').firstChild, 3);
-      range.setEnd(
-          element.querySelectorAll('div.contentText')[4].firstChild, 2);
-      selection.addRange(range);
-      assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-    });
-
-    test('copies comments', () => {
       element.classList.add('selected-left');
       element.classList.add('selected-comment');
       element.classList.remove('selected-right');
-      const selection = window.getSelection();
+      selection = window.getSelection();
       selection.removeAllRanges();
-      const range = document.createRange();
-      range.setStart(
-          element.querySelector('.gr-formatted-text *').firstChild, 3);
-      range.setEnd(
-          element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2], 7);
+      range = document.createRange();
+      nodes = element.querySelectorAll('.gr-formatted-text *');
+    });
+
+    test('multi level element contained in range', () => {
+      range.setStart(nodes[2].childNodes[0], 1);
+      range.setEnd(nodes[2].childNodes[2], 7);
       selection.addRange(range);
-      assert.equal('s is a comment\nThis is a differ',
-          element._getSelectedText('left', true));
+      assert.equal(element._getTextContentForRange(element, selection, range),
+          'his is a differ');
     });
 
-    test('respects astral chars in comments', () => {
-      element.classList.add('selected-left');
-      element.classList.add('selected-comment');
-      element.classList.remove('selected-right');
-      const selection = window.getSelection();
-      selection.removeAllRanges();
-      const range = document.createRange();
-      const nodes = element.querySelectorAll('.gr-formatted-text *');
-      range.setStart(nodes[2].childNodes[2], 13);
-      range.setEnd(nodes[2].childNodes[2], 23);
+    test('multi level element as startContainer of range', () => {
+      range.setStart(nodes[2].childNodes[1], 0);
+      range.setEnd(nodes[2].childNodes[2], 7);
       selection.addRange(range);
-      assert.equal('mment 💩 u',
-          element._getSelectedText('left', true));
+      assert.equal(element._getTextContentForRange(element, selection, range),
+          'a differ');
     });
 
-    test('defers to default behavior for textarea', () => {
-      element.classList.add('selected-left');
-      element.classList.remove('selected-right');
-      const selectedTextSpy = sandbox.spy(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('textarea'));
-      assert.isFalse(selectedTextSpy.called);
-    });
-
-    test('regression test for 4794', () => {
-      element._cachedDiffBuilder.getLineElByChild = function(child) {
-        while (!child.classList.contains('content') && child.parentElement) {
-          child = child.parentElement;
-        }
-        return child.previousElementSibling;
-      };
-
-      element.classList.add('selected-right');
-      element.classList.remove('selected-left');
-
-      const selection = window.getSelection();
-      selection.removeAllRanges();
-      const range = document.createRange();
-      range.setStart(
-          element.querySelectorAll('div.contentText')[1].firstChild, 4);
-      range.setEnd(
-          element.querySelectorAll('div.contentText')[1].firstChild, 10);
+    test('startContainer === endContainer', () => {
+      range.setStart(nodes[0].firstChild, 2);
+      range.setEnd(nodes[0].firstChild, 12);
       selection.addRange(range);
-      assert.equal(element._getSelectedText('right'), ' other');
-    });
-
-    test('copies to end of side (issue 7895)', () => {
-      element._cachedDiffBuilder.getLineElByChild = function(child) {
-        // Return null for the end container.
-        if (child.textContent === 'ga ga') { return null; }
-        while (!child.classList.contains('content') && child.parentElement) {
-          child = child.parentElement;
-        }
-        return child.previousElementSibling;
-      };
-      element.classList.add('selected-left');
-      element.classList.remove('selected-right');
-      const selection = window.getSelection();
-      selection.removeAllRanges();
-      const range = document.createRange();
-      range.setStart(element.querySelector('div.contentText').firstChild, 3);
-      range.setEnd(
-          element.querySelectorAll('div.contentText')[4].firstChild, 2);
-      selection.addRange(range);
-      assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-    });
-
-    suite('_getTextContentForRange', () => {
-      let selection;
-      let range;
-      let nodes;
-
-      setup(() => {
-        element.classList.add('selected-left');
-        element.classList.add('selected-comment');
-        element.classList.remove('selected-right');
-        selection = window.getSelection();
-        selection.removeAllRanges();
-        range = document.createRange();
-        nodes = element.querySelectorAll('.gr-formatted-text *');
-      });
-
-      test('multi level element contained in range', () => {
-        range.setStart(nodes[2].childNodes[0], 1);
-        range.setEnd(nodes[2].childNodes[2], 7);
-        selection.addRange(range);
-        assert.equal(element._getTextContentForRange(element, selection, range),
-            'his is a differ');
-      });
-
-      test('multi level element as startContainer of range', () => {
-        range.setStart(nodes[2].childNodes[1], 0);
-        range.setEnd(nodes[2].childNodes[2], 7);
-        selection.addRange(range);
-        assert.equal(element._getTextContentForRange(element, selection, range),
-            'a differ');
-      });
-
-      test('startContainer === endContainer', () => {
-        range.setStart(nodes[0].firstChild, 2);
-        range.setEnd(nodes[0].firstChild, 12);
-        selection.addRange(range);
-        assert.equal(element._getTextContentForRange(element, selection, range),
-            'is is a co');
-      });
-    });
-
-    test('cache is reset when diff changes', () => {
-      element._linesCache = {left: 'test', right: 'test'};
-      element.diff = {};
-      flushAsynchronousOperations();
-      assert.deepEqual(element._linesCache, {left: null, right: null});
+      assert.equal(element._getTextContentForRange(element, selection, range),
+          'is is a co');
     });
   });
+
+  test('cache is reset when diff changes', () => {
+    element._linesCache = {left: 'test', right: 'test'};
+    element.diff = {};
+    flushAsynchronousOperations();
+    assert.deepEqual(element._linesCache, {left: null, right: null});
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index eb5ea017..cf29a03 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,1225 +14,1258 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
-  const MSG_LOADING_BLAME = 'Loading blame...';
-  const MSG_LOADED_BLAME = 'Blame loaded';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import '@polymer/iron-dropdown/iron-dropdown.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
+import '../../shared/gr-dropdown/gr-dropdown.js';
+import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
+import '../../shared/gr-fixed-panel/gr-fixed-panel.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import '../../shared/revision-info/revision-info.js';
+import '../gr-comment-api/gr-comment-api.js';
+import '../gr-diff-cursor/gr-diff-cursor.js';
+import '../gr-apply-fix-dialog/gr-apply-fix-dialog.js';
+import '../gr-diff-host/gr-diff-host.js';
+import '../gr-diff-mode-selector/gr-diff-mode-selector.js';
+import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
+import '../gr-patch-range-select/gr-patch-range-select.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-view_html.js';
 
-  const PARENT = 'PARENT';
+const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
+const MSG_LOADING_BLAME = 'Loading blame...';
+const MSG_LOADED_BLAME = 'Blame loaded';
 
-  const DiffSides = {
-    LEFT: 'left',
-    RIGHT: 'right',
-  };
+const PARENT = 'PARENT';
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+const DiffSides = {
+  LEFT: 'left',
+  RIGHT: 'right',
+};
+
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @appliesMixin Gerrit.PathListMixin
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @extends Polymer.Element
+ */
+class GrDiffView extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+  Gerrit.PatchSetBehavior,
+  Gerrit.PathListBehavior,
+  Gerrit.RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-view'; }
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @appliesMixin Gerrit.PathListMixin
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @extends Polymer.Element
+   * Fired when user tries to navigate away while comments are pending save.
+   *
+   * @event show-alert
    */
-  class GrDiffView extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-    Gerrit.PatchSetBehavior,
-    Gerrit.PathListBehavior,
-    Gerrit.RESTClientBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-diff-view'; }
-    /**
-     * Fired when the title of the page should change.
-     *
-     * @event title-change
-     */
 
+  static get properties() {
+    return {
     /**
-     * Fired when user tries to navigate away while comments are pending save.
-     *
-     * @event show-alert
+     * URL params passed from the router.
      */
-
-    static get properties() {
-      return {
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+      keyEventTarget: {
+        type: Object,
+        value() { return document.body; },
+      },
       /**
-       * URL params passed from the router.
+       * @type {{ diffMode: (string|undefined) }}
        */
-        params: {
-          type: Object,
-          observer: '_paramsChanged',
-        },
-        keyEventTarget: {
-          type: Object,
-          value() { return document.body; },
-        },
-        /**
-         * @type {{ diffMode: (string|undefined) }}
-         */
-        changeViewState: {
-          type: Object,
-          notify: true,
-          value() { return {}; },
-          observer: '_changeViewStateChanged',
-        },
-        disableDiffPrefs: {
-          type: Boolean,
-          value: false,
-        },
-        _diffPrefsDisabled: {
-          type: Boolean,
-          computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
-        },
-        /** @type {?} */
-        _patchRange: Object,
-        /** @type {?} */
-        _commitRange: Object,
-        /**
-         * @type {{
-         *  subject: string,
-         *  project: string,
-         *  revisions: string,
-         * }}
-         */
-        _change: Object,
-        /** @type {?} */
-        _changeComments: Object,
-        _changeNum: String,
-        /**
-         * This is a DiffInfo object.
-         * This is retrieved and owned by a child component.
-         */
-        _diff: Object,
-        // An array specifically formatted to be used in a gr-dropdown-list
-        // element for selected a file to view.
-        _formattedFiles: {
-          type: Array,
-          computed: '_formatFilesForDropdown(_files, ' +
-            '_patchRange.patchNum, _changeComments)',
-        },
-        // An sorted array of files, as returned by the rest API.
-        _fileList: {
-          type: Array,
-          computed: '_getSortedFileList(_files)',
-        },
-        /**
-         * Contains information about files as returned by the rest API.
-         *
-         * @type {{ sortedFileList: Array<string>, changeFilesByPath: Object }}
-         */
-        _files: {
-          type: Object,
-          value() { return {sortedFileList: [], changeFilesByPath: {}}; },
-        },
+      changeViewState: {
+        type: Object,
+        notify: true,
+        value() { return {}; },
+        observer: '_changeViewStateChanged',
+      },
+      disableDiffPrefs: {
+        type: Boolean,
+        value: false,
+      },
+      _diffPrefsDisabled: {
+        type: Boolean,
+        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+      },
+      /** @type {?} */
+      _patchRange: Object,
+      /** @type {?} */
+      _commitRange: Object,
+      /**
+       * @type {{
+       *  subject: string,
+       *  project: string,
+       *  revisions: string,
+       * }}
+       */
+      _change: Object,
+      /** @type {?} */
+      _changeComments: Object,
+      _changeNum: String,
+      /**
+       * This is a DiffInfo object.
+       * This is retrieved and owned by a child component.
+       */
+      _diff: Object,
+      // An array specifically formatted to be used in a gr-dropdown-list
+      // element for selected a file to view.
+      _formattedFiles: {
+        type: Array,
+        computed: '_formatFilesForDropdown(_files, ' +
+          '_patchRange.patchNum, _changeComments)',
+      },
+      // An sorted array of files, as returned by the rest API.
+      _fileList: {
+        type: Array,
+        computed: '_getSortedFileList(_files)',
+      },
+      /**
+       * Contains information about files as returned by the rest API.
+       *
+       * @type {{ sortedFileList: Array<string>, changeFilesByPath: Object }}
+       */
+      _files: {
+        type: Object,
+        value() { return {sortedFileList: [], changeFilesByPath: {}}; },
+      },
 
-        _path: {
-          type: String,
-          observer: '_pathChanged',
-        },
-        _fileNum: {
-          type: Number,
-          computed: '_computeFileNum(_path, _formattedFiles)',
-        },
-        _loggedIn: {
-          type: Boolean,
-          value: false,
-        },
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-        _prefs: Object,
-        _localPrefs: Object,
-        _projectConfig: Object,
-        _userPrefs: Object,
-        _diffMode: {
-          type: String,
-          computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
-        },
-        _isImageDiff: Boolean,
-        _filesWeblinks: Object,
+      _path: {
+        type: String,
+        observer: '_pathChanged',
+      },
+      _fileNum: {
+        type: Number,
+        computed: '_computeFileNum(_path, _formattedFiles)',
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _prefs: Object,
+      _localPrefs: Object,
+      _projectConfig: Object,
+      _userPrefs: Object,
+      _diffMode: {
+        type: String,
+        computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
+      },
+      _isImageDiff: Boolean,
+      _filesWeblinks: Object,
 
-        /**
-         * Map of paths in the current change and patch range that have comments
-         * or drafts or robot comments.
-         */
-        _commentMap: Object,
+      /**
+       * Map of paths in the current change and patch range that have comments
+       * or drafts or robot comments.
+       */
+      _commentMap: Object,
 
-        _commentsForDiff: Object,
+      _commentsForDiff: Object,
 
-        /**
-         * Object to contain the path of the next and previous file in the current
-         * change and patch range that has comments.
-         */
-        _commentSkips: {
-          type: Object,
-          computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
-        },
-        _panelFloatingDisabled: {
-          type: Boolean,
-          value: () => window.PANEL_FLOATING_DISABLED,
-        },
-        _editMode: {
-          type: Boolean,
-          computed: '_computeEditMode(_patchRange.*)',
-        },
-        _isBlameLoaded: Boolean,
-        _isBlameLoading: {
-          type: Boolean,
-          value: false,
-        },
-        _allPatchSets: {
-          type: Array,
-          computed: 'computeAllPatchSets(_change, _change.revisions.*)',
-        },
-        _revisionInfo: {
-          type: Object,
-          computed: '_getRevisionInfo(_change)',
-        },
-        _reviewedFiles: {
-          type: Object,
-          value: () => new Set(),
-        },
+      /**
+       * Object to contain the path of the next and previous file in the current
+       * change and patch range that has comments.
+       */
+      _commentSkips: {
+        type: Object,
+        computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
+      },
+      _panelFloatingDisabled: {
+        type: Boolean,
+        value: () => window.PANEL_FLOATING_DISABLED,
+      },
+      _editMode: {
+        type: Boolean,
+        computed: '_computeEditMode(_patchRange.*)',
+      },
+      _isBlameLoaded: Boolean,
+      _isBlameLoading: {
+        type: Boolean,
+        value: false,
+      },
+      _allPatchSets: {
+        type: Array,
+        computed: 'computeAllPatchSets(_change, _change.revisions.*)',
+      },
+      _revisionInfo: {
+        type: Object,
+        computed: '_getRevisionInfo(_change)',
+      },
+      _reviewedFiles: {
+        type: Object,
+        value: () => new Set(),
+      },
 
-        /**
-         * gr-diff-view has gr-fixed-panel on top. The panel can
-         * intersect a main element and partially hides a content of
-         * the main element. To correctly calculates visibility of an
-         * element, the cursor must know how much height occuped by a fixed
-         * panel.
-         * The scrollTopMargin defines margin occuped by fixed panel.
-         */
-        _scrollTopMargin: {
-          type: Number,
-          value: 0,
-        },
-      };
-    }
+      /**
+       * gr-diff-view has gr-fixed-panel on top. The panel can
+       * intersect a main element and partially hides a content of
+       * the main element. To correctly calculates visibility of an
+       * element, the cursor must know how much height occuped by a fixed
+       * panel.
+       * The scrollTopMargin defines margin occuped by fixed panel.
+       */
+      _scrollTopMargin: {
+        type: Number,
+        value: 0,
+      },
+    };
+  }
 
-    static get observers() {
-      return [
-        '_getProjectConfig(_change.project)',
-        '_getFiles(_changeNum, _patchRange.*, _changeComments)',
-        '_setReviewedObserver(_loggedIn, params.*, _prefs)',
-      ];
-    }
+  static get observers() {
+    return [
+      '_getProjectConfig(_change.project)',
+      '_getFiles(_changeNum, _patchRange.*, _changeComments)',
+      '_setReviewedObserver(_loggedIn, params.*, _prefs)',
+    ];
+  }
 
-    get keyBindings() {
-      return {
-        esc: '_handleEscKey',
-      };
-    }
+  get keyBindings() {
+    return {
+      esc: '_handleEscKey',
+    };
+  }
 
-    keyboardShortcuts() {
-      return {
-        [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
-        [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
-        [this.Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
-        [this.Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
-        [this.Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
-        [this.Shortcut.NEXT_FILE_WITH_COMMENTS]:
-            '_handleNextLineOrFileWithComments',
-        [this.Shortcut.PREV_FILE_WITH_COMMENTS]:
-            '_handlePrevLineOrFileWithComments',
-        [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
-        [this.Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
-        [this.Shortcut.NEXT_FILE]: '_handleNextFile',
-        [this.Shortcut.PREV_FILE]: '_handlePrevFile',
-        [this.Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
-        [this.Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
-        [this.Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
-        [this.Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
-        [this.Shortcut.OPEN_REPLY_DIALOG]:
-            '_handleOpenReplyDialogOrToggleLeftPane',
-        [this.Shortcut.TOGGLE_LEFT_PANE]:
-            '_handleOpenReplyDialogOrToggleLeftPane',
-        [this.Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
-        [this.Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
-        [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-        [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
-        [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
-        [this.Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
-        [this.Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [this.Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
+      [this.Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
+      [this.Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
+      [this.Shortcut.NEXT_FILE_WITH_COMMENTS]:
+          '_handleNextLineOrFileWithComments',
+      [this.Shortcut.PREV_FILE_WITH_COMMENTS]:
+          '_handlePrevLineOrFileWithComments',
+      [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [this.Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
+      [this.Shortcut.NEXT_FILE]: '_handleNextFile',
+      [this.Shortcut.PREV_FILE]: '_handlePrevFile',
+      [this.Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
+      [this.Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
+      [this.Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
+      [this.Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
+      [this.Shortcut.OPEN_REPLY_DIALOG]:
+          '_handleOpenReplyDialogOrToggleLeftPane',
+      [this.Shortcut.TOGGLE_LEFT_PANE]:
+          '_handleOpenReplyDialogOrToggleLeftPane',
+      [this.Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
+      [this.Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
+      [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+      [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+      [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+      [this.Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
+      [this.Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
 
-        // Final two are actually handled by gr-comment-thread.
-        [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-        [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-      };
-    }
+      // Final two are actually handled by gr-comment-thread.
+      [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+    };
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-      });
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
 
-      this.addEventListener('open-fix-preview',
-          this._onOpenFixPreview.bind(this));
-      this.$.cursor.push('diffs', this.$.diffHost);
-    }
+    this.addEventListener('open-fix-preview',
+        this._onOpenFixPreview.bind(this));
+    this.$.cursor.push('diffs', this.$.diffHost);
+  }
 
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    }
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
 
-    _getProjectConfig(project) {
-      return this.$.restAPI.getProjectConfig(project).then(
-          config => {
-            this._projectConfig = config;
-          });
-    }
-
-    _getChangeDetail(changeNum) {
-      return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
-        this._change = change;
-        return change;
-      });
-    }
-
-    _getChangeEdit(changeNum) {
-      return this.$.restAPI.getChangeEdit(this._changeNum);
-    }
-
-    _getSortedFileList(files) {
-      return files.sortedFileList;
-    }
-
-    _getFiles(changeNum, patchRangeRecord, changeComments) {
-      // Polymer 2: check for undefined
-      if ([changeNum, patchRangeRecord, patchRangeRecord.base, changeComments]
-          .some(arg => arg === undefined)) {
-        return Promise.resolve();
-      }
-
-      const patchRange = patchRangeRecord.base;
-      return this.$.restAPI.getChangeFiles(
-          changeNum, patchRange).then(changeFiles => {
-        if (!changeFiles) return;
-        const commentedPaths = changeComments.getPaths(patchRange);
-        const files = Object.assign({}, changeFiles);
-        Object.keys(commentedPaths).forEach(commentedPath => {
-          if (files.hasOwnProperty(commentedPath)) { return; }
-          files[commentedPath] = {status: 'U'};
+  _getProjectConfig(project) {
+    return this.$.restAPI.getProjectConfig(project).then(
+        config => {
+          this._projectConfig = config;
         });
-        this._files = {
-          sortedFileList: Object.keys(files).sort(this.specialFilePathCompare),
-          changeFilesByPath: files,
-        };
+  }
+
+  _getChangeDetail(changeNum) {
+    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+      this._change = change;
+      return change;
+    });
+  }
+
+  _getChangeEdit(changeNum) {
+    return this.$.restAPI.getChangeEdit(this._changeNum);
+  }
+
+  _getSortedFileList(files) {
+    return files.sortedFileList;
+  }
+
+  _getFiles(changeNum, patchRangeRecord, changeComments) {
+    // Polymer 2: check for undefined
+    if ([changeNum, patchRangeRecord, patchRangeRecord.base, changeComments]
+        .some(arg => arg === undefined)) {
+      return Promise.resolve();
+    }
+
+    const patchRange = patchRangeRecord.base;
+    return this.$.restAPI.getChangeFiles(
+        changeNum, patchRange).then(changeFiles => {
+      if (!changeFiles) return;
+      const commentedPaths = changeComments.getPaths(patchRange);
+      const files = Object.assign({}, changeFiles);
+      Object.keys(commentedPaths).forEach(commentedPath => {
+        if (files.hasOwnProperty(commentedPath)) { return; }
+        files[commentedPath] = {status: 'U'};
       });
+      this._files = {
+        sortedFileList: Object.keys(files).sort(this.specialFilePathCompare),
+        changeFilesByPath: files,
+      };
+    });
+  }
+
+  _getDiffPreferences() {
+    return this.$.restAPI.getDiffPreferences().then(prefs => {
+      this._prefs = prefs;
+    });
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  _getWindowWidth() {
+    return window.innerWidth;
+  }
+
+  _handleReviewedChange(e) {
+    this._setReviewed(dom(e).rootTarget.checked);
+  }
+
+  _setReviewed(reviewed) {
+    if (this._editMode) { return; }
+    this.$.reviewed.checked = reviewed;
+    this._saveReviewedState(reviewed).catch(err => {
+      this.fire('show-alert', {message: ERR_REVIEW_STATUS});
+      throw err;
+    });
+  }
+
+  _saveReviewedState(reviewed) {
+    return this.$.restAPI.saveFileReviewed(this._changeNum,
+        this._patchRange.patchNum, this._path, reviewed);
+  }
+
+  _handleToggleFileReviewed(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this._setReviewed(!this.$.reviewed.checked);
+  }
+
+  _handleEscKey(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this.$.diffHost.displayLine = false;
+  }
+
+  _handleLeftPane(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    e.preventDefault();
+    this.$.cursor.moveLeft();
+  }
+
+  _handleRightPane(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    e.preventDefault();
+    this.$.cursor.moveRight();
+  }
+
+  _handlePrevLineOrFileWithComments(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    if (e.detail.keyboardEvent.shiftKey &&
+        e.detail.keyboardEvent.keyCode === 75) { // 'K'
+      this._moveToPreviousFileWithComment();
+      return;
     }
+    if (this.modifierPressed(e)) { return; }
 
-    _getDiffPreferences() {
-      return this.$.restAPI.getDiffPreferences().then(prefs => {
-        this._prefs = prefs;
-      });
+    e.preventDefault();
+    this.$.diffHost.displayLine = true;
+    this.$.cursor.moveUp();
+  }
+
+  _handleVisibleLine(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    e.preventDefault();
+    this.$.cursor.moveToVisibleArea();
+  }
+
+  _onOpenFixPreview(e) {
+    this.$.applyFixDialog.open(e);
+  }
+
+  _handleNextLineOrFileWithComments(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    if (e.detail.keyboardEvent.shiftKey &&
+        e.detail.keyboardEvent.keyCode === 74) { // 'J'
+      this._moveToNextFileWithComment();
+      return;
     }
+    if (this.modifierPressed(e)) { return; }
 
-    _getPreferences() {
-      return this.$.restAPI.getPreferences();
-    }
+    e.preventDefault();
+    this.$.diffHost.displayLine = true;
+    this.$.cursor.moveDown();
+  }
 
-    _getWindowWidth() {
-      return window.innerWidth;
-    }
+  _moveToPreviousFileWithComment() {
+    if (!this._commentSkips) { return; }
 
-    _handleReviewedChange(e) {
-      this._setReviewed(Polymer.dom(e).rootTarget.checked);
-    }
-
-    _setReviewed(reviewed) {
-      if (this._editMode) { return; }
-      this.$.reviewed.checked = reviewed;
-      this._saveReviewedState(reviewed).catch(err => {
-        this.fire('show-alert', {message: ERR_REVIEW_STATUS});
-        throw err;
-      });
-    }
-
-    _saveReviewedState(reviewed) {
-      return this.$.restAPI.saveFileReviewed(this._changeNum,
-          this._patchRange.patchNum, this._path, reviewed);
-    }
-
-    _handleToggleFileReviewed(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this._setReviewed(!this.$.reviewed.checked);
-    }
-
-    _handleEscKey(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.$.diffHost.displayLine = false;
-    }
-
-    _handleLeftPane(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      e.preventDefault();
-      this.$.cursor.moveLeft();
-    }
-
-    _handleRightPane(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      e.preventDefault();
-      this.$.cursor.moveRight();
-    }
-
-    _handlePrevLineOrFileWithComments(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      if (e.detail.keyboardEvent.shiftKey &&
-          e.detail.keyboardEvent.keyCode === 75) { // 'K'
-        this._moveToPreviousFileWithComment();
-        return;
-      }
-      if (this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.$.diffHost.displayLine = true;
-      this.$.cursor.moveUp();
-    }
-
-    _handleVisibleLine(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      e.preventDefault();
-      this.$.cursor.moveToVisibleArea();
-    }
-
-    _onOpenFixPreview(e) {
-      this.$.applyFixDialog.open(e);
-    }
-
-    _handleNextLineOrFileWithComments(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      if (e.detail.keyboardEvent.shiftKey &&
-          e.detail.keyboardEvent.keyCode === 74) { // 'J'
-        this._moveToNextFileWithComment();
-        return;
-      }
-      if (this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.$.diffHost.displayLine = true;
-      this.$.cursor.moveDown();
-    }
-
-    _moveToPreviousFileWithComment() {
-      if (!this._commentSkips) { return; }
-
-      // If there is no previous diff with comments, then return to the change
-      // view.
-      if (!this._commentSkips.previous) {
-        this._navToChangeView();
-        return;
-      }
-
-      Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.previous,
-          this._patchRange.patchNum, this._patchRange.basePatchNum);
-    }
-
-    _moveToNextFileWithComment() {
-      if (!this._commentSkips) { return; }
-
-      // If there is no next diff with comments, then return to the change view.
-      if (!this._commentSkips.next) {
-        this._navToChangeView();
-        return;
-      }
-
-      Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.next,
-          this._patchRange.patchNum, this._patchRange.basePatchNum);
-    }
-
-    _handleNewComment(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-      e.preventDefault();
-      this.$.cursor.createCommentInPlace();
-    }
-
-    _handlePrevFile(e) {
-      // Check for meta key to avoid overriding native chrome shortcut.
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.getKeyboardEvent(e).metaKey) { return; }
-
-      e.preventDefault();
-      this._navToFile(this._path, this._fileList, -1);
-    }
-
-    _handleNextFile(e) {
-      // Check for meta key to avoid overriding native chrome shortcut.
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.getKeyboardEvent(e).metaKey) { return; }
-
-      e.preventDefault();
-      this._navToFile(this._path, this._fileList, 1);
-    }
-
-    _handleNextChunkOrCommentThread(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      e.preventDefault();
-      if (e.detail.keyboardEvent.shiftKey) {
-        this.$.cursor.moveToNextCommentThread();
-      } else {
-        if (this.modifierPressed(e)) { return; }
-        this.$.cursor.moveToNextChunk();
-      }
-    }
-
-    _handlePrevChunkOrCommentThread(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      e.preventDefault();
-      if (e.detail.keyboardEvent.shiftKey) {
-        this.$.cursor.moveToPreviousCommentThread();
-      } else {
-        if (this.modifierPressed(e)) { return; }
-        this.$.cursor.moveToPreviousChunk();
-      }
-    }
-
-    _handleOpenReplyDialogOrToggleLeftPane(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
-        e.preventDefault();
-        this.$.diffHost.toggleLeftDiff();
-        return;
-      }
-
-      if (this.modifierPressed(e)) { return; }
-
-      if (!this._loggedIn) { return; }
-
-      this.set('changeViewState.showReplyDialog', true);
-      e.preventDefault();
+    // If there is no previous diff with comments, then return to the change
+    // view.
+    if (!this._commentSkips.previous) {
       this._navToChangeView();
+      return;
     }
 
-    _handleUpToChange(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+    Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.previous,
+        this._patchRange.patchNum, this._patchRange.basePatchNum);
+  }
 
-      e.preventDefault();
+  _moveToNextFileWithComment() {
+    if (!this._commentSkips) { return; }
+
+    // If there is no next diff with comments, then return to the change view.
+    if (!this._commentSkips.next) {
       this._navToChangeView();
+      return;
     }
 
-    _handleCommaKey(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-      if (this._diffPrefsDisabled) { return; }
+    Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.next,
+        this._patchRange.patchNum, this._patchRange.basePatchNum);
+  }
 
+  _handleNewComment(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+    e.preventDefault();
+    this.$.cursor.createCommentInPlace();
+  }
+
+  _handlePrevFile(e) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.getKeyboardEvent(e).metaKey) { return; }
+
+    e.preventDefault();
+    this._navToFile(this._path, this._fileList, -1);
+  }
+
+  _handleNextFile(e) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.getKeyboardEvent(e).metaKey) { return; }
+
+    e.preventDefault();
+    this._navToFile(this._path, this._fileList, 1);
+  }
+
+  _handleNextChunkOrCommentThread(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    e.preventDefault();
+    if (e.detail.keyboardEvent.shiftKey) {
+      this.$.cursor.moveToNextCommentThread();
+    } else {
+      if (this.modifierPressed(e)) { return; }
+      this.$.cursor.moveToNextChunk();
+    }
+  }
+
+  _handlePrevChunkOrCommentThread(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    e.preventDefault();
+    if (e.detail.keyboardEvent.shiftKey) {
+      this.$.cursor.moveToPreviousCommentThread();
+    } else {
+      if (this.modifierPressed(e)) { return; }
+      this.$.cursor.moveToPreviousChunk();
+    }
+  }
+
+  _handleOpenReplyDialogOrToggleLeftPane(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
       e.preventDefault();
-      this.$.diffPreferencesDialog.open();
+      this.$.diffHost.toggleLeftDiff();
+      return;
     }
 
-    _handleToggleDiffMode(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+    if (this.modifierPressed(e)) { return; }
 
-      e.preventDefault();
-      if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-        this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
-      } else {
-        this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
-      }
+    if (!this._loggedIn) { return; }
+
+    this.set('changeViewState.showReplyDialog', true);
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleUpToChange(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleCommaKey(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+    if (this._diffPrefsDisabled) { return; }
+
+    e.preventDefault();
+    this.$.diffPreferencesDialog.open();
+  }
+
+  _handleToggleDiffMode(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
+    } else {
+      this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
     }
+  }
 
-    _navToChangeView() {
-      if (!this._changeNum || !this._patchRange.patchNum) { return; }
+  _navToChangeView() {
+    if (!this._changeNum || !this._patchRange.patchNum) { return; }
+    this._navigateToChange(
+        this._change,
+        this._patchRange,
+        this._change && this._change.revisions);
+  }
+
+  _navToFile(path, fileList, direction) {
+    const newPath = this._getNavLinkPath(path, fileList, direction);
+    if (!newPath) { return; }
+
+    if (newPath.up) {
       this._navigateToChange(
           this._change,
           this._patchRange,
           this._change && this._change.revisions);
+      return;
     }
 
-    _navToFile(path, fileList, direction) {
-      const newPath = this._getNavLinkPath(path, fileList, direction);
-      if (!newPath) { return; }
+    Gerrit.Nav.navigateToDiff(this._change, newPath.path,
+        this._patchRange.patchNum, this._patchRange.basePatchNum);
+  }
 
-      if (newPath.up) {
-        this._navigateToChange(
-            this._change,
-            this._patchRange,
-            this._change && this._change.revisions);
-        return;
-      }
+  /**
+   * @param {?string} path The path of the current file being shown.
+   * @param {!Array<string>} fileList The list of files in this change and
+   *     patch range.
+   * @param {number} direction Either 1 (next file) or -1 (prev file).
+   * @param {(number|boolean)} opt_noUp Whether to return to the change view
+   *     when advancing the file goes outside the bounds of fileList.
+   *
+   * @return {?string} The next URL when proceeding in the specified
+   *     direction.
+   */
+  _computeNavLinkURL(change, path, fileList, direction, opt_noUp) {
+    const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
+    if (!newPath) { return null; }
 
-      Gerrit.Nav.navigateToDiff(this._change, newPath.path,
-          this._patchRange.patchNum, this._patchRange.basePatchNum);
+    if (newPath.up) {
+      return this._getChangePath(
+          this._change,
+          this._patchRange,
+          this._change && this._change.revisions);
+    }
+    return this._getDiffUrl(this._change, this._patchRange, newPath.path);
+  }
+
+  _goToEditFile() {
+    // TODO(taoalpha): add a shortcut for editing
+    const editUrl = Gerrit.Nav.getEditUrlForDiff(
+        this._change, this._path, this._patchRange.patchNum);
+    return Gerrit.Nav.navigateToRelativeUrl(editUrl);
+  }
+
+  /**
+   * Gives an object representing the target of navigating either left or
+   * right through the change. The resulting object will have one of the
+   * following forms:
+   *   * {path: "<target file path>"} - When another file path should be the
+   *     result of the navigation.
+   *   * {up: true} - When the result of navigating should go back to the
+   *     change view.
+   *   * null - When no navigation is possible for the given direction.
+   *
+   * @param {?string} path The path of the current file being shown.
+   * @param {!Array<string>} fileList The list of files in this change and
+   *     patch range.
+   * @param {number} direction Either 1 (next file) or -1 (prev file).
+   * @param {?number|boolean=} opt_noUp Whether to return to the change view
+   *     when advancing the file goes outside the bounds of fileList.
+   * @return {?Object}
+   */
+  _getNavLinkPath(path, fileList, direction, opt_noUp) {
+    if (!path || !fileList || fileList.length === 0) { return null; }
+
+    let idx = fileList.indexOf(path);
+    if (idx === -1) {
+      const file = direction > 0 ?
+        fileList[0] :
+        fileList[fileList.length - 1];
+      return {path: file};
     }
 
-    /**
-     * @param {?string} path The path of the current file being shown.
-     * @param {!Array<string>} fileList The list of files in this change and
-     *     patch range.
-     * @param {number} direction Either 1 (next file) or -1 (prev file).
-     * @param {(number|boolean)} opt_noUp Whether to return to the change view
-     *     when advancing the file goes outside the bounds of fileList.
-     *
-     * @return {?string} The next URL when proceeding in the specified
-     *     direction.
-     */
-    _computeNavLinkURL(change, path, fileList, direction, opt_noUp) {
-      const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
-      if (!newPath) { return null; }
-
-      if (newPath.up) {
-        return this._getChangePath(
-            this._change,
-            this._patchRange,
-            this._change && this._change.revisions);
-      }
-      return this._getDiffUrl(this._change, this._patchRange, newPath.path);
+    idx += direction;
+    // Redirect to the change view if opt_noUp isn’t truthy and idx falls
+    // outside the bounds of [0, fileList.length).
+    if (idx < 0 || idx > fileList.length - 1) {
+      if (opt_noUp) { return null; }
+      return {up: true};
     }
 
-    _goToEditFile() {
-      // TODO(taoalpha): add a shortcut for editing
-      const editUrl = Gerrit.Nav.getEditUrlForDiff(
-          this._change, this._path, this._patchRange.patchNum);
-      return Gerrit.Nav.navigateToRelativeUrl(editUrl);
+    return {path: fileList[idx]};
+  }
+
+  _getReviewedFiles(changeNum, patchNum) {
+    return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
+        .then(files => {
+          this._reviewedFiles = new Set(files);
+          return this._reviewedFiles;
+        });
+  }
+
+  _getReviewedStatus(editMode, changeNum, patchNum, path) {
+    if (editMode) { return Promise.resolve(false); }
+    return this._getReviewedFiles(changeNum, patchNum)
+        .then(files => files.has(path));
+  }
+
+  _paramsChanged(value) {
+    if (value.view !== Gerrit.Nav.View.DIFF) { return; }
+
+    if (value.changeNum && value.project) {
+      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
     }
 
-    /**
-     * Gives an object representing the target of navigating either left or
-     * right through the change. The resulting object will have one of the
-     * following forms:
-     *   * {path: "<target file path>"} - When another file path should be the
-     *     result of the navigation.
-     *   * {up: true} - When the result of navigating should go back to the
-     *     change view.
-     *   * null - When no navigation is possible for the given direction.
-     *
-     * @param {?string} path The path of the current file being shown.
-     * @param {!Array<string>} fileList The list of files in this change and
-     *     patch range.
-     * @param {number} direction Either 1 (next file) or -1 (prev file).
-     * @param {?number|boolean=} opt_noUp Whether to return to the change view
-     *     when advancing the file goes outside the bounds of fileList.
-     * @return {?Object}
-     */
-    _getNavLinkPath(path, fileList, direction, opt_noUp) {
-      if (!path || !fileList || fileList.length === 0) { return null; }
+    this.$.diffHost.lineOfInterest = this._getLineOfInterest(this.params);
+    this._initCursor(this.params);
 
-      let idx = fileList.indexOf(path);
-      if (idx === -1) {
-        const file = direction > 0 ?
-          fileList[0] :
-          fileList[fileList.length - 1];
-        return {path: file};
-      }
+    this._changeNum = value.changeNum;
+    this._path = value.path;
+    this._patchRange = {
+      patchNum: value.patchNum,
+      basePatchNum: value.basePatchNum || PARENT,
+    };
 
-      idx += direction;
-      // Redirect to the change view if opt_noUp isn’t truthy and idx falls
-      // outside the bounds of [0, fileList.length).
-      if (idx < 0 || idx > fileList.length - 1) {
-        if (opt_noUp) { return null; }
-        return {up: true};
-      }
+    // NOTE: This may be called before attachment (e.g. while parentElement is
+    // null). Fire title-change in an async so that, if attachment to the DOM
+    // has been queued, the event can bubble up to the handler in gr-app.
+    this.async(() => {
+      this.fire('title-change',
+          {title: this.computeTruncatedPath(this._path)});
+    });
 
-      return {path: fileList[idx]};
+    // When navigating away from the page, there is a possibility that the
+    // patch number is no longer a part of the URL (say when navigating to
+    // the top-level change info view) and therefore undefined in `params`.
+    if (!this._patchRange.patchNum) {
+      return;
     }
 
-    _getReviewedFiles(changeNum, patchNum) {
-      return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
-          .then(files => {
-            this._reviewedFiles = new Set(files);
-            return this._reviewedFiles;
-          });
-    }
+    const promises = [];
 
-    _getReviewedStatus(editMode, changeNum, patchNum, path) {
-      if (editMode) { return Promise.resolve(false); }
-      return this._getReviewedFiles(changeNum, patchNum)
-          .then(files => files.has(path));
-    }
+    promises.push(this._getDiffPreferences());
 
-    _paramsChanged(value) {
-      if (value.view !== Gerrit.Nav.View.DIFF) { return; }
+    promises.push(this._getPreferences().then(prefs => {
+      this._userPrefs = prefs;
+    }));
 
-      if (value.changeNum && value.project) {
-        this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
-      }
-
-      this.$.diffHost.lineOfInterest = this._getLineOfInterest(this.params);
-      this._initCursor(this.params);
-
-      this._changeNum = value.changeNum;
-      this._path = value.path;
-      this._patchRange = {
-        patchNum: value.patchNum,
-        basePatchNum: value.basePatchNum || PARENT,
-      };
-
-      // NOTE: This may be called before attachment (e.g. while parentElement is
-      // null). Fire title-change in an async so that, if attachment to the DOM
-      // has been queued, the event can bubble up to the handler in gr-app.
-      this.async(() => {
-        this.fire('title-change',
-            {title: this.computeTruncatedPath(this._path)});
-      });
-
-      // When navigating away from the page, there is a possibility that the
-      // patch number is no longer a part of the URL (say when navigating to
-      // the top-level change info view) and therefore undefined in `params`.
-      if (!this._patchRange.patchNum) {
-        return;
-      }
-
-      const promises = [];
-
-      promises.push(this._getDiffPreferences());
-
-      promises.push(this._getPreferences().then(prefs => {
-        this._userPrefs = prefs;
-      }));
-
-      promises.push(this._getChangeDetail(this._changeNum).then(change => {
-        let commit;
-        let baseCommit;
-        if (change) {
-          for (const commitSha in change.revisions) {
-            if (!change.revisions.hasOwnProperty(commitSha)) continue;
-            const revision = change.revisions[commitSha];
-            const patchNum = revision._number.toString();
-            if (patchNum === this._patchRange.patchNum) {
-              commit = commitSha;
-              const commitObj = revision.commit || {};
-              const parents = commitObj.parents || [];
-              if (this._patchRange.basePatchNum === PARENT && parents.length) {
-                baseCommit = parents[parents.length - 1].commit;
-              }
-            } else if (patchNum === this._patchRange.basePatchNum) {
-              baseCommit = commitSha;
+    promises.push(this._getChangeDetail(this._changeNum).then(change => {
+      let commit;
+      let baseCommit;
+      if (change) {
+        for (const commitSha in change.revisions) {
+          if (!change.revisions.hasOwnProperty(commitSha)) continue;
+          const revision = change.revisions[commitSha];
+          const patchNum = revision._number.toString();
+          if (patchNum === this._patchRange.patchNum) {
+            commit = commitSha;
+            const commitObj = revision.commit || {};
+            const parents = commitObj.parents || [];
+            if (this._patchRange.basePatchNum === PARENT && parents.length) {
+              baseCommit = parents[parents.length - 1].commit;
             }
+          } else if (patchNum === this._patchRange.basePatchNum) {
+            baseCommit = commitSha;
           }
-          this._commitRange = {commit, baseCommit};
         }
-      }));
+        this._commitRange = {commit, baseCommit};
+      }
+    }));
 
-      promises.push(this._loadComments());
+    promises.push(this._loadComments());
 
-      promises.push(this._getChangeEdit(this._changeNum));
+    promises.push(this._getChangeEdit(this._changeNum));
 
-      this._loading = true;
-      return Promise.all(promises)
-          .then(r => {
-            const edit = r[4];
-            if (edit) {
-              this.set('_change.revisions.' + edit.commit.commit, {
-                _number: this.EDIT_NAME,
-                basePatchNum: edit.base_patch_set_number,
-                commit: edit.commit,
-              });
-            }
-            this._loading = false;
-            this.$.diffHost.comments = this._commentsForDiff;
-            return this.$.diffHost.reload(true);
-          })
-          .then(() => {
-            this.$.reporting.diffViewFullyLoaded();
-            // If diff view displayed has not ended yet, it ends here.
-            this.$.reporting.diffViewDisplayed();
-          });
-    }
-
-    _changeViewStateChanged(changeViewState) {
-      if (changeViewState.diffMode === null) {
-        // If screen size is small, always default to unified view.
-        this.$.restAPI.getPreferences().then(prefs => {
-          this.set('changeViewState.diffMode', prefs.default_diff_view);
+    this._loading = true;
+    return Promise.all(promises)
+        .then(r => {
+          const edit = r[4];
+          if (edit) {
+            this.set('_change.revisions.' + edit.commit.commit, {
+              _number: this.EDIT_NAME,
+              basePatchNum: edit.base_patch_set_number,
+              commit: edit.commit,
+            });
+          }
+          this._loading = false;
+          this.$.diffHost.comments = this._commentsForDiff;
+          return this.$.diffHost.reload(true);
+        })
+        .then(() => {
+          this.$.reporting.diffViewFullyLoaded();
+          // If diff view displayed has not ended yet, it ends here.
+          this.$.reporting.diffViewDisplayed();
         });
-      }
-    }
+  }
 
-    _setReviewedObserver(_loggedIn, paramsRecord, _prefs) {
-      // Polymer 2: check for undefined
-      if ([_loggedIn, paramsRecord, _prefs].some(arg => arg === undefined)) {
-        return;
-      }
-
-      const params = paramsRecord.base || {};
-      if (!_loggedIn) { return; }
-
-      if (_prefs.manual_review) {
-        // Checkbox state needs to be set explicitly only when manual_review
-        // is specified.
-        this._getReviewedStatus(this.editMode, this._changeNum,
-            this._patchRange.patchNum, this._path).then(status => {
-          this.$.reviewed.checked = status;
-        });
-        return;
-      }
-
-      if (params.view === Gerrit.Nav.View.DIFF) {
-        this._setReviewed(true);
-      }
-    }
-
-    /**
-     * If the params specify a diff address then configure the diff cursor.
-     */
-    _initCursor(params) {
-      if (params.lineNum === undefined) { return; }
-      if (params.leftSide) {
-        this.$.cursor.side = DiffSides.LEFT;
-      } else {
-        this.$.cursor.side = DiffSides.RIGHT;
-      }
-      this.$.cursor.initialLineNumber = params.lineNum;
-    }
-
-    _getLineOfInterest(params) {
-      // If there is a line number specified, pass it along to the diff so that
-      // it will not get collapsed.
-      if (!params.lineNum) { return null; }
-      return {number: params.lineNum, leftSide: params.leftSide};
-    }
-
-    _pathChanged(path) {
-      if (path) {
-        this.fire('title-change',
-            {title: this.computeTruncatedPath(path)});
-      }
-
-      if (this._fileList.length == 0) { return; }
-
-      this.set('changeViewState.selectedFileIndex',
-          this._fileList.indexOf(path));
-    }
-
-    _getDiffUrl(change, patchRange, path) {
-      if ([change, patchRange, path].some(arg => arg === undefined)) {
-        return '';
-      }
-      return Gerrit.Nav.getUrlForDiff(change, path, patchRange.patchNum,
-          patchRange.basePatchNum);
-    }
-
-    _patchRangeStr(patchRange) {
-      let patchStr = patchRange.patchNum;
-      if (patchRange.basePatchNum != null &&
-          patchRange.basePatchNum != PARENT) {
-        patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
-      }
-      return patchStr;
-    }
-
-    /**
-     * When the latest patch of the change is selected (and there is no base
-     * patch) then the patch range need not appear in the URL. Return a patch
-     * range object with undefined values when a range is not needed.
-     *
-     * @param {!Object} patchRange
-     * @param {!Object} revisions
-     * @return {!Object}
-     */
-    _getChangeUrlRange(patchRange, revisions) {
-      let patchNum = undefined;
-      let basePatchNum = undefined;
-      let latestPatchNum = -1;
-      for (const rev of Object.values(revisions || {})) {
-        latestPatchNum = Math.max(latestPatchNum, rev._number);
-      }
-      if (patchRange.basePatchNum !== PARENT ||
-          parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
-        patchNum = patchRange.patchNum;
-        basePatchNum = patchRange.basePatchNum;
-      }
-      return {patchNum, basePatchNum};
-    }
-
-    _getChangePath(change, patchRange, revisions) {
-      if ([change, patchRange].some(arg => arg === undefined)) {
-        return '';
-      }
-      const range = this._getChangeUrlRange(patchRange, revisions);
-      return Gerrit.Nav.getUrlForChange(change, range.patchNum,
-          range.basePatchNum);
-    }
-
-    _navigateToChange(change, patchRange, revisions) {
-      const range = this._getChangeUrlRange(patchRange, revisions);
-      Gerrit.Nav.navigateToChange(change, range.patchNum, range.basePatchNum);
-    }
-
-    _computeChangePath(change, patchRangeRecord, revisions) {
-      return this._getChangePath(change, patchRangeRecord.base, revisions);
-    }
-
-    _formatFilesForDropdown(files, patchNum, changeComments) {
-      // Polymer 2: check for undefined
-      if ([
-        files,
-        patchNum,
-        changeComments,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      if (!files) { return; }
-      const dropdownContent = [];
-      for (const path of files.sortedFileList) {
-        dropdownContent.push({
-          text: this.computeDisplayPath(path),
-          mobileText: this.computeTruncatedPath(path),
-          value: path,
-          bottomText: this._computeCommentString(changeComments, patchNum,
-              path, files.changeFilesByPath[path]),
-        });
-      }
-      return dropdownContent;
-    }
-
-    _computeCommentString(changeComments, patchNum, path, changeFileInfo) {
-      const unresolvedCount = changeComments.computeUnresolvedNum(patchNum,
-          path);
-      const commentCount = changeComments.computeCommentCount(patchNum, path);
-      const commentString = GrCountStringFormatter.computePluralString(
-          commentCount, 'comment');
-      const unresolvedString = GrCountStringFormatter.computeString(
-          unresolvedCount, 'unresolved');
-
-      const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes': '';
-
-      return [
-        unmodifiedString,
-        commentString,
-        unresolvedString]
-          .filter(v => v && v.length > 0).join(', ');
-    }
-
-    _computePrefsButtonHidden(prefs, prefsDisabled) {
-      return prefsDisabled || !prefs;
-    }
-
-    _handleFileChange(e) {
-      // This is when it gets set initially.
-      const path = e.detail.value;
-      if (path === this._path) {
-        return;
-      }
-
-      Gerrit.Nav.navigateToDiff(this._change, path, this._patchRange.patchNum,
-          this._patchRange.basePatchNum);
-    }
-
-    _handleFileTap(e) {
-      // async is needed so that that the click event is fired before the
-      // dropdown closes (This was a bug for touch devices).
-      this.async(() => {
-        this.$.dropdown.close();
-      }, 1);
-    }
-
-    _handlePatchChange(e) {
-      const {basePatchNum, patchNum} = e.detail;
-      if (this.patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
-          this.patchNumEquals(patchNum, this._patchRange.patchNum)) { return; }
-      Gerrit.Nav.navigateToDiff(
-          this._change, this._path, patchNum, basePatchNum);
-    }
-
-    _handlePrefsTap(e) {
-      e.preventDefault();
-      this.$.diffPreferencesDialog.open();
-    }
-
-    /**
-     * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
-     * the current state.
-     *
-     * The expected behavior is to use the mode specified in the user's
-     * preferences unless they have manually chosen the alternative view or they
-     * are on a mobile device. If the user navigates up to the change view, it
-     * should clear this choice and revert to the preference the next time a
-     * diff is viewed.
-     *
-     * Use side-by-side if the user is not logged in.
-     *
-     * @return {string}
-     */
-    _getDiffViewMode() {
-      if (this.changeViewState.diffMode) {
-        return this.changeViewState.diffMode;
-      } else if (this._userPrefs) {
-        this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
-        return this._userPrefs.default_diff_view;
-      } else {
-        return 'SIDE_BY_SIDE';
-      }
-    }
-
-    _computeModeSelectHideClass(isImageDiff) {
-      return isImageDiff ? 'hide' : '';
-    }
-
-    _onLineSelected(e, detail) {
-      this.$.cursor.moveToLineNumber(detail.number, detail.side);
-      if (!this._change) { return; }
-      const cursorAddress = this.$.cursor.getAddress();
-      const number = cursorAddress ? cursorAddress.number : undefined;
-      const leftSide = cursorAddress ? cursorAddress.leftSide : undefined;
-      const url = Gerrit.Nav.getUrlForDiffById(this._changeNum,
-          this._change.project, this._path, this._patchRange.patchNum,
-          this._patchRange.basePatchNum, number, leftSide);
-      history.replaceState(null, '', url);
-    }
-
-    _computeDownloadDropdownLinks(
-        project, changeNum, patchRange, path, diff) {
-      if (!patchRange || !patchRange.patchNum) { return []; }
-
-      const links = [
-        {
-          url: this._computeDownloadPatchLink(
-              project, changeNum, patchRange, path),
-          name: 'Patch',
-        },
-      ];
-
-      if (diff && diff.meta_a) {
-        let leftPath = path;
-        if (diff.change_type === 'RENAMED') {
-          leftPath = diff.meta_a.name;
-        }
-        links.push(
-            {
-              url: this._computeDownloadFileLink(
-                  project, changeNum, patchRange, leftPath, true),
-              name: 'Left Content',
-            }
-        );
-      }
-
-      if (diff && diff.meta_b) {
-        links.push(
-            {
-              url: this._computeDownloadFileLink(
-                  project, changeNum, patchRange, path, false),
-              name: 'Right Content',
-            }
-        );
-      }
-
-      return links;
-    }
-
-    _computeDownloadFileLink(
-        project, changeNum, patchRange, path, isBase) {
-      let patchNum = patchRange.patchNum;
-
-      const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
-
-      if (isBase && !comparedAgainsParent) {
-        patchNum = patchRange.basePatchNum;
-      }
-
-      let url = this.changeBaseURL(project, changeNum, patchNum) +
-          `/files/${encodeURIComponent(path)}/download`;
-
-      if (isBase && comparedAgainsParent) {
-        url += '?parent=1';
-      }
-
-      return url;
-    }
-
-    _computeDownloadPatchLink(project, changeNum, patchRange, path) {
-      let url = this.changeBaseURL(project, changeNum, patchRange.patchNum);
-      url += '/patch?zip&path=' + encodeURIComponent(path);
-      return url;
-    }
-
-    _loadComments() {
-      return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
-        this._changeComments = comments;
-        this._commentMap = this._getPaths(this._patchRange);
-
-        this._commentsForDiff = this._getCommentsForPath(this._path,
-            this._patchRange, this._projectConfig);
+  _changeViewStateChanged(changeViewState) {
+    if (changeViewState.diffMode === null) {
+      // If screen size is small, always default to unified view.
+      this.$.restAPI.getPreferences().then(prefs => {
+        this.set('changeViewState.diffMode', prefs.default_diff_view);
       });
     }
-
-    _getPaths(patchRange) {
-      return this._changeComments.getPaths(patchRange);
-    }
-
-    _getCommentsForPath(path, patchRange, projectConfig) {
-      return this._changeComments.getCommentsBySideForPath(path, patchRange,
-          projectConfig);
-    }
-
-    _getDiffDrafts() {
-      return this.$.restAPI.getDiffDrafts(this._changeNum);
-    }
-
-    _computeCommentSkips(commentMap, fileList, path) {
-      // Polymer 2: check for undefined
-      if ([
-        commentMap,
-        fileList,
-        path,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      const skips = {previous: null, next: null};
-      if (!fileList.length) { return skips; }
-      const pathIndex = fileList.indexOf(path);
-
-      // Scan backward for the previous file.
-      for (let i = pathIndex - 1; i >= 0; i--) {
-        if (commentMap[fileList[i]]) {
-          skips.previous = fileList[i];
-          break;
-        }
-      }
-
-      // Scan forward for the next file.
-      for (let i = pathIndex + 1; i < fileList.length; i++) {
-        if (commentMap[fileList[i]]) {
-          skips.next = fileList[i];
-          break;
-        }
-      }
-
-      return skips;
-    }
-
-    _computeDiffClass(panelFloatingDisabled) {
-      if (panelFloatingDisabled) {
-        return 'noOverflow';
-      }
-    }
-
-    /**
-     * @param {!Object} patchRangeRecord
-     */
-    _computeEditMode(patchRangeRecord) {
-      const patchRange = patchRangeRecord.base || {};
-      return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
-    }
-
-    /**
-     * @param {boolean} editMode
-     */
-    _computeContainerClass(editMode) {
-      return editMode ? 'editMode' : '';
-    }
-
-    _computeBlameToggleLabel(loaded, loading) {
-      if (loaded) { return 'Hide blame'; }
-      return 'Show blame';
-    }
-
-    /**
-     * Load and display blame information if it has not already been loaded.
-     * Otherwise hide it.
-     */
-    _toggleBlame() {
-      if (this._isBlameLoaded) {
-        this.$.diffHost.clearBlame();
-        return;
-      }
-
-      this._isBlameLoading = true;
-      this.fire('show-alert', {message: MSG_LOADING_BLAME});
-      this.$.diffHost.loadBlame()
-          .then(() => {
-            this._isBlameLoading = false;
-            this.fire('show-alert', {message: MSG_LOADED_BLAME});
-          })
-          .catch(() => {
-            this._isBlameLoading = false;
-          });
-    }
-
-    _handleToggleBlame(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-      this._toggleBlame();
-    }
-
-    _computeBlameLoaderClass(isImageDiff, path) {
-      return !this.isMagicPath(path) && !isImageDiff ? 'show' : '';
-    }
-
-    _getRevisionInfo(change) {
-      return new Gerrit.RevisionInfo(change);
-    }
-
-    _computeFileNum(file, files) {
-      // Polymer 2: check for undefined
-      if ([file, files].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      return files.findIndex(({value}) => value === file) + 1;
-    }
-
-    /**
-     * @param {number} fileNum
-     * @param {!Array<string>} files
-     * @return {string}
-     */
-    _computeFileNumClass(fileNum, files) {
-      if (files && fileNum > 0) {
-        return 'show';
-      }
-      return '';
-    }
-
-    _handleExpandAllDiffContext(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      this.$.diffHost.expandAllContext();
-    }
-
-    _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
-      return disableDiffPrefs || !loggedIn;
-    }
-
-    _handleNextUnreviewedFile(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      this._setReviewed(true);
-      // Ensure that the currently viewed file always appears in unreviewedFiles
-      // so we resolve the right "next" file.
-      const unreviewedFiles = this._fileList
-          .filter(file =>
-            (file === this._path || !this._reviewedFiles.has(file)));
-      this._navToFile(this._path, unreviewedFiles, 1);
-    }
-
-    _handleReloadingDiffPreference() {
-      this._getDiffPreferences();
-    }
-
-    _onChangeHeaderPanelHeightChanged(e) {
-      this._scrollTopMargin = e.detail.value;
-    }
-
-    _computeIsLoggedIn(loggedIn) {
-      return loggedIn ? true : false;
-    }
   }
 
-  customElements.define(GrDiffView.is, GrDiffView);
-})();
+  _setReviewedObserver(_loggedIn, paramsRecord, _prefs) {
+    // Polymer 2: check for undefined
+    if ([_loggedIn, paramsRecord, _prefs].some(arg => arg === undefined)) {
+      return;
+    }
+
+    const params = paramsRecord.base || {};
+    if (!_loggedIn) { return; }
+
+    if (_prefs.manual_review) {
+      // Checkbox state needs to be set explicitly only when manual_review
+      // is specified.
+      this._getReviewedStatus(this.editMode, this._changeNum,
+          this._patchRange.patchNum, this._path).then(status => {
+        this.$.reviewed.checked = status;
+      });
+      return;
+    }
+
+    if (params.view === Gerrit.Nav.View.DIFF) {
+      this._setReviewed(true);
+    }
+  }
+
+  /**
+   * If the params specify a diff address then configure the diff cursor.
+   */
+  _initCursor(params) {
+    if (params.lineNum === undefined) { return; }
+    if (params.leftSide) {
+      this.$.cursor.side = DiffSides.LEFT;
+    } else {
+      this.$.cursor.side = DiffSides.RIGHT;
+    }
+    this.$.cursor.initialLineNumber = params.lineNum;
+  }
+
+  _getLineOfInterest(params) {
+    // If there is a line number specified, pass it along to the diff so that
+    // it will not get collapsed.
+    if (!params.lineNum) { return null; }
+    return {number: params.lineNum, leftSide: params.leftSide};
+  }
+
+  _pathChanged(path) {
+    if (path) {
+      this.fire('title-change',
+          {title: this.computeTruncatedPath(path)});
+    }
+
+    if (this._fileList.length == 0) { return; }
+
+    this.set('changeViewState.selectedFileIndex',
+        this._fileList.indexOf(path));
+  }
+
+  _getDiffUrl(change, patchRange, path) {
+    if ([change, patchRange, path].some(arg => arg === undefined)) {
+      return '';
+    }
+    return Gerrit.Nav.getUrlForDiff(change, path, patchRange.patchNum,
+        patchRange.basePatchNum);
+  }
+
+  _patchRangeStr(patchRange) {
+    let patchStr = patchRange.patchNum;
+    if (patchRange.basePatchNum != null &&
+        patchRange.basePatchNum != PARENT) {
+      patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
+    }
+    return patchStr;
+  }
+
+  /**
+   * When the latest patch of the change is selected (and there is no base
+   * patch) then the patch range need not appear in the URL. Return a patch
+   * range object with undefined values when a range is not needed.
+   *
+   * @param {!Object} patchRange
+   * @param {!Object} revisions
+   * @return {!Object}
+   */
+  _getChangeUrlRange(patchRange, revisions) {
+    let patchNum = undefined;
+    let basePatchNum = undefined;
+    let latestPatchNum = -1;
+    for (const rev of Object.values(revisions || {})) {
+      latestPatchNum = Math.max(latestPatchNum, rev._number);
+    }
+    if (patchRange.basePatchNum !== PARENT ||
+        parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
+      patchNum = patchRange.patchNum;
+      basePatchNum = patchRange.basePatchNum;
+    }
+    return {patchNum, basePatchNum};
+  }
+
+  _getChangePath(change, patchRange, revisions) {
+    if ([change, patchRange].some(arg => arg === undefined)) {
+      return '';
+    }
+    const range = this._getChangeUrlRange(patchRange, revisions);
+    return Gerrit.Nav.getUrlForChange(change, range.patchNum,
+        range.basePatchNum);
+  }
+
+  _navigateToChange(change, patchRange, revisions) {
+    const range = this._getChangeUrlRange(patchRange, revisions);
+    Gerrit.Nav.navigateToChange(change, range.patchNum, range.basePatchNum);
+  }
+
+  _computeChangePath(change, patchRangeRecord, revisions) {
+    return this._getChangePath(change, patchRangeRecord.base, revisions);
+  }
+
+  _formatFilesForDropdown(files, patchNum, changeComments) {
+    // Polymer 2: check for undefined
+    if ([
+      files,
+      patchNum,
+      changeComments,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    if (!files) { return; }
+    const dropdownContent = [];
+    for (const path of files.sortedFileList) {
+      dropdownContent.push({
+        text: this.computeDisplayPath(path),
+        mobileText: this.computeTruncatedPath(path),
+        value: path,
+        bottomText: this._computeCommentString(changeComments, patchNum,
+            path, files.changeFilesByPath[path]),
+      });
+    }
+    return dropdownContent;
+  }
+
+  _computeCommentString(changeComments, patchNum, path, changeFileInfo) {
+    const unresolvedCount = changeComments.computeUnresolvedNum(patchNum,
+        path);
+    const commentCount = changeComments.computeCommentCount(patchNum, path);
+    const commentString = GrCountStringFormatter.computePluralString(
+        commentCount, 'comment');
+    const unresolvedString = GrCountStringFormatter.computeString(
+        unresolvedCount, 'unresolved');
+
+    const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes': '';
+
+    return [
+      unmodifiedString,
+      commentString,
+      unresolvedString]
+        .filter(v => v && v.length > 0).join(', ');
+  }
+
+  _computePrefsButtonHidden(prefs, prefsDisabled) {
+    return prefsDisabled || !prefs;
+  }
+
+  _handleFileChange(e) {
+    // This is when it gets set initially.
+    const path = e.detail.value;
+    if (path === this._path) {
+      return;
+    }
+
+    Gerrit.Nav.navigateToDiff(this._change, path, this._patchRange.patchNum,
+        this._patchRange.basePatchNum);
+  }
+
+  _handleFileTap(e) {
+    // async is needed so that that the click event is fired before the
+    // dropdown closes (This was a bug for touch devices).
+    this.async(() => {
+      this.$.dropdown.close();
+    }, 1);
+  }
+
+  _handlePatchChange(e) {
+    const {basePatchNum, patchNum} = e.detail;
+    if (this.patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
+        this.patchNumEquals(patchNum, this._patchRange.patchNum)) { return; }
+    Gerrit.Nav.navigateToDiff(
+        this._change, this._path, patchNum, basePatchNum);
+  }
+
+  _handlePrefsTap(e) {
+    e.preventDefault();
+    this.$.diffPreferencesDialog.open();
+  }
+
+  /**
+   * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
+   * the current state.
+   *
+   * The expected behavior is to use the mode specified in the user's
+   * preferences unless they have manually chosen the alternative view or they
+   * are on a mobile device. If the user navigates up to the change view, it
+   * should clear this choice and revert to the preference the next time a
+   * diff is viewed.
+   *
+   * Use side-by-side if the user is not logged in.
+   *
+   * @return {string}
+   */
+  _getDiffViewMode() {
+    if (this.changeViewState.diffMode) {
+      return this.changeViewState.diffMode;
+    } else if (this._userPrefs) {
+      this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
+      return this._userPrefs.default_diff_view;
+    } else {
+      return 'SIDE_BY_SIDE';
+    }
+  }
+
+  _computeModeSelectHideClass(isImageDiff) {
+    return isImageDiff ? 'hide' : '';
+  }
+
+  _onLineSelected(e, detail) {
+    this.$.cursor.moveToLineNumber(detail.number, detail.side);
+    if (!this._change) { return; }
+    const cursorAddress = this.$.cursor.getAddress();
+    const number = cursorAddress ? cursorAddress.number : undefined;
+    const leftSide = cursorAddress ? cursorAddress.leftSide : undefined;
+    const url = Gerrit.Nav.getUrlForDiffById(this._changeNum,
+        this._change.project, this._path, this._patchRange.patchNum,
+        this._patchRange.basePatchNum, number, leftSide);
+    history.replaceState(null, '', url);
+  }
+
+  _computeDownloadDropdownLinks(
+      project, changeNum, patchRange, path, diff) {
+    if (!patchRange || !patchRange.patchNum) { return []; }
+
+    const links = [
+      {
+        url: this._computeDownloadPatchLink(
+            project, changeNum, patchRange, path),
+        name: 'Patch',
+      },
+    ];
+
+    if (diff && diff.meta_a) {
+      let leftPath = path;
+      if (diff.change_type === 'RENAMED') {
+        leftPath = diff.meta_a.name;
+      }
+      links.push(
+          {
+            url: this._computeDownloadFileLink(
+                project, changeNum, patchRange, leftPath, true),
+            name: 'Left Content',
+          }
+      );
+    }
+
+    if (diff && diff.meta_b) {
+      links.push(
+          {
+            url: this._computeDownloadFileLink(
+                project, changeNum, patchRange, path, false),
+            name: 'Right Content',
+          }
+      );
+    }
+
+    return links;
+  }
+
+  _computeDownloadFileLink(
+      project, changeNum, patchRange, path, isBase) {
+    let patchNum = patchRange.patchNum;
+
+    const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
+
+    if (isBase && !comparedAgainsParent) {
+      patchNum = patchRange.basePatchNum;
+    }
+
+    let url = this.changeBaseURL(project, changeNum, patchNum) +
+        `/files/${encodeURIComponent(path)}/download`;
+
+    if (isBase && comparedAgainsParent) {
+      url += '?parent=1';
+    }
+
+    return url;
+  }
+
+  _computeDownloadPatchLink(project, changeNum, patchRange, path) {
+    let url = this.changeBaseURL(project, changeNum, patchRange.patchNum);
+    url += '/patch?zip&path=' + encodeURIComponent(path);
+    return url;
+  }
+
+  _loadComments() {
+    return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
+      this._changeComments = comments;
+      this._commentMap = this._getPaths(this._patchRange);
+
+      this._commentsForDiff = this._getCommentsForPath(this._path,
+          this._patchRange, this._projectConfig);
+    });
+  }
+
+  _getPaths(patchRange) {
+    return this._changeComments.getPaths(patchRange);
+  }
+
+  _getCommentsForPath(path, patchRange, projectConfig) {
+    return this._changeComments.getCommentsBySideForPath(path, patchRange,
+        projectConfig);
+  }
+
+  _getDiffDrafts() {
+    return this.$.restAPI.getDiffDrafts(this._changeNum);
+  }
+
+  _computeCommentSkips(commentMap, fileList, path) {
+    // Polymer 2: check for undefined
+    if ([
+      commentMap,
+      fileList,
+      path,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const skips = {previous: null, next: null};
+    if (!fileList.length) { return skips; }
+    const pathIndex = fileList.indexOf(path);
+
+    // Scan backward for the previous file.
+    for (let i = pathIndex - 1; i >= 0; i--) {
+      if (commentMap[fileList[i]]) {
+        skips.previous = fileList[i];
+        break;
+      }
+    }
+
+    // Scan forward for the next file.
+    for (let i = pathIndex + 1; i < fileList.length; i++) {
+      if (commentMap[fileList[i]]) {
+        skips.next = fileList[i];
+        break;
+      }
+    }
+
+    return skips;
+  }
+
+  _computeDiffClass(panelFloatingDisabled) {
+    if (panelFloatingDisabled) {
+      return 'noOverflow';
+    }
+  }
+
+  /**
+   * @param {!Object} patchRangeRecord
+   */
+  _computeEditMode(patchRangeRecord) {
+    const patchRange = patchRangeRecord.base || {};
+    return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
+  }
+
+  /**
+   * @param {boolean} editMode
+   */
+  _computeContainerClass(editMode) {
+    return editMode ? 'editMode' : '';
+  }
+
+  _computeBlameToggleLabel(loaded, loading) {
+    if (loaded) { return 'Hide blame'; }
+    return 'Show blame';
+  }
+
+  /**
+   * Load and display blame information if it has not already been loaded.
+   * Otherwise hide it.
+   */
+  _toggleBlame() {
+    if (this._isBlameLoaded) {
+      this.$.diffHost.clearBlame();
+      return;
+    }
+
+    this._isBlameLoading = true;
+    this.fire('show-alert', {message: MSG_LOADING_BLAME});
+    this.$.diffHost.loadBlame()
+        .then(() => {
+          this._isBlameLoading = false;
+          this.fire('show-alert', {message: MSG_LOADED_BLAME});
+        })
+        .catch(() => {
+          this._isBlameLoading = false;
+        });
+  }
+
+  _handleToggleBlame(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+      this.modifierPressed(e)) { return; }
+    this._toggleBlame();
+  }
+
+  _computeBlameLoaderClass(isImageDiff, path) {
+    return !this.isMagicPath(path) && !isImageDiff ? 'show' : '';
+  }
+
+  _getRevisionInfo(change) {
+    return new Gerrit.RevisionInfo(change);
+  }
+
+  _computeFileNum(file, files) {
+    // Polymer 2: check for undefined
+    if ([file, files].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    return files.findIndex(({value}) => value === file) + 1;
+  }
+
+  /**
+   * @param {number} fileNum
+   * @param {!Array<string>} files
+   * @return {string}
+   */
+  _computeFileNumClass(fileNum, files) {
+    if (files && fileNum > 0) {
+      return 'show';
+    }
+    return '';
+  }
+
+  _handleExpandAllDiffContext(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    this.$.diffHost.expandAllContext();
+  }
+
+  _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
+    return disableDiffPrefs || !loggedIn;
+  }
+
+  _handleNextUnreviewedFile(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    this._setReviewed(true);
+    // Ensure that the currently viewed file always appears in unreviewedFiles
+    // so we resolve the right "next" file.
+    const unreviewedFiles = this._fileList
+        .filter(file =>
+          (file === this._path || !this._reviewedFiles.has(file)));
+    this._navToFile(this._path, unreviewedFiles, 1);
+  }
+
+  _handleReloadingDiffPreference() {
+    this._getDiffPreferences();
+  }
+
+  _onChangeHeaderPanelHeightChanged(e) {
+    this._scrollTopMargin = e.detail.value;
+  }
+
+  _computeIsLoggedIn(loggedIn) {
+    return loggedIn ? true : false;
+  }
+}
+
+customElements.define(GrDiffView.is, GrDiffView);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
index 947ccbd..cf4cf92 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
@@ -1,50 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
-<link rel="import" href="../../shared/gr-dropdown-list/gr-dropdown-list.html">
-<link rel="import" href="../../shared/gr-fixed-panel/gr-fixed-panel.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../../shared/revision-info/revision-info.html">
-<link rel="import" href="../gr-comment-api/gr-comment-api.html">
-<link rel="import" href="../gr-diff-cursor/gr-diff-cursor.html">
-<link rel="import" href="../gr-apply-fix-dialog/gr-apply-fix-dialog.html">
-<link rel="import" href="../gr-diff-host/gr-diff-host.html">
-<link rel="import" href="../gr-diff-mode-selector/gr-diff-mode-selector.html">
-<link rel="import" href="../gr-diff-preferences-dialog/gr-diff-preferences-dialog.html">
-<link rel="import" href="../gr-patch-range-select/gr-patch-range-select.html">
-
-<dom-module id="gr-diff-view">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         background-color: var(--view-background-color);
@@ -225,78 +197,43 @@
         }
       }
     </style>
-    <gr-fixed-panel
-        class$="[[_computeContainerClass(_editMode)]]"
-        floating-disabled="[[_panelFloatingDisabled]]"
-        keep-on-scroll
-        ready-for-measure="[[!_loading]]"
-        on-floating-height-changed="_onChangeHeaderPanelHeightChanged"
-    >
+    <gr-fixed-panel class\$="[[_computeContainerClass(_editMode)]]" floating-disabled="[[_panelFloatingDisabled]]" keep-on-scroll="" ready-for-measure="[[!_loading]]" on-floating-height-changed="_onChangeHeaderPanelHeightChanged">
       <header>
         <div>
-          <a href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]">[[_changeNum]]</a><!--
+          <a href\$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]">[[_changeNum]]</a><!--
        --><span class="changeNumberColon">:</span>
           <span class="headerSubject">[[_change.subject]]</span>
-          <input id="reviewed"
-              class="reviewed hideOnEdit"
-              type="checkbox"
-              on-change="_handleReviewedChange"
-              hidden$="[[!_loggedIn]]" hidden><!--
+          <input id="reviewed" class="reviewed hideOnEdit" type="checkbox" on-change="_handleReviewedChange" hidden\$="[[!_loggedIn]]" hidden=""><!--
        --><div class="jumpToFileContainer">
-            <gr-dropdown-list
-                id="dropdown"
-                value="[[_path]]"
-                on-value-change="_handleFileChange"
-                items="[[_formattedFiles]]"
-                initial-count="75">
+            <gr-dropdown-list id="dropdown" value="[[_path]]" on-value-change="_handleFileChange" items="[[_formattedFiles]]" initial-count="75">
            </gr-dropdown-list>
           </div>
         </div>
         <div class="navLinks desktop">
-          <span class$="fileNum [[_computeFileNumClass(_fileNum, _formattedFiles)]]">
+          <span class\$="fileNum [[_computeFileNumClass(_fileNum, _formattedFiles)]]">
             File [[_fileNum]] of [[_formattedFiles.length]]
             <span class="separator"></span>
           </span>
-          <a class="navLink"
-              title="[[createTitle(Shortcut.PREV_FILE,
-                    ShortcutSection.NAVIGATION)]]"
-              href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]">
+          <a class="navLink" title="[[createTitle(Shortcut.PREV_FILE,
+                    ShortcutSection.NAVIGATION)]]" href\$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]">
             Prev</a>
           <span class="separator"></span>
-          <a class="navLink"
-              title="[[createTitle(Shortcut.UP_TO_CHANGE,
-                ShortcutSection.NAVIGATION)]]"
-              href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]">
+          <a class="navLink" title="[[createTitle(Shortcut.UP_TO_CHANGE,
+                ShortcutSection.NAVIGATION)]]" href\$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]">
             Up</a>
           <span class="separator"></span>
-          <a class="navLink"
-              title="[[createTitle(Shortcut.NEXT_FILE,
-                ShortcutSection.NAVIGATION)]]"
-              href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]">
+          <a class="navLink" title="[[createTitle(Shortcut.NEXT_FILE,
+                ShortcutSection.NAVIGATION)]]" href\$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]">
             Next</a>
         </div>
       </header>
       <div class="subHeader">
         <div class="patchRangeLeft">
-          <gr-patch-range-select
-              id="rangeSelect"
-              change-num="[[_changeNum]]"
-              change-comments="[[_changeComments]]"
-              patch-num="[[_patchRange.patchNum]]"
-              base-patch-num="[[_patchRange.basePatchNum]]"
-              files-weblinks="[[_filesWeblinks]]"
-              available-patches="[[_allPatchSets]]"
-              revisions="[[_change.revisions]]"
-              revision-info="[[_revisionInfo]]"
-              on-patch-range-change="_handlePatchChange">
+          <gr-patch-range-select id="rangeSelect" change-num="[[_changeNum]]" change-comments="[[_changeComments]]" patch-num="[[_patchRange.patchNum]]" base-patch-num="[[_patchRange.basePatchNum]]" files-weblinks="[[_filesWeblinks]]" available-patches="[[_allPatchSets]]" revisions="[[_change.revisions]]" revision-info="[[_revisionInfo]]" on-patch-range-change="_handlePatchChange">
           </gr-patch-range-select>
           <span class="download desktop">
             <span class="separator"></span>
-            <gr-dropdown
-                link
-                down-arrow
-                items="[[_computeDownloadDropdownLinks(_change.project, _changeNum, _patchRange, _path, _diff)]]"
-                horizontal-align="left">
+            <gr-dropdown link="" down-arrow="" items="[[_computeDownloadDropdownLinks(_change.project, _changeNum, _patchRange, _path, _diff)]]" horizontal-align="left">
               <span class="downloadTitle">
                 Download
               </span>
@@ -304,99 +241,54 @@
           </span>
         </div>
         <div class="rightControls">
-          <span class$="blameLoader [[_computeBlameLoaderClass(_isImageDiff, _path)]]">
-            <gr-button
-                link
-                id='toggleBlame'
-                title="[[createTitle(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS)]]"
-                disabled="[[_isBlameLoading]]"
-                on-click="_toggleBlame">[[_computeBlameToggleLabel(_isBlameLoaded, _isBlameLoading)]]</gr-button>
+          <span class\$="blameLoader [[_computeBlameLoaderClass(_isImageDiff, _path)]]">
+            <gr-button link="" id="toggleBlame" title="[[createTitle(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS)]]" disabled="[[_isBlameLoading]]" on-click="_toggleBlame">[[_computeBlameToggleLabel(_isBlameLoaded, _isBlameLoading)]]</gr-button>
           </span>
           <template is="dom-if" if="[[_computeIsLoggedIn(_loggedIn)]]">
             <span class="separator"></span>
             <span class="editButton">
-              <gr-button
-                link
-                title="Edit current file"
-                on-click="_goToEditFile">edit</gr-button>
+              <gr-button link="" title="Edit current file" on-click="_goToEditFile">edit</gr-button>
             </span>
           </template>
           <span class="separator"></span>
-          <div class$="diffModeSelector [[_computeModeSelectHideClass(_isImageDiff)]]">
+          <div class\$="diffModeSelector [[_computeModeSelectHideClass(_isImageDiff)]]">
             <span>Diff view:</span>
-            <gr-diff-mode-selector
-                id="modeSelect"
-                save-on-change="[[!_diffPrefsDisabled]]"
-                mode="{{changeViewState.diffMode}}"></gr-diff-mode-selector>
+            <gr-diff-mode-selector id="modeSelect" save-on-change="[[!_diffPrefsDisabled]]" mode="{{changeViewState.diffMode}}"></gr-diff-mode-selector>
           </div>
-          <span id="diffPrefsContainer"
-              hidden$="[[_computePrefsButtonHidden(_prefs, _diffPrefsDisabled)]]" hidden>
+          <span id="diffPrefsContainer" hidden\$="[[_computePrefsButtonHidden(_prefs, _diffPrefsDisabled)]]" hidden="">
             <span class="preferences desktop">
-              <gr-button
-                  link
-                  class="prefsButton"
-                  has-tooltip
-                  title="Diff preferences"
-                  on-click="_handlePrefsTap"><iron-icon icon="gr-icons:settings"></iron-icon></gr-button>
+              <gr-button link="" class="prefsButton" has-tooltip="" title="Diff preferences" on-click="_handlePrefsTap"><iron-icon icon="gr-icons:settings"></iron-icon></gr-button>
             </span>
           </span>
           <gr-endpoint-decorator name="annotation-toggler">
-            <span hidden id="annotation-span">
+            <span hidden="" id="annotation-span">
               <label for="annotation-checkbox" id="annotation-label"></label>
-              <iron-input type="checkbox" disabled>
-                <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
+              <iron-input type="checkbox" disabled="">
+                <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled="">
               </iron-input>
             </span>
           </gr-endpoint-decorator>
         </div>
       </div>
       <div class="fileNav mobile">
-        <a class="mobileNavLink"
-          href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]">
+        <a class="mobileNavLink" href\$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]">
           &lt;</a>
         <div class="fullFileName mobile">[[computeDisplayPath(_path)]]
         </div>
-        <a class="mobileNavLink"
-            href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]">
+        <a class="mobileNavLink" href\$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]">
           &gt;</a>
       </div>
     </gr-fixed-panel>
-    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-    <gr-diff-host
-        id="diffHost"
-        hidden
-        hidden$="[[_loading]]"
-        class$="[[_computeDiffClass(_panelFloatingDisabled)]]"
-        is-image-diff="{{_isImageDiff}}"
-        files-weblinks="{{_filesWeblinks}}"
-        diff="{{_diff}}"
-        change-num="[[_changeNum]]"
-        commit-range="[[_commitRange]]"
-        patch-range="[[_patchRange]]"
-        path="[[_path]]"
-        prefs="[[_prefs]]"
-        project-name="[[_change.project]]"
-        view-mode="[[_diffMode]]"
-        is-blame-loaded="{{_isBlameLoaded}}"
-        on-comment-anchor-tap="_onLineSelected"
-        on-line-selected="_onLineSelected">
+    <div class="loading" hidden\$="[[!_loading]]">Loading...</div>
+    <gr-diff-host id="diffHost" hidden="" hidden\$="[[_loading]]" class\$="[[_computeDiffClass(_panelFloatingDisabled)]]" is-image-diff="{{_isImageDiff}}" files-weblinks="{{_filesWeblinks}}" diff="{{_diff}}" change-num="[[_changeNum]]" commit-range="[[_commitRange]]" patch-range="[[_patchRange]]" path="[[_path]]" prefs="[[_prefs]]" project-name="[[_change.project]]" view-mode="[[_diffMode]]" is-blame-loaded="{{_isBlameLoaded}}" on-comment-anchor-tap="_onLineSelected" on-line-selected="_onLineSelected">
     </gr-diff-host>
-    <gr-apply-fix-dialog
-      id="applyFixDialog"
-      prefs="[[_prefs]]"
-      change="[[_change]]"
-      change-num="[[_changeNum]]">
+    <gr-apply-fix-dialog id="applyFixDialog" prefs="[[_prefs]]" change="[[_change]]" change-num="[[_changeNum]]">
     </gr-apply-fix-dialog>
-    <gr-diff-preferences-dialog
-        id="diffPreferencesDialog"
-        diff-prefs="{{_prefs}}"
-        on-reload-diff-preference="_handleReloadingDiffPreference">
+    <gr-diff-preferences-dialog id="diffPreferencesDialog" diff-prefs="{{_prefs}}" on-reload-diff-preference="_handleReloadingDiffPreference">
     </gr-diff-preferences-dialog>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
     <gr-diff-cursor id="cursor" scroll-top-margin="[[_scrollTopMargin]]"></gr-diff-cursor>
     <gr-comment-api id="commentAPI"></gr-comment-api>
     <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-diff-view.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index a992a6e..35aa664 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -19,18 +19,24 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script src="/node_modules/page/page.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-diff-view.html">
+<script type="module" src="./gr-diff-view.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-diff-view.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -44,102 +50,533 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-view tests', async () => {
-    await readyToTest();
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-diff-view.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-diff-view tests', () => {
+  suite('basic tests', () => {
+    const kb = window.Gerrit.KeyboardShortcutBinder;
+    kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
+    kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
+    kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
+    kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
+    kb.bindShortcut(kb.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+    kb.bindShortcut(kb.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+    kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
+    kb.bindShortcut(kb.Shortcut.SAVE_COMMENT, 'ctrl+s');
+    kb.bindShortcut(kb.Shortcut.NEXT_FILE, ']');
+    kb.bindShortcut(kb.Shortcut.PREV_FILE, '[');
+    kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
+    kb.bindShortcut(kb.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+    kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
+    kb.bindShortcut(kb.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+    kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+    kb.bindShortcut(kb.Shortcut.UP_TO_CHANGE, 'u');
+    kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
+    kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
+    kb.bindShortcut(kb.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_BLAME, 'b');
 
-    suite('basic tests', async () => {
-      const kb = window.Gerrit.KeyboardShortcutBinder;
-      kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
-      kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
-      kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
-      kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
-      kb.bindShortcut(kb.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-      kb.bindShortcut(kb.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-      kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
-      kb.bindShortcut(kb.Shortcut.SAVE_COMMENT, 'ctrl+s');
-      kb.bindShortcut(kb.Shortcut.NEXT_FILE, ']');
-      kb.bindShortcut(kb.Shortcut.PREV_FILE, '[');
-      kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
-      kb.bindShortcut(kb.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-      kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
-      kb.bindShortcut(kb.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-      kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
-      kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-      kb.bindShortcut(kb.Shortcut.UP_TO_CHANGE, 'u');
-      kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
-      kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
-      kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-      kb.bindShortcut(kb.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
-      kb.bindShortcut(kb.Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
-      kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
-      kb.bindShortcut(kb.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
-      kb.bindShortcut(kb.Shortcut.TOGGLE_BLAME, 'b');
+    let element;
+    let sandbox;
 
-      let element;
-      let sandbox;
+    const PARENT = 'PARENT';
 
-      const PARENT = 'PARENT';
+    function getFilesFromFileList(fileList) {
+      const changeFilesByPath = fileList.reduce((files, path) => {
+        files[path] = {};
+        return files;
+      }, {});
+      return {
+        sortedFileList: fileList,
+        changeFilesByPath,
+      };
+    }
 
-      function getFilesFromFileList(fileList) {
-        const changeFilesByPath = fileList.reduce((files, path) => {
-          files[path] = {};
-          return files;
-        }, {});
-        return {
-          sortedFileList: fileList,
-          changeFilesByPath,
-        };
-      }
+    setup(() => {
+      sandbox = sinon.sandbox.create();
 
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({change: {}}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getProjectConfig() { return Promise.resolve({}); },
+        getDiffChangeDetail() { return Promise.resolve({}); },
+        getChangeFiles() { return Promise.resolve({}); },
+        saveFileReviewed() { return Promise.resolve(); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+        getReviewedFiles() { return Promise.resolve([]); },
+      });
+      element = fixture('basic');
+      return element._loadComments();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('params change triggers diffViewDisplayed()', () => {
+      sandbox.stub(element.$.reporting, 'diffViewDisplayed');
+      sandbox.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+      sandbox.spy(element, '_paramsChanged');
+      element.params = {
+        view: Gerrit.Nav.View.DIFF,
+        changeNum: '42',
+        patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
+      };
+
+      return element._paramsChanged.returnValues[0].then(() => {
+        assert.isTrue(element.$.reporting.diffViewDisplayed.calledOnce);
+      });
+    });
+
+    test('toggle left diff with a hotkey', () => {
+      const toggleLeftDiffStub = sandbox.stub(
+          element.$.diffHost, 'toggleLeftDiff');
+      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+      assert.isTrue(toggleLeftDiffStub.calledOnce);
+    });
+
+    test('keyboard shortcuts', () => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: '10',
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 10, commit: {parents: []}},
+        },
+      };
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
+      element._path = 'glados.txt';
+      element.changeViewState.selectedFileIndex = 1;
+      element._loggedIn = true;
+
+      const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert(changeNavStub.lastCall.calledWith(element._change),
+          'Should navigate to /c/42/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
+      assert(diffNavStub.lastCall.calledWith(element._change, 'wheatley.md',
+          '10', PARENT), 'Should navigate to /c/42/10/wheatley.md');
+      element._path = 'wheatley.md';
+      assert.equal(element.changeViewState.selectedFileIndex, 2);
+      assert.isTrue(element._loading);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(diffNavStub.lastCall.calledWith(element._change, 'glados.txt',
+          '10', PARENT), 'Should navigate to /c/42/10/glados.txt');
+      element._path = 'glados.txt';
+      assert.equal(element.changeViewState.selectedFileIndex, 1);
+      assert.isTrue(element._loading);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', '10',
+          PARENT), 'Should navigate to /c/42/10/chell.go');
+      element._path = 'chell.go';
+      assert.equal(element.changeViewState.selectedFileIndex, 0);
+      assert.isTrue(element._loading);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(changeNavStub.lastCall.calledWith(element._change),
+          'Should navigate to /c/42/');
+      assert.equal(element.changeViewState.selectedFileIndex, 0);
+      assert.isTrue(element._loading);
+
+      const showPrefsStub =
+          sandbox.stub(element.$.diffPreferencesDialog, 'open',
+              () => Promise.resolve());
+
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert(showPrefsStub.calledOnce);
+
+      element.disableDiffPrefs = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert(showPrefsStub.calledOnce);
+
+      let scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sandbox.stub(element.$.cursor, 'moveToPreviousChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sandbox.stub(element.$.cursor, 'moveToNextCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sandbox.stub(element.$.cursor,
+          'moveToPreviousCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
+      assert(scrollStub.calledOnce);
+
+      const computeContainerClassStub = sandbox.stub(element.$.diffHost.$.diff,
+          '_computeContainerClass');
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert(computeContainerClassStub.lastCall.calledWithExactly(
+          false, 'SIDE_BY_SIDE', true));
+
+      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+      assert(computeContainerClassStub.lastCall.calledWithExactly(
+          false, 'SIDE_BY_SIDE', false));
+
+      sandbox.stub(element, '_setReviewed');
+      element.$.reviewed.checked = false;
+      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      assert.isFalse(element._setReviewed.called);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      assert.isTrue(element._setReviewed.called);
+      assert.equal(element._setReviewed.lastCall.args[0], true);
+    });
+
+    test('shift+x shortcut expands all diff context', () => {
+      const expandStub = sandbox.stub(element.$.diffHost, 'expandAllContext');
+      MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
+      flushAsynchronousOperations();
+      assert.isTrue(expandStub.called);
+    });
+
+    test('keyboard shortcuts with patch range', () => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '5',
+        patchNum: '10',
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 10, commit: {parents: []}},
+          b: {_number: 5, commit: {parents: []}},
+        },
+      };
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
+      element._path = 'glados.txt';
+
+      const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
+          'should only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
+          '5'), 'Should navigate to /c/42/5..10');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
+          '5'), 'Should navigate to /c/42/5..10');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
+      assert.isTrue(element._loading);
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'wheatley.md', '10', '5'),
+      'Should navigate to /c/42/5..10/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert.isTrue(element._loading);
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'glados.txt', '10', '5'),
+      'Should navigate to /c/42/5..10/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert.isTrue(element._loading);
+      assert(diffNavStub.lastCall.calledWithExactly(
+          element._change,
+          'chell.go',
+          '10',
+          '5'),
+      'Should navigate to /c/42/5..10/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert.isTrue(element._loading);
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
+          '5'),
+      'Should navigate to /c/42/5..10');
+    });
+
+    test('keyboard shortcuts with old patch number', () => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: '1',
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 1, commit: {parents: []}},
+          b: {_number: 2, commit: {parents: []}},
+        },
+      };
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
+      element._path = 'glados.txt';
+
+      const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
+          'should only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+          PARENT), 'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+          PARENT), 'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'wheatley.md', '1', PARENT),
+      'Should navigate to /c/42/1/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'glados.txt', '1', PARENT),
+      'Should navigate to /c/42/1/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(diffNavStub.lastCall.calledWithExactly(
+          element._change,
+          'chell.go',
+          '1',
+          PARENT), 'Should navigate to /c/42/1/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+          PARENT), 'Should navigate to /c/42/1');
+    });
+
+    test('edit should redirect to edit page', done => {
+      element._loggedIn = true;
+      element._path = 't.txt';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: '1',
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 1, commit: {parents: []}},
+          b: {_number: 2, commit: {parents: []}},
+        },
+      };
+      const redirectStub = sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
+      flush(() => {
+        const editBtn = element.shadowRoot
+            .querySelector('.editButton gr-button');
+        assert.isTrue(!!editBtn);
+        MockInteractions.tap(editBtn);
+        assert.isTrue(redirectStub.called);
+        done();
+      });
+    });
+
+    test('edit hidden when not logged in', done => {
+      element._loggedIn = false;
+      element._path = 't.txt';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: '1',
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 1, commit: {parents: []}},
+          b: {_number: 2, commit: {parents: []}},
+        },
+      };
+      flush(() => {
+        const editBtn = element.shadowRoot
+            .querySelector('.editButton gr-button');
+        assert.isFalse(!!editBtn);
+        done();
+      });
+    });
+
+    suite('diff prefs hidden', () => {
+      test('when no prefs or logged out', () => {
+        element.disableDiffPrefs = false;
+        element._loggedIn = false;
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+        element._loggedIn = true;
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+        element._loggedIn = false;
+        element._prefs = {font_size: '12'};
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+        element._loggedIn = true;
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.diffPrefsContainer.hidden);
+      });
+
+      test('when disableDiffPrefs is set', () => {
+        element._loggedIn = true;
+        element._prefs = {font_size: '12'};
+        element.disableDiffPrefs = false;
+        flushAsynchronousOperations();
+
+        assert.isFalse(element.$.diffPrefsContainer.hidden);
+        element.disableDiffPrefs = true;
+        flushAsynchronousOperations();
+
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+      });
+    });
+
+    test('prefsButton opens gr-diff-preferences', () => {
+      const handlePrefsTapSpy = sandbox.spy(element, '_handlePrefsTap');
+      const overlayOpenStub = sandbox.stub(element.$.diffPreferencesDialog,
+          'open');
+      const prefsButton =
+          dom(element.root).querySelector('.prefsButton');
+
+      MockInteractions.tap(prefsButton);
+
+      assert.isTrue(handlePrefsTapSpy.called);
+      assert.isTrue(overlayOpenStub.called);
+    });
+
+    test('_computeCommentString', done => {
+      const path = '/test';
+      element.$.commentAPI.loadAll().then(comments => {
+        const commentCountStub =
+            sandbox.stub(comments, 'computeCommentCount');
+        const unresolvedCountStub =
+            sandbox.stub(comments, 'computeUnresolvedNum');
+        commentCountStub.withArgs(1, path).returns(0);
+        commentCountStub.withArgs(2, path).returns(1);
+        commentCountStub.withArgs(3, path).returns(2);
+        commentCountStub.withArgs(4, path).returns(0);
+        unresolvedCountStub.withArgs(1, path).returns(1);
+        unresolvedCountStub.withArgs(2, path).returns(0);
+        unresolvedCountStub.withArgs(3, path).returns(2);
+        unresolvedCountStub.withArgs(4, path).returns(0);
+
+        assert.equal(element._computeCommentString(comments, 1, path, {}),
+            '1 unresolved');
+        assert.equal(
+            element._computeCommentString(comments, 2, path, {status: 'M'}),
+            '1 comment');
+        assert.equal(
+            element._computeCommentString(comments, 2, path, {status: 'U'}),
+            'no changes, 1 comment');
+        assert.equal(
+            element._computeCommentString(comments, 3, path, {status: 'A'}),
+            '2 comments, 2 unresolved');
+        assert.equal(
+            element._computeCommentString(
+                comments, 4, path, {status: 'M'}
+            ), '');
+        assert.equal(
+            element._computeCommentString(comments, 4, path, {status: 'U'}),
+            'no changes');
+        done();
+      });
+    });
+
+    suite('url params', () => {
       setup(() => {
-        sandbox = sinon.sandbox.create();
-
-        stub('gr-rest-api-interface', {
-          getConfig() { return Promise.resolve({change: {}}); },
-          getLoggedIn() { return Promise.resolve(false); },
-          getProjectConfig() { return Promise.resolve({}); },
-          getDiffChangeDetail() { return Promise.resolve({}); },
-          getChangeFiles() { return Promise.resolve({}); },
-          saveFileReviewed() { return Promise.resolve(); },
-          getDiffComments() { return Promise.resolve({}); },
-          getDiffRobotComments() { return Promise.resolve({}); },
-          getDiffDrafts() { return Promise.resolve({}); },
-          getReviewedFiles() { return Promise.resolve([]); },
-        });
-        element = fixture('basic');
-        return element._loadComments();
+        sandbox.stub(
+            Gerrit.Nav,
+            'getUrlForDiff',
+            (c, p, pn, bpn) => `${c._number}-${p}-${pn}-${bpn}`);
+        sandbox.stub(
+            Gerrit.Nav
+            , 'getUrlForChange',
+            (c, pn, bpn) => `${c._number}-${pn}-${bpn}`);
       });
 
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      test('params change triggers diffViewDisplayed()', () => {
-        sandbox.stub(element.$.reporting, 'diffViewDisplayed');
-        sandbox.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-        sandbox.spy(element, '_paramsChanged');
-        element.params = {
-          view: Gerrit.Nav.View.DIFF,
-          changeNum: '42',
-          patchNum: '2',
-          basePatchNum: '1',
-          path: '/COMMIT_MSG',
+      test('_formattedFiles', () => {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: PARENT,
+          patchNum: '10',
         };
+        element._change = {_number: 42};
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md',
+              '/COMMIT_MSG', '/MERGE_LIST']);
+        element._path = 'glados.txt';
+        const expectedFormattedFiles = [
+          {
+            text: 'chell.go',
+            mobileText: 'chell.go',
+            value: 'chell.go',
+            bottomText: '',
+          }, {
+            text: 'glados.txt',
+            mobileText: 'glados.txt',
+            value: 'glados.txt',
+            bottomText: '',
+          }, {
+            text: 'wheatley.md',
+            mobileText: 'wheatley.md',
+            value: 'wheatley.md',
+            bottomText: '',
+          },
+          {
+            text: 'Commit message',
+            mobileText: 'Commit message',
+            value: '/COMMIT_MSG',
+            bottomText: '',
+          },
+          {
+            text: 'Merge list',
+            mobileText: 'Merge list',
+            value: '/MERGE_LIST',
+            bottomText: '',
+          },
+        ];
 
-        return element._paramsChanged.returnValues[0].then(() => {
-          assert.isTrue(element.$.reporting.diffViewDisplayed.calledOnce);
-        });
+        assert.deepEqual(element._formattedFiles, expectedFormattedFiles);
+        assert.equal(element._formattedFiles[1].value, element._path);
       });
 
-      test('toggle left diff with a hotkey', () => {
-        const toggleLeftDiffStub = sandbox.stub(
-            element.$.diffHost, 'toggleLeftDiff');
-        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-        assert.isTrue(toggleLeftDiffStub.calledOnce);
-      });
-
-      test('keyboard shortcuts', () => {
+      test('prev/up/next links', () => {
         element._changeNum = '42';
         element._patchRange = {
           basePatchNum: PARENT,
@@ -154,99 +591,34 @@
         element._files = getFilesFromFileList(
             ['chell.go', 'glados.txt', 'wheatley.md']);
         element._path = 'glados.txt';
-        element.changeViewState.selectedFileIndex = 1;
-        element._loggedIn = true;
-
-        const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-        const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-        assert(changeNavStub.lastCall.calledWith(element._change),
-            'Should navigate to /c/42/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-        assert(diffNavStub.lastCall.calledWith(element._change, 'wheatley.md',
-            '10', PARENT), 'Should navigate to /c/42/10/wheatley.md');
-        element._path = 'wheatley.md';
-        assert.equal(element.changeViewState.selectedFileIndex, 2);
-        assert.isTrue(element._loading);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-        assert(diffNavStub.lastCall.calledWith(element._change, 'glados.txt',
-            '10', PARENT), 'Should navigate to /c/42/10/glados.txt');
-        element._path = 'glados.txt';
-        assert.equal(element.changeViewState.selectedFileIndex, 1);
-        assert.isTrue(element._loading);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-        assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', '10',
-            PARENT), 'Should navigate to /c/42/10/chell.go');
-        element._path = 'chell.go';
-        assert.equal(element.changeViewState.selectedFileIndex, 0);
-        assert.isTrue(element._loading);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-        assert(changeNavStub.lastCall.calledWith(element._change),
-            'Should navigate to /c/42/');
-        assert.equal(element.changeViewState.selectedFileIndex, 0);
-        assert.isTrue(element._loading);
-
-        const showPrefsStub =
-            sandbox.stub(element.$.diffPreferencesDialog, 'open',
-                () => Promise.resolve());
-
-        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-        assert(showPrefsStub.calledOnce);
-
-        element.disableDiffPrefs = true;
-        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-        assert(showPrefsStub.calledOnce);
-
-        let scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert(scrollStub.calledOnce);
-
-        scrollStub = sandbox.stub(element.$.cursor, 'moveToPreviousChunk');
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-        assert(scrollStub.calledOnce);
-
-        scrollStub = sandbox.stub(element.$.cursor, 'moveToNextCommentThread');
-        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-        assert(scrollStub.calledOnce);
-
-        scrollStub = sandbox.stub(element.$.cursor,
-            'moveToPreviousCommentThread');
-        MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
-        assert(scrollStub.calledOnce);
-
-        const computeContainerClassStub = sandbox.stub(element.$.diffHost.$.diff,
-            '_computeContainerClass');
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-        assert(computeContainerClassStub.lastCall.calledWithExactly(
-            false, 'SIDE_BY_SIDE', true));
-
-        MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
-        assert(computeContainerClassStub.lastCall.calledWithExactly(
-            false, 'SIDE_BY_SIDE', false));
-
-        sandbox.stub(element, '_setReviewed');
-        element.$.reviewed.checked = false;
-        MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-        assert.isFalse(element._setReviewed.called);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.isTrue(element._setReviewed.called);
-        assert.equal(element._setReviewed.lastCall.args[0], true);
-      });
-
-      test('shift+x shortcut expands all diff context', () => {
-        const expandStub = sandbox.stub(element.$.diffHost, 'expandAllContext');
-        MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
         flushAsynchronousOperations();
-        assert.isTrue(expandStub.called);
+        const linkEls = dom(element.root).querySelectorAll('.navLink');
+        assert.equal(linkEls.length, 3);
+        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-PARENT');
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.equal(linkEls[2].getAttribute('href'),
+            '42-wheatley.md-10-PARENT');
+        element._path = 'wheatley.md';
+        flushAsynchronousOperations();
+        assert.equal(linkEls[0].getAttribute('href'),
+            '42-glados.txt-10-PARENT');
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.isFalse(linkEls[2].hasAttribute('href'));
+        element._path = 'chell.go';
+        flushAsynchronousOperations();
+        assert.isFalse(linkEls[0].hasAttribute('href'));
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.equal(linkEls[2].getAttribute('href'),
+            '42-glados.txt-10-PARENT');
+        element._path = 'not_a_real_file';
+        flushAsynchronousOperations();
+        assert.equal(linkEls[0].getAttribute('href'),
+            '42-wheatley.md-10-PARENT');
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.equal(linkEls[2].getAttribute('href'), '42-chell.go-10-PARENT');
       });
 
-      test('keyboard shortcuts with patch range', () => {
+      test('prev/up/next links with patch range', () => {
         element._changeNum = '42';
         element._patchRange = {
           basePatchNum: '5',
@@ -255,1168 +627,805 @@
         element._change = {
           _number: 42,
           revisions: {
-            a: {_number: 10, commit: {parents: []}},
-            b: {_number: 5, commit: {parents: []}},
+            a: {_number: 5, commit: {parents: []}},
+            b: {_number: 10, commit: {parents: []}},
           },
         };
         element._files = getFilesFromFileList(
             ['chell.go', 'glados.txt', 'wheatley.md']);
         element._path = 'glados.txt';
-
-        const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-        const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
-            'should only work when the user is logged in.');
-        assert.isNull(window.sessionStorage.getItem(
-            'changeView.showReplyDialog'));
-
-        element._loggedIn = true;
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        assert.isTrue(element.changeViewState.showReplyDialog);
-
-        assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
-            '5'), 'Should navigate to /c/42/5..10');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-        assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
-            '5'), 'Should navigate to /c/42/5..10');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-        assert.isTrue(element._loading);
-        assert(diffNavStub.lastCall.calledWithExactly(element._change,
-            'wheatley.md', '10', '5'),
-        'Should navigate to /c/42/5..10/wheatley.md');
+        flushAsynchronousOperations();
+        const linkEls = dom(element.root).querySelectorAll('.navLink');
+        assert.equal(linkEls.length, 3);
+        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-5');
+        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
+        assert.equal(linkEls[2].getAttribute('href'), '42-wheatley.md-10-5');
         element._path = 'wheatley.md';
-
-        MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-        assert.isTrue(element._loading);
-        assert(diffNavStub.lastCall.calledWithExactly(element._change,
-            'glados.txt', '10', '5'),
-        'Should navigate to /c/42/5..10/glados.txt');
-        element._path = 'glados.txt';
-
-        MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-        assert.isTrue(element._loading);
-        assert(diffNavStub.lastCall.calledWithExactly(
-            element._change,
-            'chell.go',
-            '10',
-            '5'),
-        'Should navigate to /c/42/5..10/chell.go');
+        flushAsynchronousOperations();
+        assert.equal(linkEls[0].getAttribute('href'), '42-glados.txt-10-5');
+        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
+        assert.isFalse(linkEls[2].hasAttribute('href'));
         element._path = 'chell.go';
-
-        MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-        assert.isTrue(element._loading);
-        assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
-            '5'),
-        'Should navigate to /c/42/5..10');
-      });
-
-      test('keyboard shortcuts with old patch number', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: '1',
-        };
-        element._change = {
-          _number: 42,
-          revisions: {
-            a: {_number: 1, commit: {parents: []}},
-            b: {_number: 2, commit: {parents: []}},
-          },
-        };
-        element._files = getFilesFromFileList(
-            ['chell.go', 'glados.txt', 'wheatley.md']);
-        element._path = 'glados.txt';
-
-        const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-        const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
-            'should only work when the user is logged in.');
-        assert.isNull(window.sessionStorage.getItem(
-            'changeView.showReplyDialog'));
-
-        element._loggedIn = true;
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        assert.isTrue(element.changeViewState.showReplyDialog);
-
-        assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
-            PARENT), 'Should navigate to /c/42/1');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-        assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
-            PARENT), 'Should navigate to /c/42/1');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-        assert(diffNavStub.lastCall.calledWithExactly(element._change,
-            'wheatley.md', '1', PARENT),
-        'Should navigate to /c/42/1/wheatley.md');
-        element._path = 'wheatley.md';
-
-        MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-        assert(diffNavStub.lastCall.calledWithExactly(element._change,
-            'glados.txt', '1', PARENT),
-        'Should navigate to /c/42/1/glados.txt');
-        element._path = 'glados.txt';
-
-        MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-        assert(diffNavStub.lastCall.calledWithExactly(
-            element._change,
-            'chell.go',
-            '1',
-            PARENT), 'Should navigate to /c/42/1/chell.go');
-        element._path = 'chell.go';
-
-        MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-        assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
-            PARENT), 'Should navigate to /c/42/1');
-      });
-
-      test('edit should redirect to edit page', done => {
-        element._loggedIn = true;
-        element._path = 't.txt';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: '1',
-        };
-        element._change = {
-          _number: 42,
-          revisions: {
-            a: {_number: 1, commit: {parents: []}},
-            b: {_number: 2, commit: {parents: []}},
-          },
-        };
-        const redirectStub = sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
-        flush(() => {
-          const editBtn = element.shadowRoot
-              .querySelector('.editButton gr-button');
-          assert.isTrue(!!editBtn);
-          MockInteractions.tap(editBtn);
-          assert.isTrue(redirectStub.called);
-          done();
-        });
-      });
-
-      test('edit hidden when not logged in', done => {
-        element._loggedIn = false;
-        element._path = 't.txt';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: '1',
-        };
-        element._change = {
-          _number: 42,
-          revisions: {
-            a: {_number: 1, commit: {parents: []}},
-            b: {_number: 2, commit: {parents: []}},
-          },
-        };
-        flush(() => {
-          const editBtn = element.shadowRoot
-              .querySelector('.editButton gr-button');
-          assert.isFalse(!!editBtn);
-          done();
-        });
-      });
-
-      suite('diff prefs hidden', () => {
-        test('when no prefs or logged out', () => {
-          element.disableDiffPrefs = false;
-          element._loggedIn = false;
-          flushAsynchronousOperations();
-          assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-          element._loggedIn = true;
-          flushAsynchronousOperations();
-          assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-          element._loggedIn = false;
-          element._prefs = {font_size: '12'};
-          flushAsynchronousOperations();
-          assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-          element._loggedIn = true;
-          flushAsynchronousOperations();
-          assert.isFalse(element.$.diffPrefsContainer.hidden);
-        });
-
-        test('when disableDiffPrefs is set', () => {
-          element._loggedIn = true;
-          element._prefs = {font_size: '12'};
-          element.disableDiffPrefs = false;
-          flushAsynchronousOperations();
-
-          assert.isFalse(element.$.diffPrefsContainer.hidden);
-          element.disableDiffPrefs = true;
-          flushAsynchronousOperations();
-
-          assert.isTrue(element.$.diffPrefsContainer.hidden);
-        });
-      });
-
-      test('prefsButton opens gr-diff-preferences', () => {
-        const handlePrefsTapSpy = sandbox.spy(element, '_handlePrefsTap');
-        const overlayOpenStub = sandbox.stub(element.$.diffPreferencesDialog,
-            'open');
-        const prefsButton =
-            Polymer.dom(element.root).querySelector('.prefsButton');
-
-        MockInteractions.tap(prefsButton);
-
-        assert.isTrue(handlePrefsTapSpy.called);
-        assert.isTrue(overlayOpenStub.called);
-      });
-
-      test('_computeCommentString', done => {
-        const path = '/test';
-        element.$.commentAPI.loadAll().then(comments => {
-          const commentCountStub =
-              sandbox.stub(comments, 'computeCommentCount');
-          const unresolvedCountStub =
-              sandbox.stub(comments, 'computeUnresolvedNum');
-          commentCountStub.withArgs(1, path).returns(0);
-          commentCountStub.withArgs(2, path).returns(1);
-          commentCountStub.withArgs(3, path).returns(2);
-          commentCountStub.withArgs(4, path).returns(0);
-          unresolvedCountStub.withArgs(1, path).returns(1);
-          unresolvedCountStub.withArgs(2, path).returns(0);
-          unresolvedCountStub.withArgs(3, path).returns(2);
-          unresolvedCountStub.withArgs(4, path).returns(0);
-
-          assert.equal(element._computeCommentString(comments, 1, path, {}),
-              '1 unresolved');
-          assert.equal(
-              element._computeCommentString(comments, 2, path, {status: 'M'}),
-              '1 comment');
-          assert.equal(
-              element._computeCommentString(comments, 2, path, {status: 'U'}),
-              'no changes, 1 comment');
-          assert.equal(
-              element._computeCommentString(comments, 3, path, {status: 'A'}),
-              '2 comments, 2 unresolved');
-          assert.equal(
-              element._computeCommentString(
-                  comments, 4, path, {status: 'M'}
-              ), '');
-          assert.equal(
-              element._computeCommentString(comments, 4, path, {status: 'U'}),
-              'no changes');
-          done();
-        });
-      });
-
-      suite('url params', () => {
-        setup(() => {
-          sandbox.stub(
-              Gerrit.Nav,
-              'getUrlForDiff',
-              (c, p, pn, bpn) => `${c._number}-${p}-${pn}-${bpn}`);
-          sandbox.stub(
-              Gerrit.Nav
-              , 'getUrlForChange',
-              (c, pn, bpn) => `${c._number}-${pn}-${bpn}`);
-        });
-
-        test('_formattedFiles', () => {
-          element._changeNum = '42';
-          element._patchRange = {
-            basePatchNum: PARENT,
-            patchNum: '10',
-          };
-          element._change = {_number: 42};
-          element._files = getFilesFromFileList(
-              ['chell.go', 'glados.txt', 'wheatley.md',
-                '/COMMIT_MSG', '/MERGE_LIST']);
-          element._path = 'glados.txt';
-          const expectedFormattedFiles = [
-            {
-              text: 'chell.go',
-              mobileText: 'chell.go',
-              value: 'chell.go',
-              bottomText: '',
-            }, {
-              text: 'glados.txt',
-              mobileText: 'glados.txt',
-              value: 'glados.txt',
-              bottomText: '',
-            }, {
-              text: 'wheatley.md',
-              mobileText: 'wheatley.md',
-              value: 'wheatley.md',
-              bottomText: '',
-            },
-            {
-              text: 'Commit message',
-              mobileText: 'Commit message',
-              value: '/COMMIT_MSG',
-              bottomText: '',
-            },
-            {
-              text: 'Merge list',
-              mobileText: 'Merge list',
-              value: '/MERGE_LIST',
-              bottomText: '',
-            },
-          ];
-
-          assert.deepEqual(element._formattedFiles, expectedFormattedFiles);
-          assert.equal(element._formattedFiles[1].value, element._path);
-        });
-
-        test('prev/up/next links', () => {
-          element._changeNum = '42';
-          element._patchRange = {
-            basePatchNum: PARENT,
-            patchNum: '10',
-          };
-          element._change = {
-            _number: 42,
-            revisions: {
-              a: {_number: 10, commit: {parents: []}},
-            },
-          };
-          element._files = getFilesFromFileList(
-              ['chell.go', 'glados.txt', 'wheatley.md']);
-          element._path = 'glados.txt';
-          flushAsynchronousOperations();
-          const linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
-          assert.equal(linkEls.length, 3);
-          assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-PARENT');
-          assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-          assert.equal(linkEls[2].getAttribute('href'),
-              '42-wheatley.md-10-PARENT');
-          element._path = 'wheatley.md';
-          flushAsynchronousOperations();
-          assert.equal(linkEls[0].getAttribute('href'),
-              '42-glados.txt-10-PARENT');
-          assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-          assert.isFalse(linkEls[2].hasAttribute('href'));
-          element._path = 'chell.go';
-          flushAsynchronousOperations();
-          assert.isFalse(linkEls[0].hasAttribute('href'));
-          assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-          assert.equal(linkEls[2].getAttribute('href'),
-              '42-glados.txt-10-PARENT');
-          element._path = 'not_a_real_file';
-          flushAsynchronousOperations();
-          assert.equal(linkEls[0].getAttribute('href'),
-              '42-wheatley.md-10-PARENT');
-          assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-          assert.equal(linkEls[2].getAttribute('href'), '42-chell.go-10-PARENT');
-        });
-
-        test('prev/up/next links with patch range', () => {
-          element._changeNum = '42';
-          element._patchRange = {
-            basePatchNum: '5',
-            patchNum: '10',
-          };
-          element._change = {
-            _number: 42,
-            revisions: {
-              a: {_number: 5, commit: {parents: []}},
-              b: {_number: 10, commit: {parents: []}},
-            },
-          };
-          element._files = getFilesFromFileList(
-              ['chell.go', 'glados.txt', 'wheatley.md']);
-          element._path = 'glados.txt';
-          flushAsynchronousOperations();
-          const linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
-          assert.equal(linkEls.length, 3);
-          assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-5');
-          assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-          assert.equal(linkEls[2].getAttribute('href'), '42-wheatley.md-10-5');
-          element._path = 'wheatley.md';
-          flushAsynchronousOperations();
-          assert.equal(linkEls[0].getAttribute('href'), '42-glados.txt-10-5');
-          assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-          assert.isFalse(linkEls[2].hasAttribute('href'));
-          element._path = 'chell.go';
-          flushAsynchronousOperations();
-          assert.isFalse(linkEls[0].hasAttribute('href'));
-          assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-          assert.equal(linkEls[2].getAttribute('href'), '42-glados.txt-10-5');
-        });
-      });
-
-      test('_handlePatchChange calls navigateToDiff correctly', () => {
-        const navigateStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-        element._change = {_number: 321, project: 'foo/bar'};
-        element._path = 'path/to/file.txt';
-
-        element._patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: '3',
-        };
-
-        const detail = {
-          basePatchNum: 'PARENT',
-          patchNum: '1',
-        };
-
-        element.$.rangeSelect.dispatchEvent(
-            new CustomEvent('patch-range-change', {detail, bubbles: false}));
-
-        assert(navigateStub.lastCall.calledWithExactly(element._change,
-            element._path, '1', 'PARENT'));
-      });
-
-      test('_prefs.manual_review is respected', () => {
-        const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
-            () => Promise.resolve());
-        const getReviewedStub = sandbox.stub(element, '_getReviewedStatus',
-            () => Promise.resolve());
-
-        sandbox.stub(element.$.diffHost, 'reload');
-        element._loggedIn = true;
-        element.params = {
-          view: Gerrit.Nav.View.DIFF,
-          changeNum: '42',
-          patchNum: '2',
-          basePatchNum: '1',
-          path: '/COMMIT_MSG',
-        };
-        element._prefs = {manual_review: true};
         flushAsynchronousOperations();
-
-        assert.isFalse(saveReviewedStub.called);
-        assert.isTrue(getReviewedStub.called);
-
-        element._prefs = {};
-        flushAsynchronousOperations();
-
-        assert.isTrue(saveReviewedStub.called);
-        assert.isTrue(getReviewedStub.calledOnce);
+        assert.isFalse(linkEls[0].hasAttribute('href'));
+        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
+        assert.equal(linkEls[2].getAttribute('href'), '42-glados.txt-10-5');
       });
+    });
 
-      test('file review status', () => {
-        const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
-            () => Promise.resolve());
-        sandbox.stub(element.$.diffHost, 'reload');
+    test('_handlePatchChange calls navigateToDiff correctly', () => {
+      const navigateStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._path = 'path/to/file.txt';
 
-        element._loggedIn = true;
-        element.params = {
-          view: Gerrit.Nav.View.DIFF,
-          changeNum: '42',
-          patchNum: '2',
-          basePatchNum: '1',
-          path: '/COMMIT_MSG',
-        };
-        element._prefs = {};
-        flushAsynchronousOperations();
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '3',
+      };
 
-        const commitMsg = Polymer.dom(element.root).querySelector(
-            'input[type="checkbox"]');
+      const detail = {
+        basePatchNum: 'PARENT',
+        patchNum: '1',
+      };
 
-        assert.isTrue(commitMsg.checked);
-        MockInteractions.tap(commitMsg);
-        assert.isFalse(commitMsg.checked);
-        assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(false));
+      element.$.rangeSelect.dispatchEvent(
+          new CustomEvent('patch-range-change', {detail, bubbles: false}));
 
-        MockInteractions.tap(commitMsg);
-        assert.isTrue(commitMsg.checked);
-        assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(true));
-        const callCount = saveReviewedStub.callCount;
+      assert(navigateStub.lastCall.calledWithExactly(element._change,
+          element._path, '1', 'PARENT'));
+    });
 
-        element.set('params.view', Gerrit.Nav.View.CHANGE);
-        flushAsynchronousOperations();
+    test('_prefs.manual_review is respected', () => {
+      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
+          () => Promise.resolve());
+      const getReviewedStub = sandbox.stub(element, '_getReviewedStatus',
+          () => Promise.resolve());
 
-        // saveReviewedState observer observes params, but should not fire when
-        // view !== Gerrit.Nav.View.DIFF.
-        assert.equal(saveReviewedStub.callCount, callCount);
+      sandbox.stub(element.$.diffHost, 'reload');
+      element._loggedIn = true;
+      element.params = {
+        view: Gerrit.Nav.View.DIFF,
+        changeNum: '42',
+        patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
+      };
+      element._prefs = {manual_review: true};
+      flushAsynchronousOperations();
+
+      assert.isFalse(saveReviewedStub.called);
+      assert.isTrue(getReviewedStub.called);
+
+      element._prefs = {};
+      flushAsynchronousOperations();
+
+      assert.isTrue(saveReviewedStub.called);
+      assert.isTrue(getReviewedStub.calledOnce);
+    });
+
+    test('file review status', () => {
+      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
+          () => Promise.resolve());
+      sandbox.stub(element.$.diffHost, 'reload');
+
+      element._loggedIn = true;
+      element.params = {
+        view: Gerrit.Nav.View.DIFF,
+        changeNum: '42',
+        patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
+      };
+      element._prefs = {};
+      flushAsynchronousOperations();
+
+      const commitMsg = dom(element.root).querySelector(
+          'input[type="checkbox"]');
+
+      assert.isTrue(commitMsg.checked);
+      MockInteractions.tap(commitMsg);
+      assert.isFalse(commitMsg.checked);
+      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(false));
+
+      MockInteractions.tap(commitMsg);
+      assert.isTrue(commitMsg.checked);
+      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(true));
+      const callCount = saveReviewedStub.callCount;
+
+      element.set('params.view', Gerrit.Nav.View.CHANGE);
+      flushAsynchronousOperations();
+
+      // saveReviewedState observer observes params, but should not fire when
+      // view !== Gerrit.Nav.View.DIFF.
+      assert.equal(saveReviewedStub.callCount, callCount);
+    });
+
+    test('file review status with edit loaded', () => {
+      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState');
+
+      element._patchRange = {patchNum: element.EDIT_NAME};
+      flushAsynchronousOperations();
+
+      assert.isTrue(element._editMode);
+      element._setReviewed();
+      assert.isFalse(saveReviewedStub.called);
+    });
+
+    test('hash is determined from params', done => {
+      sandbox.stub(element.$.diffHost, 'reload');
+      sandbox.stub(element, '_initCursor');
+
+      element._loggedIn = true;
+      element.params = {
+        view: Gerrit.Nav.View.DIFF,
+        changeNum: '42',
+        patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
+        hash: 10,
+      };
+
+      flush(() => {
+        assert.isTrue(element._initCursor.calledOnce);
+        done();
       });
+    });
 
-      test('file review status with edit loaded', () => {
-        const saveReviewedStub = sandbox.stub(element, '_saveReviewedState');
+    test('diff mode selector correctly toggles the diff', () => {
+      const select = element.$.modeSelect;
+      const diffDisplay = element.$.diffHost;
+      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
 
-        element._patchRange = {patchNum: element.EDIT_NAME};
-        flushAsynchronousOperations();
+      // The mode selected in the view state reflects the selected option.
+      assert.equal(element._getDiffViewMode(), select.mode);
 
-        assert.isTrue(element._editMode);
-        element._setReviewed();
-        assert.isFalse(saveReviewedStub.called);
+      // The mode selected in the view state reflects the view rednered in the
+      // diff.
+      assert.equal(select.mode, diffDisplay.viewMode);
+
+      // We will simulate a user change of the selected mode.
+      const newMode = 'UNIFIED_DIFF';
+
+      // Set the mode, and simulate the change event.
+      element.set('changeViewState.diffMode', newMode);
+
+      // Make sure the handler was called and the state is still coherent.
+      assert.equal(element._getDiffViewMode(), newMode);
+      assert.equal(element._getDiffViewMode(), select.mode);
+      assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
+    });
+
+    test('diff mode selector initializes from preferences', () => {
+      let resolvePrefs;
+      const prefsPromise = new Promise(resolve => {
+        resolvePrefs = resolve;
       });
+      sandbox.stub(element.$.restAPI, 'getPreferences', () => prefsPromise);
 
-      test('hash is determined from params', done => {
+      // Attach a new gr-diff-view so we can intercept the preferences fetch.
+      const view = document.createElement('gr-diff-view');
+      fixture('blank').appendChild(view);
+      flushAsynchronousOperations();
+
+      // At this point the diff mode doesn't yet have the user's preference.
+      assert.equal(view._getDiffViewMode(), 'SIDE_BY_SIDE');
+
+      // Receive the overriding preference.
+      resolvePrefs({default_diff_view: 'UNIFIED'});
+      flushAsynchronousOperations();
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+    });
+
+    suite('_commitRange', () => {
+      setup(() => {
         sandbox.stub(element.$.diffHost, 'reload');
         sandbox.stub(element, '_initCursor');
+        sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve({
+          _number: 42,
+          revisions: {
+            'commit-sha-1': {
+              _number: 1,
+              commit: {
+                parents: [{commit: 'sha-1-parent'}],
+              },
+            },
+            'commit-sha-2': {_number: 2},
+            'commit-sha-3': {_number: 3},
+            'commit-sha-4': {_number: 4},
+            'commit-sha-5': {
+              _number: 5,
+              commit: {
+                parents: [{commit: 'sha-5-parent'}],
+              },
+            },
+          },
+        }));
+      });
 
-        element._loggedIn = true;
+      test('uses the patchNum and basePatchNum ', done => {
         element.params = {
           view: Gerrit.Nav.View.DIFF,
           changeNum: '42',
-          patchNum: '2',
-          basePatchNum: '1',
+          patchNum: '4',
+          basePatchNum: '2',
           path: '/COMMIT_MSG',
-          hash: 10,
         };
-
         flush(() => {
-          assert.isTrue(element._initCursor.calledOnce);
+          assert.deepEqual(element._commitRange, {
+            baseCommit: 'commit-sha-2',
+            commit: 'commit-sha-4',
+          });
           done();
         });
       });
 
-      test('diff mode selector correctly toggles the diff', () => {
-        const select = element.$.modeSelect;
-        const diffDisplay = element.$.diffHost;
-        element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
-
-        // The mode selected in the view state reflects the selected option.
-        assert.equal(element._getDiffViewMode(), select.mode);
-
-        // The mode selected in the view state reflects the view rednered in the
-        // diff.
-        assert.equal(select.mode, diffDisplay.viewMode);
-
-        // We will simulate a user change of the selected mode.
-        const newMode = 'UNIFIED_DIFF';
-
-        // Set the mode, and simulate the change event.
-        element.set('changeViewState.diffMode', newMode);
-
-        // Make sure the handler was called and the state is still coherent.
-        assert.equal(element._getDiffViewMode(), newMode);
-        assert.equal(element._getDiffViewMode(), select.mode);
-        assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
-      });
-
-      test('diff mode selector initializes from preferences', () => {
-        let resolvePrefs;
-        const prefsPromise = new Promise(resolve => {
-          resolvePrefs = resolve;
-        });
-        sandbox.stub(element.$.restAPI, 'getPreferences', () => prefsPromise);
-
-        // Attach a new gr-diff-view so we can intercept the preferences fetch.
-        const view = document.createElement('gr-diff-view');
-        fixture('blank').appendChild(view);
-        flushAsynchronousOperations();
-
-        // At this point the diff mode doesn't yet have the user's preference.
-        assert.equal(view._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-        // Receive the overriding preference.
-        resolvePrefs({default_diff_view: 'UNIFIED'});
-        flushAsynchronousOperations();
-        assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-      });
-
-      suite('_commitRange', () => {
-        setup(() => {
-          sandbox.stub(element.$.diffHost, 'reload');
-          sandbox.stub(element, '_initCursor');
-          sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve({
-            _number: 42,
-            revisions: {
-              'commit-sha-1': {
-                _number: 1,
-                commit: {
-                  parents: [{commit: 'sha-1-parent'}],
-                },
-              },
-              'commit-sha-2': {_number: 2},
-              'commit-sha-3': {_number: 3},
-              'commit-sha-4': {_number: 4},
-              'commit-sha-5': {
-                _number: 5,
-                commit: {
-                  parents: [{commit: 'sha-5-parent'}],
-                },
-              },
-            },
-          }));
-        });
-
-        test('uses the patchNum and basePatchNum ', done => {
-          element.params = {
-            view: Gerrit.Nav.View.DIFF,
-            changeNum: '42',
-            patchNum: '4',
-            basePatchNum: '2',
-            path: '/COMMIT_MSG',
-          };
-          flush(() => {
-            assert.deepEqual(element._commitRange, {
-              baseCommit: 'commit-sha-2',
-              commit: 'commit-sha-4',
-            });
-            done();
+      test('uses the parent when there is no base patch num ', done => {
+        element.params = {
+          view: Gerrit.Nav.View.DIFF,
+          changeNum: '42',
+          patchNum: '5',
+          path: '/COMMIT_MSG',
+        };
+        flush(() => {
+          assert.deepEqual(element._commitRange, {
+            commit: 'commit-sha-5',
+            baseCommit: 'sha-5-parent',
           });
+          done();
         });
+      });
+    });
 
-        test('uses the parent when there is no base patch num ', done => {
-          element.params = {
-            view: Gerrit.Nav.View.DIFF,
-            changeNum: '42',
-            patchNum: '5',
-            path: '/COMMIT_MSG',
-          };
-          flush(() => {
-            assert.deepEqual(element._commitRange, {
-              commit: 'commit-sha-5',
-              baseCommit: 'sha-5-parent',
-            });
-            done();
-          });
+    test('_initCursor', () => {
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Does nothing when params specify no cursor address:
+      element._initCursor({});
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Does nothing when params specify side but no number:
+      element._initCursor({leftSide: true});
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Revision hash: specifies lineNum but not side.
+      element._initCursor({lineNum: 234});
+      assert.equal(element.$.cursor.initialLineNumber, 234);
+      assert.equal(element.$.cursor.side, 'right');
+
+      // Base hash: specifies lineNum and side.
+      element._initCursor({leftSide: true, lineNum: 345});
+      assert.equal(element.$.cursor.initialLineNumber, 345);
+      assert.equal(element.$.cursor.side, 'left');
+
+      // Specifies right side:
+      element._initCursor({leftSide: false, lineNum: 123});
+      assert.equal(element.$.cursor.initialLineNumber, 123);
+      assert.equal(element.$.cursor.side, 'right');
+    });
+
+    test('_getLineOfInterest', () => {
+      assert.isNull(element._getLineOfInterest({}));
+
+      let result = element._getLineOfInterest({lineNum: 12});
+      assert.equal(result.number, 12);
+      assert.isNotOk(result.leftSide);
+
+      result = element._getLineOfInterest({lineNum: 12, leftSide: true});
+      assert.equal(result.number, 12);
+      assert.isOk(result.leftSide);
+    });
+
+    test('_onLineSelected', () => {
+      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
+      const replaceStateStub = sandbox.stub(history, 'replaceState');
+      const moveStub = sandbox.stub(element.$.cursor, 'moveToLineNumber');
+      sandbox.stub(element.$.cursor, 'getAddress')
+          .returns({number: 123, isLeftSide: false});
+
+      element._changeNum = 321;
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._patchRange = {
+        basePatchNum: '3',
+        patchNum: '5',
+      };
+      const e = {};
+      const detail = {number: 123, side: 'right'};
+
+      element._onLineSelected(e, detail);
+
+      assert.isTrue(moveStub.called);
+      assert.equal(moveStub.lastCall.args[0], detail.number);
+      assert.equal(moveStub.lastCall.args[1], detail.side);
+
+      assert.isTrue(replaceStateStub.called);
+      assert.isTrue(getUrlStub.called);
+    });
+
+    test('_onLineSelected w/o line address', () => {
+      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
+      sandbox.stub(history, 'replaceState');
+      sandbox.stub(element.$.cursor, 'moveToLineNumber');
+      sandbox.stub(element.$.cursor, 'getAddress').returns(null);
+      element._changeNum = 321;
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._patchRange = {basePatchNum: '3', patchNum: '5'};
+      element._onLineSelected({}, {number: 123, side: 'right'});
+      assert.isTrue(getUrlStub.calledOnce);
+      assert.isUndefined(getUrlStub.lastCall.args[5]);
+      assert.isUndefined(getUrlStub.lastCall.args[6]);
+    });
+
+    test('_getDiffViewMode', () => {
+      // No user prefs or change view state set.
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+
+      // User prefs but no change view state set.
+      element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
+      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+
+      // User prefs and change view state set.
+      element.changeViewState = {diffMode: 'SIDE_BY_SIDE'};
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+    });
+
+    test('_handleToggleDiffMode', () => {
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const e = {preventDefault: () => {}};
+      // Initial state.
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+
+      element._handleToggleDiffMode(e);
+      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+
+      element._handleToggleDiffMode(e);
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+    });
+
+    suite('_loadComments', () => {
+      test('empty', done => {
+        element._loadComments().then(() => {
+          assert.equal(Object.keys(element._commentMap).length, 0);
+          done();
         });
       });
 
-      test('_initCursor', () => {
-        assert.isNotOk(element.$.cursor.initialLineNumber);
-
-        // Does nothing when params specify no cursor address:
-        element._initCursor({});
-        assert.isNotOk(element.$.cursor.initialLineNumber);
-
-        // Does nothing when params specify side but no number:
-        element._initCursor({leftSide: true});
-        assert.isNotOk(element.$.cursor.initialLineNumber);
-
-        // Revision hash: specifies lineNum but not side.
-        element._initCursor({lineNum: 234});
-        assert.equal(element.$.cursor.initialLineNumber, 234);
-        assert.equal(element.$.cursor.side, 'right');
-
-        // Base hash: specifies lineNum and side.
-        element._initCursor({leftSide: true, lineNum: 345});
-        assert.equal(element.$.cursor.initialLineNumber, 345);
-        assert.equal(element.$.cursor.side, 'left');
-
-        // Specifies right side:
-        element._initCursor({leftSide: false, lineNum: 123});
-        assert.equal(element.$.cursor.initialLineNumber, 123);
-        assert.equal(element.$.cursor.side, 'right');
-      });
-
-      test('_getLineOfInterest', () => {
-        assert.isNull(element._getLineOfInterest({}));
-
-        let result = element._getLineOfInterest({lineNum: 12});
-        assert.equal(result.number, 12);
-        assert.isNotOk(result.leftSide);
-
-        result = element._getLineOfInterest({lineNum: 12, leftSide: true});
-        assert.equal(result.number, 12);
-        assert.isOk(result.leftSide);
-      });
-
-      test('_onLineSelected', () => {
-        const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
-        const replaceStateStub = sandbox.stub(history, 'replaceState');
-        const moveStub = sandbox.stub(element.$.cursor, 'moveToLineNumber');
-        sandbox.stub(element.$.cursor, 'getAddress')
-            .returns({number: 123, isLeftSide: false});
-
-        element._changeNum = 321;
-        element._change = {_number: 321, project: 'foo/bar'};
+      test('has paths', done => {
+        sandbox.stub(element, '_getPaths').returns({
+          'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
+          'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
+        });
+        sandbox.stub(element, '_getCommentsForPath').returns({meta: {}});
+        element._changeNum = '42';
         element._patchRange = {
           basePatchNum: '3',
           patchNum: '5',
         };
-        const e = {};
-        const detail = {number: 123, side: 'right'};
-
-        element._onLineSelected(e, detail);
-
-        assert.isTrue(moveStub.called);
-        assert.equal(moveStub.lastCall.args[0], detail.number);
-        assert.equal(moveStub.lastCall.args[1], detail.side);
-
-        assert.isTrue(replaceStateStub.called);
-        assert.isTrue(getUrlStub.called);
-      });
-
-      test('_onLineSelected w/o line address', () => {
-        const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
-        sandbox.stub(history, 'replaceState');
-        sandbox.stub(element.$.cursor, 'moveToLineNumber');
-        sandbox.stub(element.$.cursor, 'getAddress').returns(null);
-        element._changeNum = 321;
-        element._change = {_number: 321, project: 'foo/bar'};
-        element._patchRange = {basePatchNum: '3', patchNum: '5'};
-        element._onLineSelected({}, {number: 123, side: 'right'});
-        assert.isTrue(getUrlStub.calledOnce);
-        assert.isUndefined(getUrlStub.lastCall.args[5]);
-        assert.isUndefined(getUrlStub.lastCall.args[6]);
-      });
-
-      test('_getDiffViewMode', () => {
-        // No user prefs or change view state set.
-        assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-        // User prefs but no change view state set.
-        element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
-        assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
-
-        // User prefs and change view state set.
-        element.changeViewState = {diffMode: 'SIDE_BY_SIDE'};
-        assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-      });
-
-      test('_handleToggleDiffMode', () => {
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        const e = {preventDefault: () => {}};
-        // Initial state.
-        assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-        element._handleToggleDiffMode(e);
-        assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
-
-        element._handleToggleDiffMode(e);
-        assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-      });
-
-      suite('_loadComments', () => {
-        test('empty', done => {
-          element._loadComments().then(() => {
-            assert.equal(Object.keys(element._commentMap).length, 0);
-            done();
-          });
-        });
-
-        test('has paths', done => {
-          sandbox.stub(element, '_getPaths').returns({
-            'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
-            'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
-          });
-          sandbox.stub(element, '_getCommentsForPath').returns({meta: {}});
-          element._changeNum = '42';
-          element._patchRange = {
-            basePatchNum: '3',
-            patchNum: '5',
-          };
-          element._loadComments().then(() => {
-            assert.deepEqual(Object.keys(element._commentMap),
-                ['path/to/file/one.cpp', 'path-to/file/two.py']);
-            done();
-          });
+        element._loadComments().then(() => {
+          assert.deepEqual(Object.keys(element._commentMap),
+              ['path/to/file/one.cpp', 'path-to/file/two.py']);
+          done();
         });
       });
+    });
 
-      suite('_computeCommentSkips', () => {
-        test('empty file list', () => {
-          const commentMap = {
-            'path/one.jpg': true,
-            'path/three.wav': true,
-          };
-          const path = 'path/two.m4v';
-          const fileList = [];
-          const result = element._computeCommentSkips(commentMap, fileList, path);
-          assert.isNull(result.previous);
-          assert.isNull(result.next);
-        });
-
-        test('finds skips', () => {
-          const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
-          let path = fileList[1];
-          const commentMap = {};
-          commentMap[fileList[0]] = true;
-          commentMap[fileList[1]] = false;
-          commentMap[fileList[2]] = true;
-
-          let result = element._computeCommentSkips(commentMap, fileList, path);
-          assert.equal(result.previous, fileList[0]);
-          assert.equal(result.next, fileList[2]);
-
-          commentMap[fileList[1]] = true;
-
-          result = element._computeCommentSkips(commentMap, fileList, path);
-          assert.equal(result.previous, fileList[0]);
-          assert.equal(result.next, fileList[2]);
-
-          path = fileList[0];
-
-          result = element._computeCommentSkips(commentMap, fileList, path);
-          assert.isNull(result.previous);
-          assert.equal(result.next, fileList[1]);
-
-          path = fileList[2];
-
-          result = element._computeCommentSkips(commentMap, fileList, path);
-          assert.equal(result.previous, fileList[1]);
-          assert.isNull(result.next);
-        });
-
-        suite('skip next/previous', () => {
-          let navToChangeStub;
-          let navToDiffStub;
-
-          setup(() => {
-            navToChangeStub = sandbox.stub(element, '_navToChangeView');
-            navToDiffStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-            element._files = getFilesFromFileList([
-              'path/one.jpg', 'path/two.m4v', 'path/three.wav',
-            ]);
-            element._patchRange = {patchNum: '2', basePatchNum: '1'};
-          });
-
-          suite('_moveToPreviousFileWithComment', () => {
-            test('no skips', () => {
-              element._moveToPreviousFileWithComment();
-              assert.isFalse(navToChangeStub.called);
-              assert.isFalse(navToDiffStub.called);
-            });
-
-            test('no previous', () => {
-              const commentMap = {};
-              commentMap[element._fileList[0]] = false;
-              commentMap[element._fileList[1]] = false;
-              commentMap[element._fileList[2]] = true;
-              element._commentMap = commentMap;
-              element._path = element._fileList[1];
-
-              element._moveToPreviousFileWithComment();
-              assert.isTrue(navToChangeStub.calledOnce);
-              assert.isFalse(navToDiffStub.called);
-            });
-
-            test('w/ previous', () => {
-              const commentMap = {};
-              commentMap[element._fileList[0]] = true;
-              commentMap[element._fileList[1]] = false;
-              commentMap[element._fileList[2]] = true;
-              element._commentMap = commentMap;
-              element._path = element._fileList[1];
-
-              element._moveToPreviousFileWithComment();
-              assert.isFalse(navToChangeStub.called);
-              assert.isTrue(navToDiffStub.calledOnce);
-            });
-          });
-
-          suite('_moveToNextFileWithComment', () => {
-            test('no skips', () => {
-              element._moveToNextFileWithComment();
-              assert.isFalse(navToChangeStub.called);
-              assert.isFalse(navToDiffStub.called);
-            });
-
-            test('no previous', () => {
-              const commentMap = {};
-              commentMap[element._fileList[0]] = true;
-              commentMap[element._fileList[1]] = false;
-              commentMap[element._fileList[2]] = false;
-              element._commentMap = commentMap;
-              element._path = element._fileList[1];
-
-              element._moveToNextFileWithComment();
-              assert.isTrue(navToChangeStub.calledOnce);
-              assert.isFalse(navToDiffStub.called);
-            });
-
-            test('w/ previous', () => {
-              const commentMap = {};
-              commentMap[element._fileList[0]] = true;
-              commentMap[element._fileList[1]] = false;
-              commentMap[element._fileList[2]] = true;
-              element._commentMap = commentMap;
-              element._path = element._fileList[1];
-
-              element._moveToNextFileWithComment();
-              assert.isFalse(navToChangeStub.called);
-              assert.isTrue(navToDiffStub.calledOnce);
-            });
-          });
-        });
-      });
-
-      test('_computeEditMode', () => {
-        const callCompute = range => element._computeEditMode({base: range});
-        assert.isFalse(callCompute({}));
-        assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}));
-        assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}));
-        assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}));
-      });
-
-      test('_computeFileNum', () => {
-        assert.equal(element._computeFileNum('/foo',
-            [{value: '/foo'}, {value: '/bar'}]), 1);
-        assert.equal(element._computeFileNum('/bar',
-            [{value: '/foo'}, {value: '/bar'}]), 2);
-      });
-
-      test('_computeFileNumClass', () => {
-        assert.equal(element._computeFileNumClass(0, []), '');
-        assert.equal(element._computeFileNumClass(1,
-            [{value: '/foo'}, {value: '/bar'}]), 'show');
-      });
-
-      test('_getReviewedStatus', () => {
-        const promises = [];
-        element.$.restAPI.getReviewedFiles.restore();
-
-        sandbox.stub(element.$.restAPI, 'getReviewedFiles')
-            .returns(Promise.resolve(['path']));
-
-        promises.push(element._getReviewedStatus(true, null, null, 'path')
-            .then(reviewed => assert.isFalse(reviewed)));
-
-        promises.push(element._getReviewedStatus(false, null, null, 'otherPath')
-            .then(reviewed => assert.isFalse(reviewed)));
-
-        promises.push(element._getReviewedStatus(false, null, null, 'path')
-            .then(reviewed => assert.isTrue(reviewed)));
-
-        return Promise.all(promises);
-      });
-
-      suite('blame', () => {
-        test('toggle blame with button', () => {
-          const toggleBlame = sandbox.stub(
-              element.$.diffHost, 'loadBlame', () => Promise.resolve());
-          MockInteractions.tap(element.$.toggleBlame);
-          assert.isTrue(toggleBlame.calledOnce);
-        });
-        test('toggle blame with shortcut', () => {
-          const toggleBlame = sandbox.stub(
-              element.$.diffHost, 'loadBlame', () => Promise.resolve());
-          MockInteractions.pressAndReleaseKeyOn(element, 66, null, 'b');
-          assert.isTrue(toggleBlame.calledOnce);
-        });
-      });
-
-      suite('editMode behavior', () => {
-        setup(() => {
-          element._loggedIn = true;
-        });
-
-        const isVisible = el => {
-          assert.ok(el);
-          return getComputedStyle(el).getPropertyValue('display') !== 'none';
+    suite('_computeCommentSkips', () => {
+      test('empty file list', () => {
+        const commentMap = {
+          'path/one.jpg': true,
+          'path/three.wav': true,
         };
-
-        test('reviewed checkbox', () => {
-          sandbox.stub(element, '_handlePatchChange');
-          element._patchRange = {patchNum: '1'};
-          // Reviewed checkbox should be shown.
-          assert.isTrue(isVisible(element.$.reviewed));
-          element.set('_patchRange.patchNum', element.EDIT_NAME);
-          flushAsynchronousOperations();
-
-          assert.isFalse(isVisible(element.$.reviewed));
-        });
+        const path = 'path/two.m4v';
+        const fileList = [];
+        const result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.isNull(result.previous);
+        assert.isNull(result.next);
       });
 
-      test('_paramsChanged sets in projectLookup', () => {
-        sandbox.stub(element, '_getLineOfInterest');
-        sandbox.stub(element, '_initCursor');
-        const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-        element._paramsChanged({
-          view: Gerrit.Nav.View.DIFF,
-          changeNum: 101,
-          project: 'test-project',
-          path: '',
-        });
-        assert.isTrue(setStub.calledOnce);
-        assert.isTrue(setStub.calledWith(101, 'test-project'));
+      test('finds skips', () => {
+        const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
+        let path = fileList[1];
+        const commentMap = {};
+        commentMap[fileList[0]] = true;
+        commentMap[fileList[1]] = false;
+        commentMap[fileList[2]] = true;
+
+        let result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.equal(result.previous, fileList[0]);
+        assert.equal(result.next, fileList[2]);
+
+        commentMap[fileList[1]] = true;
+
+        result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.equal(result.previous, fileList[0]);
+        assert.equal(result.next, fileList[2]);
+
+        path = fileList[0];
+
+        result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.isNull(result.previous);
+        assert.equal(result.next, fileList[1]);
+
+        path = fileList[2];
+
+        result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.equal(result.previous, fileList[1]);
+        assert.isNull(result.next);
       });
 
-      test('shift+m navigates to next unreviewed file', () => {
-        element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-        element._reviewedFiles = new Set(['file1', 'file2']);
-        element._path = 'file1';
-        const reviewedStub = sandbox.stub(element, '_setReviewed');
-        const navStub = sandbox.stub(element, '_navToFile');
-        MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
+      suite('skip next/previous', () => {
+        let navToChangeStub;
+        let navToDiffStub;
+
+        setup(() => {
+          navToChangeStub = sandbox.stub(element, '_navToChangeView');
+          navToDiffStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+          element._files = getFilesFromFileList([
+            'path/one.jpg', 'path/two.m4v', 'path/three.wav',
+          ]);
+          element._patchRange = {patchNum: '2', basePatchNum: '1'};
+        });
+
+        suite('_moveToPreviousFileWithComment', () => {
+          test('no skips', () => {
+            element._moveToPreviousFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('no previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = false;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = true;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToPreviousFileWithComment();
+            assert.isTrue(navToChangeStub.calledOnce);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('w/ previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = true;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = true;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToPreviousFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isTrue(navToDiffStub.calledOnce);
+          });
+        });
+
+        suite('_moveToNextFileWithComment', () => {
+          test('no skips', () => {
+            element._moveToNextFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('no previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = true;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = false;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToNextFileWithComment();
+            assert.isTrue(navToChangeStub.calledOnce);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('w/ previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = true;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = true;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToNextFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isTrue(navToDiffStub.calledOnce);
+          });
+        });
+      });
+    });
+
+    test('_computeEditMode', () => {
+      const callCompute = range => element._computeEditMode({base: range});
+      assert.isFalse(callCompute({}));
+      assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}));
+      assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}));
+      assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}));
+    });
+
+    test('_computeFileNum', () => {
+      assert.equal(element._computeFileNum('/foo',
+          [{value: '/foo'}, {value: '/bar'}]), 1);
+      assert.equal(element._computeFileNum('/bar',
+          [{value: '/foo'}, {value: '/bar'}]), 2);
+    });
+
+    test('_computeFileNumClass', () => {
+      assert.equal(element._computeFileNumClass(0, []), '');
+      assert.equal(element._computeFileNumClass(1,
+          [{value: '/foo'}, {value: '/bar'}]), 'show');
+    });
+
+    test('_getReviewedStatus', () => {
+      const promises = [];
+      element.$.restAPI.getReviewedFiles.restore();
+
+      sandbox.stub(element.$.restAPI, 'getReviewedFiles')
+          .returns(Promise.resolve(['path']));
+
+      promises.push(element._getReviewedStatus(true, null, null, 'path')
+          .then(reviewed => assert.isFalse(reviewed)));
+
+      promises.push(element._getReviewedStatus(false, null, null, 'otherPath')
+          .then(reviewed => assert.isFalse(reviewed)));
+
+      promises.push(element._getReviewedStatus(false, null, null, 'path')
+          .then(reviewed => assert.isTrue(reviewed)));
+
+      return Promise.all(promises);
+    });
+
+    suite('blame', () => {
+      test('toggle blame with button', () => {
+        const toggleBlame = sandbox.stub(
+            element.$.diffHost, 'loadBlame', () => Promise.resolve());
+        MockInteractions.tap(element.$.toggleBlame);
+        assert.isTrue(toggleBlame.calledOnce);
+      });
+      test('toggle blame with shortcut', () => {
+        const toggleBlame = sandbox.stub(
+            element.$.diffHost, 'loadBlame', () => Promise.resolve());
+        MockInteractions.pressAndReleaseKeyOn(element, 66, null, 'b');
+        assert.isTrue(toggleBlame.calledOnce);
+      });
+    });
+
+    suite('editMode behavior', () => {
+      setup(() => {
+        element._loggedIn = true;
+      });
+
+      const isVisible = el => {
+        assert.ok(el);
+        return getComputedStyle(el).getPropertyValue('display') !== 'none';
+      };
+
+      test('reviewed checkbox', () => {
+        sandbox.stub(element, '_handlePatchChange');
+        element._patchRange = {patchNum: '1'};
+        // Reviewed checkbox should be shown.
+        assert.isTrue(isVisible(element.$.reviewed));
+        element.set('_patchRange.patchNum', element.EDIT_NAME);
         flushAsynchronousOperations();
 
-        assert.isTrue(reviewedStub.lastCall.args[0]);
-        assert.deepEqual(navStub.lastCall.args, [
-          'file1',
-          ['file1', 'file3'],
-          1,
-        ]);
-      });
-
-      test('File change should trigger navigateToDiff once', () => {
-        element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-        sandbox.stub(element, '_getLineOfInterest');
-        sandbox.stub(element, '_initCursor');
-        sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-
-        // Load file1
-        element._paramsChanged({
-          view: Gerrit.Nav.View.DIFF,
-          patchNum: 1,
-          changeNum: 101,
-          project: 'test-project',
-          path: 'file1',
-        });
-        assert.isTrue(Gerrit.Nav.navigateToDiff.notCalled);
-
-        // Switch to file2
-        element.$.dropdown.value = 'file2';
-        assert.isTrue(Gerrit.Nav.navigateToDiff.calledOnce);
-
-        // This is to mock the param change triggered by above navigate
-        element._paramsChanged({
-          view: Gerrit.Nav.View.DIFF,
-          patchNum: 1,
-          changeNum: 101,
-          project: 'test-project',
-          path: 'file2',
-        });
-
-        // No extra call
-        assert.isTrue(Gerrit.Nav.navigateToDiff.calledOnce);
-      });
-
-      test('_computeDownloadDropdownLinks', () => {
-        const downloadLinks = [
-          {
-            url: '/changes/test~12/revisions/1/patch?zip&path=index.php',
-            name: 'Patch',
-          },
-          {
-            url: '/changes/test~12/revisions/1' +
-                '/files/index.php/download?parent=1',
-            name: 'Left Content',
-          },
-          {
-            url: '/changes/test~12/revisions/1' +
-                '/files/index.php/download',
-            name: 'Right Content',
-          },
-        ];
-
-        const side = {
-          meta_a: true,
-          meta_b: true,
-        };
-
-        const base = {
-          patchNum: 1,
-          basePatchNum: 'PARENT',
-        };
-
-        assert.deepEqual(
-            element._computeDownloadDropdownLinks(
-                'test', 12, base, 'index.php', side),
-            downloadLinks);
-      });
-
-      test('_computeDownloadDropdownLinks diff returns renamed', () => {
-        const downloadLinks = [
-          {
-            url: '/changes/test~12/revisions/3/patch?zip&path=index.php',
-            name: 'Patch',
-          },
-          {
-            url: '/changes/test~12/revisions/2' +
-                '/files/index2.php/download',
-            name: 'Left Content',
-          },
-          {
-            url: '/changes/test~12/revisions/3' +
-                '/files/index.php/download',
-            name: 'Right Content',
-          },
-        ];
-
-        const side = {
-          change_type: 'RENAMED',
-          meta_a: {
-            name: 'index2.php',
-          },
-          meta_b: true,
-        };
-
-        const base = {
-          patchNum: 3,
-          basePatchNum: 2,
-        };
-
-        assert.deepEqual(
-            element._computeDownloadDropdownLinks(
-                'test', 12, base, 'index.php', side),
-            downloadLinks);
-      });
-
-      test('_computeDownloadFileLink', () => {
-        const base = {
-          patchNum: 1,
-          basePatchNum: 'PARENT',
-        };
-
-        assert.equal(
-            element._computeDownloadFileLink(
-                'test', 12, base, 'index.php', true),
-            '/changes/test~12/revisions/1/files/index.php/download?parent=1');
-
-        assert.equal(
-            element._computeDownloadFileLink(
-                'test', 12, base, 'index.php', false),
-            '/changes/test~12/revisions/1/files/index.php/download');
-      });
-
-      test('_computeDownloadPatchLink', () => {
-        assert.equal(
-            element._computeDownloadPatchLink(
-                'test', 12, {patchNum: 1}, 'index.php'),
-            '/changes/test~12/revisions/1/patch?zip&path=index.php');
+        assert.isFalse(isVisible(element.$.reviewed));
       });
     });
 
-    suite('gr-diff-view tests unmodified files with comments', () => {
-      let sandbox;
-      let element;
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-        const changedFiles = {
-          'file1.txt': {},
-          'a/b/test.c': {},
-        };
-        stub('gr-rest-api-interface', {
-          getConfig() { return Promise.resolve({change: {}}); },
-          getLoggedIn() { return Promise.resolve(false); },
-          getProjectConfig() { return Promise.resolve({}); },
-          getDiffChangeDetail() { return Promise.resolve({}); },
-          getChangeFiles() { return Promise.resolve(changedFiles); },
-          saveFileReviewed() { return Promise.resolve(); },
-          getDiffComments() { return Promise.resolve({}); },
-          getDiffRobotComments() { return Promise.resolve({}); },
-          getDiffDrafts() { return Promise.resolve({}); },
-          getReviewedFiles() { return Promise.resolve([]); },
-        });
-        element = fixture('basic');
-        return element._loadComments();
+    test('_paramsChanged sets in projectLookup', () => {
+      sandbox.stub(element, '_getLineOfInterest');
+      sandbox.stub(element, '_initCursor');
+      const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+      element._paramsChanged({
+        view: Gerrit.Nav.View.DIFF,
+        changeNum: 101,
+        project: 'test-project',
+        path: '',
+      });
+      assert.isTrue(setStub.calledOnce);
+      assert.isTrue(setStub.calledWith(101, 'test-project'));
+    });
+
+    test('shift+m navigates to next unreviewed file', () => {
+      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
+      element._reviewedFiles = new Set(['file1', 'file2']);
+      element._path = 'file1';
+      const reviewedStub = sandbox.stub(element, '_setReviewed');
+      const navStub = sandbox.stub(element, '_navToFile');
+      MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
+      flushAsynchronousOperations();
+
+      assert.isTrue(reviewedStub.lastCall.args[0]);
+      assert.deepEqual(navStub.lastCall.args, [
+        'file1',
+        ['file1', 'file3'],
+        1,
+      ]);
+    });
+
+    test('File change should trigger navigateToDiff once', () => {
+      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
+      sandbox.stub(element, '_getLineOfInterest');
+      sandbox.stub(element, '_initCursor');
+      sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+
+      // Load file1
+      element._paramsChanged({
+        view: Gerrit.Nav.View.DIFF,
+        patchNum: 1,
+        changeNum: 101,
+        project: 'test-project',
+        path: 'file1',
+      });
+      assert.isTrue(Gerrit.Nav.navigateToDiff.notCalled);
+
+      // Switch to file2
+      element.$.dropdown.value = 'file2';
+      assert.isTrue(Gerrit.Nav.navigateToDiff.calledOnce);
+
+      // This is to mock the param change triggered by above navigate
+      element._paramsChanged({
+        view: Gerrit.Nav.View.DIFF,
+        patchNum: 1,
+        changeNum: 101,
+        project: 'test-project',
+        path: 'file2',
       });
 
-      teardown(() => {
-        sandbox.restore();
-      });
+      // No extra call
+      assert.isTrue(Gerrit.Nav.navigateToDiff.calledOnce);
+    });
 
-      test('_getFiles add files with comments without changes', () => {
-        const patchChangeRecord = {
-          base: {
-            basePatchNum: '5',
-            patchNum: '10',
-          },
-        };
-        const changeComments = {
-          getPaths: sandbox.stub().returns({
-            'file2.txt': {},
-            'file1.txt': {},
-          }),
-        };
-        return element._getFiles(23, patchChangeRecord, changeComments)
-            .then(() => {
-              assert.deepEqual(element._files, {
-                sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
-                changeFilesByPath: {
-                  'file1.txt': {},
-                  'file2.txt': {status: 'U'},
-                  'a/b/test.c': {},
-                },
-              });
-            });
-      });
+    test('_computeDownloadDropdownLinks', () => {
+      const downloadLinks = [
+        {
+          url: '/changes/test~12/revisions/1/patch?zip&path=index.php',
+          name: 'Patch',
+        },
+        {
+          url: '/changes/test~12/revisions/1' +
+              '/files/index.php/download?parent=1',
+          name: 'Left Content',
+        },
+        {
+          url: '/changes/test~12/revisions/1' +
+              '/files/index.php/download',
+          name: 'Right Content',
+        },
+      ];
+
+      const side = {
+        meta_a: true,
+        meta_b: true,
+      };
+
+      const base = {
+        patchNum: 1,
+        basePatchNum: 'PARENT',
+      };
+
+      assert.deepEqual(
+          element._computeDownloadDropdownLinks(
+              'test', 12, base, 'index.php', side),
+          downloadLinks);
+    });
+
+    test('_computeDownloadDropdownLinks diff returns renamed', () => {
+      const downloadLinks = [
+        {
+          url: '/changes/test~12/revisions/3/patch?zip&path=index.php',
+          name: 'Patch',
+        },
+        {
+          url: '/changes/test~12/revisions/2' +
+              '/files/index2.php/download',
+          name: 'Left Content',
+        },
+        {
+          url: '/changes/test~12/revisions/3' +
+              '/files/index.php/download',
+          name: 'Right Content',
+        },
+      ];
+
+      const side = {
+        change_type: 'RENAMED',
+        meta_a: {
+          name: 'index2.php',
+        },
+        meta_b: true,
+      };
+
+      const base = {
+        patchNum: 3,
+        basePatchNum: 2,
+      };
+
+      assert.deepEqual(
+          element._computeDownloadDropdownLinks(
+              'test', 12, base, 'index.php', side),
+          downloadLinks);
+    });
+
+    test('_computeDownloadFileLink', () => {
+      const base = {
+        patchNum: 1,
+        basePatchNum: 'PARENT',
+      };
+
+      assert.equal(
+          element._computeDownloadFileLink(
+              'test', 12, base, 'index.php', true),
+          '/changes/test~12/revisions/1/files/index.php/download?parent=1');
+
+      assert.equal(
+          element._computeDownloadFileLink(
+              'test', 12, base, 'index.php', false),
+          '/changes/test~12/revisions/1/files/index.php/download');
+    });
+
+    test('_computeDownloadPatchLink', () => {
+      assert.equal(
+          element._computeDownloadPatchLink(
+              'test', 12, {patchNum: 1}, 'index.php'),
+          '/changes/test~12/revisions/1/patch?zip&path=index.php');
     });
   });
+
+  suite('gr-diff-view tests unmodified files with comments', () => {
+    let sandbox;
+    let element;
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      const changedFiles = {
+        'file1.txt': {},
+        'a/b/test.c': {},
+      };
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({change: {}}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getProjectConfig() { return Promise.resolve({}); },
+        getDiffChangeDetail() { return Promise.resolve({}); },
+        getChangeFiles() { return Promise.resolve(changedFiles); },
+        saveFileReviewed() { return Promise.resolve(); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+        getReviewedFiles() { return Promise.resolve([]); },
+      });
+      element = fixture('basic');
+      return element._loadComments();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_getFiles add files with comments without changes', () => {
+      const patchChangeRecord = {
+        base: {
+          basePatchNum: '5',
+          patchNum: '10',
+        },
+      };
+      const changeComments = {
+        getPaths: sandbox.stub().returns({
+          'file2.txt': {},
+          'file1.txt': {},
+        }),
+      };
+      return element._getFiles(23, patchChangeRecord, changeComments)
+          .then(() => {
+            assert.deepEqual(element._files, {
+              sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
+              changeFilesByPath: {
+                'file1.txt': {},
+                'file2.txt': {status: 'U'},
+                'a/b/test.c': {},
+              },
+            });
+          });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
index 00907d6..c461e93 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
@@ -19,193 +19,195 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-group</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="gr-diff-line.js"></script>
-<script src="gr-diff-group.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-diff-line.js"></script>
+<script type="module" src="./gr-diff-group.js"></script>
 
-<script>
-  suite('gr-diff-group tests', async () => {
-    await readyToTest();
-    test('delta line pairs', () => {
-      let group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-      const l1 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 128);
-      const l2 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 129);
-      const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE, 64, 0);
-      group.addLine(l1);
-      group.addLine(l2);
-      group.addLine(l3);
-      assert.deepEqual(group.lines, [l1, l2, l3]);
-      assert.deepEqual(group.adds, [l1, l2]);
-      assert.deepEqual(group.removes, [l3]);
-      assert.deepEqual(group.lineRange, {
-        left: {start: 64, end: 64},
-        right: {start: 128, end: 129},
-      });
-
-      let pairs = group.getSideBySidePairs();
-      assert.deepEqual(pairs, [
-        {left: l3, right: l1},
-        {left: GrDiffLine.BLANK_LINE, right: l2},
-      ]);
-
-      group = new GrDiffGroup(GrDiffGroup.Type.DELTA, [l1, l2, l3]);
-      assert.deepEqual(group.lines, [l1, l2, l3]);
-      assert.deepEqual(group.adds, [l1, l2]);
-      assert.deepEqual(group.removes, [l3]);
-
-      pairs = group.getSideBySidePairs();
-      assert.deepEqual(pairs, [
-        {left: l3, right: l1},
-        {left: GrDiffLine.BLANK_LINE, right: l2},
-      ]);
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-diff-line.js';
+import './gr-diff-group.js';
+suite('gr-diff-group tests', () => {
+  test('delta line pairs', () => {
+    let group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+    const l1 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 128);
+    const l2 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 129);
+    const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE, 64, 0);
+    group.addLine(l1);
+    group.addLine(l2);
+    group.addLine(l3);
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, [l1, l2]);
+    assert.deepEqual(group.removes, [l3]);
+    assert.deepEqual(group.lineRange, {
+      left: {start: 64, end: 64},
+      right: {start: 128, end: 129},
     });
 
-    test('group/header line pairs', () => {
-      const l1 = new GrDiffLine(GrDiffLine.Type.BOTH, 64, 128);
-      const l2 = new GrDiffLine(GrDiffLine.Type.BOTH, 65, 129);
-      const l3 = new GrDiffLine(GrDiffLine.Type.BOTH, 66, 130);
+    let pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l3, right: l1},
+      {left: GrDiffLine.BLANK_LINE, right: l2},
+    ]);
 
-      let group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
+    group = new GrDiffGroup(GrDiffGroup.Type.DELTA, [l1, l2, l3]);
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, [l1, l2]);
+    assert.deepEqual(group.removes, [l3]);
 
-      assert.deepEqual(group.lines, [l1, l2, l3]);
-      assert.deepEqual(group.adds, []);
-      assert.deepEqual(group.removes, []);
-
-      assert.deepEqual(group.lineRange, {
-        left: {start: 64, end: 66},
-        right: {start: 128, end: 130},
-      });
-
-      let pairs = group.getSideBySidePairs();
-      assert.deepEqual(pairs, [
-        {left: l1, right: l1},
-        {left: l2, right: l2},
-        {left: l3, right: l3},
-      ]);
-
-      group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL, [l1, l2, l3]);
-      assert.deepEqual(group.lines, [l1, l2, l3]);
-      assert.deepEqual(group.adds, []);
-      assert.deepEqual(group.removes, []);
-
-      pairs = group.getSideBySidePairs();
-      assert.deepEqual(pairs, [
-        {left: l1, right: l1},
-        {left: l2, right: l2},
-        {left: l3, right: l3},
-      ]);
-    });
-
-    test('adding delta lines to non-delta group', () => {
-      const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
-      const l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
-
-      let group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
-      assert.throws(group.addLine.bind(group, l1));
-      assert.throws(group.addLine.bind(group, l2));
-      assert.doesNotThrow(group.addLine.bind(group, l3));
-
-      group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL);
-      assert.throws(group.addLine.bind(group, l1));
-      assert.throws(group.addLine.bind(group, l2));
-      assert.doesNotThrow(group.addLine.bind(group, l3));
-    });
-
-    suite('hideInContextControl', () => {
-      let groups;
-      setup(() => {
-        groups = [
-          new GrDiffGroup(GrDiffGroup.Type.BOTH, [
-            new GrDiffLine(GrDiffLine.Type.BOTH, 5, 7),
-            new GrDiffLine(GrDiffLine.Type.BOTH, 6, 8),
-            new GrDiffLine(GrDiffLine.Type.BOTH, 7, 9),
-          ]),
-          new GrDiffGroup(GrDiffGroup.Type.DELTA, [
-            new GrDiffLine(GrDiffLine.Type.REMOVE, 8),
-            new GrDiffLine(GrDiffLine.Type.ADD, 0, 10),
-            new GrDiffLine(GrDiffLine.Type.REMOVE, 9),
-            new GrDiffLine(GrDiffLine.Type.ADD, 0, 11),
-            new GrDiffLine(GrDiffLine.Type.REMOVE, 10),
-            new GrDiffLine(GrDiffLine.Type.ADD, 0, 12),
-          ]),
-          new GrDiffGroup(GrDiffGroup.Type.BOTH, [
-            new GrDiffLine(GrDiffLine.Type.BOTH, 11, 13),
-            new GrDiffLine(GrDiffLine.Type.BOTH, 12, 14),
-            new GrDiffLine(GrDiffLine.Type.BOTH, 13, 15),
-          ]),
-        ];
-      });
-
-      test('hides hidden groups in context control', () => {
-        const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 3, 6);
-        assert.equal(collapsedGroups.length, 3);
-
-        assert.equal(collapsedGroups[0], groups[0]);
-
-        assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-        assert.equal(collapsedGroups[1].lines.length, 1);
-        assert.equal(
-            collapsedGroups[1].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
-        assert.equal(
-            collapsedGroups[1].lines[0].contextGroups.length, 1);
-        assert.equal(
-            collapsedGroups[1].lines[0].contextGroups[0], groups[1]);
-
-        assert.equal(collapsedGroups[2], groups[2]);
-      });
-
-      test('splits partially hidden groups', () => {
-        const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 4, 7);
-        assert.equal(collapsedGroups.length, 4);
-        assert.equal(collapsedGroups[0], groups[0]);
-
-        assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.DELTA);
-        assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
-        assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
-
-        assert.equal(collapsedGroups[2].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-        assert.equal(collapsedGroups[2].lines.length, 1);
-        assert.equal(
-            collapsedGroups[2].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
-        assert.equal(
-            collapsedGroups[2].lines[0].contextGroups.length, 2);
-
-        assert.equal(
-            collapsedGroups[2].lines[0].contextGroups[0].type,
-            GrDiffGroup.Type.DELTA);
-        assert.deepEqual(
-            collapsedGroups[2].lines[0].contextGroups[0].adds,
-            groups[1].adds.slice(1));
-        assert.deepEqual(
-            collapsedGroups[2].lines[0].contextGroups[0].removes,
-            groups[1].removes.slice(1));
-
-        assert.equal(
-            collapsedGroups[2].lines[0].contextGroups[1].type,
-            GrDiffGroup.Type.BOTH);
-        assert.deepEqual(
-            collapsedGroups[2].lines[0].contextGroups[1].lines,
-            [groups[2].lines[0]]);
-
-        assert.equal(collapsedGroups[3].type, GrDiffGroup.Type.BOTH);
-        assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
-      });
-
-      test('groups unchanged if the hidden range is empty', () => {
-        assert.deepEqual(
-            GrDiffGroup.hideInContextControl(groups, 0, 0), groups);
-      });
-
-      test('groups unchanged if there is only 1 line to hide', () => {
-        assert.deepEqual(
-            GrDiffGroup.hideInContextControl(groups, 3, 4), groups);
-      });
-    });
+    pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l3, right: l1},
+      {left: GrDiffLine.BLANK_LINE, right: l2},
+    ]);
   });
 
+  test('group/header line pairs', () => {
+    const l1 = new GrDiffLine(GrDiffLine.Type.BOTH, 64, 128);
+    const l2 = new GrDiffLine(GrDiffLine.Type.BOTH, 65, 129);
+    const l3 = new GrDiffLine(GrDiffLine.Type.BOTH, 66, 130);
+
+    let group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
+
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, []);
+    assert.deepEqual(group.removes, []);
+
+    assert.deepEqual(group.lineRange, {
+      left: {start: 64, end: 66},
+      right: {start: 128, end: 130},
+    });
+
+    let pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l1, right: l1},
+      {left: l2, right: l2},
+      {left: l3, right: l3},
+    ]);
+
+    group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL, [l1, l2, l3]);
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, []);
+    assert.deepEqual(group.removes, []);
+
+    pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l1, right: l1},
+      {left: l2, right: l2},
+      {left: l3, right: l3},
+    ]);
+  });
+
+  test('adding delta lines to non-delta group', () => {
+    const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
+    const l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
+    const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
+
+    let group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+    assert.throws(group.addLine.bind(group, l1));
+    assert.throws(group.addLine.bind(group, l2));
+    assert.doesNotThrow(group.addLine.bind(group, l3));
+
+    group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL);
+    assert.throws(group.addLine.bind(group, l1));
+    assert.throws(group.addLine.bind(group, l2));
+    assert.doesNotThrow(group.addLine.bind(group, l3));
+  });
+
+  suite('hideInContextControl', () => {
+    let groups;
+    setup(() => {
+      groups = [
+        new GrDiffGroup(GrDiffGroup.Type.BOTH, [
+          new GrDiffLine(GrDiffLine.Type.BOTH, 5, 7),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 6, 8),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 7, 9),
+        ]),
+        new GrDiffGroup(GrDiffGroup.Type.DELTA, [
+          new GrDiffLine(GrDiffLine.Type.REMOVE, 8),
+          new GrDiffLine(GrDiffLine.Type.ADD, 0, 10),
+          new GrDiffLine(GrDiffLine.Type.REMOVE, 9),
+          new GrDiffLine(GrDiffLine.Type.ADD, 0, 11),
+          new GrDiffLine(GrDiffLine.Type.REMOVE, 10),
+          new GrDiffLine(GrDiffLine.Type.ADD, 0, 12),
+        ]),
+        new GrDiffGroup(GrDiffGroup.Type.BOTH, [
+          new GrDiffLine(GrDiffLine.Type.BOTH, 11, 13),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 12, 14),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 13, 15),
+        ]),
+      ];
+    });
+
+    test('hides hidden groups in context control', () => {
+      const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 3, 6);
+      assert.equal(collapsedGroups.length, 3);
+
+      assert.equal(collapsedGroups[0], groups[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[1].lines.length, 1);
+      assert.equal(
+          collapsedGroups[1].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
+      assert.equal(
+          collapsedGroups[1].lines[0].contextGroups.length, 1);
+      assert.equal(
+          collapsedGroups[1].lines[0].contextGroups[0], groups[1]);
+
+      assert.equal(collapsedGroups[2], groups[2]);
+    });
+
+    test('splits partially hidden groups', () => {
+      const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 4, 7);
+      assert.equal(collapsedGroups.length, 4);
+      assert.equal(collapsedGroups[0], groups[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.DELTA);
+      assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
+      assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
+
+      assert.equal(collapsedGroups[2].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[2].lines.length, 1);
+      assert.equal(
+          collapsedGroups[2].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
+      assert.equal(
+          collapsedGroups[2].lines[0].contextGroups.length, 2);
+
+      assert.equal(
+          collapsedGroups[2].lines[0].contextGroups[0].type,
+          GrDiffGroup.Type.DELTA);
+      assert.deepEqual(
+          collapsedGroups[2].lines[0].contextGroups[0].adds,
+          groups[1].adds.slice(1));
+      assert.deepEqual(
+          collapsedGroups[2].lines[0].contextGroups[0].removes,
+          groups[1].removes.slice(1));
+
+      assert.equal(
+          collapsedGroups[2].lines[0].contextGroups[1].type,
+          GrDiffGroup.Type.BOTH);
+      assert.deepEqual(
+          collapsedGroups[2].lines[0].contextGroups[1].lines,
+          [groups[2].lines[0]]);
+
+      assert.equal(collapsedGroups[3].type, GrDiffGroup.Type.BOTH);
+      assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
+    });
+
+    test('groups unchanged if the hidden range is empty', () => {
+      assert.deepEqual(
+          GrDiffGroup.hideInContextControl(groups, 0, 0), groups);
+    });
+
+    test('groups unchanged if there is only 1 line to hide', () => {
+      assert.deepEqual(
+          GrDiffGroup.hideInContextControl(groups, 3, 4), groups);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 8e96147..5bca7be 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,964 +14,983 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const ERR_COMMENT_ON_EDIT = 'You cannot comment on an edit.';
-  const ERR_COMMENT_ON_EDIT_BASE = 'You cannot comment on the base patch set ' +
-      'of an edit.';
-  const ERR_INVALID_LINE = 'Invalid line number: ';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../gr-diff-builder/gr-diff-builder-element.js';
+import '../gr-diff-highlight/gr-diff-highlight.js';
+import '../gr-diff-selection/gr-diff-selection.js';
+import '../gr-syntax-themes/gr-syntax-theme.js';
+import '../gr-ranged-comment-themes/gr-ranged-comment-theme.js';
+import '../../../scripts/hiddenscroll.js';
+import './gr-diff-line.js';
+import './gr-diff-group.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {htmlTemplate} from './gr-diff_html.js';
 
-  const NO_NEWLINE_BASE = 'No newline at end of base file.';
-  const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+const ERR_COMMENT_ON_EDIT = 'You cannot comment on an edit.';
+const ERR_COMMENT_ON_EDIT_BASE = 'You cannot comment on the base patch set ' +
+    'of an edit.';
+const ERR_INVALID_LINE = 'Invalid line number: ';
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+const NO_NEWLINE_BASE = 'No newline at end of base file.';
+const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
 
-  const DiffSide = {
-    LEFT: 'left',
-    RIGHT: 'right',
-  };
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
 
-  const LARGE_DIFF_THRESHOLD_LINES = 10000;
-  const FULL_CONTEXT = -1;
-  const LIMITED_CONTEXT = 10;
+const DiffSide = {
+  LEFT: 'left',
+  RIGHT: 'right',
+};
+
+const LARGE_DIFF_THRESHOLD_LINES = 10000;
+const FULL_CONTEXT = -1;
+const LIMITED_CONTEXT = 10;
+
+/**
+ * Compare two ranges. Either argument may be falsy, but will only return
+ * true if both are falsy or if neither are falsy and have the same position
+ * values.
+ *
+ * @param {Gerrit.Range=} a range 1
+ * @param {Gerrit.Range=} b range 2
+ * @return {boolean}
+ */
+Gerrit.rangesEqual = function(a, b) {
+  if (!a && !b) { return true; }
+  if (!a || !b) { return false; }
+  return a.start_line === b.start_line &&
+      a.start_character === b.start_character &&
+      a.end_line === b.end_line &&
+      a.end_character === b.end_character;
+};
+
+function isThreadEl(node) {
+  return node.nodeType === Node.ELEMENT_NODE &&
+      node.classList.contains('comment-thread');
+}
+
+/**
+ * Turn a slot element into the corresponding content element.
+ * Slots are only fully supported in Polymer 2 - in Polymer 1, they are
+ * replaced with content elements during template parsing. This conversion is
+ * not applied for imperatively created slot elements, so this method
+ * implements the same behavior as the template parsing for imperative slots.
+ */
+Gerrit.slotToContent = function(slot) {
+  if (PolymerElement) {
+    return slot;
+  }
+  const content = document.createElement('content');
+  content.name = slot.name;
+  content.setAttribute('select', `[slot='${slot.name}']`);
+  return content;
+};
+
+const COMMIT_MSG_PATH = '/COMMIT_MSG';
+/**
+ * 72 is the inofficial length standard for git commit messages.
+ * Derived from the fact that git log/show appends 4 ws in the beginning of
+ * each line when displaying commit messages. To center the commit message
+ * in an 80 char terminal a 4 ws border is added to the rightmost side:
+ * 4 + 72 + 4
+ */
+const COMMIT_MSG_LINE_LENGTH = 72;
+
+const RENDER_DIFF_TABLE_DEBOUNCE_NAME = 'renderDiffTable';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @extends Polymer.Element
+ */
+class GrDiff extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.PatchSetBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff'; }
+  /**
+   * Fired when the user selects a line.
+   *
+   * @event line-selected
+   */
 
   /**
-   * Compare two ranges. Either argument may be falsy, but will only return
-   * true if both are falsy or if neither are falsy and have the same position
-   * values.
+   * Fired if being logged in is required.
    *
-   * @param {Gerrit.Range=} a range 1
-   * @param {Gerrit.Range=} b range 2
-   * @return {boolean}
+   * @event show-auth-required
    */
-  Gerrit.rangesEqual = function(a, b) {
-    if (!a && !b) { return true; }
-    if (!a || !b) { return false; }
-    return a.start_line === b.start_line &&
-        a.start_character === b.start_character &&
-        a.end_line === b.end_line &&
-        a.end_character === b.end_character;
-  };
 
-  function isThreadEl(node) {
-    return node.nodeType === Node.ELEMENT_NODE &&
-        node.classList.contains('comment-thread');
+  /**
+   * Fired when a comment is created
+   *
+   * @event create-comment
+   */
+
+  /**
+   * Fired when rendering, including syntax highlighting, is done. Also fired
+   * when no rendering can be done because required preferences are not set.
+   *
+   * @event render
+   */
+
+  /**
+   * Fired for interaction reporting when a diff context is expanded.
+   * Contains an event.detail with numLines about the number of lines that
+   * were expanded.
+   *
+   * @event diff-context-expanded
+   */
+
+  static get properties() {
+    return {
+      changeNum: String,
+      noAutoRender: {
+        type: Boolean,
+        value: false,
+      },
+      /** @type {?} */
+      patchRange: Object,
+      path: {
+        type: String,
+        observer: '_pathObserver',
+      },
+      prefs: {
+        type: Object,
+        observer: '_prefsObserver',
+      },
+      projectName: String,
+      displayLine: {
+        type: Boolean,
+        value: false,
+      },
+      isImageDiff: {
+        type: Boolean,
+      },
+      commitRange: Object,
+      hidden: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
+      noRenderOnPrefsChange: Boolean,
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      _commentRanges: {
+        type: Array,
+        value: () => [],
+      },
+      /** @type {!Array<!Gerrit.CoverageRange>} */
+      coverageRanges: {
+        type: Array,
+        value: () => [],
+      },
+      lineWrapping: {
+        type: Boolean,
+        value: false,
+        observer: '_lineWrappingObserver',
+      },
+      viewMode: {
+        type: String,
+        value: DiffViewMode.SIDE_BY_SIDE,
+        observer: '_viewModeObserver',
+      },
+
+      /** @type {?Gerrit.LineOfInterest} */
+      lineOfInterest: Object,
+
+      loading: {
+        type: Boolean,
+        value: false,
+        observer: '_loadingChanged',
+      },
+
+      loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      diff: {
+        type: Object,
+        observer: '_diffChanged',
+      },
+      _diffHeaderItems: {
+        type: Array,
+        value: [],
+        computed: '_computeDiffHeaderItems(diff.*)',
+      },
+      _diffTableClass: {
+        type: String,
+        value: '',
+      },
+      /** @type {?Object} */
+      baseImage: Object,
+      /** @type {?Object} */
+      revisionImage: Object,
+
+      /**
+       * Whether the safety check for large diffs when whole-file is set has
+       * been bypassed. If the value is null, then the safety has not been
+       * bypassed. If the value is a number, then that number represents the
+       * context preference to use when rendering the bypassed diff.
+       *
+       * @type {number|null}
+       */
+      _safetyBypass: {
+        type: Number,
+        value: null,
+      },
+
+      _showWarning: Boolean,
+
+      /** @type {?string} */
+      errorMessage: {
+        type: String,
+        value: null,
+      },
+
+      /** @type {?Object} */
+      blame: {
+        type: Object,
+        value: null,
+        observer: '_blameChanged',
+      },
+
+      parentIndex: Number,
+
+      showNewlineWarningLeft: {
+        type: Boolean,
+        value: false,
+      },
+      showNewlineWarningRight: {
+        type: Boolean,
+        value: false,
+      },
+
+      _newlineWarning: {
+        type: String,
+        computed: '_computeNewlineWarning(' +
+            'showNewlineWarningLeft, showNewlineWarningRight)',
+      },
+
+      _diffLength: Number,
+
+      /**
+       * Observes comment nodes added or removed after the initial render.
+       * Can be used to unregister when the entire diff is (re-)rendered or upon
+       * detachment.
+       *
+       * @type {?PolymerDomApi.ObserveHandle}
+       */
+      _incrementalNodeObserver: Object,
+
+      /**
+       * Observes comment nodes added or removed at any point.
+       * Can be used to unregister upon detachment.
+       *
+       * @type {?PolymerDomApi.ObserveHandle}
+       */
+      _nodeObserver: Object,
+
+      /** Set by Polymer. */
+      isAttached: Boolean,
+      layers: Array,
+    };
+  }
+
+  static get observers() {
+    return [
+      '_enableSelectionObserver(loggedIn, isAttached)',
+    ];
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('create-range-comment',
+        e => this._handleCreateRangeComment(e));
+    this.addEventListener('render-content',
+        () => this._handleRenderContent());
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._observeNodes();
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this._unobserveIncrementalNodes();
+    this._unobserveNodes();
+  }
+
+  showNoChangeMessage(loading, prefs, diffLength) {
+    return !loading &&
+      prefs && prefs.ignore_whitespace !== 'IGNORE_NONE' &&
+      diffLength === 0;
+  }
+
+  _enableSelectionObserver(loggedIn, isAttached) {
+    // Polymer 2: check for undefined
+    if ([loggedIn, isAttached].some(arg => arg === undefined)) {
+      return;
+    }
+
+    if (loggedIn && isAttached) {
+      this.listen(document, 'selectionchange', '_handleSelectionChange');
+      this.listen(document, 'mouseup', '_handleMouseUp');
+    } else {
+      this.unlisten(document, 'selectionchange', '_handleSelectionChange');
+      this.unlisten(document, 'mouseup', '_handleMouseUp');
+    }
+  }
+
+  _handleSelectionChange() {
+    // Because of shadow DOM selections, we handle the selectionchange here,
+    // and pass the shadow DOM selection into gr-diff-highlight, where the
+    // corresponding range is determined and normalized.
+    const selection = this._getShadowOrDocumentSelection();
+    this.$.highlights.handleSelectionChange(selection, false);
+  }
+
+  _handleMouseUp(e) {
+    // To handle double-click outside of text creating comments, we check on
+    // mouse-up if there's a selection that just covers a line change. We
+    // can't do that on selection change since the user may still be dragging.
+    const selection = this._getShadowOrDocumentSelection();
+    this.$.highlights.handleSelectionChange(selection, true);
+  }
+
+  /** Gets the current selection, preferring the shadow DOM selection. */
+  _getShadowOrDocumentSelection() {
+    // When using native shadow DOM, the selection returned by
+    // document.getSelection() cannot reference the actual DOM elements making
+    // up the diff, because they are in the shadow DOM of the gr-diff element.
+    // This takes the shadow DOM selection if one exists.
+    return this.root.getSelection ?
+      this.root.getSelection() :
+      document.getSelection();
+  }
+
+  _observeNodes() {
+    this._nodeObserver = dom(this).observeNodes(info => {
+      const addedThreadEls = info.addedNodes.filter(isThreadEl);
+      const removedThreadEls = info.removedNodes.filter(isThreadEl);
+      this._updateRanges(addedThreadEls, removedThreadEls);
+      this._redispatchHoverEvents(addedThreadEls);
+    });
+  }
+
+  _updateRanges(addedThreadEls, removedThreadEls) {
+    function commentRangeFromThreadEl(threadEl) {
+      const side = threadEl.getAttribute('comment-side');
+      const range = JSON.parse(threadEl.getAttribute('range'));
+      return {side, range, hovering: false};
+    }
+
+    const addedCommentRanges = addedThreadEls
+        .map(commentRangeFromThreadEl)
+        .filter(({range}) => range);
+    const removedCommentRanges = removedThreadEls
+        .map(commentRangeFromThreadEl)
+        .filter(({range}) => range);
+    for (const removedCommentRange of removedCommentRanges) {
+      const i = this._commentRanges
+          .findIndex(
+              cr => cr.side === removedCommentRange.side &&
+            Gerrit.rangesEqual(cr.range, removedCommentRange.range)
+          );
+      this.splice('_commentRanges', i, 1);
+    }
+
+    if (addedCommentRanges && addedCommentRanges.length) {
+      this.push('_commentRanges', ...addedCommentRanges);
+    }
   }
 
   /**
-   * Turn a slot element into the corresponding content element.
-   * Slots are only fully supported in Polymer 2 - in Polymer 1, they are
-   * replaced with content elements during template parsing. This conversion is
-   * not applied for imperatively created slot elements, so this method
-   * implements the same behavior as the template parsing for imperative slots.
+   * The key locations based on the comments and line of interests,
+   * where lines should not be collapsed.
+   *
+   * @return {{left: Object<(string|number), boolean>,
+   *     right: Object<(string|number), boolean>}}
    */
-  Gerrit.slotToContent = function(slot) {
-    if (Polymer.Element) {
-      return slot;
+  _computeKeyLocations() {
+    const keyLocations = {left: {}, right: {}};
+    if (this.lineOfInterest) {
+      const side = this.lineOfInterest.leftSide ? 'left' : 'right';
+      keyLocations[side][this.lineOfInterest.number] = true;
     }
-    const content = document.createElement('content');
-    content.name = slot.name;
-    content.setAttribute('select', `[slot='${slot.name}']`);
-    return content;
-  };
+    const threadEls = dom(this).getEffectiveChildNodes()
+        .filter(isThreadEl);
 
-  const COMMIT_MSG_PATH = '/COMMIT_MSG';
-  /**
-   * 72 is the inofficial length standard for git commit messages.
-   * Derived from the fact that git log/show appends 4 ws in the beginning of
-   * each line when displaying commit messages. To center the commit message
-   * in an 80 char terminal a 4 ws border is added to the rightmost side:
-   * 4 + 72 + 4
-   */
-  const COMMIT_MSG_LINE_LENGTH = 72;
+    for (const threadEl of threadEls) {
+      const commentSide = threadEl.getAttribute('comment-side');
+      const lineNum = Number(threadEl.getAttribute('line-num')) ||
+          GrDiffLine.FILE;
+      const commentRange = threadEl.range || {};
+      keyLocations[commentSide][lineNum] = true;
+      // Add start_line as well if exists,
+      // the being and end of the range should not be collapsed.
+      if (commentRange.start_line) {
+        keyLocations[commentSide][commentRange.start_line] = true;
+      }
+    }
+    return keyLocations;
+  }
 
-  const RENDER_DIFF_TABLE_DEBOUNCE_NAME = 'renderDiffTable';
+  // Dispatch events that are handled by the gr-diff-highlight.
+  _redispatchHoverEvents(addedThreadEls) {
+    for (const threadEl of addedThreadEls) {
+      threadEl.addEventListener('mouseenter', () => {
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseenter', {bubbles: true, composed: true}));
+      });
+      threadEl.addEventListener('mouseleave', () => {
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseleave', {bubbles: true, composed: true}));
+      });
+    }
+  }
 
-  /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @extends Polymer.Element
-   */
-  class GrDiff extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.PatchSetBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-diff'; }
-    /**
-     * Fired when the user selects a line.
-     *
-     * @event line-selected
-     */
+  /** Cancel any remaining diff builder rendering work. */
+  cancel() {
+    this.$.diffBuilder.cancel();
+    this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
+  }
 
-    /**
-     * Fired if being logged in is required.
-     *
-     * @event show-auth-required
-     */
-
-    /**
-     * Fired when a comment is created
-     *
-     * @event create-comment
-     */
-
-    /**
-     * Fired when rendering, including syntax highlighting, is done. Also fired
-     * when no rendering can be done because required preferences are not set.
-     *
-     * @event render
-     */
-
-    /**
-     * Fired for interaction reporting when a diff context is expanded.
-     * Contains an event.detail with numLines about the number of lines that
-     * were expanded.
-     *
-     * @event diff-context-expanded
-     */
-
-    static get properties() {
-      return {
-        changeNum: String,
-        noAutoRender: {
-          type: Boolean,
-          value: false,
-        },
-        /** @type {?} */
-        patchRange: Object,
-        path: {
-          type: String,
-          observer: '_pathObserver',
-        },
-        prefs: {
-          type: Object,
-          observer: '_prefsObserver',
-        },
-        projectName: String,
-        displayLine: {
-          type: Boolean,
-          value: false,
-        },
-        isImageDiff: {
-          type: Boolean,
-        },
-        commitRange: Object,
-        hidden: {
-          type: Boolean,
-          reflectToAttribute: true,
-        },
-        noRenderOnPrefsChange: Boolean,
-        /** @type {!Array<!Gerrit.HoveredRange>} */
-        _commentRanges: {
-          type: Array,
-          value: () => [],
-        },
-        /** @type {!Array<!Gerrit.CoverageRange>} */
-        coverageRanges: {
-          type: Array,
-          value: () => [],
-        },
-        lineWrapping: {
-          type: Boolean,
-          value: false,
-          observer: '_lineWrappingObserver',
-        },
-        viewMode: {
-          type: String,
-          value: DiffViewMode.SIDE_BY_SIDE,
-          observer: '_viewModeObserver',
-        },
-
-        /** @type {?Gerrit.LineOfInterest} */
-        lineOfInterest: Object,
-
-        loading: {
-          type: Boolean,
-          value: false,
-          observer: '_loadingChanged',
-        },
-
-        loggedIn: {
-          type: Boolean,
-          value: false,
-        },
-        diff: {
-          type: Object,
-          observer: '_diffChanged',
-        },
-        _diffHeaderItems: {
-          type: Array,
-          value: [],
-          computed: '_computeDiffHeaderItems(diff.*)',
-        },
-        _diffTableClass: {
-          type: String,
-          value: '',
-        },
-        /** @type {?Object} */
-        baseImage: Object,
-        /** @type {?Object} */
-        revisionImage: Object,
-
-        /**
-         * Whether the safety check for large diffs when whole-file is set has
-         * been bypassed. If the value is null, then the safety has not been
-         * bypassed. If the value is a number, then that number represents the
-         * context preference to use when rendering the bypassed diff.
-         *
-         * @type {number|null}
-         */
-        _safetyBypass: {
-          type: Number,
-          value: null,
-        },
-
-        _showWarning: Boolean,
-
-        /** @type {?string} */
-        errorMessage: {
-          type: String,
-          value: null,
-        },
-
-        /** @type {?Object} */
-        blame: {
-          type: Object,
-          value: null,
-          observer: '_blameChanged',
-        },
-
-        parentIndex: Number,
-
-        showNewlineWarningLeft: {
-          type: Boolean,
-          value: false,
-        },
-        showNewlineWarningRight: {
-          type: Boolean,
-          value: false,
-        },
-
-        _newlineWarning: {
-          type: String,
-          computed: '_computeNewlineWarning(' +
-              'showNewlineWarningLeft, showNewlineWarningRight)',
-        },
-
-        _diffLength: Number,
-
-        /**
-         * Observes comment nodes added or removed after the initial render.
-         * Can be used to unregister when the entire diff is (re-)rendered or upon
-         * detachment.
-         *
-         * @type {?PolymerDomApi.ObserveHandle}
-         */
-        _incrementalNodeObserver: Object,
-
-        /**
-         * Observes comment nodes added or removed at any point.
-         * Can be used to unregister upon detachment.
-         *
-         * @type {?PolymerDomApi.ObserveHandle}
-         */
-        _nodeObserver: Object,
-
-        /** Set by Polymer. */
-        isAttached: Boolean,
-        layers: Array,
-      };
+  /** @return {!Array<!HTMLElement>} */
+  getCursorStops() {
+    if (this.hidden && this.noAutoRender) {
+      return [];
     }
 
-    static get observers() {
-      return [
-        '_enableSelectionObserver(loggedIn, isAttached)',
-      ];
-    }
+    return Array.from(
+        dom(this.root).querySelectorAll('.diff-row'));
+  }
 
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('create-range-comment',
-          e => this._handleCreateRangeComment(e));
-      this.addEventListener('render-content',
-          () => this._handleRenderContent());
-    }
+  /** @return {boolean} */
+  isRangeSelected() {
+    return !!this.$.highlights.selectedRange;
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this._observeNodes();
-    }
+  toggleLeftDiff() {
+    this.toggleClass('no-left');
+  }
 
-    /** @override */
-    detached() {
-      super.detached();
-      this._unobserveIncrementalNodes();
-      this._unobserveNodes();
+  _blameChanged(newValue) {
+    this.$.diffBuilder.setBlame(newValue);
+    if (newValue) {
+      this.classList.add('showBlame');
+    } else {
+      this.classList.remove('showBlame');
     }
+  }
 
-    showNoChangeMessage(loading, prefs, diffLength) {
-      return !loading &&
-        prefs && prefs.ignore_whitespace !== 'IGNORE_NONE' &&
-        diffLength === 0;
+  /** @return {string} */
+  _computeContainerClass(loggedIn, viewMode, displayLine) {
+    const classes = ['diffContainer'];
+    switch (viewMode) {
+      case DiffViewMode.UNIFIED:
+        classes.push('unified');
+        break;
+      case DiffViewMode.SIDE_BY_SIDE:
+        classes.push('sideBySide');
+        break;
+      default:
+        throw Error('Invalid view mode: ', viewMode);
     }
+    if (Gerrit.hiddenscroll) {
+      classes.push('hiddenscroll');
+    }
+    if (loggedIn) {
+      classes.push('canComment');
+    }
+    if (displayLine) {
+      classes.push('displayLine');
+    }
+    return classes.join(' ');
+  }
 
-    _enableSelectionObserver(loggedIn, isAttached) {
-      // Polymer 2: check for undefined
-      if ([loggedIn, isAttached].some(arg => arg === undefined)) {
+  _handleTap(e) {
+    const el = dom(e).localTarget;
+
+    if (el.classList.contains('showContext')) {
+      this.fire('diff-context-expanded', {
+        numLines: e.detail.numLines,
+      });
+      this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
+    } else if (el.classList.contains('lineNum')) {
+      this.addDraftAtLine(el);
+    } else if (el.tagName === 'HL' ||
+        el.classList.contains('content') ||
+        el.classList.contains('contentText')) {
+      const target = this.$.diffBuilder.getLineElByChild(el);
+      if (target) { this._selectLine(target); }
+    }
+  }
+
+  _selectLine(el) {
+    this.fire('line-selected', {
+      side: el.classList.contains('left') ? DiffSide.LEFT : DiffSide.RIGHT,
+      number: el.getAttribute('data-value'),
+      path: this.path,
+    });
+  }
+
+  addDraftAtLine(el) {
+    this._selectLine(el);
+    if (!this._isValidElForComment(el)) { return; }
+
+    const value = el.getAttribute('data-value');
+    let lineNum;
+    if (value !== GrDiffLine.FILE) {
+      lineNum = parseInt(value, 10);
+      if (isNaN(lineNum)) {
+        this.fire('show-alert', {message: ERR_INVALID_LINE + value});
         return;
       }
-
-      if (loggedIn && isAttached) {
-        this.listen(document, 'selectionchange', '_handleSelectionChange');
-        this.listen(document, 'mouseup', '_handleMouseUp');
-      } else {
-        this.unlisten(document, 'selectionchange', '_handleSelectionChange');
-        this.unlisten(document, 'mouseup', '_handleMouseUp');
-      }
     }
+    this._createComment(el, lineNum);
+  }
 
-    _handleSelectionChange() {
-      // Because of shadow DOM selections, we handle the selectionchange here,
-      // and pass the shadow DOM selection into gr-diff-highlight, where the
-      // corresponding range is determined and normalized.
-      const selection = this._getShadowOrDocumentSelection();
-      this.$.highlights.handleSelectionChange(selection, false);
+  createRangeComment() {
+    if (!this.isRangeSelected()) {
+      throw Error('Selection is needed for new range comment');
     }
+    const {side, range} = this.$.highlights.selectedRange;
+    this._createCommentForSelection(side, range);
+  }
 
-    _handleMouseUp(e) {
-      // To handle double-click outside of text creating comments, we check on
-      // mouse-up if there's a selection that just covers a line change. We
-      // can't do that on selection change since the user may still be dragging.
-      const selection = this._getShadowOrDocumentSelection();
-      this.$.highlights.handleSelectionChange(selection, true);
+  _createCommentForSelection(side, range) {
+    const lineNum = range.end_line;
+    const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
+    if (this._isValidElForComment(lineEl)) {
+      this._createComment(lineEl, lineNum, side, range);
     }
+  }
 
-    /** Gets the current selection, preferring the shadow DOM selection. */
-    _getShadowOrDocumentSelection() {
-      // When using native shadow DOM, the selection returned by
-      // document.getSelection() cannot reference the actual DOM elements making
-      // up the diff, because they are in the shadow DOM of the gr-diff element.
-      // This takes the shadow DOM selection if one exists.
-      return this.root.getSelection ?
-        this.root.getSelection() :
-        document.getSelection();
-    }
+  _handleCreateRangeComment(e) {
+    const range = e.detail.range;
+    const side = e.detail.side;
+    this._createCommentForSelection(side, range);
+  }
 
-    _observeNodes() {
-      this._nodeObserver = Polymer.dom(this).observeNodes(info => {
-        const addedThreadEls = info.addedNodes.filter(isThreadEl);
-        const removedThreadEls = info.removedNodes.filter(isThreadEl);
-        this._updateRanges(addedThreadEls, removedThreadEls);
-        this._redispatchHoverEvents(addedThreadEls);
-      });
-    }
-
-    _updateRanges(addedThreadEls, removedThreadEls) {
-      function commentRangeFromThreadEl(threadEl) {
-        const side = threadEl.getAttribute('comment-side');
-        const range = JSON.parse(threadEl.getAttribute('range'));
-        return {side, range, hovering: false};
-      }
-
-      const addedCommentRanges = addedThreadEls
-          .map(commentRangeFromThreadEl)
-          .filter(({range}) => range);
-      const removedCommentRanges = removedThreadEls
-          .map(commentRangeFromThreadEl)
-          .filter(({range}) => range);
-      for (const removedCommentRange of removedCommentRanges) {
-        const i = this._commentRanges
-            .findIndex(
-                cr => cr.side === removedCommentRange.side &&
-              Gerrit.rangesEqual(cr.range, removedCommentRange.range)
-            );
-        this.splice('_commentRanges', i, 1);
-      }
-
-      if (addedCommentRanges && addedCommentRanges.length) {
-        this.push('_commentRanges', ...addedCommentRanges);
-      }
-    }
-
-    /**
-     * The key locations based on the comments and line of interests,
-     * where lines should not be collapsed.
-     *
-     * @return {{left: Object<(string|number), boolean>,
-     *     right: Object<(string|number), boolean>}}
-     */
-    _computeKeyLocations() {
-      const keyLocations = {left: {}, right: {}};
-      if (this.lineOfInterest) {
-        const side = this.lineOfInterest.leftSide ? 'left' : 'right';
-        keyLocations[side][this.lineOfInterest.number] = true;
-      }
-      const threadEls = Polymer.dom(this).getEffectiveChildNodes()
-          .filter(isThreadEl);
-
-      for (const threadEl of threadEls) {
-        const commentSide = threadEl.getAttribute('comment-side');
-        const lineNum = Number(threadEl.getAttribute('line-num')) ||
-            GrDiffLine.FILE;
-        const commentRange = threadEl.range || {};
-        keyLocations[commentSide][lineNum] = true;
-        // Add start_line as well if exists,
-        // the being and end of the range should not be collapsed.
-        if (commentRange.start_line) {
-          keyLocations[commentSide][commentRange.start_line] = true;
-        }
-      }
-      return keyLocations;
-    }
-
-    // Dispatch events that are handled by the gr-diff-highlight.
-    _redispatchHoverEvents(addedThreadEls) {
-      for (const threadEl of addedThreadEls) {
-        threadEl.addEventListener('mouseenter', () => {
-          threadEl.dispatchEvent(new CustomEvent(
-              'comment-thread-mouseenter', {bubbles: true, composed: true}));
-        });
-        threadEl.addEventListener('mouseleave', () => {
-          threadEl.dispatchEvent(new CustomEvent(
-              'comment-thread-mouseleave', {bubbles: true, composed: true}));
-        });
-      }
-    }
-
-    /** Cancel any remaining diff builder rendering work. */
-    cancel() {
-      this.$.diffBuilder.cancel();
-      this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
-    }
-
-    /** @return {!Array<!HTMLElement>} */
-    getCursorStops() {
-      if (this.hidden && this.noAutoRender) {
-        return [];
-      }
-
-      return Array.from(
-          Polymer.dom(this.root).querySelectorAll('.diff-row'));
-    }
-
-    /** @return {boolean} */
-    isRangeSelected() {
-      return !!this.$.highlights.selectedRange;
-    }
-
-    toggleLeftDiff() {
-      this.toggleClass('no-left');
-    }
-
-    _blameChanged(newValue) {
-      this.$.diffBuilder.setBlame(newValue);
-      if (newValue) {
-        this.classList.add('showBlame');
-      } else {
-        this.classList.remove('showBlame');
-      }
-    }
-
-    /** @return {string} */
-    _computeContainerClass(loggedIn, viewMode, displayLine) {
-      const classes = ['diffContainer'];
-      switch (viewMode) {
-        case DiffViewMode.UNIFIED:
-          classes.push('unified');
-          break;
-        case DiffViewMode.SIDE_BY_SIDE:
-          classes.push('sideBySide');
-          break;
-        default:
-          throw Error('Invalid view mode: ', viewMode);
-      }
-      if (Gerrit.hiddenscroll) {
-        classes.push('hiddenscroll');
-      }
-      if (loggedIn) {
-        classes.push('canComment');
-      }
-      if (displayLine) {
-        classes.push('displayLine');
-      }
-      return classes.join(' ');
-    }
-
-    _handleTap(e) {
-      const el = Polymer.dom(e).localTarget;
-
-      if (el.classList.contains('showContext')) {
-        this.fire('diff-context-expanded', {
-          numLines: e.detail.numLines,
-        });
-        this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
-      } else if (el.classList.contains('lineNum')) {
-        this.addDraftAtLine(el);
-      } else if (el.tagName === 'HL' ||
-          el.classList.contains('content') ||
-          el.classList.contains('contentText')) {
-        const target = this.$.diffBuilder.getLineElByChild(el);
-        if (target) { this._selectLine(target); }
-      }
-    }
-
-    _selectLine(el) {
-      this.fire('line-selected', {
-        side: el.classList.contains('left') ? DiffSide.LEFT : DiffSide.RIGHT,
-        number: el.getAttribute('data-value'),
-        path: this.path,
-      });
-    }
-
-    addDraftAtLine(el) {
-      this._selectLine(el);
-      if (!this._isValidElForComment(el)) { return; }
-
-      const value = el.getAttribute('data-value');
-      let lineNum;
-      if (value !== GrDiffLine.FILE) {
-        lineNum = parseInt(value, 10);
-        if (isNaN(lineNum)) {
-          this.fire('show-alert', {message: ERR_INVALID_LINE + value});
-          return;
-        }
-      }
-      this._createComment(el, lineNum);
-    }
-
-    createRangeComment() {
-      if (!this.isRangeSelected()) {
-        throw Error('Selection is needed for new range comment');
-      }
-      const {side, range} = this.$.highlights.selectedRange;
-      this._createCommentForSelection(side, range);
-    }
-
-    _createCommentForSelection(side, range) {
-      const lineNum = range.end_line;
-      const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
-      if (this._isValidElForComment(lineEl)) {
-        this._createComment(lineEl, lineNum, side, range);
-      }
-    }
-
-    _handleCreateRangeComment(e) {
-      const range = e.detail.range;
-      const side = e.detail.side;
-      this._createCommentForSelection(side, range);
-    }
-
-    /** @return {boolean} */
-    _isValidElForComment(el) {
-      if (!this.loggedIn) {
-        this.fire('show-auth-required');
-        return false;
-      }
-      const patchNum = el.classList.contains(DiffSide.LEFT) ?
-        this.patchRange.basePatchNum :
-        this.patchRange.patchNum;
-
-      const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
-      const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
-          this.patchNumEquals(this.patchRange.patchNum, this.EDIT_NAME);
-
-      if (isEdit) {
-        this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT});
-        return false;
-      } else if (isEditBase) {
-        this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT_BASE});
-        return false;
-      }
-      return true;
-    }
-
-    /**
-     * @param {!Object} lineEl
-     * @param {number=} lineNum
-     * @param {string=} side
-     * @param {!Object=} range
-     */
-    _createComment(lineEl, lineNum, side, range) {
-      const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
-      const contentEl = contentText.parentElement;
-      side = side ||
-          this._getCommentSideByLineAndContent(lineEl, contentEl);
-      const patchForNewThreads = this._getPatchNumByLineAndContent(
-          lineEl, contentEl);
-      const isOnParent =
-          this._getIsParentCommentByLineAndContent(lineEl, contentEl);
-      this.dispatchEvent(new CustomEvent('create-comment', {
-        bubbles: true,
-        composed: true,
-        detail: {
-          lineNum,
-          side,
-          patchNum: patchForNewThreads,
-          isOnParent,
-          range,
-        },
-      }));
-    }
-
-    _getThreadGroupForLine(contentEl) {
-      return contentEl.querySelector('.thread-group');
-    }
-
-    /**
-     * Gets or creates a comment thread group for a specific line and side on a
-     * diff.
-     *
-     * @param {!Object} contentEl
-     * @param {!Gerrit.DiffSide} commentSide
-     * @return {!Node}
-     */
-    _getOrCreateThreadGroup(contentEl, commentSide) {
-      // Check if thread group exists.
-      let threadGroupEl = this._getThreadGroupForLine(contentEl);
-      if (!threadGroupEl) {
-        threadGroupEl = document.createElement('div');
-        threadGroupEl.className = 'thread-group';
-        threadGroupEl.setAttribute('data-side', commentSide);
-        contentEl.appendChild(threadGroupEl);
-      }
-      return threadGroupEl;
-    }
-
-    /**
-     * The value to be used for the patch number of new comments created at the
-     * given line and content elements.
-     *
-     * In two cases of creating a comment on the left side, the patch number to
-     * be used should actually be right side of the patch range:
-     * - When the patch range is against the parent comment of a normal change.
-     *   Such comments declare themmselves to be on the left using side=PARENT.
-     * - If the patch range is against the indexed parent of a merge change.
-     *   Such comments declare themselves to be on the given parent by
-     *   specifying the parent index via parent=i.
-     *
-     * @return {number}
-     */
-    _getPatchNumByLineAndContent(lineEl, contentEl) {
-      let patchNum = this.patchRange.patchNum;
-
-      if ((lineEl.classList.contains(DiffSide.LEFT) ||
-          contentEl.classList.contains('remove')) &&
-          this.patchRange.basePatchNum !== 'PARENT' &&
-          !this.isMergeParent(this.patchRange.basePatchNum)) {
-        patchNum = this.patchRange.basePatchNum;
-      }
-      return patchNum;
-    }
-
-    /** @return {boolean} */
-    _getIsParentCommentByLineAndContent(lineEl, contentEl) {
-      if ((lineEl.classList.contains(DiffSide.LEFT) ||
-          contentEl.classList.contains('remove')) &&
-          (this.patchRange.basePatchNum === 'PARENT' ||
-          this.isMergeParent(this.patchRange.basePatchNum))) {
-        return true;
-      }
+  /** @return {boolean} */
+  _isValidElForComment(el) {
+    if (!this.loggedIn) {
+      this.fire('show-auth-required');
       return false;
     }
+    const patchNum = el.classList.contains(DiffSide.LEFT) ?
+      this.patchRange.basePatchNum :
+      this.patchRange.patchNum;
 
-    /** @return {string} */
-    _getCommentSideByLineAndContent(lineEl, contentEl) {
-      let side = 'right';
-      if (lineEl.classList.contains(DiffSide.LEFT) ||
-          contentEl.classList.contains('remove')) {
-        side = 'left';
-      }
-      return side;
+    const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
+    const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
+        this.patchNumEquals(this.patchRange.patchNum, this.EDIT_NAME);
+
+    if (isEdit) {
+      this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT});
+      return false;
+    } else if (isEditBase) {
+      this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT_BASE});
+      return false;
     }
+    return true;
+  }
 
-    _prefsObserver(newPrefs, oldPrefs) {
-      // Scan the preference objects one level deep to see if they differ.
-      let differ = !oldPrefs;
-      if (newPrefs && oldPrefs) {
-        for (const key in newPrefs) {
-          if (newPrefs[key] !== oldPrefs[key]) {
-            differ = true;
-          }
+  /**
+   * @param {!Object} lineEl
+   * @param {number=} lineNum
+   * @param {string=} side
+   * @param {!Object=} range
+   */
+  _createComment(lineEl, lineNum, side, range) {
+    const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
+    const contentEl = contentText.parentElement;
+    side = side ||
+        this._getCommentSideByLineAndContent(lineEl, contentEl);
+    const patchForNewThreads = this._getPatchNumByLineAndContent(
+        lineEl, contentEl);
+    const isOnParent =
+        this._getIsParentCommentByLineAndContent(lineEl, contentEl);
+    this.dispatchEvent(new CustomEvent('create-comment', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        lineNum,
+        side,
+        patchNum: patchForNewThreads,
+        isOnParent,
+        range,
+      },
+    }));
+  }
+
+  _getThreadGroupForLine(contentEl) {
+    return contentEl.querySelector('.thread-group');
+  }
+
+  /**
+   * Gets or creates a comment thread group for a specific line and side on a
+   * diff.
+   *
+   * @param {!Object} contentEl
+   * @param {!Gerrit.DiffSide} commentSide
+   * @return {!Node}
+   */
+  _getOrCreateThreadGroup(contentEl, commentSide) {
+    // Check if thread group exists.
+    let threadGroupEl = this._getThreadGroupForLine(contentEl);
+    if (!threadGroupEl) {
+      threadGroupEl = document.createElement('div');
+      threadGroupEl.className = 'thread-group';
+      threadGroupEl.setAttribute('data-side', commentSide);
+      contentEl.appendChild(threadGroupEl);
+    }
+    return threadGroupEl;
+  }
+
+  /**
+   * The value to be used for the patch number of new comments created at the
+   * given line and content elements.
+   *
+   * In two cases of creating a comment on the left side, the patch number to
+   * be used should actually be right side of the patch range:
+   * - When the patch range is against the parent comment of a normal change.
+   *   Such comments declare themmselves to be on the left using side=PARENT.
+   * - If the patch range is against the indexed parent of a merge change.
+   *   Such comments declare themselves to be on the given parent by
+   *   specifying the parent index via parent=i.
+   *
+   * @return {number}
+   */
+  _getPatchNumByLineAndContent(lineEl, contentEl) {
+    let patchNum = this.patchRange.patchNum;
+
+    if ((lineEl.classList.contains(DiffSide.LEFT) ||
+        contentEl.classList.contains('remove')) &&
+        this.patchRange.basePatchNum !== 'PARENT' &&
+        !this.isMergeParent(this.patchRange.basePatchNum)) {
+      patchNum = this.patchRange.basePatchNum;
+    }
+    return patchNum;
+  }
+
+  /** @return {boolean} */
+  _getIsParentCommentByLineAndContent(lineEl, contentEl) {
+    if ((lineEl.classList.contains(DiffSide.LEFT) ||
+        contentEl.classList.contains('remove')) &&
+        (this.patchRange.basePatchNum === 'PARENT' ||
+        this.isMergeParent(this.patchRange.basePatchNum))) {
+      return true;
+    }
+    return false;
+  }
+
+  /** @return {string} */
+  _getCommentSideByLineAndContent(lineEl, contentEl) {
+    let side = 'right';
+    if (lineEl.classList.contains(DiffSide.LEFT) ||
+        contentEl.classList.contains('remove')) {
+      side = 'left';
+    }
+    return side;
+  }
+
+  _prefsObserver(newPrefs, oldPrefs) {
+    // Scan the preference objects one level deep to see if they differ.
+    let differ = !oldPrefs;
+    if (newPrefs && oldPrefs) {
+      for (const key in newPrefs) {
+        if (newPrefs[key] !== oldPrefs[key]) {
+          differ = true;
         }
       }
-
-      if (differ) {
-        this._prefsChanged(newPrefs);
-      }
     }
 
-    _pathObserver() {
-      // Call _prefsChanged(), because line-limit style value depends on path.
-      this._prefsChanged(this.prefs);
-    }
-
-    _viewModeObserver() {
-      this._prefsChanged(this.prefs);
-    }
-
-    /** @param {boolean} newValue */
-    _loadingChanged(newValue) {
-      if (newValue) {
-        this.cancel();
-        this._blame = null;
-        this._safetyBypass = null;
-        this._showWarning = false;
-        this.clearDiffContent();
-      }
-    }
-
-    _lineWrappingObserver() {
-      this._prefsChanged(this.prefs);
-    }
-
-    _prefsChanged(prefs) {
-      if (!prefs) { return; }
-
-      this._blame = null;
-
-      const lineLength = this.path === COMMIT_MSG_PATH ?
-        COMMIT_MSG_LINE_LENGTH : prefs.line_length;
-      const stylesToUpdate = {};
-
-      if (prefs.line_wrapping) {
-        this._diffTableClass = 'full-width';
-        if (this.viewMode === 'SIDE_BY_SIDE') {
-          stylesToUpdate['--content-width'] = 'none';
-          stylesToUpdate['--line-limit'] = lineLength + 'ch';
-        }
-      } else {
-        this._diffTableClass = '';
-        stylesToUpdate['--content-width'] = lineLength + 'ch';
-      }
-
-      if (prefs.font_size) {
-        stylesToUpdate['--font-size'] = prefs.font_size + 'px';
-      }
-
-      this.updateStyles(stylesToUpdate);
-
-      if (this.diff && !this.noRenderOnPrefsChange) {
-        this._debounceRenderDiffTable();
-      }
-    }
-
-    _diffChanged(newValue) {
-      if (newValue) {
-        this._diffLength = this.getDiffLength(newValue);
-        this._debounceRenderDiffTable();
-      }
-    }
-
-    /**
-     * When called multiple times from the same microtask, will call
-     * _renderDiffTable only once, in the next microtask, unless it is cancelled
-     * before that microtask runs.
-     *
-     * This should be used instead of calling _renderDiffTable directly to
-     * render the diff in response to an input change, because there may be
-     * multiple inputs changing in the same microtask, but we only want to
-     * render once.
-     */
-    _debounceRenderDiffTable() {
-      this.debounce(
-          RENDER_DIFF_TABLE_DEBOUNCE_NAME, () => this._renderDiffTable());
-    }
-
-    _renderDiffTable() {
-      this._unobserveIncrementalNodes();
-      if (!this.prefs) {
-        this.dispatchEvent(
-            new CustomEvent('render', {bubbles: true, composed: true}));
-        return;
-      }
-      if (this.prefs.context === -1 &&
-          this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
-          this._safetyBypass === null) {
-        this._showWarning = true;
-        this.dispatchEvent(
-            new CustomEvent('render', {bubbles: true, composed: true}));
-        return;
-      }
-
-      this._showWarning = false;
-
-      const keyLocations = this._computeKeyLocations();
-      this.$.diffBuilder.render(keyLocations, this._getBypassPrefs())
-          .then(() => {
-            this.dispatchEvent(
-                new CustomEvent('render', {
-                  bubbles: true,
-                  composed: true,
-                  detail: {contentRendered: true},
-                }));
-          });
-    }
-
-    _handleRenderContent() {
-      this._incrementalNodeObserver = Polymer.dom(this).observeNodes(info => {
-        const addedThreadEls = info.addedNodes.filter(isThreadEl);
-        // Removed nodes do not need to be handled because all this code does is
-        // adding a slot for the added thread elements, and the extra slots do
-        // not hurt. It's probably a bigger performance cost to remove them than
-        // to keep them around. Medium term we can even consider to add one slot
-        // for each line from the start.
-        let lastEl;
-        for (const threadEl of addedThreadEls) {
-          const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
-          const commentSide = threadEl.getAttribute('comment-side');
-          const lineEl = this.$.diffBuilder.getLineElByNumber(
-              lineNumString, commentSide);
-          const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
-          const contentEl = contentText.parentElement;
-          const threadGroupEl = this._getOrCreateThreadGroup(
-              contentEl, commentSide);
-          // Create a slot for the thread and attach it to the thread group.
-          // The Polyfill has some bugs and this only works if the slot is
-          // attached to the group after the group is attached to the DOM.
-          // The thread group may already have a slot with the right name, but
-          // that is okay because the first matching slot is used and the rest
-          // are ignored.
-          const slot = document.createElement('slot');
-          slot.name = threadEl.getAttribute('slot');
-          Polymer.dom(threadGroupEl).appendChild(Gerrit.slotToContent(slot));
-          lastEl = threadEl;
-        }
-
-        // Safari is not binding newly created comment-thread
-        // with the slot somehow, replace itself will rebind it
-        // @see Issue 11182
-        if (lastEl && lastEl.replaceWith) {
-          lastEl.replaceWith(lastEl);
-        }
-      });
-    }
-
-    _unobserveIncrementalNodes() {
-      if (this._incrementalNodeObserver) {
-        Polymer.dom(this).unobserveNodes(this._incrementalNodeObserver);
-      }
-    }
-
-    _unobserveNodes() {
-      if (this._nodeObserver) {
-        Polymer.dom(this).unobserveNodes(this._nodeObserver);
-      }
-    }
-
-    /**
-     * Get the preferences object including the safety bypass context (if any).
-     */
-    _getBypassPrefs() {
-      if (this._safetyBypass !== null) {
-        return Object.assign({}, this.prefs, {context: this._safetyBypass});
-      }
-      return this.prefs;
-    }
-
-    clearDiffContent() {
-      this._unobserveIncrementalNodes();
-      this.$.diffTable.innerHTML = null;
-    }
-
-    /** @return {!Array} */
-    _computeDiffHeaderItems(diffInfoRecord) {
-      const diffInfo = diffInfoRecord.base;
-      if (!diffInfo || !diffInfo.diff_header) { return []; }
-      return diffInfo.diff_header
-          .filter(item => !(item.startsWith('diff --git ') ||
-            item.startsWith('index ') ||
-            item.startsWith('+++ ') ||
-            item.startsWith('--- ') ||
-            item === 'Binary files differ'));
-    }
-
-    /** @return {boolean} */
-    _computeDiffHeaderHidden(items) {
-      return items.length === 0;
-    }
-
-    _handleFullBypass() {
-      this._safetyBypass = FULL_CONTEXT;
-      this._debounceRenderDiffTable();
-    }
-
-    _handleLimitedBypass() {
-      this._safetyBypass = LIMITED_CONTEXT;
-      this._debounceRenderDiffTable();
-    }
-
-    /** @return {string} */
-    _computeWarningClass(showWarning) {
-      return showWarning ? 'warn' : '';
-    }
-
-    /**
-     * @param {string} errorMessage
-     * @return {string}
-     */
-    _computeErrorClass(errorMessage) {
-      return errorMessage ? 'showError' : '';
-    }
-
-    expandAllContext() {
-      this._handleFullBypass();
-    }
-
-    /**
-     * @param {!boolean} warnLeft
-     * @param {!boolean} warnRight
-     * @return {string|null}
-     */
-    _computeNewlineWarning(warnLeft, warnRight) {
-      const messages = [];
-      if (warnLeft) {
-        messages.push(NO_NEWLINE_BASE);
-      }
-      if (warnRight) {
-        messages.push(NO_NEWLINE_REVISION);
-      }
-      if (!messages.length) { return null; }
-      return messages.join(' — ');
-    }
-
-    /**
-     * @param {string} warning
-     * @param {boolean} loading
-     * @return {string}
-     */
-    _computeNewlineWarningClass(warning, loading) {
-      if (loading || !warning) { return 'newlineWarning hidden'; }
-      return 'newlineWarning';
-    }
-
-    /**
-     * Get the approximate length of the diff as the sum of the maximum
-     * length of the chunks.
-     *
-     * @param {Object} diff object
-     * @return {number}
-     */
-    getDiffLength(diff) {
-      if (!diff) return 0;
-      return diff.content.reduce((sum, sec) => {
-        if (sec.hasOwnProperty('ab')) {
-          return sum + sec.ab.length;
-        } else {
-          return sum + Math.max(
-              sec.hasOwnProperty('a') ? sec.a.length : 0,
-              sec.hasOwnProperty('b') ? sec.b.length : 0);
-        }
-      }, 0);
+    if (differ) {
+      this._prefsChanged(newPrefs);
     }
   }
 
-  customElements.define(GrDiff.is, GrDiff);
-})();
+  _pathObserver() {
+    // Call _prefsChanged(), because line-limit style value depends on path.
+    this._prefsChanged(this.prefs);
+  }
+
+  _viewModeObserver() {
+    this._prefsChanged(this.prefs);
+  }
+
+  /** @param {boolean} newValue */
+  _loadingChanged(newValue) {
+    if (newValue) {
+      this.cancel();
+      this._blame = null;
+      this._safetyBypass = null;
+      this._showWarning = false;
+      this.clearDiffContent();
+    }
+  }
+
+  _lineWrappingObserver() {
+    this._prefsChanged(this.prefs);
+  }
+
+  _prefsChanged(prefs) {
+    if (!prefs) { return; }
+
+    this._blame = null;
+
+    const lineLength = this.path === COMMIT_MSG_PATH ?
+      COMMIT_MSG_LINE_LENGTH : prefs.line_length;
+    const stylesToUpdate = {};
+
+    if (prefs.line_wrapping) {
+      this._diffTableClass = 'full-width';
+      if (this.viewMode === 'SIDE_BY_SIDE') {
+        stylesToUpdate['--content-width'] = 'none';
+        stylesToUpdate['--line-limit'] = lineLength + 'ch';
+      }
+    } else {
+      this._diffTableClass = '';
+      stylesToUpdate['--content-width'] = lineLength + 'ch';
+    }
+
+    if (prefs.font_size) {
+      stylesToUpdate['--font-size'] = prefs.font_size + 'px';
+    }
+
+    this.updateStyles(stylesToUpdate);
+
+    if (this.diff && !this.noRenderOnPrefsChange) {
+      this._debounceRenderDiffTable();
+    }
+  }
+
+  _diffChanged(newValue) {
+    if (newValue) {
+      this._diffLength = this.getDiffLength(newValue);
+      this._debounceRenderDiffTable();
+    }
+  }
+
+  /**
+   * When called multiple times from the same microtask, will call
+   * _renderDiffTable only once, in the next microtask, unless it is cancelled
+   * before that microtask runs.
+   *
+   * This should be used instead of calling _renderDiffTable directly to
+   * render the diff in response to an input change, because there may be
+   * multiple inputs changing in the same microtask, but we only want to
+   * render once.
+   */
+  _debounceRenderDiffTable() {
+    this.debounce(
+        RENDER_DIFF_TABLE_DEBOUNCE_NAME, () => this._renderDiffTable());
+  }
+
+  _renderDiffTable() {
+    this._unobserveIncrementalNodes();
+    if (!this.prefs) {
+      this.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true}));
+      return;
+    }
+    if (this.prefs.context === -1 &&
+        this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
+        this._safetyBypass === null) {
+      this._showWarning = true;
+      this.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true}));
+      return;
+    }
+
+    this._showWarning = false;
+
+    const keyLocations = this._computeKeyLocations();
+    this.$.diffBuilder.render(keyLocations, this._getBypassPrefs())
+        .then(() => {
+          this.dispatchEvent(
+              new CustomEvent('render', {
+                bubbles: true,
+                composed: true,
+                detail: {contentRendered: true},
+              }));
+        });
+  }
+
+  _handleRenderContent() {
+    this._incrementalNodeObserver = dom(this).observeNodes(info => {
+      const addedThreadEls = info.addedNodes.filter(isThreadEl);
+      // Removed nodes do not need to be handled because all this code does is
+      // adding a slot for the added thread elements, and the extra slots do
+      // not hurt. It's probably a bigger performance cost to remove them than
+      // to keep them around. Medium term we can even consider to add one slot
+      // for each line from the start.
+      let lastEl;
+      for (const threadEl of addedThreadEls) {
+        const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
+        const commentSide = threadEl.getAttribute('comment-side');
+        const lineEl = this.$.diffBuilder.getLineElByNumber(
+            lineNumString, commentSide);
+        const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
+        const contentEl = contentText.parentElement;
+        const threadGroupEl = this._getOrCreateThreadGroup(
+            contentEl, commentSide);
+        // Create a slot for the thread and attach it to the thread group.
+        // The Polyfill has some bugs and this only works if the slot is
+        // attached to the group after the group is attached to the DOM.
+        // The thread group may already have a slot with the right name, but
+        // that is okay because the first matching slot is used and the rest
+        // are ignored.
+        const slot = document.createElement('slot');
+        slot.name = threadEl.getAttribute('slot');
+        dom(threadGroupEl).appendChild(Gerrit.slotToContent(slot));
+        lastEl = threadEl;
+      }
+
+      // Safari is not binding newly created comment-thread
+      // with the slot somehow, replace itself will rebind it
+      // @see Issue 11182
+      if (lastEl && lastEl.replaceWith) {
+        lastEl.replaceWith(lastEl);
+      }
+    });
+  }
+
+  _unobserveIncrementalNodes() {
+    if (this._incrementalNodeObserver) {
+      dom(this).unobserveNodes(this._incrementalNodeObserver);
+    }
+  }
+
+  _unobserveNodes() {
+    if (this._nodeObserver) {
+      dom(this).unobserveNodes(this._nodeObserver);
+    }
+  }
+
+  /**
+   * Get the preferences object including the safety bypass context (if any).
+   */
+  _getBypassPrefs() {
+    if (this._safetyBypass !== null) {
+      return Object.assign({}, this.prefs, {context: this._safetyBypass});
+    }
+    return this.prefs;
+  }
+
+  clearDiffContent() {
+    this._unobserveIncrementalNodes();
+    this.$.diffTable.innerHTML = null;
+  }
+
+  /** @return {!Array} */
+  _computeDiffHeaderItems(diffInfoRecord) {
+    const diffInfo = diffInfoRecord.base;
+    if (!diffInfo || !diffInfo.diff_header) { return []; }
+    return diffInfo.diff_header
+        .filter(item => !(item.startsWith('diff --git ') ||
+          item.startsWith('index ') ||
+          item.startsWith('+++ ') ||
+          item.startsWith('--- ') ||
+          item === 'Binary files differ'));
+  }
+
+  /** @return {boolean} */
+  _computeDiffHeaderHidden(items) {
+    return items.length === 0;
+  }
+
+  _handleFullBypass() {
+    this._safetyBypass = FULL_CONTEXT;
+    this._debounceRenderDiffTable();
+  }
+
+  _handleLimitedBypass() {
+    this._safetyBypass = LIMITED_CONTEXT;
+    this._debounceRenderDiffTable();
+  }
+
+  /** @return {string} */
+  _computeWarningClass(showWarning) {
+    return showWarning ? 'warn' : '';
+  }
+
+  /**
+   * @param {string} errorMessage
+   * @return {string}
+   */
+  _computeErrorClass(errorMessage) {
+    return errorMessage ? 'showError' : '';
+  }
+
+  expandAllContext() {
+    this._handleFullBypass();
+  }
+
+  /**
+   * @param {!boolean} warnLeft
+   * @param {!boolean} warnRight
+   * @return {string|null}
+   */
+  _computeNewlineWarning(warnLeft, warnRight) {
+    const messages = [];
+    if (warnLeft) {
+      messages.push(NO_NEWLINE_BASE);
+    }
+    if (warnRight) {
+      messages.push(NO_NEWLINE_REVISION);
+    }
+    if (!messages.length) { return null; }
+    return messages.join(' — ');
+  }
+
+  /**
+   * @param {string} warning
+   * @param {boolean} loading
+   * @return {string}
+   */
+  _computeNewlineWarningClass(warning, loading) {
+    if (loading || !warning) { return 'newlineWarning hidden'; }
+    return 'newlineWarning';
+  }
+
+  /**
+   * Get the approximate length of the diff as the sum of the maximum
+   * length of the chunks.
+   *
+   * @param {Object} diff object
+   * @return {number}
+   */
+  getDiffLength(diff) {
+    if (!diff) return 0;
+    return diff.content.reduce((sum, sec) => {
+      if (sec.hasOwnProperty('ab')) {
+        return sum + sec.ab.length;
+      } else {
+        return sum + Math.max(
+            sec.hasOwnProperty('a') ? sec.a.length : 0,
+            sec.hasOwnProperty('b') ? sec.b.length : 0);
+      }
+    }, 0);
+  }
+}
+
+customElements.define(GrDiff.is, GrDiff);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js
index 38f6265..c6a7e98 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js
@@ -1,37 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../gr-diff-builder/gr-diff-builder-element.html">
-<link rel="import" href="../gr-diff-highlight/gr-diff-highlight.html">
-<link rel="import" href="../gr-diff-selection/gr-diff-selection.html">
-<link rel="import" href="../gr-syntax-themes/gr-syntax-theme.html">
-<link rel="import" href="../gr-ranged-comment-themes/gr-ranged-comment-theme.html">
-
-<script src="../../../scripts/hiddenscroll.js"></script>
-<script src="gr-diff-line.js"></script>
-<script src="gr-diff-group.js"></script>
-
-<dom-module id="gr-diff">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host(.no-left) .sideBySide .left,
       :host(.no-left) .sideBySide .left + td,
@@ -184,7 +169,7 @@
 
       .content .contentText:empty:after {
         /* Newline, to ensure empty lines are one line-height tall. */
-        content: '\A';
+        content: '\\A';
       }
       .contextControl {
         background-color: var(--diff-context-control-background-color);
@@ -214,7 +199,7 @@
       }
       .br:after {
         /* Line feed */
-        content: '\A';
+        content: '\\A';
       }
       .tab {
         display: inline-block;
@@ -222,7 +207,7 @@
       .tab-indicator:before {
         color: var(--diff-tab-indicator-color);
         /* >> character */
-        content: '\00BB';
+        content: '\\00BB';
         position: absolute;
       }
       /* Is defined after other background-colors, such that this
@@ -363,38 +348,16 @@
     <style include="gr-ranged-comment-theme">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
-    <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
-      <template
-          is="dom-repeat"
-          items="[[_diffHeaderItems]]">
+    <div id="diffHeader" hidden\$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
+      <template is="dom-repeat" items="[[_diffHeaderItems]]">
         <div>[[item]]</div>
       </template>
     </div>
-    <div class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
-        on-tap="_handleTap">
+    <div class\$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]" on-tap="_handleTap">
       <gr-diff-selection diff="[[diff]]">
-        <gr-diff-highlight
-            id="highlights"
-            logged-in="[[loggedIn]]"
-            comment-ranges="{{_commentRanges}}">
-          <gr-diff-builder
-              id="diffBuilder"
-              comment-ranges="[[_commentRanges]]"
-              coverage-ranges="[[coverageRanges]]"
-              project-name="[[projectName]]"
-              diff="[[diff]]"
-              path="[[path]]"
-              change-num="[[changeNum]]"
-              patch-num="[[patchRange.patchNum]]"
-              view-mode="[[viewMode]]"
-              is-image-diff="[[isImageDiff]]"
-              base-image="[[baseImage]]"
-              layers="[[layers]]"
-              revision-image="[[revisionImage]]">
-            <table
-                id="diffTable"
-                class$="[[_diffTableClass]]"
-                role="presentation"></table>
+        <gr-diff-highlight id="highlights" logged-in="[[loggedIn]]" comment-ranges="{{_commentRanges}}">
+          <gr-diff-builder id="diffBuilder" comment-ranges="[[_commentRanges]]" coverage-ranges="[[coverageRanges]]" project-name="[[projectName]]" diff="[[diff]]" path="[[path]]" change-num="[[changeNum]]" patch-num="[[patchRange.patchNum]]" view-mode="[[viewMode]]" is-image-diff="[[isImageDiff]]" base-image="[[baseImage]]" layers="[[layers]]" revision-image="[[revisionImage]]">
+            <table id="diffTable" class\$="[[_diffTableClass]]" role="presentation"></table>
 
             <template is="dom-if" if="[[showNoChangeMessage(loading, prefs, _diffLength)]]">
               <div class="whitespace-change-only-message">
@@ -406,13 +369,13 @@
         </gr-diff-highlight>
       </gr-diff-selection>
     </div>
-    <div class$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
+    <div class\$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
       [[_newlineWarning]]
     </div>
-    <div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
+    <div id="loadingError" class\$="[[_computeErrorClass(errorMessage)]]">
       [[errorMessage]]
     </div>
-    <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
+    <div id="sizeWarning" class\$="[[_computeWarningClass(_showWarning)]]">
       <p>
         Prevented render because "Whole file" is enabled and this diff is very
         large (about [[_diffLength]] lines).
@@ -424,6 +387,4 @@
         Render anyway (may be slow)
       </gr-button>
     </div>
-  </template>
-  <script src="gr-diff.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index ba46549..884c729 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -19,20 +19,28 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
 <script src="/components/web-component-tester/data/a11ySuite.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
-<link rel="import" href="gr-diff.html">
+<script type="module" src="../../shared/gr-rest-api-interface/gr-rest-api-interface.js"></script>
+<script type="module" src="../../shared/gr-rest-api-interface/mock-diff-response_test.js"></script>
+<script type="module" src="./gr-diff.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-rest-api-interface/mock-diff-response_test.js';
+import './gr-diff.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -40,932 +48,248 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-rest-api-interface/mock-diff-response_test.js';
+import './gr-diff.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-diff tests', () => {
+  let element;
+  let sandbox;
 
-    const MINIMAL_PREFS = {tab_size: 2, line_length: 80};
+  const MINIMAL_PREFS = {tab_size: 2, line_length: 80};
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('selectionchange event handling', () => {
+    const emulateSelection = function() {
+      document.dispatchEvent(new CustomEvent('selectionchange'));
+    };
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('selectionchange event handling', () => {
-      const emulateSelection = function() {
-        document.dispatchEvent(new CustomEvent('selectionchange'));
-      };
-
-      setup(() => {
-        element = fixture('basic');
-        sandbox.stub(element.$.highlights, 'handleSelectionChange');
-      });
-
-      test('enabled if logged in', () => {
-        element.loggedIn = true;
-        emulateSelection();
-        assert.isTrue(element.$.highlights.handleSelectionChange.called);
-      });
-
-      test('ignored if logged out', () => {
-        element.loggedIn = false;
-        emulateSelection();
-        assert.isFalse(element.$.highlights.handleSelectionChange.called);
-      });
-    });
-
-    test('cancel', () => {
       element = fixture('basic');
-      const cancelStub = sandbox.stub(element.$.diffBuilder, 'cancel');
-      element.cancel();
-      assert.isTrue(cancelStub.calledOnce);
+      sandbox.stub(element.$.highlights, 'handleSelectionChange');
     });
 
-    test('line limit with line_wrapping', () => {
+    test('enabled if logged in', () => {
+      element.loggedIn = true;
+      emulateSelection();
+      assert.isTrue(element.$.highlights.handleSelectionChange.called);
+    });
+
+    test('ignored if logged out', () => {
+      element.loggedIn = false;
+      emulateSelection();
+      assert.isFalse(element.$.highlights.handleSelectionChange.called);
+    });
+  });
+
+  test('cancel', () => {
+    element = fixture('basic');
+    const cancelStub = sandbox.stub(element.$.diffBuilder, 'cancel');
+    element.cancel();
+    assert.isTrue(cancelStub.calledOnce);
+  });
+
+  test('line limit with line_wrapping', () => {
+    element = fixture('basic');
+    element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: true});
+    flushAsynchronousOperations();
+    assert.equal(util.getComputedStyleValue('--line-limit', element), '80ch');
+  });
+
+  test('line limit without line_wrapping', () => {
+    element = fixture('basic');
+    element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: false});
+    flushAsynchronousOperations();
+    assert.isNotOk(util.getComputedStyleValue('--line-limit', element));
+  });
+
+  suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
+    let lineEl;
+    let contentEl;
+
+    setup(() => {
       element = fixture('basic');
-      element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: true});
-      flushAsynchronousOperations();
-      assert.equal(util.getComputedStyleValue('--line-limit', element), '80ch');
+      lineEl = document.createElement('td');
+      contentEl = document.createElement('span');
     });
 
-    test('line limit without line_wrapping', () => {
-      element = fixture('basic');
-      element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: false});
-      flushAsynchronousOperations();
-      assert.isNotOk(util.getComputedStyleValue('--line-limit', element));
-    });
-
-    suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
-      let lineEl;
-      let contentEl;
-
-      setup(() => {
-        element = fixture('basic');
-        lineEl = document.createElement('td');
-        contentEl = document.createElement('span');
+    suite('_getPatchNumByLineAndContent', () => {
+      test('right side', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('right');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            4);
       });
 
-      suite('_getPatchNumByLineAndContent', () => {
-        test('right side', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-          lineEl.classList.add('right');
-          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-              4);
-        });
-
-        test('left side parent by linenum', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-          lineEl.classList.add('left');
-          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-              4);
-        });
-
-        test('left side parent by content', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-          contentEl.classList.add('remove');
-          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-              4);
-        });
-
-        test('left side merge parent', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: -2};
-          contentEl.classList.add('remove');
-          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-              4);
-        });
-
-        test('left side non parent', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 3};
-          contentEl.classList.add('remove');
-          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-              3);
-        });
+      test('left side parent by linenum', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('left');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            4);
       });
 
-      suite('_getIsParentCommentByLineAndContent', () => {
-        test('right side', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-          lineEl.classList.add('right');
-          assert.isFalse(
-              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-        });
+      test('left side parent by content', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        contentEl.classList.add('remove');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            4);
+      });
 
-        test('left side parent by linenum', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-          lineEl.classList.add('left');
-          assert.isTrue(
-              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-        });
+      test('left side merge parent', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: -2};
+        contentEl.classList.add('remove');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            4);
+      });
 
-        test('left side parent by content', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-          contentEl.classList.add('remove');
-          assert.isTrue(
-              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-        });
-
-        test('left side merge parent', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: -2};
-          contentEl.classList.add('remove');
-          assert.isTrue(
-              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-        });
-
-        test('left side non parent', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 3};
-          contentEl.classList.add('remove');
-          assert.isFalse(
-              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-        });
+      test('left side non parent', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 3};
+        contentEl.classList.add('remove');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            3);
       });
     });
 
-    suite('not logged in', () => {
-      setup(() => {
-        const getLoggedInPromise = Promise.resolve(false);
-        stub('gr-rest-api-interface', {
-          getLoggedIn() { return getLoggedInPromise; },
-        });
-        element = fixture('basic');
-        return getLoggedInPromise;
-      });
-
-      test('toggleLeftDiff', () => {
-        element.toggleLeftDiff();
-        assert.isTrue(element.classList.contains('no-left'));
-        element.toggleLeftDiff();
-        assert.isFalse(element.classList.contains('no-left'));
-      });
-
-      test('addDraftAtLine', () => {
-        sandbox.stub(element, '_selectLine');
-        const loggedInErrorSpy = sandbox.spy();
-        element.addEventListener('show-auth-required', loggedInErrorSpy);
-        element.addDraftAtLine();
-        assert.isTrue(loggedInErrorSpy.called);
-      });
-
-      test('view does not start with displayLine classList', () => {
+    suite('_getIsParentCommentByLineAndContent', () => {
+      test('right side', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('right');
         assert.isFalse(
-            element.shadowRoot
-                .querySelector('.diffContainer')
-                .classList
-                .contains('displayLine'));
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
       });
 
-      test('displayLine class added called when displayLine is true', () => {
-        const spy = sandbox.spy(element, '_computeContainerClass');
-        element.displayLine = true;
-        assert.isTrue(spy.called);
+      test('left side parent by linenum', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('left');
         assert.isTrue(
-            element.shadowRoot
-                .querySelector('.diffContainer')
-                .classList
-                .contains('displayLine'));
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
       });
 
-      test('thread groups', () => {
-        const contentEl = document.createElement('div');
-
-        element.changeNum = 123;
-        element.patchRange = {basePatchNum: 1, patchNum: 2};
-        element.path = 'file.txt';
-
-        const mock = document.createElement('mock-diff-response');
-        element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
-            mock.diffResponse, Object.assign({}, MINIMAL_PREFS));
-
-        // No thread groups.
-        assert.isNotOk(element._getThreadGroupForLine(contentEl));
-
-        // A thread group gets created.
-        const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
-        assert.isOk(threadGroupEl);
-
-        // The new thread group can be fetched.
-        assert.isOk(element._getThreadGroupForLine(contentEl));
-
-        assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
+      test('left side parent by content', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        contentEl.classList.add('remove');
+        assert.isTrue(
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
       });
 
-      suite('image diffs', () => {
-        let mockFile1;
-        let mockFile2;
-        setup(() => {
-          mockFile1 = {
-            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-            'wsAAAAAAAAAAAAAAAAA/w==',
-            type: 'image/bmp',
-          };
-          mockFile2 = {
-            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-            'wsAAAAAAAAAAAAA/////w==',
-            type: 'image/bmp',
-          };
-
-          element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-          element.isImageDiff = true;
-          element.prefs = {
-            auto_hide_diff_table_header: true,
-            context: 10,
-            cursor_blink_rate: 0,
-            font_size: 12,
-            ignore_whitespace: 'IGNORE_NONE',
-            intraline_difference: true,
-            line_length: 100,
-            line_wrapping: false,
-            show_line_endings: true,
-            show_tabs: true,
-            show_whitespace_errors: true,
-            syntax_highlighting: true,
-            tab_size: 8,
-            theme: 'DEFAULT',
-          };
-        });
-
-        test('renders image diffs with same file name', done => {
-          const rendered = () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            // Left image rendered with the parent commit's version of the file.
-            const leftImage = element.$.diffTable.querySelector('td.left img');
-            const leftLabel =
-                element.$.diffTable.querySelector('td.left label');
-            const leftLabelContent = leftLabel.querySelector('.label');
-            const leftLabelName = leftLabel.querySelector('.name');
-
-            const rightImage =
-                element.$.diffTable.querySelector('td.right img');
-            const rightLabel = element.$.diffTable.querySelector(
-                'td.right label');
-            const rightLabelContent = rightLabel.querySelector('.label');
-            const rightLabelName = rightLabel.querySelector('.name');
-
-            assert.isNotOk(rightLabelName);
-            assert.isNotOk(leftLabelName);
-
-            let leftLoaded = false;
-            let rightLoaded = false;
-
-            leftImage.addEventListener('load', () => {
-              assert.isOk(leftImage);
-              assert.equal(leftImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile1.body);
-              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-              leftLoaded = true;
-              if (rightLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-
-            rightImage.addEventListener('load', () => {
-              assert.isOk(rightImage);
-              assert.equal(rightImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile2.body);
-              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-              rightLoaded = true;
-              if (leftLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-          };
-
-          element.addEventListener('render', rendered);
-
-          element.baseImage = mockFile1;
-          element.revisionImage = mockFile2;
-          element.diff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'MODIFIED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index 2adc47d..f9c2f2c 100644',
-              '--- a/carrot.jpg',
-              '+++ b/carrot.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-        });
-
-        test('renders image diffs with a different file name', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-            meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'MODIFIED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot2.jpg',
-              'index 2adc47d..f9c2f2c 100644',
-              '--- a/carrot.jpg',
-              '+++ b/carrot2.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-
-          const rendered = () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            // Left image rendered with the parent commit's version of the file.
-            const leftImage = element.$.diffTable.querySelector('td.left img');
-            const leftLabel =
-                element.$.diffTable.querySelector('td.left label');
-            const leftLabelContent = leftLabel.querySelector('.label');
-            const leftLabelName = leftLabel.querySelector('.name');
-
-            const rightImage =
-                element.$.diffTable.querySelector('td.right img');
-            const rightLabel = element.$.diffTable.querySelector(
-                'td.right label');
-            const rightLabelContent = rightLabel.querySelector('.label');
-            const rightLabelName = rightLabel.querySelector('.name');
-
-            assert.isOk(rightLabelName);
-            assert.isOk(leftLabelName);
-            assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-            assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-            let leftLoaded = false;
-            let rightLoaded = false;
-
-            leftImage.addEventListener('load', () => {
-              assert.isOk(leftImage);
-              assert.equal(leftImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile1.body);
-              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-              leftLoaded = true;
-              if (rightLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-
-            rightImage.addEventListener('load', () => {
-              assert.isOk(rightImage);
-              assert.equal(rightImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile2.body);
-              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-              rightLoaded = true;
-              if (leftLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-          };
-
-          element.addEventListener('render', rendered);
-
-          element.baseImage = mockFile1;
-          element.baseImage._name = mockDiff.meta_a.name;
-          element.revisionImage = mockFile2;
-          element.revisionImage._name = mockDiff.meta_b.name;
-          element.diff = mockDiff;
-        });
-
-        test('renders added image', done => {
-          const mockDiff = {
-            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'ADDED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index 0000000..f9c2f2c 100644',
-              '--- /dev/null',
-              '+++ b/carrot.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-
-          function rendered() {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            const leftImage = element.$.diffTable.querySelector('td.left img');
-            const rightImage = element.$.diffTable.querySelector('td.right img');
-
-            assert.isNotOk(leftImage);
-            assert.isOk(rightImage);
-            done();
-            element.removeEventListener('render', rendered);
-          }
-          element.addEventListener('render', rendered);
-
-          element.revisionImage = mockFile2;
-          element.diff = mockDiff;
-        });
-
-        test('renders removed image', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'DELETED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index f9c2f2c..0000000 100644',
-              '--- a/carrot.jpg',
-              '+++ /dev/null',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-
-          function rendered() {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            const leftImage = element.$.diffTable.querySelector('td.left img');
-            const rightImage = element.$.diffTable.querySelector('td.right img');
-
-            assert.isOk(leftImage);
-            assert.isNotOk(rightImage);
-            done();
-            element.removeEventListener('render', rendered);
-          }
-          element.addEventListener('render', rendered);
-
-          element.baseImage = mockFile1;
-          element.diff = mockDiff;
-        });
-
-        test('does not render disallowed image type', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'DELETED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index f9c2f2c..0000000 100644',
-              '--- a/carrot.jpg',
-              '+++ /dev/null',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          mockFile1.type = 'image/jpeg-evil';
-
-          function rendered() {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diffBuilder._builder, GrDiffBuilderImage);
-            const leftImage = element.$.diffTable.querySelector('td.left img');
-            assert.isNotOk(leftImage);
-            done();
-            element.removeEventListener('render', rendered);
-          }
-          element.addEventListener('render', rendered);
-
-          element.baseImage = mockFile1;
-          element.diff = mockDiff;
-        });
+      test('left side merge parent', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: -2};
+        contentEl.classList.add('remove');
+        assert.isTrue(
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
       });
 
-      test('_handleTap lineNum', done => {
-        const addDraftStub = sandbox.stub(element, 'addDraftAtLine');
-        const el = document.createElement('div');
-        el.className = 'lineNum';
-        el.addEventListener('click', e => {
-          element._handleTap(e);
-          assert.isTrue(addDraftStub.called);
-          assert.equal(addDraftStub.lastCall.args[0], el);
-          done();
-        });
-        el.click();
+      test('left side non parent', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 3};
+        contentEl.classList.add('remove');
+        assert.isFalse(
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
       });
+    });
+  });
 
-      test('_handleTap context', done => {
-        const showContextStub =
-            sandbox.stub(element.$.diffBuilder, 'showContext');
-        const el = document.createElement('div');
-        el.className = 'showContext';
-        el.addEventListener('click', e => {
-          element._handleTap(e);
-          assert.isTrue(showContextStub.called);
-          done();
-        });
-        el.click();
+  suite('not logged in', () => {
+    setup(() => {
+      const getLoggedInPromise = Promise.resolve(false);
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return getLoggedInPromise; },
       });
+      element = fixture('basic');
+      return getLoggedInPromise;
+    });
 
-      test('_handleTap content', done => {
-        const content = document.createElement('div');
-        const lineEl = document.createElement('div');
+    test('toggleLeftDiff', () => {
+      element.toggleLeftDiff();
+      assert.isTrue(element.classList.contains('no-left'));
+      element.toggleLeftDiff();
+      assert.isFalse(element.classList.contains('no-left'));
+    });
 
-        const selectStub = sandbox.stub(element, '_selectLine');
-        sandbox.stub(element.$.diffBuilder, 'getLineElByChild', () => lineEl);
+    test('addDraftAtLine', () => {
+      sandbox.stub(element, '_selectLine');
+      const loggedInErrorSpy = sandbox.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      element.addDraftAtLine();
+      assert.isTrue(loggedInErrorSpy.called);
+    });
 
-        content.className = 'content';
-        content.addEventListener('click', e => {
-          element._handleTap(e);
-          assert.isTrue(selectStub.called);
-          assert.equal(selectStub.lastCall.args[0], lineEl);
-          done();
-        });
-        content.click();
-      });
+    test('view does not start with displayLine classList', () => {
+      assert.isFalse(
+          element.shadowRoot
+              .querySelector('.diffContainer')
+              .classList
+              .contains('displayLine'));
+    });
 
-      suite('getCursorStops', () => {
-        const setupDiff = function() {
-          const mock = document.createElement('mock-diff-response');
-          element.diff = mock.diffResponse;
-          element.prefs = {
-            context: 10,
-            tab_size: 8,
-            font_size: 12,
-            line_length: 100,
-            cursor_blink_rate: 0,
-            line_wrapping: false,
-            intraline_difference: true,
-            show_line_endings: true,
-            show_tabs: true,
-            show_whitespace_errors: true,
-            syntax_highlighting: true,
-            auto_hide_diff_table_header: true,
-            theme: 'DEFAULT',
-            ignore_whitespace: 'IGNORE_NONE',
-          };
+    test('displayLine class added called when displayLine is true', () => {
+      const spy = sandbox.spy(element, '_computeContainerClass');
+      element.displayLine = true;
+      assert.isTrue(spy.called);
+      assert.isTrue(
+          element.shadowRoot
+              .querySelector('.diffContainer')
+              .classList
+              .contains('displayLine'));
+    });
 
-          element._renderDiffTable();
-          flushAsynchronousOperations();
+    test('thread groups', () => {
+      const contentEl = document.createElement('div');
+
+      element.changeNum = 123;
+      element.patchRange = {basePatchNum: 1, patchNum: 2};
+      element.path = 'file.txt';
+
+      const mock = document.createElement('mock-diff-response');
+      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
+          mock.diffResponse, Object.assign({}, MINIMAL_PREFS));
+
+      // No thread groups.
+      assert.isNotOk(element._getThreadGroupForLine(contentEl));
+
+      // A thread group gets created.
+      const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
+      assert.isOk(threadGroupEl);
+
+      // The new thread group can be fetched.
+      assert.isOk(element._getThreadGroupForLine(contentEl));
+
+      assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
+    });
+
+    suite('image diffs', () => {
+      let mockFile1;
+      let mockFile2;
+      setup(() => {
+        mockFile1 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAAAAAA/w==',
+          type: 'image/bmp',
+        };
+        mockFile2 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAA/////w==',
+          type: 'image/bmp',
         };
 
-        test('getCursorStops returns [] when hidden and noAutoRender', () => {
-          element.noAutoRender = true;
-          setupDiff();
-          element.hidden = true;
-          assert.equal(element.getCursorStops().length, 0);
-        });
-
-        test('getCursorStops', () => {
-          setupDiff();
-          assert.equal(element.getCursorStops().length, 50);
-        });
-      });
-
-      test('adds .hiddenscroll', () => {
-        Gerrit.hiddenscroll = true;
-        element.displayLine = true;
-        assert.include(element.shadowRoot
-            .querySelector('.diffContainer').className, 'hiddenscroll');
-      });
-    });
-
-    suite('logged in', () => {
-      let fakeLineEl;
-      setup(() => {
-        element = fixture('basic');
-        element.loggedIn = true;
-        element.patchRange = {};
-
-        fakeLineEl = {
-          getAttribute: sandbox.stub().returns(42),
-          classList: {
-            contains: sandbox.stub().returns(true),
-          },
-        };
-      });
-
-      test('addDraftAtLine', () => {
-        sandbox.stub(element, '_selectLine');
-        sandbox.stub(element, '_createComment');
-        element.addDraftAtLine(fakeLineEl);
-        assert.isTrue(element._createComment
-            .calledWithExactly(fakeLineEl, 42));
-      });
-
-      test('addDraftAtLine on an edit', () => {
-        element.patchRange.basePatchNum = element.EDIT_NAME;
-        sandbox.stub(element, '_selectLine');
-        sandbox.stub(element, '_createComment');
-        const alertSpy = sandbox.spy();
-        element.addEventListener('show-alert', alertSpy);
-        element.addDraftAtLine(fakeLineEl);
-        assert.isTrue(alertSpy.called);
-        assert.isFalse(element._createComment.called);
-      });
-
-      test('addDraftAtLine on an edit base', () => {
-        element.patchRange.patchNum = element.EDIT_NAME;
-        element.patchRange.basePatchNum = element.PARENT_NAME;
-        sandbox.stub(element, '_selectLine');
-        sandbox.stub(element, '_createComment');
-        const alertSpy = sandbox.spy();
-        element.addEventListener('show-alert', alertSpy);
-        element.addDraftAtLine(fakeLineEl);
-        assert.isTrue(alertSpy.called);
-        assert.isFalse(element._createComment.called);
-      });
-
-      suite('change in preferences', () => {
-        setup(() => {
-          element.diff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            diff_header: [],
-            intraline_status: 'OK',
-            change_type: 'MODIFIED',
-            content: [{skip: 66}],
-          };
-          element.flushDebouncer('renderDiffTable');
-        });
-
-        test('change in preferences re-renders diff', () => {
-          sandbox.stub(element, '_renderDiffTable');
-          element.prefs = Object.assign(
-              {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
-          element.flushDebouncer('renderDiffTable');
-          assert.isTrue(element._renderDiffTable.called);
-        });
-
-        test('change in preferences does not re-renders diff with ' +
-            'noRenderOnPrefsChange', () => {
-          sandbox.stub(element, '_renderDiffTable');
-          element.noRenderOnPrefsChange = true;
-          element.prefs = Object.assign(
-              {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
-          element.flushDebouncer('renderDiffTable');
-          assert.isFalse(element._renderDiffTable.called);
-        });
-      });
-    });
-
-    suite('diff header', () => {
-      setup(() => {
-        element = fixture('basic');
-        element.diff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          diff_header: [],
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          content: [{skip: 66}],
-        };
-      });
-
-      test('hidden', () => {
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', '--- a/test.jpg');
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', '+++ b/test.jpg');
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', 'test');
-        assert.equal(element._diffHeaderItems.length, 1);
-        flushAsynchronousOperations();
-
-        assert.equal(element.$.diffHeader.textContent.trim(), 'test');
-      });
-
-      test('binary files', () => {
-        element.diff.binary = true;
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', 'test');
-        assert.equal(element._diffHeaderItems.length, 1);
-        element.push('diff.diff_header', 'Binary files differ');
-        assert.equal(element._diffHeaderItems.length, 1);
-      });
-    });
-
-    suite('safety and bypass', () => {
-      let renderStub;
-
-      setup(() => {
-        element = fixture('basic');
-        renderStub = sandbox.stub(element.$.diffBuilder, 'render',
-            () => {
-              element.$.diffBuilder.dispatchEvent(
-                  new CustomEvent('render', {bubbles: true, composed: true}));
-              return Promise.resolve({});
-            });
-        const mock = document.createElement('mock-diff-response');
-        sandbox.stub(element, 'getDiffLength').returns(10000);
-        element.diff = mock.diffResponse;
-        element.noRenderOnPrefsChange = true;
-      });
-
-      test('large render w/ context = 10', done => {
-        element.prefs = Object.assign({}, MINIMAL_PREFS, {context: 10});
-        function rendered() {
-          assert.isTrue(renderStub.called);
-          assert.isFalse(element._showWarning);
-          done();
-          element.removeEventListener('render', rendered);
-        }
-        element.addEventListener('render', rendered);
-        element._renderDiffTable();
-      });
-
-      test('large render w/ whole file and bypass', done => {
-        element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
-        element._safetyBypass = 10;
-        function rendered() {
-          assert.isTrue(renderStub.called);
-          assert.isFalse(element._showWarning);
-          done();
-          element.removeEventListener('render', rendered);
-        }
-        element.addEventListener('render', rendered);
-        element._renderDiffTable();
-      });
-
-      test('large render w/ whole file and no bypass', done => {
-        element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
-        function rendered() {
-          assert.isFalse(renderStub.called);
-          assert.isTrue(element._showWarning);
-          done();
-          element.removeEventListener('render', rendered);
-        }
-        element.addEventListener('render', rendered);
-        element._renderDiffTable();
-      });
-    });
-
-    suite('blame', () => {
-      setup(() => {
-        element = fixture('basic');
-      });
-
-      test('unsetting', () => {
-        element.blame = [];
-        const setBlameSpy = sandbox.spy(element.$.diffBuilder, 'setBlame');
-        element.classList.add('showBlame');
-        element.blame = null;
-        assert.isTrue(setBlameSpy.calledWithExactly(null));
-        assert.isFalse(element.classList.contains('showBlame'));
-      });
-
-      test('setting', () => {
-        const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-        element.blame = mockBlame;
-        assert.isTrue(element.classList.contains('showBlame'));
-      });
-    });
-
-    suite('trailing newline warnings', () => {
-      const NO_NEWLINE_BASE = 'No newline at end of base file.';
-      const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
-
-      const getWarning = element =>
-        element.shadowRoot.querySelector('.newlineWarning').textContent;
-
-      setup(() => {
-        element = fixture('basic');
-        element.showNewlineWarningLeft = false;
-        element.showNewlineWarningRight = false;
-      });
-
-      test('shows combined warning if both sides set to warn', () => {
-        element.showNewlineWarningLeft = true;
-        element.showNewlineWarningRight = true;
-        assert.include(getWarning(element),
-            NO_NEWLINE_BASE + ' — ' + NO_NEWLINE_REVISION);
-      });
-
-      suite('showNewlineWarningLeft', () => {
-        test('show warning if true', () => {
-          element.showNewlineWarningLeft = true;
-          assert.include(getWarning(element), NO_NEWLINE_BASE);
-        });
-
-        test('hide warning if false', () => {
-          element.showNewlineWarningLeft = false;
-          assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
-        });
-
-        test('hide warning if undefined', () => {
-          element.showNewlineWarningLeft = undefined;
-          assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
-        });
-      });
-
-      suite('showNewlineWarningRight', () => {
-        test('show warning if true', () => {
-          element.showNewlineWarningRight = true;
-          assert.include(getWarning(element), NO_NEWLINE_REVISION);
-        });
-
-        test('hide warning if false', () => {
-          element.showNewlineWarningRight = false;
-          assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
-        });
-
-        test('hide warning if undefined', () => {
-          element.showNewlineWarningRight = undefined;
-          assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
-        });
-      });
-
-      test('_computeNewlineWarningClass', () => {
-        const hidden = 'newlineWarning hidden';
-        const shown = 'newlineWarning';
-        assert.equal(element._computeNewlineWarningClass(null, true), hidden);
-        assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
-        assert.equal(element._computeNewlineWarningClass(null, false), hidden);
-        assert.equal(element._computeNewlineWarningClass('foo', false), shown);
-      });
-    });
-
-    suite('key locations', () => {
-      let renderStub;
-
-      setup(() => {
-        element = fixture('basic');
-        element.prefs = {};
-        renderStub = sandbox.stub(element.$.diffBuilder, 'render')
-            .returns(new Promise(() => {}));
-      });
-
-      test('lineOfInterest is a key location', () => {
-        element.lineOfInterest = {number: 789, leftSide: true};
-        element._renderDiffTable();
-        assert.isTrue(renderStub.called);
-        assert.deepEqual(renderStub.lastCall.args[0], {
-          left: {789: true},
-          right: {},
-        });
-      });
-
-      test('line comments are key locations', () => {
-        const threadEl = document.createElement('div');
-        threadEl.className = 'comment-thread';
-        threadEl.setAttribute('comment-side', 'right');
-        threadEl.setAttribute('line-num', 3);
-        Polymer.dom(element).appendChild(threadEl);
-        Polymer.dom.flush();
-
-        element._renderDiffTable();
-        assert.isTrue(renderStub.called);
-        assert.deepEqual(renderStub.lastCall.args[0], {
-          left: {},
-          right: {3: true},
-        });
-      });
-
-      test('file comments are key locations', () => {
-        const threadEl = document.createElement('div');
-        threadEl.className = 'comment-thread';
-        threadEl.setAttribute('comment-side', 'left');
-        Polymer.dom(element).appendChild(threadEl);
-        Polymer.dom.flush();
-
-        element._renderDiffTable();
-        assert.isTrue(renderStub.called);
-        assert.deepEqual(renderStub.lastCall.args[0], {
-          left: {FILE: true},
-          right: {},
-        });
-      });
-    });
-
-    suite('whitespace changes only message', () => {
-      const setupDiff = function(ignore_whitespace, diffContent) {
-        element = fixture('basic');
+        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+        element.isImageDiff = true;
         element.prefs = {
-          ignore_whitespace,
           auto_hide_diff_table_header: true,
           context: 10,
           cursor_blink_rate: 0,
           font_size: 12,
+          ignore_whitespace: 'IGNORE_NONE',
           intraline_difference: true,
           line_length: 100,
           line_wrapping: false,
@@ -976,98 +300,788 @@
           tab_size: 8,
           theme: 'DEFAULT',
         };
+      });
 
+      test('renders image diffs with same file name', done => {
+        const rendered = () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          // Left image rendered with the parent commit's version of the file.
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          const leftLabel =
+              element.$.diffTable.querySelector('td.left label');
+          const leftLabelContent = leftLabel.querySelector('.label');
+          const leftLabelName = leftLabel.querySelector('.name');
+
+          const rightImage =
+              element.$.diffTable.querySelector('td.right img');
+          const rightLabel = element.$.diffTable.querySelector(
+              'td.right label');
+          const rightLabelContent = rightLabel.querySelector('.label');
+          const rightLabelName = rightLabel.querySelector('.name');
+
+          assert.isNotOk(rightLabelName);
+          assert.isNotOk(leftLabelName);
+
+          let leftLoaded = false;
+          let rightLoaded = false;
+
+          leftImage.addEventListener('load', () => {
+            assert.isOk(leftImage);
+            assert.equal(leftImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile1.body);
+            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+            leftLoaded = true;
+            if (rightLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+
+          rightImage.addEventListener('load', () => {
+            assert.isOk(rightImage);
+            assert.equal(rightImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile2.body);
+            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+            rightLoaded = true;
+            if (leftLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+        };
+
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.revisionImage = mockFile2;
         element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
           intraline_status: 'OK',
           change_type: 'MODIFIED',
           diff_header: [
-            'diff --git a/carrot.js b/carrot.js',
+            'diff --git a/carrot.jpg b/carrot.jpg',
             'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.js',
-            '+++ b/carrot.jjs',
-            'file differ',
+            '--- a/carrot.jpg',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
           ],
-          content: diffContent,
+          content: [{skip: 66}],
           binary: true,
         };
+      });
+
+      test('renders image diffs with a different file name', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot2.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot2.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        const rendered = () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          // Left image rendered with the parent commit's version of the file.
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          const leftLabel =
+              element.$.diffTable.querySelector('td.left label');
+          const leftLabelContent = leftLabel.querySelector('.label');
+          const leftLabelName = leftLabel.querySelector('.name');
+
+          const rightImage =
+              element.$.diffTable.querySelector('td.right img');
+          const rightLabel = element.$.diffTable.querySelector(
+              'td.right label');
+          const rightLabelContent = rightLabel.querySelector('.label');
+          const rightLabelName = rightLabel.querySelector('.name');
+
+          assert.isOk(rightLabelName);
+          assert.isOk(leftLabelName);
+          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+          let leftLoaded = false;
+          let rightLoaded = false;
+
+          leftImage.addEventListener('load', () => {
+            assert.isOk(leftImage);
+            assert.equal(leftImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile1.body);
+            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+            leftLoaded = true;
+            if (rightLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+
+          rightImage.addEventListener('load', () => {
+            assert.isOk(rightImage);
+            assert.equal(rightImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile2.body);
+            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+            rightLoaded = true;
+            if (leftLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+        };
+
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.baseImage._name = mockDiff.meta_a.name;
+        element.revisionImage = mockFile2;
+        element.revisionImage._name = mockDiff.meta_b.name;
+        element.diff = mockDiff;
+      });
+
+      test('renders added image', done => {
+        const mockDiff = {
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'ADDED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 0000000..f9c2f2c 100644',
+            '--- /dev/null',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        function rendered() {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          const rightImage = element.$.diffTable.querySelector('td.right img');
+
+          assert.isNotOk(leftImage);
+          assert.isOk(rightImage);
+          done();
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
+
+        element.revisionImage = mockFile2;
+        element.diff = mockDiff;
+      });
+
+      test('renders removed image', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        function rendered() {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          const rightImage = element.$.diffTable.querySelector('td.right img');
+
+          assert.isOk(leftImage);
+          assert.isNotOk(rightImage);
+          done();
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+      });
+
+      test('does not render disallowed image type', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        mockFile1.type = 'image/jpeg-evil';
+
+        function rendered() {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          assert.isNotOk(leftImage);
+          done();
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+      });
+    });
+
+    test('_handleTap lineNum', done => {
+      const addDraftStub = sandbox.stub(element, 'addDraftAtLine');
+      const el = document.createElement('div');
+      el.className = 'lineNum';
+      el.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(addDraftStub.called);
+        assert.equal(addDraftStub.lastCall.args[0], el);
+        done();
+      });
+      el.click();
+    });
+
+    test('_handleTap context', done => {
+      const showContextStub =
+          sandbox.stub(element.$.diffBuilder, 'showContext');
+      const el = document.createElement('div');
+      el.className = 'showContext';
+      el.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(showContextStub.called);
+        done();
+      });
+      el.click();
+    });
+
+    test('_handleTap content', done => {
+      const content = document.createElement('div');
+      const lineEl = document.createElement('div');
+
+      const selectStub = sandbox.stub(element, '_selectLine');
+      sandbox.stub(element.$.diffBuilder, 'getLineElByChild', () => lineEl);
+
+      content.className = 'content';
+      content.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(selectStub.called);
+        assert.equal(selectStub.lastCall.args[0], lineEl);
+        done();
+      });
+      content.click();
+    });
+
+    suite('getCursorStops', () => {
+      const setupDiff = function() {
+        const mock = document.createElement('mock-diff-response');
+        element.diff = mock.diffResponse;
+        element.prefs = {
+          context: 10,
+          tab_size: 8,
+          font_size: 12,
+          line_length: 100,
+          cursor_blink_rate: 0,
+          line_wrapping: false,
+          intraline_difference: true,
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          auto_hide_diff_table_header: true,
+          theme: 'DEFAULT',
+          ignore_whitespace: 'IGNORE_NONE',
+        };
 
         element._renderDiffTable();
         flushAsynchronousOperations();
       };
 
-      test('show the message if ignore_whitespace is criteria matches', () => {
-        setupDiff('IGNORE_ALL', [{skip: 100}]);
-        assert.isTrue(element.showNoChangeMessage(
-            /* loading= */ false,
-            element.prefs,
-            element._diffLength
-        ));
+      test('getCursorStops returns [] when hidden and noAutoRender', () => {
+        element.noAutoRender = true;
+        setupDiff();
+        element.hidden = true;
+        assert.equal(element.getCursorStops().length, 0);
       });
 
-      test('do not show the message if still loading', () => {
-        setupDiff('IGNORE_ALL', [{skip: 100}]);
-        assert.isFalse(element.showNoChangeMessage(
-            /* loading= */ true,
-            element.prefs,
-            element._diffLength
-        ));
-      });
-
-      test('do not show the message if contains valid changes', () => {
-        const content = [{
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        }, {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        }];
-        setupDiff('IGNORE_ALL', content);
-        assert.equal(element._diffLength, 3);
-        assert.isFalse(element.showNoChangeMessage(
-            /* loading= */ false,
-            element.prefs,
-            element._diffLength
-        ));
-      });
-
-      test('do not show message if ignore whitespace is disabled', () => {
-        const content = [{
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        }, {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        }];
-        setupDiff('IGNORE_NONE', content);
-        assert.isFalse(element.showNoChangeMessage(
-            /* loading= */ false,
-            element.prefs,
-            element._diffLength
-        ));
+      test('getCursorStops', () => {
+        setupDiff();
+        assert.equal(element.getCursorStops().length, 50);
       });
     });
 
-    test('getDiffLength', () => {
-      const diff = document.createElement('mock-diff-response').diffResponse;
-      assert.equal(element.getDiffLength(diff), 52);
+    test('adds .hiddenscroll', () => {
+      Gerrit.hiddenscroll = true;
+      element.displayLine = true;
+      assert.include(element.shadowRoot
+          .querySelector('.diffContainer').className, 'hiddenscroll');
     });
+  });
 
-    test('`render` event has contentRendered field in detail', done => {
+  suite('logged in', () => {
+    let fakeLineEl;
+    setup(() => {
       element = fixture('basic');
-      element.prefs = {};
-      sandbox.stub(element.$.diffBuilder, 'render')
-          .returns(Promise.resolve());
-      element.addEventListener('render', event => {
-        assert.isTrue(event.detail.contentRendered);
-        done();
+      element.loggedIn = true;
+      element.patchRange = {};
+
+      fakeLineEl = {
+        getAttribute: sandbox.stub().returns(42),
+        classList: {
+          contains: sandbox.stub().returns(true),
+        },
+      };
+    });
+
+    test('addDraftAtLine', () => {
+      sandbox.stub(element, '_selectLine');
+      sandbox.stub(element, '_createComment');
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(element._createComment
+          .calledWithExactly(fakeLineEl, 42));
+    });
+
+    test('addDraftAtLine on an edit', () => {
+      element.patchRange.basePatchNum = element.EDIT_NAME;
+      sandbox.stub(element, '_selectLine');
+      sandbox.stub(element, '_createComment');
+      const alertSpy = sandbox.spy();
+      element.addEventListener('show-alert', alertSpy);
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(alertSpy.called);
+      assert.isFalse(element._createComment.called);
+    });
+
+    test('addDraftAtLine on an edit base', () => {
+      element.patchRange.patchNum = element.EDIT_NAME;
+      element.patchRange.basePatchNum = element.PARENT_NAME;
+      sandbox.stub(element, '_selectLine');
+      sandbox.stub(element, '_createComment');
+      const alertSpy = sandbox.spy();
+      element.addEventListener('show-alert', alertSpy);
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(alertSpy.called);
+      assert.isFalse(element._createComment.called);
+    });
+
+    suite('change in preferences', () => {
+      setup(() => {
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          diff_header: [],
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          content: [{skip: 66}],
+        };
+        element.flushDebouncer('renderDiffTable');
       });
+
+      test('change in preferences re-renders diff', () => {
+        sandbox.stub(element, '_renderDiffTable');
+        element.prefs = Object.assign(
+            {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
+        element.flushDebouncer('renderDiffTable');
+        assert.isTrue(element._renderDiffTable.called);
+      });
+
+      test('change in preferences does not re-renders diff with ' +
+          'noRenderOnPrefsChange', () => {
+        sandbox.stub(element, '_renderDiffTable');
+        element.noRenderOnPrefsChange = true;
+        element.prefs = Object.assign(
+            {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
+        element.flushDebouncer('renderDiffTable');
+        assert.isFalse(element._renderDiffTable.called);
+      });
+    });
+  });
+
+  suite('diff header', () => {
+    setup(() => {
+      element = fixture('basic');
+      element.diff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+          lines: 560},
+        diff_header: [],
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        content: [{skip: 66}],
+      };
+    });
+
+    test('hidden', () => {
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', '--- a/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', '+++ b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'test');
+      assert.equal(element._diffHeaderItems.length, 1);
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.diffHeader.textContent.trim(), 'test');
+    });
+
+    test('binary files', () => {
+      element.diff.binary = true;
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'test');
+      assert.equal(element._diffHeaderItems.length, 1);
+      element.push('diff.diff_header', 'Binary files differ');
+      assert.equal(element._diffHeaderItems.length, 1);
+    });
+  });
+
+  suite('safety and bypass', () => {
+    let renderStub;
+
+    setup(() => {
+      element = fixture('basic');
+      renderStub = sandbox.stub(element.$.diffBuilder, 'render',
+          () => {
+            element.$.diffBuilder.dispatchEvent(
+                new CustomEvent('render', {bubbles: true, composed: true}));
+            return Promise.resolve({});
+          });
+      const mock = document.createElement('mock-diff-response');
+      sandbox.stub(element, 'getDiffLength').returns(10000);
+      element.diff = mock.diffResponse;
+      element.noRenderOnPrefsChange = true;
+    });
+
+    test('large render w/ context = 10', done => {
+      element.prefs = Object.assign({}, MINIMAL_PREFS, {context: 10});
+      function rendered() {
+        assert.isTrue(renderStub.called);
+        assert.isFalse(element._showWarning);
+        done();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+    });
+
+    test('large render w/ whole file and bypass', done => {
+      element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
+      element._safetyBypass = 10;
+      function rendered() {
+        assert.isTrue(renderStub.called);
+        assert.isFalse(element._showWarning);
+        done();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+    });
+
+    test('large render w/ whole file and no bypass', done => {
+      element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
+      function rendered() {
+        assert.isFalse(renderStub.called);
+        assert.isTrue(element._showWarning);
+        done();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
       element._renderDiffTable();
     });
   });
 
-  a11ySuite('basic');
+  suite('blame', () => {
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('unsetting', () => {
+      element.blame = [];
+      const setBlameSpy = sandbox.spy(element.$.diffBuilder, 'setBlame');
+      element.classList.add('showBlame');
+      element.blame = null;
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isFalse(element.classList.contains('showBlame'));
+    });
+
+    test('setting', () => {
+      const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+      element.blame = mockBlame;
+      assert.isTrue(element.classList.contains('showBlame'));
+    });
+  });
+
+  suite('trailing newline warnings', () => {
+    const NO_NEWLINE_BASE = 'No newline at end of base file.';
+    const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+
+    const getWarning = element =>
+      element.shadowRoot.querySelector('.newlineWarning').textContent;
+
+    setup(() => {
+      element = fixture('basic');
+      element.showNewlineWarningLeft = false;
+      element.showNewlineWarningRight = false;
+    });
+
+    test('shows combined warning if both sides set to warn', () => {
+      element.showNewlineWarningLeft = true;
+      element.showNewlineWarningRight = true;
+      assert.include(getWarning(element),
+          NO_NEWLINE_BASE + ' — ' + NO_NEWLINE_REVISION);
+    });
+
+    suite('showNewlineWarningLeft', () => {
+      test('show warning if true', () => {
+        element.showNewlineWarningLeft = true;
+        assert.include(getWarning(element), NO_NEWLINE_BASE);
+      });
+
+      test('hide warning if false', () => {
+        element.showNewlineWarningLeft = false;
+        assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
+      });
+
+      test('hide warning if undefined', () => {
+        element.showNewlineWarningLeft = undefined;
+        assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
+      });
+    });
+
+    suite('showNewlineWarningRight', () => {
+      test('show warning if true', () => {
+        element.showNewlineWarningRight = true;
+        assert.include(getWarning(element), NO_NEWLINE_REVISION);
+      });
+
+      test('hide warning if false', () => {
+        element.showNewlineWarningRight = false;
+        assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
+      });
+
+      test('hide warning if undefined', () => {
+        element.showNewlineWarningRight = undefined;
+        assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
+      });
+    });
+
+    test('_computeNewlineWarningClass', () => {
+      const hidden = 'newlineWarning hidden';
+      const shown = 'newlineWarning';
+      assert.equal(element._computeNewlineWarningClass(null, true), hidden);
+      assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
+      assert.equal(element._computeNewlineWarningClass(null, false), hidden);
+      assert.equal(element._computeNewlineWarningClass('foo', false), shown);
+    });
+  });
+
+  suite('key locations', () => {
+    let renderStub;
+
+    setup(() => {
+      element = fixture('basic');
+      element.prefs = {};
+      renderStub = sandbox.stub(element.$.diffBuilder, 'render')
+          .returns(new Promise(() => {}));
+    });
+
+    test('lineOfInterest is a key location', () => {
+      element.lineOfInterest = {number: 789, leftSide: true};
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {789: true},
+        right: {},
+      });
+    });
+
+    test('line comments are key locations', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('line-num', 3);
+      dom(element).appendChild(threadEl);
+      flush();
+
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {},
+        right: {3: true},
+      });
+    });
+
+    test('file comments are key locations', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'left');
+      dom(element).appendChild(threadEl);
+      flush();
+
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {FILE: true},
+        right: {},
+      });
+    });
+  });
+
+  suite('whitespace changes only message', () => {
+    const setupDiff = function(ignore_whitespace, diffContent) {
+      element = fixture('basic');
+      element.prefs = {
+        ignore_whitespace,
+        auto_hide_diff_table_header: true,
+        context: 10,
+        cursor_blink_rate: 0,
+        font_size: 12,
+        intraline_difference: true,
+        line_length: 100,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        tab_size: 8,
+        theme: 'DEFAULT',
+      };
+
+      element.diff = {
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        diff_header: [
+          'diff --git a/carrot.js b/carrot.js',
+          'index 2adc47d..f9c2f2c 100644',
+          '--- a/carrot.js',
+          '+++ b/carrot.jjs',
+          'file differ',
+        ],
+        content: diffContent,
+        binary: true,
+      };
+
+      element._renderDiffTable();
+      flushAsynchronousOperations();
+    };
+
+    test('show the message if ignore_whitespace is criteria matches', () => {
+      setupDiff('IGNORE_ALL', [{skip: 100}]);
+      assert.isTrue(element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength
+      ));
+    });
+
+    test('do not show the message if still loading', () => {
+      setupDiff('IGNORE_ALL', [{skip: 100}]);
+      assert.isFalse(element.showNoChangeMessage(
+          /* loading= */ true,
+          element.prefs,
+          element._diffLength
+      ));
+    });
+
+    test('do not show the message if contains valid changes', () => {
+      const content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+      setupDiff('IGNORE_ALL', content);
+      assert.equal(element._diffLength, 3);
+      assert.isFalse(element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength
+      ));
+    });
+
+    test('do not show message if ignore whitespace is disabled', () => {
+      const content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+      setupDiff('IGNORE_NONE', content);
+      assert.isFalse(element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength
+      ));
+    });
+  });
+
+  test('getDiffLength', () => {
+    const diff = document.createElement('mock-diff-response').diffResponse;
+    assert.equal(element.getDiffLength(diff), 52);
+  });
+
+  test('`render` event has contentRendered field in detail', done => {
+    element = fixture('basic');
+    element.prefs = {};
+    sandbox.stub(element.$.diffBuilder, 'render')
+        .returns(Promise.resolve());
+    element.addEventListener('render', event => {
+      assert.isTrue(event.detail.contentRendered);
+      done();
+    });
+    element._renderDiffTable();
+  });
+});
+
+a11ySuite('basic');
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index d24e0bc..f2b0599 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,278 +14,290 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Maximum length for patch set descriptions.
-  const PATCH_DESC_MAX_LENGTH = 500;
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
+import '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
+import '../../shared/gr-select/gr-select.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-patch-range-select_html.js';
 
-  /**
-   * @appliesMixin Gerrit.PatchSetMixin
-   */
-  /**
-   * Fired when the patch range changes
-   *
-   * @event patch-range-change
-   *
-   * @property {string} patchNum
-   * @property {string} basePatchNum
-   * @extends Polymer.Element
-   */
-  class GrPatchRangeSelect extends Polymer.mixinBehaviors( [
-    Gerrit.PatchSetBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-patch-range-select'; }
+// Maximum length for patch set descriptions.
+const PATCH_DESC_MAX_LENGTH = 500;
 
-    static get properties() {
-      return {
-        availablePatches: Array,
-        _baseDropdownContent: {
-          type: Object,
-          computed: '_computeBaseDropdownContent(availablePatches, patchNum,' +
-            '_sortedRevisions, changeComments, revisionInfo)',
-        },
-        _patchDropdownContent: {
-          type: Object,
-          computed: '_computePatchDropdownContent(availablePatches,' +
-            'basePatchNum, _sortedRevisions, changeComments)',
-        },
-        changeNum: String,
-        changeComments: Object,
-        /** @type {{ meta_a: !Array, meta_b: !Array}} */
-        filesWeblinks: Object,
-        patchNum: String,
-        basePatchNum: String,
-        revisions: Object,
-        revisionInfo: Object,
-        _sortedRevisions: Array,
-      };
-    }
+/**
+ * @appliesMixin Gerrit.PatchSetMixin
+ */
+/**
+ * Fired when the patch range changes
+ *
+ * @event patch-range-change
+ *
+ * @property {string} patchNum
+ * @property {string} basePatchNum
+ * @extends Polymer.Element
+ */
+class GrPatchRangeSelect extends mixinBehaviors( [
+  Gerrit.PatchSetBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    static get observers() {
-      return [
-        '_updateSortedRevisions(revisions.*)',
-      ];
-    }
+  static get is() { return 'gr-patch-range-select'; }
 
-    _getShaForPatch(patch) {
-      return patch.sha.substring(0, 10);
-    }
-
-    _computeBaseDropdownContent(availablePatches, patchNum, _sortedRevisions,
-        changeComments, revisionInfo) {
-      // Polymer 2: check for undefined
-      if ([
-        availablePatches,
-        patchNum,
-        _sortedRevisions,
-        changeComments,
-        revisionInfo,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      const parentCounts = revisionInfo.getParentCountMap();
-      const currentParentCount = parentCounts.hasOwnProperty(patchNum) ?
-        parentCounts[patchNum] : 1;
-      const maxParents = revisionInfo.getMaxParents();
-      const isMerge = currentParentCount > 1;
-
-      const dropdownContent = [];
-      for (const basePatch of availablePatches) {
-        const basePatchNum = basePatch.num;
-        const entry = this._createDropdownEntry(basePatchNum, 'Patchset ',
-            _sortedRevisions, changeComments, this._getShaForPatch(basePatch));
-        dropdownContent.push(Object.assign({}, entry, {
-          disabled: this._computeLeftDisabled(
-              basePatch.num, patchNum, _sortedRevisions),
-        }));
-      }
-
-      dropdownContent.push({
-        text: isMerge ? 'Auto Merge' : 'Base',
-        value: 'PARENT',
-      });
-
-      for (let idx = 0; isMerge && idx < maxParents; idx++) {
-        dropdownContent.push({
-          disabled: idx >= currentParentCount,
-          triggerText: `Parent ${idx + 1}`,
-          text: `Parent ${idx + 1}`,
-          mobileText: `Parent ${idx + 1}`,
-          value: -(idx + 1),
-        });
-      }
-
-      return dropdownContent;
-    }
-
-    _computeMobileText(patchNum, changeComments, revisions) {
-      return `${patchNum}` +
-          `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
-          `${this._computePatchSetDescription(revisions, patchNum, true)}`;
-    }
-
-    _computePatchDropdownContent(availablePatches, basePatchNum,
-        _sortedRevisions, changeComments) {
-      // Polymer 2: check for undefined
-      if ([
-        availablePatches,
-        basePatchNum,
-        _sortedRevisions,
-        changeComments,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      const dropdownContent = [];
-      for (const patch of availablePatches) {
-        const patchNum = patch.num;
-        const entry = this._createDropdownEntry(
-            patchNum, patchNum === 'edit' ? '' : 'Patchset ', _sortedRevisions,
-            changeComments, this._getShaForPatch(patch));
-        dropdownContent.push(Object.assign({}, entry, {
-          disabled: this._computeRightDisabled(basePatchNum, patchNum,
-              _sortedRevisions),
-        }));
-      }
-      return dropdownContent;
-    }
-
-    _computeText(patchNum, prefix, changeComments, sha) {
-      return `${prefix}${patchNum}` +
-        `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
-          (` | ${sha}`);
-    }
-
-    _createDropdownEntry(patchNum, prefix, sortedRevisions, changeComments,
-        sha) {
-      const entry = {
-        triggerText: `${prefix}${patchNum}`,
-        text: this._computeText(patchNum, prefix, changeComments, sha),
-        mobileText: this._computeMobileText(patchNum, changeComments,
-            sortedRevisions),
-        bottomText: `${this._computePatchSetDescription(
-            sortedRevisions, patchNum)}`,
-        value: patchNum,
-      };
-      const date = this._computePatchSetDate(sortedRevisions, patchNum);
-      if (date) {
-        entry['date'] = date;
-      }
-      return entry;
-    }
-
-    _updateSortedRevisions(revisionsRecord) {
-      const revisions = revisionsRecord.base;
-      this._sortedRevisions = this.sortRevisions(Object.values(revisions));
-    }
-
-    /**
-     * The basePatchNum should always be <= patchNum -- because sortedRevisions
-     * is sorted in reverse order (higher patchset nums first), invalid base
-     * patch nums have an index greater than the index of patchNum.
-     *
-     * @param {number|string} basePatchNum The possible base patch num.
-     * @param {number|string} patchNum The current selected patch num.
-     * @param {!Array} sortedRevisions
-     */
-    _computeLeftDisabled(basePatchNum, patchNum, sortedRevisions) {
-      return this.findSortedIndex(basePatchNum, sortedRevisions) <=
-          this.findSortedIndex(patchNum, sortedRevisions);
-    }
-
-    /**
-     * The basePatchNum should always be <= patchNum -- because sortedRevisions
-     * is sorted in reverse order (higher patchset nums first), invalid patch
-     * nums have an index greater than the index of basePatchNum.
-     *
-     * In addition, if the current basePatchNum is 'PARENT', all patchNums are
-     * valid.
-     *
-     * If the curent basePatchNum is a parent index, then only patches that have
-     * at least that many parents are valid.
-     *
-     * @param {number|string} basePatchNum The current selected base patch num.
-     * @param {number|string} patchNum The possible patch num.
-     * @param {!Array} sortedRevisions
-     * @return {boolean}
-     */
-    _computeRightDisabled(basePatchNum, patchNum, sortedRevisions) {
-      if (this.patchNumEquals(basePatchNum, 'PARENT')) { return false; }
-
-      if (this.isMergeParent(basePatchNum)) {
-        // Note: parent indices use 1-offset.
-        return this.revisionInfo.getParentCount(patchNum) <
-            this.getParentIndex(basePatchNum);
-      }
-
-      return this.findSortedIndex(basePatchNum, sortedRevisions) <=
-          this.findSortedIndex(patchNum, sortedRevisions);
-    }
-
-    _computePatchSetCommentsString(changeComments, patchNum) {
-      if (!changeComments) { return; }
-
-      const commentCount = changeComments.computeCommentCount(patchNum);
-      const commentString = GrCountStringFormatter.computePluralString(
-          commentCount, 'comment');
-
-      const unresolvedCount = changeComments.computeUnresolvedNum(patchNum);
-      const unresolvedString = GrCountStringFormatter.computeString(
-          unresolvedCount, 'unresolved');
-
-      if (!commentString.length && !unresolvedString.length) {
-        return '';
-      }
-
-      return ` (${commentString}` +
-          // Add a comma + space if both comments and unresolved
-          (commentString && unresolvedString ? ', ' : '') +
-          `${unresolvedString})`;
-    }
-
-    /**
-     * @param {!Array} revisions
-     * @param {number|string} patchNum
-     * @param {boolean=} opt_addFrontSpace
-     */
-    _computePatchSetDescription(revisions, patchNum, opt_addFrontSpace) {
-      const rev = this.getRevisionByPatchNum(revisions, patchNum);
-      return (rev && rev.description) ?
-        (opt_addFrontSpace ? ' ' : '') +
-          rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
-    }
-
-    /**
-     * @param {!Array} revisions
-     * @param {number|string} patchNum
-     */
-    _computePatchSetDate(revisions, patchNum) {
-      const rev = this.getRevisionByPatchNum(revisions, patchNum);
-      return rev ? rev.created : undefined;
-    }
-
-    /**
-     * Catches value-change events from the patchset dropdowns and determines
-     * whether or not a patch change event should be fired.
-     */
-    _handlePatchChange(e) {
-      const detail = {patchNum: this.patchNum, basePatchNum: this.basePatchNum};
-      const target = Polymer.dom(e).localTarget;
-
-      if (target === this.$.patchNumDropdown) {
-        detail.patchNum = e.detail.value;
-      } else {
-        detail.basePatchNum = e.detail.value;
-      }
-
-      this.dispatchEvent(
-          new CustomEvent('patch-range-change', {detail, bubbles: false}));
-    }
+  static get properties() {
+    return {
+      availablePatches: Array,
+      _baseDropdownContent: {
+        type: Object,
+        computed: '_computeBaseDropdownContent(availablePatches, patchNum,' +
+          '_sortedRevisions, changeComments, revisionInfo)',
+      },
+      _patchDropdownContent: {
+        type: Object,
+        computed: '_computePatchDropdownContent(availablePatches,' +
+          'basePatchNum, _sortedRevisions, changeComments)',
+      },
+      changeNum: String,
+      changeComments: Object,
+      /** @type {{ meta_a: !Array, meta_b: !Array}} */
+      filesWeblinks: Object,
+      patchNum: String,
+      basePatchNum: String,
+      revisions: Object,
+      revisionInfo: Object,
+      _sortedRevisions: Array,
+    };
   }
 
-  customElements.define(GrPatchRangeSelect.is, GrPatchRangeSelect);
-})();
+  static get observers() {
+    return [
+      '_updateSortedRevisions(revisions.*)',
+    ];
+  }
+
+  _getShaForPatch(patch) {
+    return patch.sha.substring(0, 10);
+  }
+
+  _computeBaseDropdownContent(availablePatches, patchNum, _sortedRevisions,
+      changeComments, revisionInfo) {
+    // Polymer 2: check for undefined
+    if ([
+      availablePatches,
+      patchNum,
+      _sortedRevisions,
+      changeComments,
+      revisionInfo,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const parentCounts = revisionInfo.getParentCountMap();
+    const currentParentCount = parentCounts.hasOwnProperty(patchNum) ?
+      parentCounts[patchNum] : 1;
+    const maxParents = revisionInfo.getMaxParents();
+    const isMerge = currentParentCount > 1;
+
+    const dropdownContent = [];
+    for (const basePatch of availablePatches) {
+      const basePatchNum = basePatch.num;
+      const entry = this._createDropdownEntry(basePatchNum, 'Patchset ',
+          _sortedRevisions, changeComments, this._getShaForPatch(basePatch));
+      dropdownContent.push(Object.assign({}, entry, {
+        disabled: this._computeLeftDisabled(
+            basePatch.num, patchNum, _sortedRevisions),
+      }));
+    }
+
+    dropdownContent.push({
+      text: isMerge ? 'Auto Merge' : 'Base',
+      value: 'PARENT',
+    });
+
+    for (let idx = 0; isMerge && idx < maxParents; idx++) {
+      dropdownContent.push({
+        disabled: idx >= currentParentCount,
+        triggerText: `Parent ${idx + 1}`,
+        text: `Parent ${idx + 1}`,
+        mobileText: `Parent ${idx + 1}`,
+        value: -(idx + 1),
+      });
+    }
+
+    return dropdownContent;
+  }
+
+  _computeMobileText(patchNum, changeComments, revisions) {
+    return `${patchNum}` +
+        `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
+        `${this._computePatchSetDescription(revisions, patchNum, true)}`;
+  }
+
+  _computePatchDropdownContent(availablePatches, basePatchNum,
+      _sortedRevisions, changeComments) {
+    // Polymer 2: check for undefined
+    if ([
+      availablePatches,
+      basePatchNum,
+      _sortedRevisions,
+      changeComments,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const dropdownContent = [];
+    for (const patch of availablePatches) {
+      const patchNum = patch.num;
+      const entry = this._createDropdownEntry(
+          patchNum, patchNum === 'edit' ? '' : 'Patchset ', _sortedRevisions,
+          changeComments, this._getShaForPatch(patch));
+      dropdownContent.push(Object.assign({}, entry, {
+        disabled: this._computeRightDisabled(basePatchNum, patchNum,
+            _sortedRevisions),
+      }));
+    }
+    return dropdownContent;
+  }
+
+  _computeText(patchNum, prefix, changeComments, sha) {
+    return `${prefix}${patchNum}` +
+      `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
+        (` | ${sha}`);
+  }
+
+  _createDropdownEntry(patchNum, prefix, sortedRevisions, changeComments,
+      sha) {
+    const entry = {
+      triggerText: `${prefix}${patchNum}`,
+      text: this._computeText(patchNum, prefix, changeComments, sha),
+      mobileText: this._computeMobileText(patchNum, changeComments,
+          sortedRevisions),
+      bottomText: `${this._computePatchSetDescription(
+          sortedRevisions, patchNum)}`,
+      value: patchNum,
+    };
+    const date = this._computePatchSetDate(sortedRevisions, patchNum);
+    if (date) {
+      entry['date'] = date;
+    }
+    return entry;
+  }
+
+  _updateSortedRevisions(revisionsRecord) {
+    const revisions = revisionsRecord.base;
+    this._sortedRevisions = this.sortRevisions(Object.values(revisions));
+  }
+
+  /**
+   * The basePatchNum should always be <= patchNum -- because sortedRevisions
+   * is sorted in reverse order (higher patchset nums first), invalid base
+   * patch nums have an index greater than the index of patchNum.
+   *
+   * @param {number|string} basePatchNum The possible base patch num.
+   * @param {number|string} patchNum The current selected patch num.
+   * @param {!Array} sortedRevisions
+   */
+  _computeLeftDisabled(basePatchNum, patchNum, sortedRevisions) {
+    return this.findSortedIndex(basePatchNum, sortedRevisions) <=
+        this.findSortedIndex(patchNum, sortedRevisions);
+  }
+
+  /**
+   * The basePatchNum should always be <= patchNum -- because sortedRevisions
+   * is sorted in reverse order (higher patchset nums first), invalid patch
+   * nums have an index greater than the index of basePatchNum.
+   *
+   * In addition, if the current basePatchNum is 'PARENT', all patchNums are
+   * valid.
+   *
+   * If the curent basePatchNum is a parent index, then only patches that have
+   * at least that many parents are valid.
+   *
+   * @param {number|string} basePatchNum The current selected base patch num.
+   * @param {number|string} patchNum The possible patch num.
+   * @param {!Array} sortedRevisions
+   * @return {boolean}
+   */
+  _computeRightDisabled(basePatchNum, patchNum, sortedRevisions) {
+    if (this.patchNumEquals(basePatchNum, 'PARENT')) { return false; }
+
+    if (this.isMergeParent(basePatchNum)) {
+      // Note: parent indices use 1-offset.
+      return this.revisionInfo.getParentCount(patchNum) <
+          this.getParentIndex(basePatchNum);
+    }
+
+    return this.findSortedIndex(basePatchNum, sortedRevisions) <=
+        this.findSortedIndex(patchNum, sortedRevisions);
+  }
+
+  _computePatchSetCommentsString(changeComments, patchNum) {
+    if (!changeComments) { return; }
+
+    const commentCount = changeComments.computeCommentCount(patchNum);
+    const commentString = GrCountStringFormatter.computePluralString(
+        commentCount, 'comment');
+
+    const unresolvedCount = changeComments.computeUnresolvedNum(patchNum);
+    const unresolvedString = GrCountStringFormatter.computeString(
+        unresolvedCount, 'unresolved');
+
+    if (!commentString.length && !unresolvedString.length) {
+      return '';
+    }
+
+    return ` (${commentString}` +
+        // Add a comma + space if both comments and unresolved
+        (commentString && unresolvedString ? ', ' : '') +
+        `${unresolvedString})`;
+  }
+
+  /**
+   * @param {!Array} revisions
+   * @param {number|string} patchNum
+   * @param {boolean=} opt_addFrontSpace
+   */
+  _computePatchSetDescription(revisions, patchNum, opt_addFrontSpace) {
+    const rev = this.getRevisionByPatchNum(revisions, patchNum);
+    return (rev && rev.description) ?
+      (opt_addFrontSpace ? ' ' : '') +
+        rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
+  }
+
+  /**
+   * @param {!Array} revisions
+   * @param {number|string} patchNum
+   */
+  _computePatchSetDate(revisions, patchNum) {
+    const rev = this.getRevisionByPatchNum(revisions, patchNum);
+    return rev ? rev.created : undefined;
+  }
+
+  /**
+   * Catches value-change events from the patchset dropdowns and determines
+   * whether or not a patch change event should be fired.
+   */
+  _handlePatchChange(e) {
+    const detail = {patchNum: this.patchNum, basePatchNum: this.basePatchNum};
+    const target = dom(e).localTarget;
+
+    if (target === this.$.patchNumDropdown) {
+      detail.patchNum = e.detail.value;
+    } else {
+      detail.basePatchNum = e.detail.value;
+    }
+
+    this.dispatchEvent(
+        new CustomEvent('patch-range-change', {detail, bubbles: false}));
+  }
+}
+
+customElements.define(GrPatchRangeSelect.is, GrPatchRangeSelect);
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
index ee1f536..5779a90 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-dropdown-list/gr-dropdown-list.html">
-<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-patch-range-select">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         align-items: center;
@@ -59,34 +52,22 @@
       }
     </style>
     <span class="patchRange">
-      <gr-dropdown-list
-          id="basePatchDropdown"
-          value="[[basePatchNum]]"
-          on-value-change="_handlePatchChange"
-          items="[[_baseDropdownContent]]">
+      <gr-dropdown-list id="basePatchDropdown" value="[[basePatchNum]]" on-value-change="_handlePatchChange" items="[[_baseDropdownContent]]">
       </gr-dropdown-list>
     </span>
     <span is="dom-if" if="[[filesWeblinks.meta_a]]" class="filesWeblinks">
       <template is="dom-repeat" items="[[filesWeblinks.meta_a]]" as="weblink">
-        <a target="_blank" rel="noopener"
-           href$="[[weblink.url]]">[[weblink.name]]</a>
+        <a target="_blank" rel="noopener" href\$="[[weblink.url]]">[[weblink.name]]</a>
       </template>
     </span>
-    <span class="arrow">&rarr;</span>
+    <span class="arrow">→</span>
     <span class="patchRange">
-      <gr-dropdown-list
-          id="patchNumDropdown"
-          value="[[patchNum]]"
-          on-value-change="_handlePatchChange"
-          items="[[_patchDropdownContent]]">
+      <gr-dropdown-list id="patchNumDropdown" value="[[patchNum]]" on-value-change="_handlePatchChange" items="[[_patchDropdownContent]]">
       </gr-dropdown-list>
       <span is="dom-if" if="[[filesWeblinks.meta_b]]" class="filesWeblinks">
         <template is="dom-repeat" items="[[filesWeblinks.meta_b]]" as="weblink">
-          <a target="_blank"
-             href$="[[weblink.url]]">[[weblink.name]]</a>
+          <a target="_blank" href\$="[[weblink.url]]">[[weblink.name]]</a>
         </template>
       </span>
     </span>
-  </template>
-  <script src="gr-patch-range-select.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
index 65dedef..3c07750 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -19,21 +19,30 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-patch-range-select</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
-<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
-<link rel="import" href="../../shared/revision-info/revision-info.html">
+<script type="module" src="../gr-comment-api/gr-comment-api.js"></script>
+<script type="module" src="../../shared/gr-rest-api-interface/mock-diff-response_test.js"></script>
+<script type="module" src="../../shared/revision-info/revision-info.js"></script>
 
-<link rel="import" href="gr-patch-range-select.html">
+<script type="module" src="./gr-patch-range-select.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-comment-api/gr-comment-api.js';
+import '../../shared/gr-rest-api-interface/mock-diff-response_test.js';
+import '../../shared/revision-info/revision-info.js';
+import './gr-patch-range-select.js';
+import '../gr-comment-api/gr-comment-api-mock_test.js';
+void(0);
+</script>
 
 <dom-module id="comment-api-mock">
   <template>
@@ -41,7 +50,7 @@
         change-comments="[[_changeComments]]"></gr-patch-range-select>
     <gr-comment-api id="commentAPI"></gr-comment-api>
   </template>
-  <script src="../../diff/gr-comment-api/gr-comment-api-mock_test.js"></script>
+  <script type="module" src="../gr-comment-api/gr-comment-api-mock_test.js"></script>
 </dom-module>
 
 <test-fixture id="basic">
@@ -50,384 +59,391 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-patch-range-select tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let commentApiWrapper;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-comment-api/gr-comment-api.js';
+import '../../shared/gr-rest-api-interface/mock-diff-response_test.js';
+import '../../shared/revision-info/revision-info.js';
+import './gr-patch-range-select.js';
+import '../gr-comment-api/gr-comment-api-mock_test.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-patch-range-select tests', () => {
+  let element;
+  let sandbox;
+  let commentApiWrapper;
 
-    function getInfo(revisions) {
-      const revisionObj = {};
-      for (let i = 0; i < revisions.length; i++) {
-        revisionObj[i] = revisions[i];
-      }
-      return new Gerrit.RevisionInfo({revisions: revisionObj});
+  function getInfo(revisions) {
+    const revisionObj = {};
+    for (let i = 0; i < revisions.length; i++) {
+      revisionObj[i] = revisions[i];
     }
+    return new Gerrit.RevisionInfo({revisions: revisionObj});
+  }
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
 
-      stub('gr-rest-api-interface', {
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
+    stub('gr-rest-api-interface', {
+      getDiffComments() { return Promise.resolve({}); },
+      getDiffRobotComments() { return Promise.resolve({}); },
+      getDiffDrafts() { return Promise.resolve({}); },
+    });
+
+    // Element must be wrapped in an element with direct access to the
+    // comment API.
+    commentApiWrapper = fixture('basic');
+    element = commentApiWrapper.$.patchRange;
+
+    // Stub methods on the changeComments object after changeComments has
+    // been initialized.
+    return commentApiWrapper.loadComments();
+  });
+
+  teardown(() => sandbox.restore());
+
+  test('enabled/disabled options', () => {
+    const patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: '3',
+    };
+    const sortedRevisions = [
+      {_number: 3},
+      {_number: element.EDIT_NAME, basePatchNum: 2},
+      {_number: 2},
+      {_number: 1},
+    ];
+    for (const patchNum of ['1', '2', '3']) {
+      assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum,
+          patchNum, sortedRevisions));
+    }
+    for (const basePatchNum of ['1', '2']) {
+      assert.isFalse(element._computeLeftDisabled(basePatchNum,
+          patchRange.patchNum, sortedRevisions));
+    }
+    assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum));
+
+    patchRange.basePatchNum = element.EDIT_NAME;
+    assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum,
+        sortedRevisions));
+    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '1',
+        sortedRevisions));
+    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '2',
+        sortedRevisions));
+    assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum, '3',
+        sortedRevisions));
+    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum,
+        element.EDIT_NAME, sortedRevisions));
+  });
+
+  test('_computeBaseDropdownContent', () => {
+    const availablePatches = [
+      {num: 'edit', sha: '1'},
+      {num: 3, sha: '2'},
+      {num: 2, sha: '3'},
+      {num: 1, sha: '4'},
+    ];
+    const revisions = [
+      {
+        commit: {parents: []},
+        _number: 2,
+        description: 'description',
+      },
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+    ];
+    element.revisionInfo = getInfo(revisions);
+    const patchNum = 1;
+    const sortedRevisions = [
+      {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
+      {_number: element.EDIT_NAME, basePatchNum: 2},
+      {_number: 2, description: 'description'},
+      {_number: 1},
+    ];
+    const expectedResult = [
+      {
+        disabled: true,
+        triggerText: 'Patchset edit',
+        text: 'Patchset edit | 1',
+        mobileText: 'edit',
+        bottomText: '',
+        value: 'edit',
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 3',
+        text: 'Patchset 3 | 2',
+        mobileText: '3',
+        bottomText: '',
+        value: 3,
+        date: 'Mon, 01 Jan 2001 00:00:00 GMT',
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 2',
+        text: 'Patchset 2 | 3',
+        mobileText: '2 description',
+        bottomText: 'description',
+        value: 2,
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 1',
+        text: 'Patchset 1 | 4',
+        mobileText: '1',
+        bottomText: '',
+        value: 1,
+      },
+      {
+        text: 'Base',
+        value: 'PARENT',
+      },
+    ];
+    assert.deepEqual(element._computeBaseDropdownContent(availablePatches,
+        patchNum, sortedRevisions, element.changeComments,
+        element.revisionInfo),
+    expectedResult);
+  });
+
+  test('_computeBaseDropdownContent called when patchNum updates', () => {
+    element.revisions = [
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'},
+      {num: 2, sha: '2'},
+      {num: 3, sha: '3'},
+      {num: 'edit', sha: '4'},
+    ];
+    element.patchNum = 2;
+    element.basePatchNum = 'PARENT';
+    flushAsynchronousOperations();
+
+    sandbox.stub(element, '_computeBaseDropdownContent');
+
+    // Should be recomputed for each available patch
+    element.set('patchNum', 1);
+    assert.equal(element._computeBaseDropdownContent.callCount, 1);
+  });
+
+  test('_computeBaseDropdownContent called when changeComments update',
+      done => {
+        element.revisions = [
+          {commit: {parents: []}},
+          {commit: {parents: []}},
+          {commit: {parents: []}},
+          {commit: {parents: []}},
+        ];
+        element.revisionInfo = getInfo(element.revisions);
+        element.availablePatches = [
+          {num: 'edit', sha: '1'},
+          {num: 3, sha: '2'},
+          {num: 2, sha: '3'},
+          {num: 1, sha: '4'},
+        ];
+        element.patchNum = 2;
+        element.basePatchNum = 'PARENT';
+        flushAsynchronousOperations();
+
+        // Should be recomputed for each available patch
+        sandbox.stub(element, '_computeBaseDropdownContent');
+        assert.equal(element._computeBaseDropdownContent.callCount, 0);
+        commentApiWrapper.loadComments().then()
+            .then(() => {
+              assert.equal(element._computeBaseDropdownContent.callCount, 1);
+              done();
+            });
       });
 
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = fixture('basic');
-      element = commentApiWrapper.$.patchRange;
+  test('_computePatchDropdownContent called when basePatchNum updates', () => {
+    element.revisions = [
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'},
+      {num: 2, sha: '2'},
+      {num: 3, sha: '3'},
+      {num: 'edit', sha: '4'},
+    ];
+    element.patchNum = 2;
+    element.basePatchNum = 'PARENT';
+    flushAsynchronousOperations();
 
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
-    });
-
-    teardown(() => sandbox.restore());
-
-    test('enabled/disabled options', () => {
-      const patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '3',
-      };
-      const sortedRevisions = [
-        {_number: 3},
-        {_number: element.EDIT_NAME, basePatchNum: 2},
-        {_number: 2},
-        {_number: 1},
-      ];
-      for (const patchNum of ['1', '2', '3']) {
-        assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum,
-            patchNum, sortedRevisions));
-      }
-      for (const basePatchNum of ['1', '2']) {
-        assert.isFalse(element._computeLeftDisabled(basePatchNum,
-            patchRange.patchNum, sortedRevisions));
-      }
-      assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum));
-
-      patchRange.basePatchNum = element.EDIT_NAME;
-      assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum,
-          sortedRevisions));
-      assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '1',
-          sortedRevisions));
-      assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '2',
-          sortedRevisions));
-      assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum, '3',
-          sortedRevisions));
-      assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum,
-          element.EDIT_NAME, sortedRevisions));
-    });
-
-    test('_computeBaseDropdownContent', () => {
-      const availablePatches = [
-        {num: 'edit', sha: '1'},
-        {num: 3, sha: '2'},
-        {num: 2, sha: '3'},
-        {num: 1, sha: '4'},
-      ];
-      const revisions = [
-        {
-          commit: {parents: []},
-          _number: 2,
-          description: 'description',
-        },
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-      ];
-      element.revisionInfo = getInfo(revisions);
-      const patchNum = 1;
-      const sortedRevisions = [
-        {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-        {_number: element.EDIT_NAME, basePatchNum: 2},
-        {_number: 2, description: 'description'},
-        {_number: 1},
-      ];
-      const expectedResult = [
-        {
-          disabled: true,
-          triggerText: 'Patchset edit',
-          text: 'Patchset edit | 1',
-          mobileText: 'edit',
-          bottomText: '',
-          value: 'edit',
-        },
-        {
-          disabled: true,
-          triggerText: 'Patchset 3',
-          text: 'Patchset 3 | 2',
-          mobileText: '3',
-          bottomText: '',
-          value: 3,
-          date: 'Mon, 01 Jan 2001 00:00:00 GMT',
-        },
-        {
-          disabled: true,
-          triggerText: 'Patchset 2',
-          text: 'Patchset 2 | 3',
-          mobileText: '2 description',
-          bottomText: 'description',
-          value: 2,
-        },
-        {
-          disabled: true,
-          triggerText: 'Patchset 1',
-          text: 'Patchset 1 | 4',
-          mobileText: '1',
-          bottomText: '',
-          value: 1,
-        },
-        {
-          text: 'Base',
-          value: 'PARENT',
-        },
-      ];
-      assert.deepEqual(element._computeBaseDropdownContent(availablePatches,
-          patchNum, sortedRevisions, element.changeComments,
-          element.revisionInfo),
-      expectedResult);
-    });
-
-    test('_computeBaseDropdownContent called when patchNum updates', () => {
-      element.revisions = [
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-      ];
-      element.revisionInfo = getInfo(element.revisions);
-      element.availablePatches = [
-        {num: 1, sha: '1'},
-        {num: 2, sha: '2'},
-        {num: 3, sha: '3'},
-        {num: 'edit', sha: '4'},
-      ];
-      element.patchNum = 2;
-      element.basePatchNum = 'PARENT';
-      flushAsynchronousOperations();
-
-      sandbox.stub(element, '_computeBaseDropdownContent');
-
-      // Should be recomputed for each available patch
-      element.set('patchNum', 1);
-      assert.equal(element._computeBaseDropdownContent.callCount, 1);
-    });
-
-    test('_computeBaseDropdownContent called when changeComments update',
-        done => {
-          element.revisions = [
-            {commit: {parents: []}},
-            {commit: {parents: []}},
-            {commit: {parents: []}},
-            {commit: {parents: []}},
-          ];
-          element.revisionInfo = getInfo(element.revisions);
-          element.availablePatches = [
-            {num: 'edit', sha: '1'},
-            {num: 3, sha: '2'},
-            {num: 2, sha: '3'},
-            {num: 1, sha: '4'},
-          ];
-          element.patchNum = 2;
-          element.basePatchNum = 'PARENT';
-          flushAsynchronousOperations();
-
-          // Should be recomputed for each available patch
-          sandbox.stub(element, '_computeBaseDropdownContent');
-          assert.equal(element._computeBaseDropdownContent.callCount, 0);
-          commentApiWrapper.loadComments().then()
-              .then(() => {
-                assert.equal(element._computeBaseDropdownContent.callCount, 1);
-                done();
-              });
-        });
-
-    test('_computePatchDropdownContent called when basePatchNum updates', () => {
-      element.revisions = [
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-      ];
-      element.revisionInfo = getInfo(element.revisions);
-      element.availablePatches = [
-        {num: 1, sha: '1'},
-        {num: 2, sha: '2'},
-        {num: 3, sha: '3'},
-        {num: 'edit', sha: '4'},
-      ];
-      element.patchNum = 2;
-      element.basePatchNum = 'PARENT';
-      flushAsynchronousOperations();
-
-      // Should be recomputed for each available patch
-      sandbox.stub(element, '_computePatchDropdownContent');
-      element.set('basePatchNum', 1);
-      assert.equal(element._computePatchDropdownContent.callCount, 1);
-    });
-
-    test('_computePatchDropdownContent called when comments update', done => {
-      element.revisions = [
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-      ];
-      element.revisionInfo = getInfo(element.revisions);
-      element.availablePatches = [
-        {num: 1, sha: '1'},
-        {num: 2, sha: '2'},
-        {num: 3, sha: '3'},
-        {num: 'edit', sha: '4'},
-      ];
-      element.patchNum = 2;
-      element.basePatchNum = 'PARENT';
-      flushAsynchronousOperations();
-
-      // Should be recomputed for each available patch
-      sandbox.stub(element, '_computePatchDropdownContent');
-      assert.equal(element._computePatchDropdownContent.callCount, 0);
-      commentApiWrapper.loadComments().then()
-          .then(() => {
-            done();
-          });
-    });
-
-    test('_computePatchDropdownContent', () => {
-      const availablePatches = [
-        {num: 'edit', sha: '1'},
-        {num: 3, sha: '2'},
-        {num: 2, sha: '3'},
-        {num: 1, sha: '4'},
-      ];
-      const basePatchNum = 1;
-      const sortedRevisions = [
-        {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-        {_number: element.EDIT_NAME, basePatchNum: 2},
-        {_number: 2, description: 'description'},
-        {_number: 1},
-      ];
-
-      const expectedResult = [
-        {
-          disabled: false,
-          triggerText: 'edit',
-          text: 'edit | 1',
-          mobileText: 'edit',
-          bottomText: '',
-          value: 'edit',
-        },
-        {
-          disabled: false,
-          triggerText: 'Patchset 3',
-          text: 'Patchset 3 | 2',
-          mobileText: '3',
-          bottomText: '',
-          value: 3,
-          date: 'Mon, 01 Jan 2001 00:00:00 GMT',
-        },
-        {
-          disabled: false,
-          triggerText: 'Patchset 2',
-          text: 'Patchset 2 | 3',
-          mobileText: '2 description',
-          bottomText: 'description',
-          value: 2,
-        },
-        {
-          disabled: true,
-          triggerText: 'Patchset 1',
-          text: 'Patchset 1 | 4',
-          mobileText: '1',
-          bottomText: '',
-          value: 1,
-        },
-      ];
-
-      assert.deepEqual(element._computePatchDropdownContent(availablePatches,
-          basePatchNum, sortedRevisions, element.changeComments),
-      expectedResult);
-    });
-
-    test('filesWeblinks', () => {
-      element.filesWeblinks = {
-        meta_a: [
-          {
-            name: 'foo',
-            url: 'f.oo',
-          },
-        ],
-        meta_b: [
-          {
-            name: 'bar',
-            url: 'ba.r',
-          },
-        ],
-      };
-      flushAsynchronousOperations();
-      const domApi = Polymer.dom(element.root);
-      assert.equal(
-          domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
-      assert.equal(
-          domApi.querySelector('a[href="ba.r"]').textContent, 'bar');
-    });
-
-    test('_computePatchSetCommentsString', () => {
-      // Test string with unresolved comments.
-      element.changeComments._comments = {
-        foo: [{
-          id: '27dcee4d_f7b77cfa',
-          message: 'test',
-          patch_set: 1,
-          unresolved: true,
-          updated: '2017-10-11 20:48:40.000000000',
-        }],
-        bar: [{
-          id: '27dcee4d_f7b77cfa',
-          message: 'test',
-          patch_set: 1,
-          updated: '2017-10-12 20:48:40.000000000',
-        },
-        {
-          id: '27dcee4d_f7b77cfa',
-          message: 'test',
-          patch_set: 1,
-          updated: '2017-10-13 20:48:40.000000000',
-        }],
-        abc: [],
-      };
-
-      assert.equal(element._computePatchSetCommentsString(
-          element.changeComments, 1), ' (3 comments, 1 unresolved)');
-
-      // Test string with no unresolved comments.
-      delete element.changeComments._comments['foo'];
-      assert.equal(element._computePatchSetCommentsString(
-          element.changeComments, 1), ' (2 comments)');
-
-      // Test string with no comments.
-      delete element.changeComments._comments['bar'];
-      assert.equal(element._computePatchSetCommentsString(
-          element.changeComments, 1), '');
-    });
-
-    test('patch-range-change fires', () => {
-      const handler = sandbox.stub();
-      element.basePatchNum = 1;
-      element.patchNum = 3;
-      element.addEventListener('patch-range-change', handler);
-
-      element.$.basePatchDropdown._handleValueChange(2, [{value: 2}]);
-      assert.isTrue(handler.calledOnce);
-      assert.deepEqual(handler.lastCall.args[0].detail,
-          {basePatchNum: 2, patchNum: 3});
-
-      // BasePatchNum should not have changed, due to one-way data binding.
-      element.$.patchNumDropdown._handleValueChange('edit', [{value: 'edit'}]);
-      assert.deepEqual(handler.lastCall.args[0].detail,
-          {basePatchNum: 1, patchNum: 'edit'});
-    });
+    // Should be recomputed for each available patch
+    sandbox.stub(element, '_computePatchDropdownContent');
+    element.set('basePatchNum', 1);
+    assert.equal(element._computePatchDropdownContent.callCount, 1);
   });
+
+  test('_computePatchDropdownContent called when comments update', done => {
+    element.revisions = [
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'},
+      {num: 2, sha: '2'},
+      {num: 3, sha: '3'},
+      {num: 'edit', sha: '4'},
+    ];
+    element.patchNum = 2;
+    element.basePatchNum = 'PARENT';
+    flushAsynchronousOperations();
+
+    // Should be recomputed for each available patch
+    sandbox.stub(element, '_computePatchDropdownContent');
+    assert.equal(element._computePatchDropdownContent.callCount, 0);
+    commentApiWrapper.loadComments().then()
+        .then(() => {
+          done();
+        });
+  });
+
+  test('_computePatchDropdownContent', () => {
+    const availablePatches = [
+      {num: 'edit', sha: '1'},
+      {num: 3, sha: '2'},
+      {num: 2, sha: '3'},
+      {num: 1, sha: '4'},
+    ];
+    const basePatchNum = 1;
+    const sortedRevisions = [
+      {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
+      {_number: element.EDIT_NAME, basePatchNum: 2},
+      {_number: 2, description: 'description'},
+      {_number: 1},
+    ];
+
+    const expectedResult = [
+      {
+        disabled: false,
+        triggerText: 'edit',
+        text: 'edit | 1',
+        mobileText: 'edit',
+        bottomText: '',
+        value: 'edit',
+      },
+      {
+        disabled: false,
+        triggerText: 'Patchset 3',
+        text: 'Patchset 3 | 2',
+        mobileText: '3',
+        bottomText: '',
+        value: 3,
+        date: 'Mon, 01 Jan 2001 00:00:00 GMT',
+      },
+      {
+        disabled: false,
+        triggerText: 'Patchset 2',
+        text: 'Patchset 2 | 3',
+        mobileText: '2 description',
+        bottomText: 'description',
+        value: 2,
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 1',
+        text: 'Patchset 1 | 4',
+        mobileText: '1',
+        bottomText: '',
+        value: 1,
+      },
+    ];
+
+    assert.deepEqual(element._computePatchDropdownContent(availablePatches,
+        basePatchNum, sortedRevisions, element.changeComments),
+    expectedResult);
+  });
+
+  test('filesWeblinks', () => {
+    element.filesWeblinks = {
+      meta_a: [
+        {
+          name: 'foo',
+          url: 'f.oo',
+        },
+      ],
+      meta_b: [
+        {
+          name: 'bar',
+          url: 'ba.r',
+        },
+      ],
+    };
+    flushAsynchronousOperations();
+    const domApi = dom(element.root);
+    assert.equal(
+        domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
+    assert.equal(
+        domApi.querySelector('a[href="ba.r"]').textContent, 'bar');
+  });
+
+  test('_computePatchSetCommentsString', () => {
+    // Test string with unresolved comments.
+    element.changeComments._comments = {
+      foo: [{
+        id: '27dcee4d_f7b77cfa',
+        message: 'test',
+        patch_set: 1,
+        unresolved: true,
+        updated: '2017-10-11 20:48:40.000000000',
+      }],
+      bar: [{
+        id: '27dcee4d_f7b77cfa',
+        message: 'test',
+        patch_set: 1,
+        updated: '2017-10-12 20:48:40.000000000',
+      },
+      {
+        id: '27dcee4d_f7b77cfa',
+        message: 'test',
+        patch_set: 1,
+        updated: '2017-10-13 20:48:40.000000000',
+      }],
+      abc: [],
+    };
+
+    assert.equal(element._computePatchSetCommentsString(
+        element.changeComments, 1), ' (3 comments, 1 unresolved)');
+
+    // Test string with no unresolved comments.
+    delete element.changeComments._comments['foo'];
+    assert.equal(element._computePatchSetCommentsString(
+        element.changeComments, 1), ' (2 comments)');
+
+    // Test string with no comments.
+    delete element.changeComments._comments['bar'];
+    assert.equal(element._computePatchSetCommentsString(
+        element.changeComments, 1), '');
+  });
+
+  test('patch-range-change fires', () => {
+    const handler = sandbox.stub();
+    element.basePatchNum = 1;
+    element.patchNum = 3;
+    element.addEventListener('patch-range-change', handler);
+
+    element.$.basePatchDropdown._handleValueChange(2, [{value: 2}]);
+    assert.isTrue(handler.calledOnce);
+    assert.deepEqual(handler.lastCall.args[0].detail,
+        {basePatchNum: 2, patchNum: 3});
+
+    // BasePatchNum should not have changed, due to one-way data binding.
+    element.$.patchNumDropdown._handleValueChange('edit', [{value: 'edit'}]);
+    assert.deepEqual(handler.lastCall.args[0].detail,
+        {basePatchNum: 1, patchNum: 'edit'});
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index fd94b61..8f1b1c3 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -14,204 +14,210 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Polymer 1 adds # before array's key, while Polymer 2 doesn't
-  const HOVER_PATH_PATTERN = /^(commentRanges\.\#?\d+)\.hovering$/;
+import '../gr-diff-highlight/gr-annotation.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-ranged-comment-layer_html.js';
 
-  const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
-  const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
+// Polymer 1 adds # before array's key, while Polymer 2 doesn't
+const HOVER_PATH_PATTERN = /^(commentRanges\.\#?\d+)\.hovering$/;
 
-  /** @extends Polymer.Element */
-  class GrRangedCommentLayer extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-ranged-comment-layer'; }
-    /**
-     * Fired when the range in a range comment was malformed and had to be
-     * normalized.
-     *
-     * It's `detail` has a `lineNum` and `side` parameter.
-     *
-     * @event normalize-range
-     */
+const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
+const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
 
-    static get properties() {
-      return {
-      /** @type {!Array<!Gerrit.HoveredRange>} */
-        commentRanges: Array,
-        _listeners: {
-          type: Array,
-          value() { return []; },
-        },
-        _rangesMap: {
-          type: Object,
-          value() { return {left: {}, right: {}}; },
-        },
-      };
+/** @extends Polymer.Element */
+class GrRangedCommentLayer extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-ranged-comment-layer'; }
+  /**
+   * Fired when the range in a range comment was malformed and had to be
+   * normalized.
+   *
+   * It's `detail` has a `lineNum` and `side` parameter.
+   *
+   * @event normalize-range
+   */
+
+  static get properties() {
+    return {
+    /** @type {!Array<!Gerrit.HoveredRange>} */
+      commentRanges: Array,
+      _listeners: {
+        type: Array,
+        value() { return []; },
+      },
+      _rangesMap: {
+        type: Object,
+        value() { return {left: {}, right: {}}; },
+      },
+    };
+  }
+
+  static get observers() {
+    return [
+      '_handleCommentRangesChange(commentRanges.*)',
+    ];
+  }
+
+  get styleModuleName() {
+    return 'gr-ranged-comment-styles';
+  }
+
+  /**
+   * Layer method to add annotations to a line.
+   *
+   * @param {!HTMLElement} el The DIV.contentText element to apply the
+   *     annotation to.
+   * @param {!HTMLElement} lineNumberEl
+   * @param {!Object} line The line object. (GrDiffLine)
+   */
+  annotate(el, lineNumberEl, line) {
+    let ranges = [];
+    if (line.type === GrDiffLine.Type.REMOVE || (
+      line.type === GrDiffLine.Type.BOTH &&
+        el.getAttribute('data-side') !== 'right')) {
+      ranges = ranges.concat(this._getRangesForLine(line, 'left'));
+    }
+    if (line.type === GrDiffLine.Type.ADD || (
+      line.type === GrDiffLine.Type.BOTH &&
+        el.getAttribute('data-side') !== 'left')) {
+      ranges = ranges.concat(this._getRangesForLine(line, 'right'));
     }
 
-    static get observers() {
-      return [
-        '_handleCommentRangesChange(commentRanges.*)',
-      ];
+    for (const range of ranges) {
+      GrAnnotation.annotateElement(el, range.start,
+          range.end - range.start,
+          range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT);
     }
+  }
 
-    get styleModuleName() {
-      return 'gr-ranged-comment-styles';
+  /**
+   * Register a listener for layer updates.
+   *
+   * @param {function(number, number, string)} fn The update handler function.
+   *     Should accept as arguments the line numbers for the start and end of
+   *     the update and the side as a string.
+   */
+  addListener(fn) {
+    this._listeners.push(fn);
+  }
+
+  /**
+   * Notify Layer listeners of changes to annotations.
+   *
+   * @param {number} start The line where the update starts.
+   * @param {number} end The line where the update ends.
+   * @param {string} side The side of the update. ('left' or 'right')
+   */
+  _notifyUpdateRange(start, end, side) {
+    for (const listener of this._listeners) {
+      listener(start, end, side);
     }
+  }
 
-    /**
-     * Layer method to add annotations to a line.
-     *
-     * @param {!HTMLElement} el The DIV.contentText element to apply the
-     *     annotation to.
-     * @param {!HTMLElement} lineNumberEl
-     * @param {!Object} line The line object. (GrDiffLine)
-     */
-    annotate(el, lineNumberEl, line) {
-      let ranges = [];
-      if (line.type === GrDiffLine.Type.REMOVE || (
-        line.type === GrDiffLine.Type.BOTH &&
-          el.getAttribute('data-side') !== 'right')) {
-        ranges = ranges.concat(this._getRangesForLine(line, 'left'));
-      }
-      if (line.type === GrDiffLine.Type.ADD || (
-        line.type === GrDiffLine.Type.BOTH &&
-          el.getAttribute('data-side') !== 'left')) {
-        ranges = ranges.concat(this._getRangesForLine(line, 'right'));
-      }
+  /**
+   * Handle change in the ranges by updating the ranges maps and by
+   * emitting appropriate update notifications.
+   *
+   * @param {Object} record The change record.
+   */
+  _handleCommentRangesChange(record) {
+    if (!record) return;
 
-      for (const range of ranges) {
-        GrAnnotation.annotateElement(el, range.start,
-            range.end - range.start,
-            range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT);
+    // If the entire set of comments was changed.
+    if (record.path === 'commentRanges') {
+      this._rangesMap = {left: {}, right: {}};
+      for (const {side, range, hovering} of record.value) {
+        this._updateRangesMap(
+            side, range, hovering, (forLine, start, end, hovering) => {
+              forLine.push({start, end, hovering});
+            });
       }
     }
 
-    /**
-     * Register a listener for layer updates.
-     *
-     * @param {function(number, number, string)} fn The update handler function.
-     *     Should accept as arguments the line numbers for the start and end of
-     *     the update and the side as a string.
-     */
-    addListener(fn) {
-      this._listeners.push(fn);
+    // If the change only changed the `hovering` property of a comment.
+    const match = record.path.match(HOVER_PATH_PATTERN);
+    if (match) {
+      // The #number indicates the key of that item in the array
+      // not the index, especially in polymer 1.
+      const {side, range, hovering} = this.get(match[1]);
+
+      this._updateRangesMap(
+          side, range, hovering, (forLine, start, end, hovering) => {
+            const index = forLine.findIndex(lineRange =>
+              lineRange.start === start && lineRange.end === end);
+            forLine[index].hovering = hovering;
+          });
     }
 
-    /**
-     * Notify Layer listeners of changes to annotations.
-     *
-     * @param {number} start The line where the update starts.
-     * @param {number} end The line where the update ends.
-     * @param {string} side The side of the update. ('left' or 'right')
-     */
-    _notifyUpdateRange(start, end, side) {
-      for (const listener of this._listeners) {
-        listener(start, end, side);
-      }
-    }
-
-    /**
-     * Handle change in the ranges by updating the ranges maps and by
-     * emitting appropriate update notifications.
-     *
-     * @param {Object} record The change record.
-     */
-    _handleCommentRangesChange(record) {
-      if (!record) return;
-
-      // If the entire set of comments was changed.
-      if (record.path === 'commentRanges') {
-        this._rangesMap = {left: {}, right: {}};
-        for (const {side, range, hovering} of record.value) {
+    // If comments were spliced in or out.
+    if (record.path === 'commentRanges.splices') {
+      for (const indexSplice of record.value.indexSplices) {
+        const removed = indexSplice.removed;
+        for (const {side, range, hovering} of removed) {
+          this._updateRangesMap(
+              side, range, hovering, (forLine, start, end) => {
+                const index = forLine.findIndex(lineRange =>
+                  lineRange.start === start && lineRange.end === end);
+                forLine.splice(index, 1);
+              });
+        }
+        const added = indexSplice.object.slice(
+            indexSplice.index, indexSplice.index + indexSplice.addedCount);
+        for (const {side, range, hovering} of added) {
           this._updateRangesMap(
               side, range, hovering, (forLine, start, end, hovering) => {
                 forLine.push({start, end, hovering});
               });
         }
       }
-
-      // If the change only changed the `hovering` property of a comment.
-      const match = record.path.match(HOVER_PATH_PATTERN);
-      if (match) {
-        // The #number indicates the key of that item in the array
-        // not the index, especially in polymer 1.
-        const {side, range, hovering} = this.get(match[1]);
-
-        this._updateRangesMap(
-            side, range, hovering, (forLine, start, end, hovering) => {
-              const index = forLine.findIndex(lineRange =>
-                lineRange.start === start && lineRange.end === end);
-              forLine[index].hovering = hovering;
-            });
-      }
-
-      // If comments were spliced in or out.
-      if (record.path === 'commentRanges.splices') {
-        for (const indexSplice of record.value.indexSplices) {
-          const removed = indexSplice.removed;
-          for (const {side, range, hovering} of removed) {
-            this._updateRangesMap(
-                side, range, hovering, (forLine, start, end) => {
-                  const index = forLine.findIndex(lineRange =>
-                    lineRange.start === start && lineRange.end === end);
-                  forLine.splice(index, 1);
-                });
-          }
-          const added = indexSplice.object.slice(
-              indexSplice.index, indexSplice.index + indexSplice.addedCount);
-          for (const {side, range, hovering} of added) {
-            this._updateRangesMap(
-                side, range, hovering, (forLine, start, end, hovering) => {
-                  forLine.push({start, end, hovering});
-                });
-          }
-        }
-      }
-    }
-
-    _updateRangesMap(side, range, hovering, operation) {
-      const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
-      for (let line = range.start_line; line <= range.end_line; line++) {
-        const forLine = forSide[line] || (forSide[line] = []);
-        const start = line === range.start_line ? range.start_character : 0;
-        const end = line === range.end_line ? range.end_character : -1;
-        operation(forLine, start, end, hovering);
-      }
-      this._notifyUpdateRange(range.start_line, range.end_line, side);
-    }
-
-    _getRangesForLine(line, side) {
-      const lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
-      const ranges = this.get(['_rangesMap', side, lineNum]) || [];
-      return ranges
-          .map(range => {
-            // Make a copy, so that the normalization below does not mess with
-            // our map.
-            range = Object.assign({}, range);
-            range.end = range.end === -1 ? line.text.length : range.end;
-
-            // Normalize invalid ranges where the start is after the end but the
-            // start still makes sense. Set the end to the end of the line.
-            // @see Issue 5744
-            if (range.start >= range.end && range.start < line.text.length) {
-              range.end = line.text.length;
-              this.dispatchEvent(new CustomEvent('normalize-range', {
-                bubbles: true,
-                composed: true,
-                detail: {lineNum, side},
-              }));
-            }
-
-            return range;
-          })
-          // Sort the ranges so that hovering highlights are on top.
-          .sort((a, b) => (a.hovering && !b.hovering ? 1 : 0));
     }
   }
 
-  customElements.define(GrRangedCommentLayer.is, GrRangedCommentLayer);
-})();
+  _updateRangesMap(side, range, hovering, operation) {
+    const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
+    for (let line = range.start_line; line <= range.end_line; line++) {
+      const forLine = forSide[line] || (forSide[line] = []);
+      const start = line === range.start_line ? range.start_character : 0;
+      const end = line === range.end_line ? range.end_character : -1;
+      operation(forLine, start, end, hovering);
+    }
+    this._notifyUpdateRange(range.start_line, range.end_line, side);
+  }
+
+  _getRangesForLine(line, side) {
+    const lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
+    const ranges = this.get(['_rangesMap', side, lineNum]) || [];
+    return ranges
+        .map(range => {
+          // Make a copy, so that the normalization below does not mess with
+          // our map.
+          range = Object.assign({}, range);
+          range.end = range.end === -1 ? line.text.length : range.end;
+
+          // Normalize invalid ranges where the start is after the end but the
+          // start still makes sense. Set the end to the end of the line.
+          // @see Issue 5744
+          if (range.start >= range.end && range.start < line.text.length) {
+            range.end = line.text.length;
+            this.dispatchEvent(new CustomEvent('normalize-range', {
+              bubbles: true,
+              composed: true,
+              detail: {lineNum, side},
+            }));
+          }
+
+          return range;
+        })
+        // Sort the ranges so that hovering highlights are on top.
+        .sort((a, b) => (a.hovering && !b.hovering ? 1 : 0));
+  }
+}
+
+customElements.define(GrRangedCommentLayer.is, GrRangedCommentLayer);
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.js
index 7625c8a..29757e5 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.js
@@ -1,25 +1,21 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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
+export const htmlTemplate = html`
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<script src="../gr-diff-highlight/gr-annotation.js"></script>
-
-<dom-module id="gr-ranged-comment-layer">
-  <template>
-  </template>
-  <script src="gr-ranged-comment-layer.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
index 48883c1..d2d97de 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-ranged-comment-layer</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../gr-diff/gr-diff-line.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../gr-diff/gr-diff-line.js"></script>
 
-<link rel="import" href="gr-ranged-comment-layer.html">
+<script type="module" src="./gr-ranged-comment-layer.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-diff/gr-diff-line.js';
+import './gr-ranged-comment-layer.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,310 +43,313 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-ranged-comment-layer', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-diff/gr-diff-line.js';
+import './gr-ranged-comment-layer.js';
+suite('gr-ranged-comment-layer', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    const initialCommentRanges = [
+      {
+        side: 'left',
+        range: {
+          end_character: 9,
+          end_line: 39,
+          start_character: 6,
+          start_line: 36,
+        },
+      },
+      {
+        side: 'right',
+        range: {
+          end_character: 22,
+          end_line: 12,
+          start_character: 10,
+          start_line: 10,
+        },
+      },
+      {
+        side: 'right',
+        range: {
+          end_character: 15,
+          end_line: 100,
+          start_character: 5,
+          start_line: 100,
+        },
+      },
+      {
+        side: 'right',
+        range: {
+          end_character: 2,
+          end_line: 55,
+          start_character: 32,
+          start_line: 55,
+        },
+      },
+    ];
+
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.commentRanges = initialCommentRanges;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('annotate', () => {
     let sandbox;
+    let el;
+    let line;
+    let annotateElementStub;
+    const lineNumberEl = document.createElement('td');
 
     setup(() => {
-      const initialCommentRanges = [
-        {
-          side: 'left',
-          range: {
-            end_character: 9,
-            end_line: 39,
-            start_character: 6,
-            start_line: 36,
-          },
-        },
-        {
-          side: 'right',
-          range: {
-            end_character: 22,
-            end_line: 12,
-            start_character: 10,
-            start_line: 10,
-          },
-        },
-        {
-          side: 'right',
-          range: {
-            end_character: 15,
-            end_line: 100,
-            start_character: 5,
-            start_line: 100,
-          },
-        },
-        {
-          side: 'right',
-          range: {
-            end_character: 2,
-            end_line: 55,
-            start_character: 32,
-            start_line: 55,
-          },
-        },
-      ];
-
       sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.commentRanges = initialCommentRanges;
+      annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+      el = document.createElement('div');
+      el.setAttribute('data-side', 'left');
+      line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
-    suite('annotate', () => {
-      let sandbox;
-      let el;
-      let line;
-      let annotateElementStub;
-      const lineNumberEl = document.createElement('td');
+    test('type=Remove no-comment', () => {
+      line.type = GrDiffLine.Type.REMOVE;
+      line.beforeNumber = 40;
 
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-        annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
-        el = document.createElement('div');
-        el.setAttribute('data-side', 'left');
-        line = new GrDiffLine(GrDiffLine.Type.BOTH);
-        line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
-      });
+      element.annotate(el, lineNumberEl, line);
 
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      test('type=Remove no-comment', () => {
-        line.type = GrDiffLine.Type.REMOVE;
-        line.beforeNumber = 40;
-
-        element.annotate(el, lineNumberEl, line);
-
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('type=Remove has-comment', () => {
-        line.type = GrDiffLine.Type.REMOVE;
-        line.beforeNumber = 36;
-        const expectedStart = 6;
-        const expectedLength = line.text.length - expectedStart;
-
-        element.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementStub.called);
-        const lastCall = annotateElementStub.lastCall;
-        assert.equal(lastCall.args[0], el);
-        assert.equal(lastCall.args[1], expectedStart);
-        assert.equal(lastCall.args[2], expectedLength);
-        assert.equal(lastCall.args[3], 'style-scope gr-diff range');
-      });
-
-      test('type=Remove has-comment hovering', () => {
-        line.type = GrDiffLine.Type.REMOVE;
-        line.beforeNumber = 36;
-        element.set(['commentRanges', 0, 'hovering'], true);
-
-        const expectedStart = 6;
-        const expectedLength = line.text.length - expectedStart;
-
-        element.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementStub.called);
-        const lastCall = annotateElementStub.lastCall;
-        assert.equal(lastCall.args[0], el);
-        assert.equal(lastCall.args[1], expectedStart);
-        assert.equal(lastCall.args[2], expectedLength);
-        assert.equal(lastCall.args[3], 'style-scope gr-diff rangeHighlight');
-      });
-
-      test('type=Both has-comment', () => {
-        line.type = GrDiffLine.Type.BOTH;
-        line.beforeNumber = 36;
-
-        const expectedStart = 6;
-        const expectedLength = line.text.length - expectedStart;
-
-        element.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementStub.called);
-        const lastCall = annotateElementStub.lastCall;
-        assert.equal(lastCall.args[0], el);
-        assert.equal(lastCall.args[1], expectedStart);
-        assert.equal(lastCall.args[2], expectedLength);
-        assert.equal(lastCall.args[3], 'style-scope gr-diff range');
-      });
-
-      test('type=Both has-comment off side', () => {
-        line.type = GrDiffLine.Type.BOTH;
-        line.beforeNumber = 36;
-        el.setAttribute('data-side', 'right');
-
-        element.annotate(el, lineNumberEl, line);
-
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('type=Add has-comment', () => {
-        line.type = GrDiffLine.Type.ADD;
-        line.afterNumber = 12;
-        el.setAttribute('data-side', 'right');
-
-        const expectedStart = 0;
-        const expectedLength = 22;
-
-        element.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementStub.called);
-        const lastCall = annotateElementStub.lastCall;
-        assert.equal(lastCall.args[0], el);
-        assert.equal(lastCall.args[1], expectedStart);
-        assert.equal(lastCall.args[2], expectedLength);
-        assert.equal(lastCall.args[3], 'style-scope gr-diff range');
-      });
+      assert.isFalse(annotateElementStub.called);
     });
 
-    test('_handleCommentRangesChange overwrite', () => {
-      element.set('commentRanges', []);
+    test('type=Remove has-comment', () => {
+      line.type = GrDiffLine.Type.REMOVE;
+      line.beforeNumber = 36;
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
 
-      assert.equal(Object.keys(element._rangesMap.left).length, 0);
-      assert.equal(Object.keys(element._rangesMap.right).length, 0);
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(lastCall.args[3], 'style-scope gr-diff range');
     });
 
-    test('_handleCommentRangesChange hovering', () => {
-      const notifyStub = sinon.stub();
-      element.addListener(notifyStub);
-      const updateRangesMapSpy = sandbox.spy(element, '_updateRangesMap');
+    test('type=Remove has-comment hovering', () => {
+      line.type = GrDiffLine.Type.REMOVE;
+      line.beforeNumber = 36;
+      element.set(['commentRanges', 0, 'hovering'], true);
 
-      element.set(['commentRanges', 1, 'hovering'], true);
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
 
-      assert.isTrue(notifyStub.called);
-      const lastCall = notifyStub.lastCall;
-      assert.equal(lastCall.args[0], 10);
-      assert.equal(lastCall.args[1], 12);
-      assert.equal(lastCall.args[2], 'right');
+      element.annotate(el, lineNumberEl, line);
 
-      assert.isTrue(updateRangesMapSpy.called);
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(lastCall.args[3], 'style-scope gr-diff rangeHighlight');
     });
 
-    test('_handleCommentRangesChange splice out', () => {
-      const notifyStub = sinon.stub();
-      element.addListener(notifyStub);
+    test('type=Both has-comment', () => {
+      line.type = GrDiffLine.Type.BOTH;
+      line.beforeNumber = 36;
 
-      element.splice('commentRanges', 1, 1);
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
 
-      assert.isTrue(notifyStub.called);
-      const lastCall = notifyStub.lastCall;
-      assert.equal(lastCall.args[0], 10);
-      assert.equal(lastCall.args[1], 12);
-      assert.equal(lastCall.args[2], 'right');
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(lastCall.args[3], 'style-scope gr-diff range');
     });
 
-    test('_handleCommentRangesChange splice in', () => {
-      const notifyStub = sinon.stub();
-      element.addListener(notifyStub);
+    test('type=Both has-comment off side', () => {
+      line.type = GrDiffLine.Type.BOTH;
+      line.beforeNumber = 36;
+      el.setAttribute('data-side', 'right');
 
-      element.splice('commentRanges', 1, 0, {
-        side: 'left',
-        range: {
-          end_character: 15,
-          end_line: 275,
-          start_character: 5,
-          start_line: 250,
-        },
-      });
+      element.annotate(el, lineNumberEl, line);
 
-      assert.isTrue(notifyStub.called);
-      const lastCall = notifyStub.lastCall;
-      assert.equal(lastCall.args[0], 250);
-      assert.equal(lastCall.args[1], 275);
-      assert.equal(lastCall.args[2], 'left');
+      assert.isFalse(annotateElementStub.called);
     });
 
-    test('_handleCommentRangesChange mixed actions', () => {
-      const notifyStub = sinon.stub();
-      element.addListener(notifyStub);
-      const updateRangesMapSpy = sandbox.spy(element, '_updateRangesMap');
+    test('type=Add has-comment', () => {
+      line.type = GrDiffLine.Type.ADD;
+      line.afterNumber = 12;
+      el.setAttribute('data-side', 'right');
 
-      element.set(['commentRanges', 1, 'hovering'], true);
-      assert.isTrue(updateRangesMapSpy.callCount === 1);
-      element.splice('commentRanges', 1, 1);
-      assert.isTrue(updateRangesMapSpy.callCount === 2);
-      element.splice('commentRanges', 1, 1);
-      assert.isTrue(updateRangesMapSpy.callCount === 3);
-      element.splice('commentRanges', 1, 0, {
-        side: 'left',
-        range: {
-          end_character: 15,
-          end_line: 275,
-          start_character: 5,
-          start_line: 250,
-        },
-      });
-      assert.isTrue(updateRangesMapSpy.callCount === 4);
-      element.set(['commentRanges', 2, 'hovering'], true);
-      assert.isTrue(updateRangesMapSpy.callCount === 5);
-    });
+      const expectedStart = 0;
+      const expectedLength = 22;
 
-    test('_computeCommentMap creates maps correctly', () => {
-      // There is only one ranged comment on the left, but it spans ll.36-39.
-      const leftKeys = [];
-      for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
-      assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
-          leftKeys.sort());
+      element.annotate(el, lineNumberEl, line);
 
-      assert.equal(element._rangesMap.left[36].length, 1);
-      assert.equal(element._rangesMap.left[36][0].start, 6);
-      assert.equal(element._rangesMap.left[36][0].end, -1);
-
-      assert.equal(element._rangesMap.left[37].length, 1);
-      assert.equal(element._rangesMap.left[37][0].start, 0);
-      assert.equal(element._rangesMap.left[37][0].end, -1);
-
-      assert.equal(element._rangesMap.left[38].length, 1);
-      assert.equal(element._rangesMap.left[38][0].start, 0);
-      assert.equal(element._rangesMap.left[38][0].end, -1);
-
-      assert.equal(element._rangesMap.left[39].length, 1);
-      assert.equal(element._rangesMap.left[39][0].start, 0);
-      assert.equal(element._rangesMap.left[39][0].end, 9);
-
-      // The right has two ranged comments, one spanning ll.10-12 and the other
-      // on line 100.
-      const rightKeys = [];
-      for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
-      rightKeys.push('55', '100');
-      assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
-          rightKeys.sort());
-
-      assert.equal(element._rangesMap.right[10].length, 1);
-      assert.equal(element._rangesMap.right[10][0].start, 10);
-      assert.equal(element._rangesMap.right[10][0].end, -1);
-
-      assert.equal(element._rangesMap.right[11].length, 1);
-      assert.equal(element._rangesMap.right[11][0].start, 0);
-      assert.equal(element._rangesMap.right[11][0].end, -1);
-
-      assert.equal(element._rangesMap.right[12].length, 1);
-      assert.equal(element._rangesMap.right[12][0].start, 0);
-      assert.equal(element._rangesMap.right[12][0].end, 22);
-
-      assert.equal(element._rangesMap.right[100].length, 1);
-      assert.equal(element._rangesMap.right[100][0].start, 5);
-      assert.equal(element._rangesMap.right[100][0].end, 15);
-    });
-
-    test('_getRangesForLine normalizes invalid ranges', () => {
-      const line = {
-        afterNumber: 55,
-        text: '_getRangesForLine normalizes invalid ranges',
-      };
-      const ranges = element._getRangesForLine(line, 'right');
-      assert.equal(ranges.length, 1);
-      const range = ranges[0];
-      assert.isTrue(range.start < range.end, 'start and end are normalized');
-      assert.equal(range.end, line.text.length);
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(lastCall.args[3], 'style-scope gr-diff range');
     });
   });
+
+  test('_handleCommentRangesChange overwrite', () => {
+    element.set('commentRanges', []);
+
+    assert.equal(Object.keys(element._rangesMap.left).length, 0);
+    assert.equal(Object.keys(element._rangesMap.right).length, 0);
+  });
+
+  test('_handleCommentRangesChange hovering', () => {
+    const notifyStub = sinon.stub();
+    element.addListener(notifyStub);
+    const updateRangesMapSpy = sandbox.spy(element, '_updateRangesMap');
+
+    element.set(['commentRanges', 1, 'hovering'], true);
+
+    assert.isTrue(notifyStub.called);
+    const lastCall = notifyStub.lastCall;
+    assert.equal(lastCall.args[0], 10);
+    assert.equal(lastCall.args[1], 12);
+    assert.equal(lastCall.args[2], 'right');
+
+    assert.isTrue(updateRangesMapSpy.called);
+  });
+
+  test('_handleCommentRangesChange splice out', () => {
+    const notifyStub = sinon.stub();
+    element.addListener(notifyStub);
+
+    element.splice('commentRanges', 1, 1);
+
+    assert.isTrue(notifyStub.called);
+    const lastCall = notifyStub.lastCall;
+    assert.equal(lastCall.args[0], 10);
+    assert.equal(lastCall.args[1], 12);
+    assert.equal(lastCall.args[2], 'right');
+  });
+
+  test('_handleCommentRangesChange splice in', () => {
+    const notifyStub = sinon.stub();
+    element.addListener(notifyStub);
+
+    element.splice('commentRanges', 1, 0, {
+      side: 'left',
+      range: {
+        end_character: 15,
+        end_line: 275,
+        start_character: 5,
+        start_line: 250,
+      },
+    });
+
+    assert.isTrue(notifyStub.called);
+    const lastCall = notifyStub.lastCall;
+    assert.equal(lastCall.args[0], 250);
+    assert.equal(lastCall.args[1], 275);
+    assert.equal(lastCall.args[2], 'left');
+  });
+
+  test('_handleCommentRangesChange mixed actions', () => {
+    const notifyStub = sinon.stub();
+    element.addListener(notifyStub);
+    const updateRangesMapSpy = sandbox.spy(element, '_updateRangesMap');
+
+    element.set(['commentRanges', 1, 'hovering'], true);
+    assert.isTrue(updateRangesMapSpy.callCount === 1);
+    element.splice('commentRanges', 1, 1);
+    assert.isTrue(updateRangesMapSpy.callCount === 2);
+    element.splice('commentRanges', 1, 1);
+    assert.isTrue(updateRangesMapSpy.callCount === 3);
+    element.splice('commentRanges', 1, 0, {
+      side: 'left',
+      range: {
+        end_character: 15,
+        end_line: 275,
+        start_character: 5,
+        start_line: 250,
+      },
+    });
+    assert.isTrue(updateRangesMapSpy.callCount === 4);
+    element.set(['commentRanges', 2, 'hovering'], true);
+    assert.isTrue(updateRangesMapSpy.callCount === 5);
+  });
+
+  test('_computeCommentMap creates maps correctly', () => {
+    // There is only one ranged comment on the left, but it spans ll.36-39.
+    const leftKeys = [];
+    for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
+    assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
+        leftKeys.sort());
+
+    assert.equal(element._rangesMap.left[36].length, 1);
+    assert.equal(element._rangesMap.left[36][0].start, 6);
+    assert.equal(element._rangesMap.left[36][0].end, -1);
+
+    assert.equal(element._rangesMap.left[37].length, 1);
+    assert.equal(element._rangesMap.left[37][0].start, 0);
+    assert.equal(element._rangesMap.left[37][0].end, -1);
+
+    assert.equal(element._rangesMap.left[38].length, 1);
+    assert.equal(element._rangesMap.left[38][0].start, 0);
+    assert.equal(element._rangesMap.left[38][0].end, -1);
+
+    assert.equal(element._rangesMap.left[39].length, 1);
+    assert.equal(element._rangesMap.left[39][0].start, 0);
+    assert.equal(element._rangesMap.left[39][0].end, 9);
+
+    // The right has two ranged comments, one spanning ll.10-12 and the other
+    // on line 100.
+    const rightKeys = [];
+    for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
+    rightKeys.push('55', '100');
+    assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
+        rightKeys.sort());
+
+    assert.equal(element._rangesMap.right[10].length, 1);
+    assert.equal(element._rangesMap.right[10][0].start, 10);
+    assert.equal(element._rangesMap.right[10][0].end, -1);
+
+    assert.equal(element._rangesMap.right[11].length, 1);
+    assert.equal(element._rangesMap.right[11][0].start, 0);
+    assert.equal(element._rangesMap.right[11][0].end, -1);
+
+    assert.equal(element._rangesMap.right[12].length, 1);
+    assert.equal(element._rangesMap.right[12][0].start, 0);
+    assert.equal(element._rangesMap.right[12][0].end, 22);
+
+    assert.equal(element._rangesMap.right[100].length, 1);
+    assert.equal(element._rangesMap.right[100][0].start, 5);
+    assert.equal(element._rangesMap.right[100][0].end, 15);
+  });
+
+  test('_getRangesForLine normalizes invalid ranges', () => {
+    const line = {
+      afterNumber: 55,
+      text: '_getRangesForLine normalizes invalid ranges',
+    };
+    const ranges = element._getRangesForLine(line, 'right');
+    assert.equal(ranges.length, 1);
+    const range = ranges[0];
+    assert.isTrue(range.start < range.end, 'start and end are normalized');
+    assert.equal(range.end, line.text.length);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.js
index cefd241..49ed980 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.js
@@ -1,20 +1,22 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2019 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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-<dom-module id="gr-ranged-comment-theme">
+$_documentContainer.innerHTML = `<dom-module id="gr-ranged-comment-theme">
   <template>
     <style>
       .range {
@@ -27,4 +29,13 @@
       }
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index 3d831c9..20d3081 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -14,93 +14,104 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-tooltip/gr-tooltip.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-selection-action-box_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrSelectionActionBox extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-selection-action-box'; }
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the comment creation action was taken (click).
+   *
+   * @event create-comment-requested
    */
-  class GrSelectionActionBox extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-selection-action-box'; }
-    /**
-     * Fired when the comment creation action was taken (click).
-     *
-     * @event create-comment-requested
-     */
 
-    static get properties() {
-      return {
-        keyEventTarget: {
-          type: Object,
-          value() { return document.body; },
-        },
-        positionBelow: Boolean,
-      };
-    }
-
-    /** @override */
-    created() {
-      super.created();
-
-      // See https://crbug.com/gerrit/4767
-      this.addEventListener('mousedown',
-          e => this._handleMouseDown(e));
-    }
-
-    placeAbove(el) {
-      Polymer.dom.flush();
-      const rect = this._getTargetBoundingRect(el);
-      const boxRect = this.$.tooltip.getBoundingClientRect();
-      const parentRect = this._getParentBoundingClientRect();
-      this.style.top =
-          rect.top - parentRect.top - boxRect.height - 6 + 'px';
-      this.style.left =
-          rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
-    }
-
-    placeBelow(el) {
-      Polymer.dom.flush();
-      const rect = this._getTargetBoundingRect(el);
-      const boxRect = this.$.tooltip.getBoundingClientRect();
-      const parentRect = this._getParentBoundingClientRect();
-      this.style.top =
-      rect.top - parentRect.top + boxRect.height - 6 + 'px';
-      this.style.left =
-      rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
-    }
-
-    _getParentBoundingClientRect() {
-      // With native shadow DOM, the parent is the shadow root, not the gr-diff
-      // element
-      const parent = this.parentElement || this.parentNode.host;
-      return parent.getBoundingClientRect();
-    }
-
-    _getTargetBoundingRect(el) {
-      let rect;
-      if (el instanceof Text) {
-        const range = document.createRange();
-        range.selectNode(el);
-        rect = range.getBoundingClientRect();
-        range.detach();
-      } else {
-        rect = el.getBoundingClientRect();
-      }
-      return rect;
-    }
-
-    _handleMouseDown(e) {
-      if (e.button !== 0) { return; } // 0 = main button
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('create-comment-requested');
-    }
+  static get properties() {
+    return {
+      keyEventTarget: {
+        type: Object,
+        value() { return document.body; },
+      },
+      positionBelow: Boolean,
+    };
   }
 
-  customElements.define(GrSelectionActionBox.is, GrSelectionActionBox);
-})();
+  /** @override */
+  created() {
+    super.created();
+
+    // See https://crbug.com/gerrit/4767
+    this.addEventListener('mousedown',
+        e => this._handleMouseDown(e));
+  }
+
+  placeAbove(el) {
+    flush();
+    const rect = this._getTargetBoundingRect(el);
+    const boxRect = this.$.tooltip.getBoundingClientRect();
+    const parentRect = this._getParentBoundingClientRect();
+    this.style.top =
+        rect.top - parentRect.top - boxRect.height - 6 + 'px';
+    this.style.left =
+        rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
+  }
+
+  placeBelow(el) {
+    flush();
+    const rect = this._getTargetBoundingRect(el);
+    const boxRect = this.$.tooltip.getBoundingClientRect();
+    const parentRect = this._getParentBoundingClientRect();
+    this.style.top =
+    rect.top - parentRect.top + boxRect.height - 6 + 'px';
+    this.style.left =
+    rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
+  }
+
+  _getParentBoundingClientRect() {
+    // With native shadow DOM, the parent is the shadow root, not the gr-diff
+    // element
+    const parent = this.parentElement || this.parentNode.host;
+    return parent.getBoundingClientRect();
+  }
+
+  _getTargetBoundingRect(el) {
+    let rect;
+    if (el instanceof Text) {
+      const range = document.createRange();
+      range.selectNode(el);
+      rect = range.getBoundingClientRect();
+      range.detach();
+    } else {
+      rect = el.getBoundingClientRect();
+    }
+    return rect;
+  }
+
+  _handleMouseDown(e) {
+    if (e.button !== 0) { return; } // 0 = main button
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('create-comment-requested');
+  }
+}
+
+customElements.define(GrSelectionActionBox.is, GrSelectionActionBox);
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.js
index aa4d2e1..670a755 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-tooltip/gr-tooltip.html">
-
-<dom-module id="gr-selection-action-box">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         cursor: pointer;
@@ -31,10 +25,5 @@
         white-space: nowrap;
       }
     </style>
-    <gr-tooltip
-        id="tooltip"
-        text="Press c to comment"
-        position-below="[[positionBelow]]"></gr-tooltip>
-  </template>
-  <script src="gr-selection-action-box.js"></script>
-</dom-module>
+    <gr-tooltip id="tooltip" text="Press c to comment" position-below="[[positionBelow]]"></gr-tooltip>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index bb802f8..c0c711a 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-selection-action-box</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-selection-action-box.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-selection-action-box.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-selection-action-box.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -38,98 +43,100 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-selection-action-box', async () => {
-    await readyToTest();
-    let container;
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-selection-action-box.js';
+suite('gr-selection-action-box', () => {
+  let container;
+  let element;
+  let sandbox;
+
+  setup(() => {
+    container = fixture('basic');
+    element = container.querySelector('gr-selection-action-box');
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(element, 'fire');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('ignores regular keys', () => {
+    MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
+    assert.isFalse(element.fire.called);
+  });
+
+  suite('mousedown reacts only to main button', () => {
+    let e;
 
     setup(() => {
-      container = fixture('basic');
-      element = container.querySelector('gr-selection-action-box');
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(element, 'fire');
+      e = {
+        button: 0,
+        preventDefault: sandbox.stub(),
+        stopPropagation: sandbox.stub(),
+      };
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('event handled if main button', () => {
+      element._handleMouseDown(e);
+      assert.isTrue(e.preventDefault.called);
+      assert(element.fire.calledWithExactly('create-comment-requested'));
     });
 
-    test('ignores regular keys', () => {
-      MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
+    test('event ignored if not main button', () => {
+      e.button = 1;
+      element._handleMouseDown(e);
+      assert.isFalse(e.preventDefault.called);
       assert.isFalse(element.fire.called);
     });
+  });
 
-    suite('mousedown reacts only to main button', () => {
-      let e;
+  suite('placeAbove', () => {
+    let target;
 
-      setup(() => {
-        e = {
-          button: 0,
-          preventDefault: sandbox.stub(),
-          stopPropagation: sandbox.stub(),
-        };
-      });
-
-      test('event handled if main button', () => {
-        element._handleMouseDown(e);
-        assert.isTrue(e.preventDefault.called);
-        assert(element.fire.calledWithExactly('create-comment-requested'));
-      });
-
-      test('event ignored if not main button', () => {
-        e.button = 1;
-        element._handleMouseDown(e);
-        assert.isFalse(e.preventDefault.called);
-        assert.isFalse(element.fire.called);
-      });
+    setup(() => {
+      target = container.querySelector('.target');
+      sandbox.stub(container, 'getBoundingClientRect').returns(
+          {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
+      sandbox.stub(element, '_getTargetBoundingRect').returns(
+          {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
+      sandbox.stub(element.$.tooltip, 'getBoundingClientRect').returns(
+          {width: 10, height: 10});
     });
 
-    suite('placeAbove', () => {
-      let target;
+    test('placeAbove for Element argument', () => {
+      element.placeAbove(target);
+      assert.equal(element.style.top, '25px');
+      assert.equal(element.style.left, '72px');
+    });
 
-      setup(() => {
-        target = container.querySelector('.target');
-        sandbox.stub(container, 'getBoundingClientRect').returns(
-            {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
-        sandbox.stub(element, '_getTargetBoundingRect').returns(
-            {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
-        sandbox.stub(element.$.tooltip, 'getBoundingClientRect').returns(
-            {width: 10, height: 10});
-      });
+    test('placeAbove for Text Node argument', () => {
+      element.placeAbove(target.firstChild);
+      assert.equal(element.style.top, '25px');
+      assert.equal(element.style.left, '72px');
+    });
 
-      test('placeAbove for Element argument', () => {
-        element.placeAbove(target);
-        assert.equal(element.style.top, '25px');
-        assert.equal(element.style.left, '72px');
-      });
+    test('placeBelow for Element argument', () => {
+      element.placeBelow(target);
+      assert.equal(element.style.top, '45px');
+      assert.equal(element.style.left, '72px');
+    });
 
-      test('placeAbove for Text Node argument', () => {
-        element.placeAbove(target.firstChild);
-        assert.equal(element.style.top, '25px');
-        assert.equal(element.style.left, '72px');
-      });
+    test('placeBelow for Text Node argument', () => {
+      element.placeBelow(target.firstChild);
+      assert.equal(element.style.top, '45px');
+      assert.equal(element.style.left, '72px');
+    });
 
-      test('placeBelow for Element argument', () => {
-        element.placeBelow(target);
-        assert.equal(element.style.top, '45px');
-        assert.equal(element.style.left, '72px');
-      });
-
-      test('placeBelow for Text Node argument', () => {
-        element.placeBelow(target.firstChild);
-        assert.equal(element.style.top, '45px');
-        assert.equal(element.style.left, '72px');
-      });
-
-      test('uses document.createRange', () => {
-        sandbox.spy(document, 'createRange');
-        element._getTargetBoundingRect.restore();
-        sandbox.spy(element, '_getTargetBoundingRect');
-        element.placeAbove(target.firstChild);
-        assert.isTrue(document.createRange.called);
-      });
+    test('uses document.createRange', () => {
+      sandbox.spy(document, 'createRange');
+      element._getTargetBoundingRect.restore();
+      sandbox.spy(element, '_getTargetBoundingRect');
+      element.placeAbove(target.firstChild);
+      assert.isTrue(document.createRange.called);
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index b6e2884..33e894b 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -14,534 +14,543 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const LANGUAGE_MAP = {
-    'application/dart': 'dart',
-    'application/json': 'json',
-    'application/x-powershell': 'powershell',
-    'application/typescript': 'typescript',
-    'application/xml': 'xml',
-    'application/xquery': 'xquery',
-    'application/x-erb': 'erb',
-    'text/css': 'css',
-    'text/html': 'html',
-    'text/javascript': 'js',
-    'text/jsx': 'jsx',
-    'text/x-c': 'cpp',
-    'text/x-c++src': 'cpp',
-    'text/x-clojure': 'clojure',
-    'text/x-cmake': 'cmake',
-    'text/x-coffeescript': 'coffeescript',
-    'text/x-common-lisp': 'lisp',
-    'text/x-crystal': 'crystal',
-    'text/x-csharp': 'csharp',
-    'text/x-csrc': 'cpp',
-    'text/x-d': 'd',
-    'text/x-diff': 'diff',
-    'text/x-django': 'django',
-    'text/x-dockerfile': 'dockerfile',
-    'text/x-ebnf': 'ebnf',
-    'text/x-elm': 'elm',
-    'text/x-erlang': 'erlang',
-    'text/x-fortran': 'fortran',
-    'text/x-fsharp': 'fsharp',
-    'text/x-go': 'go',
-    'text/x-groovy': 'groovy',
-    'text/x-haml': 'haml',
-    'text/x-handlebars': 'handlebars',
-    'text/x-haskell': 'haskell',
-    'text/x-haxe': 'haxe',
-    'text/x-ini': 'ini',
-    'text/x-java': 'java',
-    'text/x-julia': 'julia',
-    'text/x-kotlin': 'kotlin',
-    'text/x-latex': 'latex',
-    'text/x-less': 'less',
-    'text/x-lua': 'lua',
-    'text/x-mathematica': 'mathematica',
-    'text/x-nginx-conf': 'nginx',
-    'text/x-nsis': 'nsis',
-    'text/x-objectivec': 'objectivec',
-    'text/x-ocaml': 'ocaml',
-    'text/x-perl': 'perl',
-    'text/x-pgsql': 'pgsql', // postgresql
-    'text/x-php': 'php',
-    'text/x-properties': 'properties',
-    'text/x-protobuf': 'protobuf',
-    'text/x-puppet': 'puppet',
-    'text/x-python': 'python',
-    'text/x-q': 'q',
-    'text/x-ruby': 'ruby',
-    'text/x-rustsrc': 'rust',
-    'text/x-scala': 'scala',
-    'text/x-scss': 'scss',
-    'text/x-scheme': 'scheme',
-    'text/x-shell': 'shell',
-    'text/x-soy': 'soy',
-    'text/x-spreadsheet': 'excel',
-    'text/x-sh': 'bash',
-    'text/x-sql': 'sql',
-    'text/x-swift': 'swift',
-    'text/x-systemverilog': 'sv',
-    'text/x-tcl': 'tcl',
-    'text/x-torque': 'torque',
-    'text/x-twig': 'twig',
-    'text/x-vb': 'vb',
-    'text/x-verilog': 'v',
-    'text/x-vhdl': 'vhdl',
-    'text/x-yaml': 'yaml',
-    'text/vbscript': 'vbscript',
-  };
-  const ASYNC_DELAY = 10;
+import '../../shared/gr-lib-loader/gr-lib-loader.js';
+import '../../../scripts/util.js';
+import '../gr-diff/gr-diff-line.js';
+import '../gr-diff-highlight/gr-annotation.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-syntax-layer_html.js';
 
-  const CLASS_WHITELIST = {
-    'gr-diff gr-syntax gr-syntax-attr': true,
-    'gr-diff gr-syntax gr-syntax-attribute': true,
-    'gr-diff gr-syntax gr-syntax-built_in': true,
-    'gr-diff gr-syntax gr-syntax-comment': true,
-    'gr-diff gr-syntax gr-syntax-doctag': true,
-    'gr-diff gr-syntax gr-syntax-function': true,
-    'gr-diff gr-syntax gr-syntax-keyword': true,
-    'gr-diff gr-syntax gr-syntax-link': true,
-    'gr-diff gr-syntax gr-syntax-literal': true,
-    'gr-diff gr-syntax gr-syntax-meta': true,
-    'gr-diff gr-syntax gr-syntax-meta-keyword': true,
-    'gr-diff gr-syntax gr-syntax-name': true,
-    'gr-diff gr-syntax gr-syntax-number': true,
-    'gr-diff gr-syntax gr-syntax-params': true,
-    'gr-diff gr-syntax gr-syntax-regexp': true,
-    'gr-diff gr-syntax gr-syntax-selector-attr': true,
-    'gr-diff gr-syntax gr-syntax-selector-class': true,
-    'gr-diff gr-syntax gr-syntax-selector-id': true,
-    'gr-diff gr-syntax gr-syntax-selector-pseudo': true,
-    'gr-diff gr-syntax gr-syntax-selector-tag': true,
-    'gr-diff gr-syntax gr-syntax-string': true,
-    'gr-diff gr-syntax gr-syntax-tag': true,
-    'gr-diff gr-syntax gr-syntax-template-tag': true,
-    'gr-diff gr-syntax gr-syntax-template-variable': true,
-    'gr-diff gr-syntax gr-syntax-title': true,
-    'gr-diff gr-syntax gr-syntax-type': true,
-    'gr-diff gr-syntax gr-syntax-variable': true,
-  };
+const LANGUAGE_MAP = {
+  'application/dart': 'dart',
+  'application/json': 'json',
+  'application/x-powershell': 'powershell',
+  'application/typescript': 'typescript',
+  'application/xml': 'xml',
+  'application/xquery': 'xquery',
+  'application/x-erb': 'erb',
+  'text/css': 'css',
+  'text/html': 'html',
+  'text/javascript': 'js',
+  'text/jsx': 'jsx',
+  'text/x-c': 'cpp',
+  'text/x-c++src': 'cpp',
+  'text/x-clojure': 'clojure',
+  'text/x-cmake': 'cmake',
+  'text/x-coffeescript': 'coffeescript',
+  'text/x-common-lisp': 'lisp',
+  'text/x-crystal': 'crystal',
+  'text/x-csharp': 'csharp',
+  'text/x-csrc': 'cpp',
+  'text/x-d': 'd',
+  'text/x-diff': 'diff',
+  'text/x-django': 'django',
+  'text/x-dockerfile': 'dockerfile',
+  'text/x-ebnf': 'ebnf',
+  'text/x-elm': 'elm',
+  'text/x-erlang': 'erlang',
+  'text/x-fortran': 'fortran',
+  'text/x-fsharp': 'fsharp',
+  'text/x-go': 'go',
+  'text/x-groovy': 'groovy',
+  'text/x-haml': 'haml',
+  'text/x-handlebars': 'handlebars',
+  'text/x-haskell': 'haskell',
+  'text/x-haxe': 'haxe',
+  'text/x-ini': 'ini',
+  'text/x-java': 'java',
+  'text/x-julia': 'julia',
+  'text/x-kotlin': 'kotlin',
+  'text/x-latex': 'latex',
+  'text/x-less': 'less',
+  'text/x-lua': 'lua',
+  'text/x-mathematica': 'mathematica',
+  'text/x-nginx-conf': 'nginx',
+  'text/x-nsis': 'nsis',
+  'text/x-objectivec': 'objectivec',
+  'text/x-ocaml': 'ocaml',
+  'text/x-perl': 'perl',
+  'text/x-pgsql': 'pgsql', // postgresql
+  'text/x-php': 'php',
+  'text/x-properties': 'properties',
+  'text/x-protobuf': 'protobuf',
+  'text/x-puppet': 'puppet',
+  'text/x-python': 'python',
+  'text/x-q': 'q',
+  'text/x-ruby': 'ruby',
+  'text/x-rustsrc': 'rust',
+  'text/x-scala': 'scala',
+  'text/x-scss': 'scss',
+  'text/x-scheme': 'scheme',
+  'text/x-shell': 'shell',
+  'text/x-soy': 'soy',
+  'text/x-spreadsheet': 'excel',
+  'text/x-sh': 'bash',
+  'text/x-sql': 'sql',
+  'text/x-swift': 'swift',
+  'text/x-systemverilog': 'sv',
+  'text/x-tcl': 'tcl',
+  'text/x-torque': 'torque',
+  'text/x-twig': 'twig',
+  'text/x-vb': 'vb',
+  'text/x-verilog': 'v',
+  'text/x-vhdl': 'vhdl',
+  'text/x-yaml': 'yaml',
+  'text/vbscript': 'vbscript',
+};
+const ASYNC_DELAY = 10;
 
-  const CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
-  const CPP_WCHAR_PATTERN = /L\'(\\)?.\'/g;
-  const JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
-  const GO_BACKSLASH_LITERAL = '\'\\\\\'';
-  const GLOBAL_LT_PATTERN = /</g;
+const CLASS_WHITELIST = {
+  'gr-diff gr-syntax gr-syntax-attr': true,
+  'gr-diff gr-syntax gr-syntax-attribute': true,
+  'gr-diff gr-syntax gr-syntax-built_in': true,
+  'gr-diff gr-syntax gr-syntax-comment': true,
+  'gr-diff gr-syntax gr-syntax-doctag': true,
+  'gr-diff gr-syntax gr-syntax-function': true,
+  'gr-diff gr-syntax gr-syntax-keyword': true,
+  'gr-diff gr-syntax gr-syntax-link': true,
+  'gr-diff gr-syntax gr-syntax-literal': true,
+  'gr-diff gr-syntax gr-syntax-meta': true,
+  'gr-diff gr-syntax gr-syntax-meta-keyword': true,
+  'gr-diff gr-syntax gr-syntax-name': true,
+  'gr-diff gr-syntax gr-syntax-number': true,
+  'gr-diff gr-syntax gr-syntax-params': true,
+  'gr-diff gr-syntax gr-syntax-regexp': true,
+  'gr-diff gr-syntax gr-syntax-selector-attr': true,
+  'gr-diff gr-syntax gr-syntax-selector-class': true,
+  'gr-diff gr-syntax gr-syntax-selector-id': true,
+  'gr-diff gr-syntax gr-syntax-selector-pseudo': true,
+  'gr-diff gr-syntax gr-syntax-selector-tag': true,
+  'gr-diff gr-syntax gr-syntax-string': true,
+  'gr-diff gr-syntax gr-syntax-tag': true,
+  'gr-diff gr-syntax gr-syntax-template-tag': true,
+  'gr-diff gr-syntax gr-syntax-template-variable': true,
+  'gr-diff gr-syntax gr-syntax-title': true,
+  'gr-diff gr-syntax gr-syntax-type': true,
+  'gr-diff gr-syntax gr-syntax-variable': true,
+};
 
-  /** @extends Polymer.Element */
-  class GrSyntaxLayer extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-syntax-layer'; }
+const CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
+const CPP_WCHAR_PATTERN = /L\'(\\)?.\'/g;
+const JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
+const GO_BACKSLASH_LITERAL = '\'\\\\\'';
+const GLOBAL_LT_PATTERN = /</g;
 
-    static get properties() {
-      return {
-        diff: {
-          type: Object,
-          observer: '_diffChanged',
-        },
-        enabled: {
-          type: Boolean,
-          value: true,
-        },
-        _baseRanges: {
-          type: Array,
-          value() { return []; },
-        },
-        _revisionRanges: {
-          type: Array,
-          value() { return []; },
-        },
-        _baseLanguage: String,
-        _revisionLanguage: String,
-        _listeners: {
-          type: Array,
-          value() { return []; },
-        },
-        /** @type {?number} */
-        _processHandle: Number,
-        /**
-         * The promise last returned from `process()` while the asynchronous
-         * processing is running - `null` otherwise. Provides a `cancel()`
-         * method that rejects it with `{isCancelled: true}`.
-         *
-         * @type {?Object}
-         */
-        _processPromise: {
-          type: Object,
-          value: null,
-        },
-        _hljs: Object,
-      };
+/** @extends Polymer.Element */
+class GrSyntaxLayer extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-syntax-layer'; }
+
+  static get properties() {
+    return {
+      diff: {
+        type: Object,
+        observer: '_diffChanged',
+      },
+      enabled: {
+        type: Boolean,
+        value: true,
+      },
+      _baseRanges: {
+        type: Array,
+        value() { return []; },
+      },
+      _revisionRanges: {
+        type: Array,
+        value() { return []; },
+      },
+      _baseLanguage: String,
+      _revisionLanguage: String,
+      _listeners: {
+        type: Array,
+        value() { return []; },
+      },
+      /** @type {?number} */
+      _processHandle: Number,
+      /**
+       * The promise last returned from `process()` while the asynchronous
+       * processing is running - `null` otherwise. Provides a `cancel()`
+       * method that rejects it with `{isCancelled: true}`.
+       *
+       * @type {?Object}
+       */
+      _processPromise: {
+        type: Object,
+        value: null,
+      },
+      _hljs: Object,
+    };
+  }
+
+  addListener(fn) {
+    this.push('_listeners', fn);
+  }
+
+  removeListener(fn) {
+    this._listeners = this._listeners.filter(f => f != fn);
+  }
+
+  /**
+   * Annotation layer method to add syntax annotations to the given element
+   * for the given line.
+   *
+   * @param {!HTMLElement} el
+   * @param {!HTMLElement} lineNumberEl
+   * @param {!Object} line (GrDiffLine)
+   */
+  annotate(el, lineNumberEl, line) {
+    if (!this.enabled) { return; }
+
+    // Determine the side.
+    let side;
+    if (line.type === GrDiffLine.Type.REMOVE || (
+      line.type === GrDiffLine.Type.BOTH &&
+        el.getAttribute('data-side') !== 'right')) {
+      side = 'left';
+    } else if (line.type === GrDiffLine.Type.ADD || (
+      el.getAttribute('data-side') !== 'left')) {
+      side = 'right';
     }
 
-    addListener(fn) {
-      this.push('_listeners', fn);
+    // Find the relevant syntax ranges, if any.
+    let ranges = [];
+    if (side === 'left' && this._baseRanges.length >= line.beforeNumber) {
+      ranges = this._baseRanges[line.beforeNumber - 1] || [];
+    } else if (side === 'right' &&
+        this._revisionRanges.length >= line.afterNumber) {
+      ranges = this._revisionRanges[line.afterNumber - 1] || [];
     }
 
-    removeListener(fn) {
-      this._listeners = this._listeners.filter(f => f != fn);
+    // Apply the ranges to the element.
+    for (const range of ranges) {
+      GrAnnotation.annotateElement(
+          el, range.start, range.length, range.className);
+    }
+  }
+
+  _getLanguage(diffFileMetaInfo) {
+    // The Gerrit API provides only content-type, but for other users of
+    // gr-diff it may be more convenient to specify the language directly.
+    return diffFileMetaInfo.language ||
+        LANGUAGE_MAP[diffFileMetaInfo.content_type];
+  }
+
+  /**
+   * Start processing syntax for the loaded diff and notify layer listeners
+   * as syntax info comes online.
+   *
+   * @return {Promise}
+   */
+  process() {
+    // Cancel any still running process() calls, because they append to the
+    // same _baseRanges and _revisionRanges fields.
+    this._cancel();
+
+    // Discard existing ranges.
+    this._baseRanges = [];
+    this._revisionRanges = [];
+
+    if (!this.enabled || !this.diff.content.length) {
+      return Promise.resolve();
     }
 
-    /**
-     * Annotation layer method to add syntax annotations to the given element
-     * for the given line.
-     *
-     * @param {!HTMLElement} el
-     * @param {!HTMLElement} lineNumberEl
-     * @param {!Object} line (GrDiffLine)
-     */
-    annotate(el, lineNumberEl, line) {
-      if (!this.enabled) { return; }
-
-      // Determine the side.
-      let side;
-      if (line.type === GrDiffLine.Type.REMOVE || (
-        line.type === GrDiffLine.Type.BOTH &&
-          el.getAttribute('data-side') !== 'right')) {
-        side = 'left';
-      } else if (line.type === GrDiffLine.Type.ADD || (
-        el.getAttribute('data-side') !== 'left')) {
-        side = 'right';
-      }
-
-      // Find the relevant syntax ranges, if any.
-      let ranges = [];
-      if (side === 'left' && this._baseRanges.length >= line.beforeNumber) {
-        ranges = this._baseRanges[line.beforeNumber - 1] || [];
-      } else if (side === 'right' &&
-          this._revisionRanges.length >= line.afterNumber) {
-        ranges = this._revisionRanges[line.afterNumber - 1] || [];
-      }
-
-      // Apply the ranges to the element.
-      for (const range of ranges) {
-        GrAnnotation.annotateElement(
-            el, range.start, range.length, range.className);
-      }
+    if (this.diff.meta_a) {
+      this._baseLanguage = this._getLanguage(this.diff.meta_a);
+    }
+    if (this.diff.meta_b) {
+      this._revisionLanguage = this._getLanguage(this.diff.meta_b);
+    }
+    if (!this._baseLanguage && !this._revisionLanguage) {
+      return Promise.resolve();
     }
 
-    _getLanguage(diffFileMetaInfo) {
-      // The Gerrit API provides only content-type, but for other users of
-      // gr-diff it may be more convenient to specify the language directly.
-      return diffFileMetaInfo.language ||
-          LANGUAGE_MAP[diffFileMetaInfo.content_type];
+    const state = {
+      sectionIndex: 0,
+      lineIndex: 0,
+      baseContext: undefined,
+      revisionContext: undefined,
+      lineNums: {left: 1, right: 1},
+      lastNotify: {left: 1, right: 1},
+    };
+
+    const rangesCache = new Map();
+
+    this._processPromise = util.makeCancelable(this._loadHLJS()
+        .then(() => new Promise(resolve => {
+          const nextStep = () => {
+            this._processHandle = null;
+            this._processNextLine(state, rangesCache);
+
+            // Move to the next line in the section.
+            state.lineIndex++;
+
+            // If the section has been exhausted, move to the next one.
+            if (this._isSectionDone(state)) {
+              state.lineIndex = 0;
+              state.sectionIndex++;
+            }
+
+            // If all sections have been exhausted, finish.
+            if (state.sectionIndex >= this.diff.content.length) {
+              resolve();
+              this._notify(state);
+              return;
+            }
+
+            if (state.lineIndex % 100 === 0) {
+              this._notify(state);
+              this._processHandle = this.async(nextStep, ASYNC_DELAY);
+            } else {
+              nextStep.call(this);
+            }
+          };
+
+          this._processHandle = this.async(nextStep, 1);
+        })));
+    return this._processPromise
+        .finally(() => { this._processPromise = null; });
+  }
+
+  /**
+   * Cancel any asynchronous syntax processing jobs.
+   */
+  _cancel() {
+    if (this._processHandle != null) {
+      this.cancelAsync(this._processHandle);
+      this._processHandle = null;
     }
-
-    /**
-     * Start processing syntax for the loaded diff and notify layer listeners
-     * as syntax info comes online.
-     *
-     * @return {Promise}
-     */
-    process() {
-      // Cancel any still running process() calls, because they append to the
-      // same _baseRanges and _revisionRanges fields.
-      this._cancel();
-
-      // Discard existing ranges.
-      this._baseRanges = [];
-      this._revisionRanges = [];
-
-      if (!this.enabled || !this.diff.content.length) {
-        return Promise.resolve();
-      }
-
-      if (this.diff.meta_a) {
-        this._baseLanguage = this._getLanguage(this.diff.meta_a);
-      }
-      if (this.diff.meta_b) {
-        this._revisionLanguage = this._getLanguage(this.diff.meta_b);
-      }
-      if (!this._baseLanguage && !this._revisionLanguage) {
-        return Promise.resolve();
-      }
-
-      const state = {
-        sectionIndex: 0,
-        lineIndex: 0,
-        baseContext: undefined,
-        revisionContext: undefined,
-        lineNums: {left: 1, right: 1},
-        lastNotify: {left: 1, right: 1},
-      };
-
-      const rangesCache = new Map();
-
-      this._processPromise = util.makeCancelable(this._loadHLJS()
-          .then(() => new Promise(resolve => {
-            const nextStep = () => {
-              this._processHandle = null;
-              this._processNextLine(state, rangesCache);
-
-              // Move to the next line in the section.
-              state.lineIndex++;
-
-              // If the section has been exhausted, move to the next one.
-              if (this._isSectionDone(state)) {
-                state.lineIndex = 0;
-                state.sectionIndex++;
-              }
-
-              // If all sections have been exhausted, finish.
-              if (state.sectionIndex >= this.diff.content.length) {
-                resolve();
-                this._notify(state);
-                return;
-              }
-
-              if (state.lineIndex % 100 === 0) {
-                this._notify(state);
-                this._processHandle = this.async(nextStep, ASYNC_DELAY);
-              } else {
-                nextStep.call(this);
-              }
-            };
-
-            this._processHandle = this.async(nextStep, 1);
-          })));
-      return this._processPromise
-          .finally(() => { this._processPromise = null; });
+    if (this._processPromise) {
+      this._processPromise.cancel();
     }
+  }
 
-    /**
-     * Cancel any asynchronous syntax processing jobs.
-     */
-    _cancel() {
-      if (this._processHandle != null) {
-        this.cancelAsync(this._processHandle);
-        this._processHandle = null;
-      }
-      if (this._processPromise) {
-        this._processPromise.cancel();
-      }
-    }
+  _diffChanged() {
+    this._cancel();
+    this._baseRanges = [];
+    this._revisionRanges = [];
+  }
 
-    _diffChanged() {
-      this._cancel();
-      this._baseRanges = [];
-      this._revisionRanges = [];
-    }
+  /**
+   * Take a string of HTML with the (potentially nested) syntax markers
+   * Highlight.js emits and emit a list of text ranges and classes for the
+   * markers.
+   *
+   * @param {string} str The string of HTML.
+   * @param {Map<string, !Array<!Object>>} rangesCache A map for caching
+   * ranges for each string. A cache is read and written by this method.
+   * Since diff is mostly comparing same file on two sides, there is good rate
+   * of duplication at least for parts that are on left and right parts.
+   * @return {!Array<!Object>} The list of ranges.
+   */
+  _rangesFromString(str, rangesCache) {
+    const cached = rangesCache.get(str);
+    if (cached) return cached;
 
-    /**
-     * Take a string of HTML with the (potentially nested) syntax markers
-     * Highlight.js emits and emit a list of text ranges and classes for the
-     * markers.
-     *
-     * @param {string} str The string of HTML.
-     * @param {Map<string, !Array<!Object>>} rangesCache A map for caching
-     * ranges for each string. A cache is read and written by this method.
-     * Since diff is mostly comparing same file on two sides, there is good rate
-     * of duplication at least for parts that are on left and right parts.
-     * @return {!Array<!Object>} The list of ranges.
-     */
-    _rangesFromString(str, rangesCache) {
-      const cached = rangesCache.get(str);
-      if (cached) return cached;
+    const div = document.createElement('div');
+    div.innerHTML = str;
+    const ranges = this._rangesFromElement(div, 0);
+    rangesCache.set(str, ranges);
+    return ranges;
+  }
 
-      const div = document.createElement('div');
-      div.innerHTML = str;
-      const ranges = this._rangesFromElement(div, 0);
-      rangesCache.set(str, ranges);
-      return ranges;
-    }
-
-    _rangesFromElement(elem, offset) {
-      let result = [];
-      for (const node of elem.childNodes) {
-        const nodeLength = GrAnnotation.getLength(node);
-        // Note: HLJS may emit a span with class undefined when it thinks there
-        // may be a syntax error.
-        if (node.tagName === 'SPAN' && node.className !== 'undefined') {
-          if (CLASS_WHITELIST.hasOwnProperty(node.className)) {
-            result.push({
-              start: offset,
-              length: nodeLength,
-              className: node.className,
-            });
-          }
-          if (node.children.length) {
-            result = result.concat(this._rangesFromElement(node, offset));
-          }
+  _rangesFromElement(elem, offset) {
+    let result = [];
+    for (const node of elem.childNodes) {
+      const nodeLength = GrAnnotation.getLength(node);
+      // Note: HLJS may emit a span with class undefined when it thinks there
+      // may be a syntax error.
+      if (node.tagName === 'SPAN' && node.className !== 'undefined') {
+        if (CLASS_WHITELIST.hasOwnProperty(node.className)) {
+          result.push({
+            start: offset,
+            length: nodeLength,
+            className: node.className,
+          });
         }
-        offset += nodeLength;
+        if (node.children.length) {
+          result = result.concat(this._rangesFromElement(node, offset));
+        }
       }
-      return result;
+      offset += nodeLength;
     }
+    return result;
+  }
 
-    /**
-     * For a given state, process the syntax for the next line (or pair of
-     * lines).
-     *
-     * @param {!Object} state The processing state for the layer.
-     */
-    _processNextLine(state, rangesCache) {
-      let baseLine;
-      let revisionLine;
+  /**
+   * For a given state, process the syntax for the next line (or pair of
+   * lines).
+   *
+   * @param {!Object} state The processing state for the layer.
+   */
+  _processNextLine(state, rangesCache) {
+    let baseLine;
+    let revisionLine;
 
-      const section = this.diff.content[state.sectionIndex];
-      if (section.ab) {
-        baseLine = section.ab[state.lineIndex];
-        revisionLine = section.ab[state.lineIndex];
+    const section = this.diff.content[state.sectionIndex];
+    if (section.ab) {
+      baseLine = section.ab[state.lineIndex];
+      revisionLine = section.ab[state.lineIndex];
+      state.lineNums.left++;
+      state.lineNums.right++;
+    } else {
+      if (section.a && section.a.length > state.lineIndex) {
+        baseLine = section.a[state.lineIndex];
         state.lineNums.left++;
+      }
+      if (section.b && section.b.length > state.lineIndex) {
+        revisionLine = section.b[state.lineIndex];
         state.lineNums.right++;
-      } else {
-        if (section.a && section.a.length > state.lineIndex) {
-          baseLine = section.a[state.lineIndex];
-          state.lineNums.left++;
-        }
-        if (section.b && section.b.length > state.lineIndex) {
-          revisionLine = section.b[state.lineIndex];
-          state.lineNums.right++;
-        }
-      }
-
-      // To store the result of the syntax highlighter.
-      let result;
-
-      if (this._baseLanguage && baseLine !== undefined &&
-          this._hljs.getLanguage(this._baseLanguage)) {
-        baseLine = this._workaround(this._baseLanguage, baseLine);
-        result = this._hljs.highlight(this._baseLanguage, baseLine, true,
-            state.baseContext);
-        this.push('_baseRanges',
-            this._rangesFromString(result.value, rangesCache));
-        state.baseContext = result.top;
-      }
-
-      if (this._revisionLanguage && revisionLine !== undefined &&
-          this._hljs.getLanguage(this._revisionLanguage)) {
-        revisionLine = this._workaround(this._revisionLanguage, revisionLine);
-        result = this._hljs.highlight(this._revisionLanguage, revisionLine,
-            true, state.revisionContext);
-        this.push('_revisionRanges',
-            this._rangesFromString(result.value, rangesCache));
-        state.revisionContext = result.top;
       }
     }
 
-    /**
-     * Ad hoc fixes for HLJS parsing bugs. Rewrite lines of code in constrained
-     * cases before sending them into HLJS so that they parse correctly.
-     *
-     * Important notes:
-     * * These tests should be as constrained as possible to avoid interfering
-     *   with code it shouldn't AND to avoid executing regexes as much as
-     *   possible.
-     * * These tests should document the issue clearly enough that the test can
-     *   be condidently removed when the issue is solved in HLJS.
-     * * These tests should rewrite the line of code to have the same number of
-     *   characters. This method rewrites the string that gets parsed, but NOT
-     *   the string that gets displayed and highlighted. Thus, the positions
-     *   must be consistent.
-     *
-     * @param {!string} language The name of the HLJS language plugin in use.
-     * @param {!string} line The line of code to potentially rewrite.
-     * @return {string} A potentially-rewritten line of code.
-     */
-    _workaround(language, line) {
-      if (language === 'cpp') {
-        /**
-         * Prevent confusing < and << operators for the start of a meta string
-         * by converting them to a different operator.
-         * {@see Issue 4864}
-         * {@see https://github.com/isagalaev/highlight.js/issues/1341}
-         */
-        if (CPP_DIRECTIVE_WITH_LT_PATTERN.test(line)) {
-          line = line.replace(GLOBAL_LT_PATTERN, '|');
-        }
+    // To store the result of the syntax highlighter.
+    let result;
 
-        /**
-         * Rewrite CPP wchar_t characters literals to wchar_t string literals
-         * because HLJS only understands the string form.
-         * {@see Issue 5242}
-         * {#see https://github.com/isagalaev/highlight.js/issues/1412}
-         */
-        if (CPP_WCHAR_PATTERN.test(line)) {
-          line = line.replace(CPP_WCHAR_PATTERN, 'L"$1."');
-        }
+    if (this._baseLanguage && baseLine !== undefined &&
+        this._hljs.getLanguage(this._baseLanguage)) {
+      baseLine = this._workaround(this._baseLanguage, baseLine);
+      result = this._hljs.highlight(this._baseLanguage, baseLine, true,
+          state.baseContext);
+      this.push('_baseRanges',
+          this._rangesFromString(result.value, rangesCache));
+      state.baseContext = result.top;
+    }
 
-        return line;
+    if (this._revisionLanguage && revisionLine !== undefined &&
+        this._hljs.getLanguage(this._revisionLanguage)) {
+      revisionLine = this._workaround(this._revisionLanguage, revisionLine);
+      result = this._hljs.highlight(this._revisionLanguage, revisionLine,
+          true, state.revisionContext);
+      this.push('_revisionRanges',
+          this._rangesFromString(result.value, rangesCache));
+      state.revisionContext = result.top;
+    }
+  }
+
+  /**
+   * Ad hoc fixes for HLJS parsing bugs. Rewrite lines of code in constrained
+   * cases before sending them into HLJS so that they parse correctly.
+   *
+   * Important notes:
+   * * These tests should be as constrained as possible to avoid interfering
+   *   with code it shouldn't AND to avoid executing regexes as much as
+   *   possible.
+   * * These tests should document the issue clearly enough that the test can
+   *   be condidently removed when the issue is solved in HLJS.
+   * * These tests should rewrite the line of code to have the same number of
+   *   characters. This method rewrites the string that gets parsed, but NOT
+   *   the string that gets displayed and highlighted. Thus, the positions
+   *   must be consistent.
+   *
+   * @param {!string} language The name of the HLJS language plugin in use.
+   * @param {!string} line The line of code to potentially rewrite.
+   * @return {string} A potentially-rewritten line of code.
+   */
+  _workaround(language, line) {
+    if (language === 'cpp') {
+      /**
+       * Prevent confusing < and << operators for the start of a meta string
+       * by converting them to a different operator.
+       * {@see Issue 4864}
+       * {@see https://github.com/isagalaev/highlight.js/issues/1341}
+       */
+      if (CPP_DIRECTIVE_WITH_LT_PATTERN.test(line)) {
+        line = line.replace(GLOBAL_LT_PATTERN, '|');
       }
 
       /**
-       * Prevent confusing the closing paren of a parameterized Java annotation
-       * being applied to a formal argument as the closing paren of the argument
-       * list. Rewrite the parens as spaces.
-       * {@see Issue 4776}
-       * {@see https://github.com/isagalaev/highlight.js/issues/1324}
+       * Rewrite CPP wchar_t characters literals to wchar_t string literals
+       * because HLJS only understands the string form.
+       * {@see Issue 5242}
+       * {#see https://github.com/isagalaev/highlight.js/issues/1412}
        */
-      if (language === 'java' && JAVA_PARAM_ANNOT_PATTERN.test(line)) {
-        return line.replace(JAVA_PARAM_ANNOT_PATTERN, '$1 $2 ');
-      }
-
-      /**
-       * HLJS misunderstands backslash character literals in Go.
-       * {@see Issue 5007}
-       * {#see https://github.com/isagalaev/highlight.js/issues/1411}
-       */
-      if (language === 'go' && line.includes(GO_BACKSLASH_LITERAL)) {
-        return line.replace(GO_BACKSLASH_LITERAL, '"\\\\"');
+      if (CPP_WCHAR_PATTERN.test(line)) {
+        line = line.replace(CPP_WCHAR_PATTERN, 'L"$1."');
       }
 
       return line;
     }
 
     /**
-     * Tells whether the state has exhausted its current section.
-     *
-     * @param {!Object} state
-     * @return {boolean}
+     * Prevent confusing the closing paren of a parameterized Java annotation
+     * being applied to a formal argument as the closing paren of the argument
+     * list. Rewrite the parens as spaces.
+     * {@see Issue 4776}
+     * {@see https://github.com/isagalaev/highlight.js/issues/1324}
      */
-    _isSectionDone(state) {
-      const section = this.diff.content[state.sectionIndex];
-      if (section.ab) {
-        return state.lineIndex >= section.ab.length;
-      } else {
-        return (!section.a || state.lineIndex >= section.a.length) &&
-            (!section.b || state.lineIndex >= section.b.length);
-      }
+    if (language === 'java' && JAVA_PARAM_ANNOT_PATTERN.test(line)) {
+      return line.replace(JAVA_PARAM_ANNOT_PATTERN, '$1 $2 ');
     }
 
     /**
-     * For a given state, notify layer listeners of any processed line ranges
-     * that have not yet been notified.
-     *
-     * @param {!Object} state
+     * HLJS misunderstands backslash character literals in Go.
+     * {@see Issue 5007}
+     * {#see https://github.com/isagalaev/highlight.js/issues/1411}
      */
-    _notify(state) {
-      if (state.lineNums.left - state.lastNotify.left) {
-        this._notifyRange(
-            state.lastNotify.left,
-            state.lineNums.left,
-            'left');
-        state.lastNotify.left = state.lineNums.left;
-      }
-      if (state.lineNums.right - state.lastNotify.right) {
-        this._notifyRange(
-            state.lastNotify.right,
-            state.lineNums.right,
-            'right');
-        state.lastNotify.right = state.lineNums.right;
-      }
+    if (language === 'go' && line.includes(GO_BACKSLASH_LITERAL)) {
+      return line.replace(GO_BACKSLASH_LITERAL, '"\\\\"');
     }
 
-    _notifyRange(start, end, side) {
-      for (const fn of this._listeners) {
-        fn(start, end, side);
-      }
-    }
+    return line;
+  }
 
-    _loadHLJS() {
-      return this.$.libLoader.getHLJS().then(hljs => {
-        this._hljs = hljs;
-      });
+  /**
+   * Tells whether the state has exhausted its current section.
+   *
+   * @param {!Object} state
+   * @return {boolean}
+   */
+  _isSectionDone(state) {
+    const section = this.diff.content[state.sectionIndex];
+    if (section.ab) {
+      return state.lineIndex >= section.ab.length;
+    } else {
+      return (!section.a || state.lineIndex >= section.a.length) &&
+          (!section.b || state.lineIndex >= section.b.length);
     }
   }
 
-  customElements.define(GrSyntaxLayer.is, GrSyntaxLayer);
-})();
+  /**
+   * For a given state, notify layer listeners of any processed line ranges
+   * that have not yet been notified.
+   *
+   * @param {!Object} state
+   */
+  _notify(state) {
+    if (state.lineNums.left - state.lastNotify.left) {
+      this._notifyRange(
+          state.lastNotify.left,
+          state.lineNums.left,
+          'left');
+      state.lastNotify.left = state.lineNums.left;
+    }
+    if (state.lineNums.right - state.lastNotify.right) {
+      this._notifyRange(
+          state.lastNotify.right,
+          state.lineNums.right,
+          'right');
+      state.lastNotify.right = state.lineNums.right;
+    }
+  }
+
+  _notifyRange(start, end, side) {
+    for (const fn of this._listeners) {
+      fn(start, end, side);
+    }
+  }
+
+  _loadHLJS() {
+    return this.$.libLoader.getHLJS().then(hljs => {
+      this._hljs = hljs;
+    });
+  }
+}
+
+customElements.define(GrSyntaxLayer.is, GrSyntaxLayer);
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.js
index f9e1279..183cbd1c 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.js
@@ -1,28 +1,21 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-lib-loader/gr-lib-loader.html">
-<script src="../../../scripts/util.js"></script>
-<script src="../gr-diff/gr-diff-line.js"></script>
-<script src="../gr-diff-highlight/gr-annotation.js"></script>
-
-<dom-module id="gr-syntax-layer">
-  <template>
+export const htmlTemplate = html`
     <gr-lib-loader id="libLoader"></gr-lib-loader>
-  </template>
-  <script src="gr-syntax-layer.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
index 62a3f1e..aa49f71 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
@@ -19,16 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-syntax-layer</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
-<link rel="import" href="gr-syntax-layer.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../shared/gr-rest-api-interface/mock-diff-response_test.js"></script>
+<script type="module" src="./gr-syntax-layer.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-rest-api-interface/mock-diff-response_test.js';
+import './gr-syntax-layer.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,469 +42,472 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-syntax-layer tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let diff;
-    let element;
-    const lineNumberEl = document.createElement('td');
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-rest-api-interface/mock-diff-response_test.js';
+import './gr-syntax-layer.js';
+suite('gr-syntax-layer tests', () => {
+  let sandbox;
+  let diff;
+  let element;
+  const lineNumberEl = document.createElement('td');
 
-    function getMockHLJS() {
-      const html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
-          'ipsum</span>';
-      return {
-        configure() {},
-        highlight(lang, line, ignore, state) {
-          return {
-            value: line.replace(/ipsum/, html),
-            top: state === undefined ? 1 : state + 1,
-          };
-        },
-        // Return something truthy because this method is used to check if the
-        // language is supported.
-        getLanguage(s) {
-          return {};
-        },
-      };
-    }
+  function getMockHLJS() {
+    const html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
+        'ipsum</span>';
+    return {
+      configure() {},
+      highlight(lang, line, ignore, state) {
+        return {
+          value: line.replace(/ipsum/, html),
+          top: state === undefined ? 1 : state + 1,
+        };
+      },
+      // Return something truthy because this method is used to check if the
+      // language is supported.
+      getLanguage(s) {
+        return {};
+      },
+    };
+  }
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      const mock = document.createElement('mock-diff-response');
-      diff = mock.diffResponse;
-      element.diff = diff;
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    const mock = document.createElement('mock-diff-response');
+    diff = mock.diffResponse;
+    element.diff = diff;
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('annotate without range does nothing', () => {
-      const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      const el = document.createElement('div');
-      el.textContent = 'Etiam dui, blandit wisi.';
-      const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      line.beforeNumber = 12;
+  test('annotate without range does nothing', () => {
+    const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+    const el = document.createElement('div');
+    el.textContent = 'Etiam dui, blandit wisi.';
+    const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+    line.beforeNumber = 12;
 
-      element.annotate(el, lineNumberEl, line);
+    element.annotate(el, lineNumberEl, line);
 
-      assert.isFalse(annotationSpy.called);
-    });
+    assert.isFalse(annotationSpy.called);
+  });
 
-    test('annotate with range applies it', () => {
-      const str = 'Etiam dui, blandit wisi.';
-      const start = 6;
-      const length = 3;
-      const className = 'foobar';
+  test('annotate with range applies it', () => {
+    const str = 'Etiam dui, blandit wisi.';
+    const start = 6;
+    const length = 3;
+    const className = 'foobar';
 
-      const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      const el = document.createElement('div');
-      el.textContent = str;
-      const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      line.beforeNumber = 12;
-      element._baseRanges[11] = [{
-        start,
-        length,
-        className,
-      }];
+    const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+    const el = document.createElement('div');
+    el.textContent = str;
+    const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+    line.beforeNumber = 12;
+    element._baseRanges[11] = [{
+      start,
+      length,
+      className,
+    }];
 
-      element.annotate(el, lineNumberEl, line);
+    element.annotate(el, lineNumberEl, line);
 
-      assert.isTrue(annotationSpy.called);
-      assert.equal(annotationSpy.lastCall.args[0], el);
-      assert.equal(annotationSpy.lastCall.args[1], start);
-      assert.equal(annotationSpy.lastCall.args[2], length);
-      assert.equal(annotationSpy.lastCall.args[3], className);
-      assert.isOk(el.querySelector('hl.' + className));
-    });
+    assert.isTrue(annotationSpy.called);
+    assert.equal(annotationSpy.lastCall.args[0], el);
+    assert.equal(annotationSpy.lastCall.args[1], start);
+    assert.equal(annotationSpy.lastCall.args[2], length);
+    assert.equal(annotationSpy.lastCall.args[3], className);
+    assert.isOk(el.querySelector('hl.' + className));
+  });
 
-    test('annotate with range but disabled does nothing', () => {
-      const str = 'Etiam dui, blandit wisi.';
-      const start = 6;
-      const length = 3;
-      const className = 'foobar';
+  test('annotate with range but disabled does nothing', () => {
+    const str = 'Etiam dui, blandit wisi.';
+    const start = 6;
+    const length = 3;
+    const className = 'foobar';
 
-      const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      const el = document.createElement('div');
-      el.textContent = str;
-      const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      line.beforeNumber = 12;
-      element._baseRanges[11] = [{
-        start,
-        length,
-        className,
-      }];
-      element.enabled = false;
+    const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+    const el = document.createElement('div');
+    el.textContent = str;
+    const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+    line.beforeNumber = 12;
+    element._baseRanges[11] = [{
+      start,
+      length,
+      className,
+    }];
+    element.enabled = false;
 
-      element.annotate(el, lineNumberEl, line);
+    element.annotate(el, lineNumberEl, line);
 
-      assert.isFalse(annotationSpy.called);
-    });
+    assert.isFalse(annotationSpy.called);
+  });
 
-    test('process on empty diff does nothing', done => {
-      element.diff = {
-        meta_a: {content_type: 'application/json'},
-        meta_b: {content_type: 'application/json'},
-        content: [],
-      };
-      const processNextSpy = sandbox.spy(element, '_processNextLine');
+  test('process on empty diff does nothing', done => {
+    element.diff = {
+      meta_a: {content_type: 'application/json'},
+      meta_b: {content_type: 'application/json'},
+      content: [],
+    };
+    const processNextSpy = sandbox.spy(element, '_processNextLine');
 
-      const processPromise = element.process();
+    const processPromise = element.process();
 
-      processPromise.then(() => {
-        assert.isFalse(processNextSpy.called);
-        assert.equal(element._baseRanges.length, 0);
-        assert.equal(element._revisionRanges.length, 0);
-        done();
-      });
-    });
-
-    test('process for unsupported languages does nothing', done => {
-      element.diff = {
-        meta_a: {content_type: 'text/x+objective-cobol-plus-plus'},
-        meta_b: {content_type: 'application/not-a-real-language'},
-        content: [],
-      };
-      const processNextSpy = sandbox.spy(element, '_processNextLine');
-
-      const processPromise = element.process();
-
-      processPromise.then(() => {
-        assert.isFalse(processNextSpy.called);
-        assert.equal(element._baseRanges.length, 0);
-        assert.equal(element._revisionRanges.length, 0);
-        done();
-      });
-    });
-
-    test('process while disabled does nothing', done => {
-      const processNextSpy = sandbox.spy(element, '_processNextLine');
-      element.enabled = false;
-      const loadHLJSSpy = sandbox.spy(element, '_loadHLJS');
-
-      const processPromise = element.process();
-
-      processPromise.then(() => {
-        assert.isFalse(processNextSpy.called);
-        assert.equal(element._baseRanges.length, 0);
-        assert.equal(element._revisionRanges.length, 0);
-        assert.isFalse(loadHLJSSpy.called);
-        done();
-      });
-    });
-
-    test('process highlight ipsum', done => {
-      element.diff.meta_a.content_type = 'application/json';
-      element.diff.meta_b.content_type = 'application/json';
-
-      const mockHLJS = getMockHLJS();
-      const highlightSpy = sinon.spy(mockHLJS, 'highlight');
-      sandbox.stub(element.$.libLoader, 'getHLJS',
-          () => Promise.resolve(mockHLJS));
-      const processNextSpy = sandbox.spy(element, '_processNextLine');
-      const processPromise = element.process();
-
-      processPromise.then(() => {
-        const linesA = diff.meta_a.lines;
-        const linesB = diff.meta_b.lines;
-
-        assert.isTrue(processNextSpy.called);
-        assert.equal(element._baseRanges.length, linesA);
-        assert.equal(element._revisionRanges.length, linesB);
-
-        assert.equal(highlightSpy.callCount, linesA + linesB);
-
-        // The first line of both sides have a range.
-        let ranges = [element._baseRanges[0], element._revisionRanges[0]];
-        for (const range of ranges) {
-          assert.equal(range.length, 1);
-          assert.equal(range[0].className,
-              'gr-diff gr-syntax gr-syntax-string');
-          assert.equal(range[0].start, 'lorem '.length);
-          assert.equal(range[0].length, 'ipsum'.length);
-        }
-
-        // There are no ranges from ll.1-12 on the left and ll.1-11 on the
-        // right.
-        ranges = element._baseRanges.slice(1, 12)
-            .concat(element._revisionRanges.slice(1, 11));
-
-        for (const range of ranges) {
-          assert.equal(range.length, 0);
-        }
-
-        // There should be another pair of ranges on l.13 for the left and
-        // l.12 for the right.
-        ranges = [element._baseRanges[13], element._revisionRanges[12]];
-
-        for (const range of ranges) {
-          assert.equal(range.length, 1);
-          assert.equal(range[0].className,
-              'gr-diff gr-syntax gr-syntax-string');
-          assert.equal(range[0].start, 32);
-          assert.equal(range[0].length, 'ipsum'.length);
-        }
-
-        // The next group should have a similar instance on either side.
-
-        let range = element._baseRanges[15];
-        assert.equal(range.length, 1);
-        assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
-        assert.equal(range[0].start, 34);
-        assert.equal(range[0].length, 'ipsum'.length);
-
-        range = element._revisionRanges[14];
-        assert.equal(range.length, 1);
-        assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
-        assert.equal(range[0].start, 35);
-        assert.equal(range[0].length, 'ipsum'.length);
-
-        done();
-      });
-    });
-
-    test('_diffChanged calls cancel', () => {
-      const cancelSpy = sandbox.spy(element, '_diffChanged');
-      element.diff = {content: []};
-      assert.isTrue(cancelSpy.called);
-    });
-
-    test('_rangesFromElement no ranges', () => {
-      const elem = document.createElement('span');
-      elem.textContent = 'Etiam dui, blandit wisi.';
-      const offset = 100;
-
-      const result = element._rangesFromElement(elem, offset);
-
-      assert.equal(result.length, 0);
-    });
-
-    test('_rangesFromElement single range', () => {
-      const str0 = 'Etiam ';
-      const str1 = 'dui, blandit';
-      const str2 = ' wisi.';
-      const className = 'gr-diff gr-syntax gr-syntax-string';
-      const offset = 100;
-
-      const elem = document.createElement('span');
-      elem.appendChild(document.createTextNode(str0));
-      const span = document.createElement('span');
-      span.textContent = str1;
-      span.className = className;
-      elem.appendChild(span);
-      elem.appendChild(document.createTextNode(str2));
-
-      const result = element._rangesFromElement(elem, offset);
-
-      assert.equal(result.length, 1);
-      assert.equal(result[0].start, str0.length + offset);
-      assert.equal(result[0].length, str1.length);
-      assert.equal(result[0].className, className);
-    });
-
-    test('_rangesFromElement non-whitelist', () => {
-      const str0 = 'Etiam ';
-      const str1 = 'dui, blandit';
-      const str2 = ' wisi.';
-      const className = 'not-in-the-whitelist';
-      const offset = 100;
-
-      const elem = document.createElement('span');
-      elem.appendChild(document.createTextNode(str0));
-      const span = document.createElement('span');
-      span.textContent = str1;
-      span.className = className;
-      elem.appendChild(span);
-      elem.appendChild(document.createTextNode(str2));
-
-      const result = element._rangesFromElement(elem, offset);
-
-      assert.equal(result.length, 0);
-    });
-
-    test('_rangesFromElement milti range', () => {
-      const str0 = 'Etiam ';
-      const str1 = 'dui,';
-      const str2 = ' blandit';
-      const str3 = ' wisi.';
-      const className = 'gr-diff gr-syntax gr-syntax-string';
-      const offset = 100;
-
-      const elem = document.createElement('span');
-      elem.appendChild(document.createTextNode(str0));
-      let span = document.createElement('span');
-      span.textContent = str1;
-      span.className = className;
-      elem.appendChild(span);
-      elem.appendChild(document.createTextNode(str2));
-      span = document.createElement('span');
-      span.textContent = str3;
-      span.className = className;
-      elem.appendChild(span);
-
-      const result = element._rangesFromElement(elem, offset);
-
-      assert.equal(result.length, 2);
-
-      assert.equal(result[0].start, str0.length + offset);
-      assert.equal(result[0].length, str1.length);
-      assert.equal(result[0].className, className);
-
-      assert.equal(result[1].start,
-          str0.length + str1.length + str2.length + offset);
-      assert.equal(result[1].length, str3.length);
-      assert.equal(result[1].className, className);
-    });
-
-    test('_rangesFromElement nested range', () => {
-      const str0 = 'Etiam ';
-      const str1 = 'dui,';
-      const str2 = ' blandit';
-      const str3 = ' wisi.';
-      const className = 'gr-diff gr-syntax gr-syntax-string';
-      const offset = 100;
-
-      const elem = document.createElement('span');
-      elem.appendChild(document.createTextNode(str0));
-      const span1 = document.createElement('span');
-      span1.textContent = str1;
-      span1.className = className;
-      elem.appendChild(span1);
-      const span2 = document.createElement('span');
-      span2.textContent = str2;
-      span2.className = className;
-      span1.appendChild(span2);
-      elem.appendChild(document.createTextNode(str3));
-
-      const result = element._rangesFromElement(elem, offset);
-
-      assert.equal(result.length, 2);
-
-      assert.equal(result[0].start, str0.length + offset);
-      assert.equal(result[0].length, str1.length + str2.length);
-      assert.equal(result[0].className, className);
-
-      assert.equal(result[1].start, str0.length + str1.length + offset);
-      assert.equal(result[1].length, str2.length);
-      assert.equal(result[1].className, className);
-    });
-
-    test('_rangesFromString whitelist allows recursion', () => {
-      const str = [
-        '<span class="non-whtelisted-class">',
-        '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
-        '</span>'].join('');
-      const result = element._rangesFromString(str, new Map());
-      assert.notEqual(result.length, 0);
-    });
-
-    test('_rangesFromString cache same syntax markers', () => {
-      sandbox.spy(element, '_rangesFromElement');
-      const str =
-        '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>';
-      const cacheMap = new Map();
-      element._rangesFromString(str, cacheMap);
-      element._rangesFromString(str, cacheMap);
-      assert.isTrue(element._rangesFromElement.calledOnce);
-    });
-
-    test('_isSectionDone', () => {
-      let state = {sectionIndex: 0, lineIndex: 0};
-      assert.isFalse(element._isSectionDone(state));
-
-      state = {sectionIndex: 0, lineIndex: 2};
-      assert.isFalse(element._isSectionDone(state));
-
-      state = {sectionIndex: 0, lineIndex: 4};
-      assert.isTrue(element._isSectionDone(state));
-
-      state = {sectionIndex: 1, lineIndex: 2};
-      assert.isFalse(element._isSectionDone(state));
-
-      state = {sectionIndex: 1, lineIndex: 3};
-      assert.isTrue(element._isSectionDone(state));
-
-      state = {sectionIndex: 3, lineIndex: 0};
-      assert.isFalse(element._isSectionDone(state));
-
-      state = {sectionIndex: 3, lineIndex: 3};
-      assert.isFalse(element._isSectionDone(state));
-
-      state = {sectionIndex: 3, lineIndex: 4};
-      assert.isTrue(element._isSectionDone(state));
-    });
-
-    test('workaround CPP LT directive', () => {
-      // Does nothing to regular line.
-      let line = 'int main(int argc, char** argv) { return 0; }';
-      assert.equal(element._workaround('cpp', line), line);
-
-      // Does nothing to include directive.
-      line = '#include <stdio>';
-      assert.equal(element._workaround('cpp', line), line);
-
-      // Converts left-shift operator in #define.
-      line = '#define GiB (1ull << 30)';
-      let expected = '#define GiB (1ull || 30)';
-      assert.equal(element._workaround('cpp', line), expected);
-
-      // Converts less-than operator in #if.
-      line = '  #if __GNUC__ < 4 || (__GNUC__ == 4 && __GNUC_MINOR__ < 1)';
-      expected = '  #if __GNUC__ | 4 || (__GNUC__ == 4 && __GNUC_MINOR__ | 1)';
-      assert.equal(element._workaround('cpp', line), expected);
-    });
-
-    test('workaround Java param-annotation', () => {
-      // Does nothing to regular line.
-      let line = 'public static void foo(int bar) { }';
-      assert.equal(element._workaround('java', line), line);
-
-      // Does nothing to regular annotation.
-      line = 'public static void foo(@Nullable int bar) { }';
-      assert.equal(element._workaround('java', line), line);
-
-      // Converts parameterized annotation.
-      line = 'public static void foo(@SuppressWarnings("unused") int bar) { }';
-      const expected = 'public static void foo(@SuppressWarnings "unused" ' +
-          ' int bar) { }';
-      assert.equal(element._workaround('java', line), expected);
-    });
-
-    test('workaround CPP whcar_t character literals', () => {
-      // Does nothing to regular line.
-      let line = 'int main(int argc, char** argv) { return 0; }';
-      assert.equal(element._workaround('cpp', line), line);
-
-      // Does nothing to wchar_t string.
-      line = 'wchar_t* sz = L"abc 123";';
-      assert.equal(element._workaround('cpp', line), line);
-
-      // Converts wchar_t character literal to string.
-      line = 'wchar_t myChar = L\'#\'';
-      let expected = 'wchar_t myChar = L"."';
-      assert.equal(element._workaround('cpp', line), expected);
-
-      // Converts wchar_t character literal with escape sequence to string.
-      line = 'wchar_t myChar = L\'\\"\'';
-      expected = 'wchar_t myChar = L"\\."';
-      assert.equal(element._workaround('cpp', line), expected);
-    });
-
-    test('workaround go backslash character literals', () => {
-      // Does nothing to regular line.
-      let line = 'func foo(in []byte) (lit []byte, n int, err error) {';
-      assert.equal(element._workaround('go', line), line);
-
-      // Does nothing to string with backslash literal
-      line = 'c := "\\\\"';
-      assert.equal(element._workaround('go', line), line);
-
-      // Converts backslash literal character to a string.
-      line = 'c := \'\\\\\'';
-      const expected = 'c := "\\\\"';
-      assert.equal(element._workaround('go', line), expected);
+    processPromise.then(() => {
+      assert.isFalse(processNextSpy.called);
+      assert.equal(element._baseRanges.length, 0);
+      assert.equal(element._revisionRanges.length, 0);
+      done();
     });
   });
+
+  test('process for unsupported languages does nothing', done => {
+    element.diff = {
+      meta_a: {content_type: 'text/x+objective-cobol-plus-plus'},
+      meta_b: {content_type: 'application/not-a-real-language'},
+      content: [],
+    };
+    const processNextSpy = sandbox.spy(element, '_processNextLine');
+
+    const processPromise = element.process();
+
+    processPromise.then(() => {
+      assert.isFalse(processNextSpy.called);
+      assert.equal(element._baseRanges.length, 0);
+      assert.equal(element._revisionRanges.length, 0);
+      done();
+    });
+  });
+
+  test('process while disabled does nothing', done => {
+    const processNextSpy = sandbox.spy(element, '_processNextLine');
+    element.enabled = false;
+    const loadHLJSSpy = sandbox.spy(element, '_loadHLJS');
+
+    const processPromise = element.process();
+
+    processPromise.then(() => {
+      assert.isFalse(processNextSpy.called);
+      assert.equal(element._baseRanges.length, 0);
+      assert.equal(element._revisionRanges.length, 0);
+      assert.isFalse(loadHLJSSpy.called);
+      done();
+    });
+  });
+
+  test('process highlight ipsum', done => {
+    element.diff.meta_a.content_type = 'application/json';
+    element.diff.meta_b.content_type = 'application/json';
+
+    const mockHLJS = getMockHLJS();
+    const highlightSpy = sinon.spy(mockHLJS, 'highlight');
+    sandbox.stub(element.$.libLoader, 'getHLJS',
+        () => Promise.resolve(mockHLJS));
+    const processNextSpy = sandbox.spy(element, '_processNextLine');
+    const processPromise = element.process();
+
+    processPromise.then(() => {
+      const linesA = diff.meta_a.lines;
+      const linesB = diff.meta_b.lines;
+
+      assert.isTrue(processNextSpy.called);
+      assert.equal(element._baseRanges.length, linesA);
+      assert.equal(element._revisionRanges.length, linesB);
+
+      assert.equal(highlightSpy.callCount, linesA + linesB);
+
+      // The first line of both sides have a range.
+      let ranges = [element._baseRanges[0], element._revisionRanges[0]];
+      for (const range of ranges) {
+        assert.equal(range.length, 1);
+        assert.equal(range[0].className,
+            'gr-diff gr-syntax gr-syntax-string');
+        assert.equal(range[0].start, 'lorem '.length);
+        assert.equal(range[0].length, 'ipsum'.length);
+      }
+
+      // There are no ranges from ll.1-12 on the left and ll.1-11 on the
+      // right.
+      ranges = element._baseRanges.slice(1, 12)
+          .concat(element._revisionRanges.slice(1, 11));
+
+      for (const range of ranges) {
+        assert.equal(range.length, 0);
+      }
+
+      // There should be another pair of ranges on l.13 for the left and
+      // l.12 for the right.
+      ranges = [element._baseRanges[13], element._revisionRanges[12]];
+
+      for (const range of ranges) {
+        assert.equal(range.length, 1);
+        assert.equal(range[0].className,
+            'gr-diff gr-syntax gr-syntax-string');
+        assert.equal(range[0].start, 32);
+        assert.equal(range[0].length, 'ipsum'.length);
+      }
+
+      // The next group should have a similar instance on either side.
+
+      let range = element._baseRanges[15];
+      assert.equal(range.length, 1);
+      assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
+      assert.equal(range[0].start, 34);
+      assert.equal(range[0].length, 'ipsum'.length);
+
+      range = element._revisionRanges[14];
+      assert.equal(range.length, 1);
+      assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
+      assert.equal(range[0].start, 35);
+      assert.equal(range[0].length, 'ipsum'.length);
+
+      done();
+    });
+  });
+
+  test('_diffChanged calls cancel', () => {
+    const cancelSpy = sandbox.spy(element, '_diffChanged');
+    element.diff = {content: []};
+    assert.isTrue(cancelSpy.called);
+  });
+
+  test('_rangesFromElement no ranges', () => {
+    const elem = document.createElement('span');
+    elem.textContent = 'Etiam dui, blandit wisi.';
+    const offset = 100;
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 0);
+  });
+
+  test('_rangesFromElement single range', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui, blandit';
+    const str2 = ' wisi.';
+    const className = 'gr-diff gr-syntax gr-syntax-string';
+    const offset = 100;
+
+    const elem = document.createElement('span');
+    elem.appendChild(document.createTextNode(str0));
+    const span = document.createElement('span');
+    span.textContent = str1;
+    span.className = className;
+    elem.appendChild(span);
+    elem.appendChild(document.createTextNode(str2));
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 1);
+    assert.equal(result[0].start, str0.length + offset);
+    assert.equal(result[0].length, str1.length);
+    assert.equal(result[0].className, className);
+  });
+
+  test('_rangesFromElement non-whitelist', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui, blandit';
+    const str2 = ' wisi.';
+    const className = 'not-in-the-whitelist';
+    const offset = 100;
+
+    const elem = document.createElement('span');
+    elem.appendChild(document.createTextNode(str0));
+    const span = document.createElement('span');
+    span.textContent = str1;
+    span.className = className;
+    elem.appendChild(span);
+    elem.appendChild(document.createTextNode(str2));
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 0);
+  });
+
+  test('_rangesFromElement milti range', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui,';
+    const str2 = ' blandit';
+    const str3 = ' wisi.';
+    const className = 'gr-diff gr-syntax gr-syntax-string';
+    const offset = 100;
+
+    const elem = document.createElement('span');
+    elem.appendChild(document.createTextNode(str0));
+    let span = document.createElement('span');
+    span.textContent = str1;
+    span.className = className;
+    elem.appendChild(span);
+    elem.appendChild(document.createTextNode(str2));
+    span = document.createElement('span');
+    span.textContent = str3;
+    span.className = className;
+    elem.appendChild(span);
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 2);
+
+    assert.equal(result[0].start, str0.length + offset);
+    assert.equal(result[0].length, str1.length);
+    assert.equal(result[0].className, className);
+
+    assert.equal(result[1].start,
+        str0.length + str1.length + str2.length + offset);
+    assert.equal(result[1].length, str3.length);
+    assert.equal(result[1].className, className);
+  });
+
+  test('_rangesFromElement nested range', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui,';
+    const str2 = ' blandit';
+    const str3 = ' wisi.';
+    const className = 'gr-diff gr-syntax gr-syntax-string';
+    const offset = 100;
+
+    const elem = document.createElement('span');
+    elem.appendChild(document.createTextNode(str0));
+    const span1 = document.createElement('span');
+    span1.textContent = str1;
+    span1.className = className;
+    elem.appendChild(span1);
+    const span2 = document.createElement('span');
+    span2.textContent = str2;
+    span2.className = className;
+    span1.appendChild(span2);
+    elem.appendChild(document.createTextNode(str3));
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 2);
+
+    assert.equal(result[0].start, str0.length + offset);
+    assert.equal(result[0].length, str1.length + str2.length);
+    assert.equal(result[0].className, className);
+
+    assert.equal(result[1].start, str0.length + str1.length + offset);
+    assert.equal(result[1].length, str2.length);
+    assert.equal(result[1].className, className);
+  });
+
+  test('_rangesFromString whitelist allows recursion', () => {
+    const str = [
+      '<span class="non-whtelisted-class">',
+      '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
+      '</span>'].join('');
+    const result = element._rangesFromString(str, new Map());
+    assert.notEqual(result.length, 0);
+  });
+
+  test('_rangesFromString cache same syntax markers', () => {
+    sandbox.spy(element, '_rangesFromElement');
+    const str =
+      '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>';
+    const cacheMap = new Map();
+    element._rangesFromString(str, cacheMap);
+    element._rangesFromString(str, cacheMap);
+    assert.isTrue(element._rangesFromElement.calledOnce);
+  });
+
+  test('_isSectionDone', () => {
+    let state = {sectionIndex: 0, lineIndex: 0};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 0, lineIndex: 2};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 0, lineIndex: 4};
+    assert.isTrue(element._isSectionDone(state));
+
+    state = {sectionIndex: 1, lineIndex: 2};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 1, lineIndex: 3};
+    assert.isTrue(element._isSectionDone(state));
+
+    state = {sectionIndex: 3, lineIndex: 0};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 3, lineIndex: 3};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 3, lineIndex: 4};
+    assert.isTrue(element._isSectionDone(state));
+  });
+
+  test('workaround CPP LT directive', () => {
+    // Does nothing to regular line.
+    let line = 'int main(int argc, char** argv) { return 0; }';
+    assert.equal(element._workaround('cpp', line), line);
+
+    // Does nothing to include directive.
+    line = '#include <stdio>';
+    assert.equal(element._workaround('cpp', line), line);
+
+    // Converts left-shift operator in #define.
+    line = '#define GiB (1ull << 30)';
+    let expected = '#define GiB (1ull || 30)';
+    assert.equal(element._workaround('cpp', line), expected);
+
+    // Converts less-than operator in #if.
+    line = '  #if __GNUC__ < 4 || (__GNUC__ == 4 && __GNUC_MINOR__ < 1)';
+    expected = '  #if __GNUC__ | 4 || (__GNUC__ == 4 && __GNUC_MINOR__ | 1)';
+    assert.equal(element._workaround('cpp', line), expected);
+  });
+
+  test('workaround Java param-annotation', () => {
+    // Does nothing to regular line.
+    let line = 'public static void foo(int bar) { }';
+    assert.equal(element._workaround('java', line), line);
+
+    // Does nothing to regular annotation.
+    line = 'public static void foo(@Nullable int bar) { }';
+    assert.equal(element._workaround('java', line), line);
+
+    // Converts parameterized annotation.
+    line = 'public static void foo(@SuppressWarnings("unused") int bar) { }';
+    const expected = 'public static void foo(@SuppressWarnings "unused" ' +
+        ' int bar) { }';
+    assert.equal(element._workaround('java', line), expected);
+  });
+
+  test('workaround CPP whcar_t character literals', () => {
+    // Does nothing to regular line.
+    let line = 'int main(int argc, char** argv) { return 0; }';
+    assert.equal(element._workaround('cpp', line), line);
+
+    // Does nothing to wchar_t string.
+    line = 'wchar_t* sz = L"abc 123";';
+    assert.equal(element._workaround('cpp', line), line);
+
+    // Converts wchar_t character literal to string.
+    line = 'wchar_t myChar = L\'#\'';
+    let expected = 'wchar_t myChar = L"."';
+    assert.equal(element._workaround('cpp', line), expected);
+
+    // Converts wchar_t character literal with escape sequence to string.
+    line = 'wchar_t myChar = L\'\\"\'';
+    expected = 'wchar_t myChar = L"\\."';
+    assert.equal(element._workaround('cpp', line), expected);
+  });
+
+  test('workaround go backslash character literals', () => {
+    // Does nothing to regular line.
+    let line = 'func foo(in []byte) (lit []byte, n int, err error) {';
+    assert.equal(element._workaround('go', line), line);
+
+    // Does nothing to string with backslash literal
+    line = 'c := "\\\\"';
+    assert.equal(element._workaround('go', line), line);
+
+    // Converts backslash literal character to a string.
+    line = 'c := \'\\\\\'';
+    const expected = 'c := "\\\\"';
+    assert.equal(element._workaround('go', line), expected);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.js b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.js
index e5ae06d..76a01de 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.js
@@ -1,20 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-<dom-module id="gr-syntax-theme">
+$_documentContainer.innerHTML = `<dom-module id="gr-syntax-theme">
   <template>
     <style>
       /**
@@ -107,4 +109,13 @@
       }
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
index 022a985..0a57883 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
@@ -14,78 +14,90 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.ListViewMixin
-   * @extends Polymer.Element
-   */
-  class GrDocumentationSearch extends Polymer.mixinBehaviors( [
-    Gerrit.ListViewBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-documentation-search'; }
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-list-view/gr-list-view.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-documentation-search_html.js';
 
-    static get properties() {
-      return {
-      /**
-       * URL params passed from the router.
-       */
-        params: {
-          type: Object,
-          observer: '_paramsChanged',
-        },
+/**
+ * @appliesMixin Gerrit.ListViewMixin
+ * @extends Polymer.Element
+ */
+class GrDocumentationSearch extends mixinBehaviors( [
+  Gerrit.ListViewBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-        _path: {
-          type: String,
-          readOnly: true,
-          value: '/Documentation',
-        },
-        _documentationSearches: Array,
+  static get is() { return 'gr-documentation-search'; }
 
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-        _filter: {
-          type: String,
-          value: '',
-        },
-      };
-    }
+  static get properties() {
+    return {
+    /**
+     * URL params passed from the router.
+     */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
 
-    /** @override */
-    attached() {
-      super.attached();
-      this.dispatchEvent(
-          new CustomEvent('title-change', {title: 'Documentation Search'}));
-    }
+      _path: {
+        type: String,
+        readOnly: true,
+        value: '/Documentation',
+      },
+      _documentationSearches: Array,
 
-    _paramsChanged(params) {
-      this._loading = true;
-      this._filter = this.getFilterValue(params);
-
-      return this._getDocumentationSearches(this._filter);
-    }
-
-    _getDocumentationSearches(filter) {
-      this._documentationSearches = [];
-      return this.$.restAPI.getDocumentationSearches(filter)
-          .then(searches => {
-            // Late response.
-            if (filter !== this._filter || !searches) { return; }
-            this._documentationSearches = searches;
-            this._loading = false;
-          });
-    }
-
-    _computeSearchUrl(url) {
-      if (!url) { return ''; }
-      return this.getBaseUrl() + '/' + url;
-    }
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: {
+        type: String,
+        value: '',
+      },
+    };
   }
 
-  customElements.define(GrDocumentationSearch.is, GrDocumentationSearch);
-})();
+  /** @override */
+  attached() {
+    super.attached();
+    this.dispatchEvent(
+        new CustomEvent('title-change', {title: 'Documentation Search'}));
+  }
+
+  _paramsChanged(params) {
+    this._loading = true;
+    this._filter = this.getFilterValue(params);
+
+    return this._getDocumentationSearches(this._filter);
+  }
+
+  _getDocumentationSearches(filter) {
+    this._documentationSearches = [];
+    return this.$.restAPI.getDocumentationSearches(filter)
+        .then(searches => {
+          // Late response.
+          if (filter !== this._filter || !searches) { return; }
+          this._documentationSearches = searches;
+          this._loading = false;
+        });
+  }
+
+  _computeSearchUrl(url) {
+    if (!url) { return ''; }
+    return this.getBaseUrl() + '/' + url;
+  }
+}
+
+customElements.define(GrDocumentationSearch.is, GrDocumentationSearch);
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.js
index 5ae679e..ced351a 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.js
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.js
@@ -1,56 +1,43 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-documentation-search">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
     <style include="gr-table-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
-    <gr-list-view
-        filter="[[_filter]]"
-        items=false
-        offset=0
-        loading="[[_loading]]"
-        path="[[_path]]">
+    <gr-list-view filter="[[_filter]]" items="false" offset="0" loading="[[_loading]]" path="[[_path]]">
       <table id="list" class="genericList">
-        <tr class="headerRow">
+        <tbody><tr class="headerRow">
           <th class="name topHeader">Name</th>
           <th class="name topHeader"></th>
           <th class="name topHeader"></th>
         </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+        <tr id="loading" class\$="loadingMsg [[computeLoadingClass(_loading)]]">
           <td>Loading...</td>
         </tr>
-        <tbody class$="[[computeLoadingClass(_loading)]]">
+        </tbody><tbody class\$="[[computeLoadingClass(_loading)]]">
           <template is="dom-repeat" items="[[_documentationSearches]]">
             <tr class="table">
               <td class="name">
-                <a href$="[[_computeSearchUrl(item.url)]]">[[item.title]]</a>
+                <a href\$="[[_computeSearchUrl(item.url)]]">[[item.title]]</a>
               </td>
               <td></td>
               <td></td>
@@ -60,6 +47,4 @@
       </table>
     </gr-list-view>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-documentation-search.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
index e9bf78d..9c3a08d 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-documentation-search</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-documentation-search.html">
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-documentation-search.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-documentation-search.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,90 +41,92 @@
   </template>
 </test-fixture>
 
-<script>
-  let counter;
-  const documentationGenerator = () => {
-    return {
-      title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
-      url: 'Documentation/dev-rest-api.html',
-    };
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-documentation-search.js';
+let counter;
+const documentationGenerator = () => {
+  return {
+    title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
+    url: 'Documentation/dev-rest-api.html',
   };
+};
 
-  suite('gr-documentation-search tests', async () => {
-    await readyToTest();
-    let element;
-    let documentationSearches;
-    let sandbox;
-    let value;
+suite('gr-documentation-search tests', () => {
+  let element;
+  let documentationSearches;
+  let sandbox;
+  let value;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(page, 'show');
-      element = fixture('basic');
-      counter = 0;
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(page, 'show');
+    element = fixture('basic');
+    counter = 0;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('list with searches for documentation', () => {
+    setup(done => {
+      documentationSearches = _.times(26, documentationGenerator);
+      stub('gr-rest-api-interface', {
+        getDocumentationSearches() {
+          return Promise.resolve(documentationSearches);
+        },
+      });
+      element._paramsChanged(value).then(() => { flush(done); });
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('list with searches for documentation', () => {
-      setup(done => {
-        documentationSearches = _.times(26, documentationGenerator);
-        stub('gr-rest-api-interface', {
-          getDocumentationSearches() {
-            return Promise.resolve(documentationSearches);
-          },
-        });
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('test for test repo in the list', done => {
-        flush(() => {
-          assert.equal(element._documentationSearches[0].title,
-              'Gerrit Code Review - REST API Developers Notes1');
-          assert.equal(element._documentationSearches[0].url,
-              'Documentation/dev-rest-api.html');
-          done();
-        });
-      });
-    });
-
-    suite('filter', () => {
-      setup(() => {
-        documentationSearches = _.times(25, documentationGenerator);
-        _.times(1, documentationSearches);
-      });
-
-      test('_paramsChanged', done => {
-        sandbox.stub(
-            element.$.restAPI,
-            'getDocumentationSearches',
-            () => Promise.resolve(documentationSearches));
-        const value = {
-          filter: 'test',
-        };
-        element._paramsChanged(value).then(() => {
-          assert.isTrue(element.$.restAPI.getDocumentationSearches.lastCall
-              .calledWithExactly('test'));
-          done();
-        });
-      });
-    });
-
-    suite('loading', () => {
-      test('correct contents are displayed', () => {
-        assert.isTrue(element._loading);
-        assert.equal(element.computeLoadingClass(element._loading), 'loading');
-        assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-        element._loading = false;
-        element._repos = _.times(25, documentationGenerator);
-
-        flushAsynchronousOperations();
-        assert.equal(element.computeLoadingClass(element._loading), '');
-        assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    test('test for test repo in the list', done => {
+      flush(() => {
+        assert.equal(element._documentationSearches[0].title,
+            'Gerrit Code Review - REST API Developers Notes1');
+        assert.equal(element._documentationSearches[0].url,
+            'Documentation/dev-rest-api.html');
+        done();
       });
     });
   });
+
+  suite('filter', () => {
+    setup(() => {
+      documentationSearches = _.times(25, documentationGenerator);
+      _.times(1, documentationSearches);
+    });
+
+    test('_paramsChanged', done => {
+      sandbox.stub(
+          element.$.restAPI,
+          'getDocumentationSearches',
+          () => Promise.resolve(documentationSearches));
+      const value = {
+        filter: 'test',
+      };
+      element._paramsChanged(value).then(() => {
+        assert.isTrue(element.$.restAPI.getDocumentationSearches.lastCall
+            .calledWithExactly('test'));
+        done();
+      });
+    });
+  });
+
+  suite('loading', () => {
+    test('correct contents are displayed', () => {
+      assert.isTrue(element._loading);
+      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+      element._loading = false;
+      element._repos = _.times(25, documentationGenerator);
+
+      flushAsynchronousOperations();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
index 73dbaf8..09f4abf 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
@@ -14,32 +14,38 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrDefaultEditor extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-default-editor'; }
-    /**
-     * Fired when the content of the editor changes.
-     *
-     * @event content-change
-     */
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-default-editor_html.js';
 
-    static get properties() {
-      return {
-        fileContent: String,
-      };
-    }
+/** @extends Polymer.Element */
+class GrDefaultEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _handleTextareaInput(e) {
-      this.dispatchEvent(new CustomEvent(
-          'content-change',
-          {detail: {value: e.target.value}, bubbles: true, composed: true}));
-    }
+  static get is() { return 'gr-default-editor'; }
+  /**
+   * Fired when the content of the editor changes.
+   *
+   * @event content-change
+   */
+
+  static get properties() {
+    return {
+      fileContent: String,
+    };
   }
 
-  customElements.define(GrDefaultEditor.is, GrDefaultEditor);
-})();
+  _handleTextareaInput(e) {
+    this.dispatchEvent(new CustomEvent(
+        'content-change',
+        {detail: {value: e.target.value}, bubbles: true, composed: true}));
+  }
+}
+
+customElements.define(GrDefaultEditor.is, GrDefaultEditor);
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.js
index 19a4e63..e7fc6fd 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.js
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.js
@@ -1,25 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-default-editor">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       textarea {
         border: none;
@@ -36,10 +33,5 @@
         outline: none;
       }
     </style>
-    <textarea
-        id="textarea"
-        value="[[fileContent]]"
-        on-input="_handleTextareaInput"></textarea>
-  </template>
-  <script src="gr-default-editor.js"></script>
-</dom-module>
+    <textarea id="textarea" value="[[fileContent]]" on-input="_handleTextareaInput"></textarea>
+`;
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
index 228c70e..043f656 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
@@ -18,16 +18,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-default-editor</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel="import" href="gr-default-editor.html">
+<script type="module" src="./gr-default-editor.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-default-editor.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,26 +40,28 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-default-editor tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-default-editor.js';
+suite('gr-default-editor tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-      element.fileContent = '';
-    });
-
-    test('fires content-change event', done => {
-      const contentChangedHandler = e => {
-        assert.equal(e.detail.value, 'test');
-        done();
-      };
-      const textarea = element.$.textarea;
-      element.addEventListener('content-change', contentChangedHandler);
-      textarea.value = 'test';
-      textarea.dispatchEvent(new CustomEvent('input',
-          {target: textarea, bubbles: true, composed: true}));
-    });
+  setup(() => {
+    element = fixture('basic');
+    element.fileContent = '';
   });
+
+  test('fires content-change event', done => {
+    const contentChangedHandler = e => {
+      assert.equal(e.detail.value, 'test');
+      done();
+    };
+    const textarea = element.$.textarea;
+    element.addEventListener('content-change', contentChangedHandler);
+    textarea.value = 'test';
+    textarea.dispatchEvent(new CustomEvent('input',
+        {target: textarea, bubbles: true, composed: true}));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.js b/polygerrit-ui/app/elements/edit/gr-edit-constants.js
index 5895124..2a929f2 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-constants.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-constants.js
@@ -1,33 +1,31 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.
+ */
+(function(window) {
+  'use strict';
 
-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
+  const GrEditConstants = window.GrEditConstants || {};
 
-http://www.apache.org/licenses/LICENSE-2.0
+  // Order corresponds to order in the UI.
+  GrEditConstants.Actions = {
+    OPEN: {label: 'Add/Open', id: 'open'},
+    DELETE: {label: 'Delete', id: 'delete'},
+    RENAME: {label: 'Rename', id: 'rename'},
+    RESTORE: {label: 'Restore', id: 'restore'},
+  };
 
-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.
--->
-<script>
-  (function(window) {
-    'use strict';
-
-    const GrEditConstants = window.GrEditConstants || {};
-
-    // Order corresponds to order in the UI.
-    GrEditConstants.Actions = {
-      OPEN: {label: 'Add/Open', id: 'open'},
-      DELETE: {label: 'Delete', id: 'delete'},
-      RENAME: {label: 'Rename', id: 'rename'},
-      RESTORE: {label: 'Restore', id: 'restore'},
-    };
-
-    window.GrEditConstants = GrEditConstants;
-  })(window);
-</script>
+  window.GrEditConstants = GrEditConstants;
+})(window);
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
index e655f7b..e17fe03 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
@@ -14,231 +14,250 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @extends Polymer.Element
-   */
-  class GrEditControls extends Polymer.mixinBehaviors( [
-    Gerrit.PatchSetBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-edit-controls'; }
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-dropdown/gr-dropdown.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-edit-constants.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-edit-controls_html.js';
 
-    static get properties() {
-      return {
-        change: Object,
-        patchNum: String,
+/**
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @extends Polymer.Element
+ */
+class GrEditControls extends mixinBehaviors( [
+  Gerrit.PatchSetBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-        /**
-         * TODO(kaspern): by default, the RESTORE action should be hidden in the
-         * file-list as it is a per-file action only. Remove this default value
-         * when the Actions dictionary is moved to a shared constants file and
-         * use the hiddenActions property in the parent component.
-         */
-        hiddenActions: {
-          type: Array,
-          value() { return [GrEditConstants.Actions.RESTORE.id]; },
+  static get is() { return 'gr-edit-controls'; }
+
+  static get properties() {
+    return {
+      change: Object,
+      patchNum: String,
+
+      /**
+       * TODO(kaspern): by default, the RESTORE action should be hidden in the
+       * file-list as it is a per-file action only. Remove this default value
+       * when the Actions dictionary is moved to a shared constants file and
+       * use the hiddenActions property in the parent component.
+       */
+      hiddenActions: {
+        type: Array,
+        value() { return [GrEditConstants.Actions.RESTORE.id]; },
+      },
+
+      _actions: {
+        type: Array,
+        value() { return Object.values(GrEditConstants.Actions); },
+      },
+      _path: {
+        type: String,
+        value: '',
+      },
+      _newPath: {
+        type: String,
+        value: '',
+      },
+      _query: {
+        type: Function,
+        value() {
+          return this._queryFiles.bind(this);
         },
+      },
+    };
+  }
 
-        _actions: {
-          type: Array,
-          value() { return Object.values(GrEditConstants.Actions); },
-        },
-        _path: {
-          type: String,
-          value: '',
-        },
-        _newPath: {
-          type: String,
-          value: '',
-        },
-        _query: {
-          type: Function,
-          value() {
-            return this._queryFiles.bind(this);
-          },
-        },
-      };
-    }
-
-    _handleTap(e) {
-      e.preventDefault();
-      const action = Polymer.dom(e).localTarget.id;
-      switch (action) {
-        case GrEditConstants.Actions.OPEN.id:
-          this.openOpenDialog();
-          return;
-        case GrEditConstants.Actions.DELETE.id:
-          this.openDeleteDialog();
-          return;
-        case GrEditConstants.Actions.RENAME.id:
-          this.openRenameDialog();
-          return;
-        case GrEditConstants.Actions.RESTORE.id:
-          this.openRestoreDialog();
-          return;
-      }
-    }
-
-    /**
-     * @param {string=} opt_path
-     */
-    openOpenDialog(opt_path) {
-      if (opt_path) { this._path = opt_path; }
-      return this._showDialog(this.$.openDialog);
-    }
-
-    /**
-     * @param {string=} opt_path
-     */
-    openDeleteDialog(opt_path) {
-      if (opt_path) { this._path = opt_path; }
-      return this._showDialog(this.$.deleteDialog);
-    }
-
-    /**
-     * @param {string=} opt_path
-     */
-    openRenameDialog(opt_path) {
-      if (opt_path) { this._path = opt_path; }
-      return this._showDialog(this.$.renameDialog);
-    }
-
-    /**
-     * @param {string=} opt_path
-     */
-    openRestoreDialog(opt_path) {
-      if (opt_path) { this._path = opt_path; }
-      return this._showDialog(this.$.restoreDialog);
-    }
-
-    /**
-     * Given a path string, checks that it is a valid file path.
-     *
-     * @param {string} path
-     * @return {boolean}
-     */
-    _isValidPath(path) {
-      // Double negation needed for strict boolean return type.
-      return !!path.length && !path.endsWith('/');
-    }
-
-    _computeRenameDisabled(path, newPath) {
-      return this._isValidPath(path) && this._isValidPath(newPath);
-    }
-
-    /**
-     * Given a dom event, gets the dialog that lies along this event path.
-     *
-     * @param {!Event} e
-     * @return {!Element|undefined}
-     */
-    _getDialogFromEvent(e) {
-      return Polymer.dom(e).path.find(element => {
-        if (!element.classList) { return false; }
-        return element.classList.contains('dialog');
-      });
-    }
-
-    _showDialog(dialog) {
-      // Some dialogs may not fire their on-close event when closed in certain
-      // ways (e.g. by clicking outside the dialog body). This call prevents
-      // multiple dialogs from being shown in the same overlay.
-      this._hideAllDialogs();
-
-      return this.$.overlay.open().then(() => {
-        dialog.classList.toggle('invisible', false);
-        const autocomplete = dialog.querySelector('gr-autocomplete');
-        if (autocomplete) { autocomplete.focus(); }
-        this.async(() => { this.$.overlay.center(); }, 1);
-      });
-    }
-
-    _hideAllDialogs() {
-      const dialogs = Polymer.dom(this.root).querySelectorAll('.dialog');
-      for (const dialog of dialogs) { this._closeDialog(dialog); }
-    }
-
-    /**
-     * @param {Element|undefined} dialog
-     * @param {boolean=} clearInputs
-     */
-    _closeDialog(dialog, clearInputs) {
-      if (!dialog) { return; }
-
-      if (clearInputs) {
-        // Dialog may have autocompletes and plain inputs -- as these have
-        // different properties representing their bound text, it is easier to
-        // just make two separate queries.
-        dialog.querySelectorAll('gr-autocomplete')
-            .forEach(input => { input.text = ''; });
-
-        dialog.querySelectorAll('iron-input')
-            .forEach(input => { input.bindValue = ''; });
-      }
-
-      dialog.classList.toggle('invisible', true);
-      return this.$.overlay.close();
-    }
-
-    _handleDialogCancel(e) {
-      this._closeDialog(this._getDialogFromEvent(e));
-    }
-
-    _handleOpenConfirm(e) {
-      const url = Gerrit.Nav.getEditUrlForDiff(this.change, this._path,
-          this.patchNum);
-      Gerrit.Nav.navigateToRelativeUrl(url);
-      this._closeDialog(this._getDialogFromEvent(e), true);
-    }
-
-    _handleDeleteConfirm(e) {
-      // Get the dialog before the api call as the event will change during bubbling
-      // which will make Polymer.dom(e).path an emtpy array in polymer 2
-      const dialog = this._getDialogFromEvent(e);
-      this.$.restAPI.deleteFileInChangeEdit(this.change._number, this._path)
-          .then(res => {
-            if (!res.ok) { return; }
-            this._closeDialog(dialog, true);
-            Gerrit.Nav.navigateToChange(this.change);
-          });
-    }
-
-    _handleRestoreConfirm(e) {
-      const dialog = this._getDialogFromEvent(e);
-      this.$.restAPI.restoreFileInChangeEdit(this.change._number, this._path)
-          .then(res => {
-            if (!res.ok) { return; }
-            this._closeDialog(dialog, true);
-            Gerrit.Nav.navigateToChange(this.change);
-          });
-    }
-
-    _handleRenameConfirm(e) {
-      const dialog = this._getDialogFromEvent(e);
-      return this.$.restAPI.renameFileInChangeEdit(this.change._number,
-          this._path, this._newPath).then(res => {
-        if (!res.ok) { return; }
-        this._closeDialog(dialog, true);
-        Gerrit.Nav.navigateToChange(this.change);
-      });
-    }
-
-    _queryFiles(input) {
-      return this.$.restAPI.queryChangeFiles(this.change._number,
-          this.patchNum, input).then(res => res.map(file => {
-        return {name: file};
-      }));
-    }
-
-    _computeIsInvisible(id, hiddenActions) {
-      return hiddenActions.includes(id) ? 'invisible' : '';
+  _handleTap(e) {
+    e.preventDefault();
+    const action = dom(e).localTarget.id;
+    switch (action) {
+      case GrEditConstants.Actions.OPEN.id:
+        this.openOpenDialog();
+        return;
+      case GrEditConstants.Actions.DELETE.id:
+        this.openDeleteDialog();
+        return;
+      case GrEditConstants.Actions.RENAME.id:
+        this.openRenameDialog();
+        return;
+      case GrEditConstants.Actions.RESTORE.id:
+        this.openRestoreDialog();
+        return;
     }
   }
 
-  customElements.define(GrEditControls.is, GrEditControls);
-})();
+  /**
+   * @param {string=} opt_path
+   */
+  openOpenDialog(opt_path) {
+    if (opt_path) { this._path = opt_path; }
+    return this._showDialog(this.$.openDialog);
+  }
+
+  /**
+   * @param {string=} opt_path
+   */
+  openDeleteDialog(opt_path) {
+    if (opt_path) { this._path = opt_path; }
+    return this._showDialog(this.$.deleteDialog);
+  }
+
+  /**
+   * @param {string=} opt_path
+   */
+  openRenameDialog(opt_path) {
+    if (opt_path) { this._path = opt_path; }
+    return this._showDialog(this.$.renameDialog);
+  }
+
+  /**
+   * @param {string=} opt_path
+   */
+  openRestoreDialog(opt_path) {
+    if (opt_path) { this._path = opt_path; }
+    return this._showDialog(this.$.restoreDialog);
+  }
+
+  /**
+   * Given a path string, checks that it is a valid file path.
+   *
+   * @param {string} path
+   * @return {boolean}
+   */
+  _isValidPath(path) {
+    // Double negation needed for strict boolean return type.
+    return !!path.length && !path.endsWith('/');
+  }
+
+  _computeRenameDisabled(path, newPath) {
+    return this._isValidPath(path) && this._isValidPath(newPath);
+  }
+
+  /**
+   * Given a dom event, gets the dialog that lies along this event path.
+   *
+   * @param {!Event} e
+   * @return {!Element|undefined}
+   */
+  _getDialogFromEvent(e) {
+    return dom(e).path.find(element => {
+      if (!element.classList) { return false; }
+      return element.classList.contains('dialog');
+    });
+  }
+
+  _showDialog(dialog) {
+    // Some dialogs may not fire their on-close event when closed in certain
+    // ways (e.g. by clicking outside the dialog body). This call prevents
+    // multiple dialogs from being shown in the same overlay.
+    this._hideAllDialogs();
+
+    return this.$.overlay.open().then(() => {
+      dialog.classList.toggle('invisible', false);
+      const autocomplete = dialog.querySelector('gr-autocomplete');
+      if (autocomplete) { autocomplete.focus(); }
+      this.async(() => { this.$.overlay.center(); }, 1);
+    });
+  }
+
+  _hideAllDialogs() {
+    const dialogs = dom(this.root).querySelectorAll('.dialog');
+    for (const dialog of dialogs) { this._closeDialog(dialog); }
+  }
+
+  /**
+   * @param {Element|undefined} dialog
+   * @param {boolean=} clearInputs
+   */
+  _closeDialog(dialog, clearInputs) {
+    if (!dialog) { return; }
+
+    if (clearInputs) {
+      // Dialog may have autocompletes and plain inputs -- as these have
+      // different properties representing their bound text, it is easier to
+      // just make two separate queries.
+      dialog.querySelectorAll('gr-autocomplete')
+          .forEach(input => { input.text = ''; });
+
+      dialog.querySelectorAll('iron-input')
+          .forEach(input => { input.bindValue = ''; });
+    }
+
+    dialog.classList.toggle('invisible', true);
+    return this.$.overlay.close();
+  }
+
+  _handleDialogCancel(e) {
+    this._closeDialog(this._getDialogFromEvent(e));
+  }
+
+  _handleOpenConfirm(e) {
+    const url = Gerrit.Nav.getEditUrlForDiff(this.change, this._path,
+        this.patchNum);
+    Gerrit.Nav.navigateToRelativeUrl(url);
+    this._closeDialog(this._getDialogFromEvent(e), true);
+  }
+
+  _handleDeleteConfirm(e) {
+    // Get the dialog before the api call as the event will change during bubbling
+    // which will make Polymer.dom(e).path an emtpy array in polymer 2
+    const dialog = this._getDialogFromEvent(e);
+    this.$.restAPI.deleteFileInChangeEdit(this.change._number, this._path)
+        .then(res => {
+          if (!res.ok) { return; }
+          this._closeDialog(dialog, true);
+          Gerrit.Nav.navigateToChange(this.change);
+        });
+  }
+
+  _handleRestoreConfirm(e) {
+    const dialog = this._getDialogFromEvent(e);
+    this.$.restAPI.restoreFileInChangeEdit(this.change._number, this._path)
+        .then(res => {
+          if (!res.ok) { return; }
+          this._closeDialog(dialog, true);
+          Gerrit.Nav.navigateToChange(this.change);
+        });
+  }
+
+  _handleRenameConfirm(e) {
+    const dialog = this._getDialogFromEvent(e);
+    return this.$.restAPI.renameFileInChangeEdit(this.change._number,
+        this._path, this._newPath).then(res => {
+      if (!res.ok) { return; }
+      this._closeDialog(dialog, true);
+      Gerrit.Nav.navigateToChange(this.change);
+    });
+  }
+
+  _queryFiles(input) {
+    return this.$.restAPI.queryChangeFiles(this.change._number,
+        this.patchNum, input).then(res => res.map(file => {
+      return {name: file};
+    }));
+  }
+
+  _computeIsInvisible(id, hiddenActions) {
+    return hiddenActions.includes(id) ? 'invisible' : '';
+  }
+}
+
+customElements.define(GrEditControls.is, GrEditControls);
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.js
index cb950da..2d09069 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.js
@@ -1,38 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-edit-constants.html">
-
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-edit-controls">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         align-items: center;
@@ -70,94 +54,40 @@
       }
     </style>
     <template is="dom-repeat" items="[[_actions]]" as="action">
-      <gr-button
-          id$="[[action.id]]"
-          class$="[[_computeIsInvisible(action.id, hiddenActions)]]"
-          link
-          on-click="_handleTap">[[action.label]]</gr-button>
+      <gr-button id\$="[[action.id]]" class\$="[[_computeIsInvisible(action.id, hiddenActions)]]" link="" on-click="_handleTap">[[action.label]]</gr-button>
     </template>
-    <gr-overlay id="overlay" with-backdrop>
-      <gr-dialog
-          id="openDialog"
-          class="invisible dialog"
-          disabled$="[[!_isValidPath(_path)]]"
-          confirm-label="Confirm"
-          confirm-on-enter
-          on-confirm="_handleOpenConfirm"
-          on-cancel="_handleDialogCancel">
+    <gr-overlay id="overlay" with-backdrop="">
+      <gr-dialog id="openDialog" class="invisible dialog" disabled\$="[[!_isValidPath(_path)]]" confirm-label="Confirm" confirm-on-enter="" on-confirm="_handleOpenConfirm" on-cancel="_handleDialogCancel">
         <div class="header" slot="header">
           Add a new file or open an existing file
         </div>
         <div class="main" slot="main">
-          <gr-autocomplete
-              placeholder="Enter an existing or new full file path."
-              query="[[_query]]"
-              text="{{_path}}"></gr-autocomplete>
+          <gr-autocomplete placeholder="Enter an existing or new full file path." query="[[_query]]" text="{{_path}}"></gr-autocomplete>
         </div>
       </gr-dialog>
-      <gr-dialog
-          id="deleteDialog"
-          class="invisible dialog"
-          disabled$="[[!_isValidPath(_path)]]"
-          confirm-label="Delete"
-          confirm-on-enter
-          on-confirm="_handleDeleteConfirm"
-          on-cancel="_handleDialogCancel">
+      <gr-dialog id="deleteDialog" class="invisible dialog" disabled\$="[[!_isValidPath(_path)]]" confirm-label="Delete" confirm-on-enter="" on-confirm="_handleDeleteConfirm" on-cancel="_handleDialogCancel">
         <div class="header" slot="header">Delete a file from the repo</div>
         <div class="main" slot="main">
-          <gr-autocomplete
-              placeholder="Enter an existing full file path."
-              query="[[_query]]"
-              text="{{_path}}"></gr-autocomplete>
+          <gr-autocomplete placeholder="Enter an existing full file path." query="[[_query]]" text="{{_path}}"></gr-autocomplete>
         </div>
       </gr-dialog>
-      <gr-dialog
-          id="renameDialog"
-          class="invisible dialog"
-          disabled$="[[!_computeRenameDisabled(_path, _newPath)]]"
-          confirm-label="Rename"
-          confirm-on-enter
-          on-confirm="_handleRenameConfirm"
-          on-cancel="_handleDialogCancel">
+      <gr-dialog id="renameDialog" class="invisible dialog" disabled\$="[[!_computeRenameDisabled(_path, _newPath)]]" confirm-label="Rename" confirm-on-enter="" on-confirm="_handleRenameConfirm" on-cancel="_handleDialogCancel">
         <div class="header" slot="header">Rename a file in the repo</div>
         <div class="main" slot="main">
-          <gr-autocomplete
-              placeholder="Enter an existing full file path."
-              query="[[_query]]"
-              text="{{_path}}"></gr-autocomplete>
-          <iron-input
-              class="newPathIronInput"
-              bind-value="{{_newPath}}"
-              placeholder="Enter the new path.">
-            <input
-                class="newPathInput"
-                is="iron-input"
-                bind-value="{{_newPath}}"
-                placeholder="Enter the new path.">
+          <gr-autocomplete placeholder="Enter an existing full file path." query="[[_query]]" text="{{_path}}"></gr-autocomplete>
+          <iron-input class="newPathIronInput" bind-value="{{_newPath}}" placeholder="Enter the new path.">
+            <input class="newPathInput" is="iron-input" bind-value="{{_newPath}}" placeholder="Enter the new path.">
           </iron-input>
         </div>
       </gr-dialog>
-      <gr-dialog
-          id="restoreDialog"
-          class="invisible dialog"
-          confirm-label="Restore"
-          confirm-on-enter
-          on-confirm="_handleRestoreConfirm"
-          on-cancel="_handleDialogCancel">
+      <gr-dialog id="restoreDialog" class="invisible dialog" confirm-label="Restore" confirm-on-enter="" on-confirm="_handleRestoreConfirm" on-cancel="_handleDialogCancel">
         <div class="header" slot="header">Restore this file?</div>
         <div class="main" slot="main">
-          <iron-input
-              disabled
-              bind-value="{{_path}}">
-            <input
-                is="iron-input"
-                disabled
-                bind-value="{{_path}}">
+          <iron-input disabled="" bind-value="{{_path}}">
+            <input is="iron-input" disabled="" bind-value="{{_path}}">
           </iron-input>
         </div>
       </gr-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-edit-controls.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
index 80de093..034a7a7 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
@@ -18,16 +18,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-edit-controls</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel="import" href="gr-edit-controls.html">
+<script type="module" src="./gr-edit-controls.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-edit-controls.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,351 +40,355 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-edit-controls tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let showDialogSpy;
-    let closeDialogSpy;
-    let queryStub;
-  
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-edit-controls.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+suite('gr-edit-controls tests', () => {
+  let element;
+  let sandbox;
+  let showDialogSpy;
+  let closeDialogSpy;
+  let queryStub;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.change = {_number: '42'};
+    showDialogSpy = sandbox.spy(element, '_showDialog');
+    closeDialogSpy = sandbox.spy(element, '_closeDialog');
+    sandbox.stub(element, '_hideAllDialogs');
+    queryStub = sandbox.stub(element.$.restAPI, 'queryChangeFiles')
+        .returns(Promise.resolve([]));
+    flushAsynchronousOperations();
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('all actions exist', () => {
+    assert.equal(dom(element.root).querySelectorAll('gr-button').length,
+        element._actions.length);
+  });
+
+  suite('edit button CUJ', () => {
+    let navStubs;
+    let openAutoCcmplete;
+
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.change = {_number: '42'};
-      showDialogSpy = sandbox.spy(element, '_showDialog');
-      closeDialogSpy = sandbox.spy(element, '_closeDialog');
-      sandbox.stub(element, '_hideAllDialogs');
-      queryStub = sandbox.stub(element.$.restAPI, 'queryChangeFiles')
-          .returns(Promise.resolve([]));
-      flushAsynchronousOperations();
+      navStubs = [
+        sandbox.stub(Gerrit.Nav, 'getEditUrlForDiff'),
+        sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl'),
+      ];
+      openAutoCcmplete = element.$.openDialog.querySelector('gr-autocomplete');
     });
-  
-    teardown(() => { sandbox.restore(); });
-  
-    test('all actions exist', () => {
-      assert.equal(Polymer.dom(element.root).querySelectorAll('gr-button').length,
-          element._actions.length);
+
+    test('_isValidPath', () => {
+      assert.isFalse(element._isValidPath(''));
+      assert.isFalse(element._isValidPath('test/'));
+      assert.isFalse(element._isValidPath('/'));
+      assert.isTrue(element._isValidPath('test/path.cpp'));
+      assert.isTrue(element._isValidPath('test.js'));
     });
-  
-    suite('edit button CUJ', () => {
-      let navStubs;
-      let openAutoCcmplete;
-  
-      setup(() => {
-        navStubs = [
-          sandbox.stub(Gerrit.Nav, 'getEditUrlForDiff'),
-          sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl'),
-        ];
-        openAutoCcmplete = element.$.openDialog.querySelector('gr-autocomplete');
-      });
-  
-      test('_isValidPath', () => {
-        assert.isFalse(element._isValidPath(''));
-        assert.isFalse(element._isValidPath('test/'));
-        assert.isFalse(element._isValidPath('/'));
-        assert.isTrue(element._isValidPath('test/path.cpp'));
-        assert.isTrue(element._isValidPath('test.js'));
-      });
-  
-      test('open', () => {
-        MockInteractions.tap(element.shadowRoot.querySelector('#open'));
-        element.patchNum = 1;
-        return showDialogSpy.lastCall.returnValue.then(() => {
-          assert.isTrue(element._hideAllDialogs.called);
-          assert.isTrue(element.$.openDialog.disabled);
-          assert.isFalse(queryStub.called);
-          openAutoCcmplete.noDebounce = true;
-          openAutoCcmplete.text = 'src/test.cpp';
-          assert.isTrue(queryStub.called);
-          assert.isFalse(element.$.openDialog.disabled);
-          MockInteractions.tap(element.$.openDialog.shadowRoot
-              .querySelector('gr-button[primary]'));
-          for (const stub of navStubs) { assert.isTrue(stub.called); }
-          assert.deepEqual(Gerrit.Nav.getEditUrlForDiff.lastCall.args,
-              [element.change, 'src/test.cpp', element.patchNum]);
-          assert.isTrue(closeDialogSpy.called);
-        });
-      });
-  
-      test('cancel', () => {
-        MockInteractions.tap(element.shadowRoot.querySelector('#open'));
-        return showDialogSpy.lastCall.returnValue.then(() => {
-          assert.isTrue(element.$.openDialog.disabled);
-          openAutoCcmplete.noDebounce = true;
-          openAutoCcmplete.text = 'src/test.cpp';
-          assert.isFalse(element.$.openDialog.disabled);
-          MockInteractions.tap(element.$.openDialog.shadowRoot
-              .querySelector('gr-button'));
-          for (const stub of navStubs) { assert.isFalse(stub.called); }
-          assert.isTrue(closeDialogSpy.called);
-          assert.equal(element._path, 'src/test.cpp');
-        });
+
+    test('open', () => {
+      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
+      element.patchNum = 1;
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element._hideAllDialogs.called);
+        assert.isTrue(element.$.openDialog.disabled);
+        assert.isFalse(queryStub.called);
+        openAutoCcmplete.noDebounce = true;
+        openAutoCcmplete.text = 'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isFalse(element.$.openDialog.disabled);
+        MockInteractions.tap(element.$.openDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        for (const stub of navStubs) { assert.isTrue(stub.called); }
+        assert.deepEqual(Gerrit.Nav.getEditUrlForDiff.lastCall.args,
+            [element.change, 'src/test.cpp', element.patchNum]);
+        assert.isTrue(closeDialogSpy.called);
       });
     });
-  
-    suite('delete button CUJ', () => {
-      let navStub;
-      let deleteStub;
-      let deleteAutocomplete;
-  
-      setup(() => {
-        navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-        deleteStub = sandbox.stub(element.$.restAPI, 'deleteFileInChangeEdit');
-        deleteAutocomplete =
-            element.$.deleteDialog.querySelector('gr-autocomplete');
+
+    test('cancel', () => {
+      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.openDialog.disabled);
+        openAutoCcmplete.noDebounce = true;
+        openAutoCcmplete.text = 'src/test.cpp';
+        assert.isFalse(element.$.openDialog.disabled);
+        MockInteractions.tap(element.$.openDialog.shadowRoot
+            .querySelector('gr-button'));
+        for (const stub of navStubs) { assert.isFalse(stub.called); }
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
       });
-  
-      test('delete', () => {
-        deleteStub.returns(Promise.resolve({ok: true}));
-        MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
-        return showDialogSpy.lastCall.returnValue.then(() => {
-          assert.isTrue(element.$.deleteDialog.disabled);
-          assert.isFalse(queryStub.called);
-          deleteAutocomplete.noDebounce = true;
-          deleteAutocomplete.text = 'src/test.cpp';
-          assert.isTrue(queryStub.called);
-          assert.isFalse(element.$.deleteDialog.disabled);
-          MockInteractions.tap(element.$.deleteDialog.shadowRoot
-              .querySelector('gr-button[primary]'));
-          flushAsynchronousOperations();
-  
-          assert.isTrue(deleteStub.called);
-  
-          return deleteStub.lastCall.returnValue.then(() => {
-            assert.equal(element._path, '');
-            assert.isTrue(navStub.called);
-            assert.isTrue(closeDialogSpy.called);
-          });
-        });
-      });
-  
-      test('delete fails', () => {
-        deleteStub.returns(Promise.resolve({ok: false}));
-        MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
-        return showDialogSpy.lastCall.returnValue.then(() => {
-          assert.isTrue(element.$.deleteDialog.disabled);
-          assert.isFalse(queryStub.called);
-          deleteAutocomplete.noDebounce = true;
-          deleteAutocomplete.text = 'src/test.cpp';
-          assert.isTrue(queryStub.called);
-          assert.isFalse(element.$.deleteDialog.disabled);
-          MockInteractions.tap(element.$.deleteDialog.shadowRoot
-              .querySelector('gr-button[primary]'));
-          flushAsynchronousOperations();
-  
-          assert.isTrue(deleteStub.called);
-  
-          return deleteStub.lastCall.returnValue.then(() => {
-            assert.isFalse(navStub.called);
-            assert.isFalse(closeDialogSpy.called);
-          });
-        });
-      });
-  
-      test('cancel', () => {
-        MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
-        return showDialogSpy.lastCall.returnValue.then(() => {
-          assert.isTrue(element.$.deleteDialog.disabled);
-          element.$.deleteDialog.querySelector('gr-autocomplete').text =
-              'src/test.cpp';
-          assert.isFalse(element.$.deleteDialog.disabled);
-          MockInteractions.tap(element.$.deleteDialog.shadowRoot
-              .querySelector('gr-button'));
-          assert.isFalse(navStub.called);
-          assert.isTrue(closeDialogSpy.called);
-          assert.equal(element._path, 'src/test.cpp');
-        });
-      });
-    });
-  
-    suite('rename button CUJ', () => {
-      let navStub;
-      let renameStub;
-      let renameAutocomplete;
-      const inputSelector = Polymer.Element ?
-        '.newPathIronInput' :
-        '.newPathInput';
-  
-      setup(() => {
-        navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-        renameStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
-        renameAutocomplete =
-            element.$.renameDialog.querySelector('gr-autocomplete');
-      });
-  
-      test('rename', () => {
-        renameStub.returns(Promise.resolve({ok: true}));
-        MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
-        return showDialogSpy.lastCall.returnValue.then(() => {
-          assert.isTrue(element.$.renameDialog.disabled);
-          assert.isFalse(queryStub.called);
-          renameAutocomplete.noDebounce = true;
-          renameAutocomplete.text = 'src/test.cpp';
-          assert.isTrue(queryStub.called);
-          assert.isTrue(element.$.renameDialog.disabled);
-  
-          element.$.renameDialog.querySelector(inputSelector).bindValue =
-              'src/test.newPath';
-  
-          assert.isFalse(element.$.renameDialog.disabled);
-          MockInteractions.tap(element.$.renameDialog.shadowRoot
-              .querySelector('gr-button[primary]'));
-          flushAsynchronousOperations();
-  
-          assert.isTrue(renameStub.called);
-  
-          return renameStub.lastCall.returnValue.then(() => {
-            assert.equal(element._path, '');
-            assert.isTrue(navStub.called);
-            assert.isTrue(closeDialogSpy.called);
-          });
-        });
-      });
-  
-      test('rename fails', () => {
-        renameStub.returns(Promise.resolve({ok: false}));
-        MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
-        return showDialogSpy.lastCall.returnValue.then(() => {
-          assert.isTrue(element.$.renameDialog.disabled);
-          assert.isFalse(queryStub.called);
-          renameAutocomplete.noDebounce = true;
-          renameAutocomplete.text = 'src/test.cpp';
-          assert.isTrue(queryStub.called);
-          assert.isTrue(element.$.renameDialog.disabled);
-  
-          element.$.renameDialog.querySelector(inputSelector).bindValue =
-              'src/test.newPath';
-  
-          assert.isFalse(element.$.renameDialog.disabled);
-          MockInteractions.tap(element.$.renameDialog.shadowRoot
-              .querySelector('gr-button[primary]'));
-          flushAsynchronousOperations();
-  
-          assert.isTrue(renameStub.called);
-  
-          return renameStub.lastCall.returnValue.then(() => {
-            assert.isFalse(navStub.called);
-            assert.isFalse(closeDialogSpy.called);
-          });
-        });
-      });
-  
-      test('cancel', () => {
-        MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
-        return showDialogSpy.lastCall.returnValue.then(() => {
-          assert.isTrue(element.$.renameDialog.disabled);
-          element.$.renameDialog.querySelector('gr-autocomplete').text =
-              'src/test.cpp';
-          element.$.renameDialog.querySelector(inputSelector).bindValue =
-              'src/test.newPath';
-          assert.isFalse(element.$.renameDialog.disabled);
-          MockInteractions.tap(element.$.renameDialog.shadowRoot
-              .querySelector('gr-button'));
-          assert.isFalse(navStub.called);
-          assert.isTrue(closeDialogSpy.called);
-          assert.equal(element._path, 'src/test.cpp');
-          assert.equal(element._newPath, 'src/test.newPath');
-        });
-      });
-    });
-  
-    suite('restore button CUJ', () => {
-      let navStub;
-      let restoreStub;
-  
-      setup(() => {
-        navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-        restoreStub = sandbox.stub(element.$.restAPI, 'restoreFileInChangeEdit');
-      });
-  
-      test('restore hidden by default', () => {
-        assert.isTrue(element.shadowRoot
-            .querySelector('#restore').classList.contains('invisible'));
-      });
-  
-      test('restore', () => {
-        restoreStub.returns(Promise.resolve({ok: true}));
-        element._path = 'src/test.cpp';
-        MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
-        return showDialogSpy.lastCall.returnValue.then(() => {
-          MockInteractions.tap(element.$.restoreDialog.shadowRoot
-              .querySelector('gr-button[primary]'));
-          flushAsynchronousOperations();
-  
-          assert.isTrue(restoreStub.called);
-          assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
-          return restoreStub.lastCall.returnValue.then(() => {
-            assert.equal(element._path, '');
-            assert.isTrue(navStub.called);
-            assert.isTrue(closeDialogSpy.called);
-          });
-        });
-      });
-  
-      test('restore fails', () => {
-        restoreStub.returns(Promise.resolve({ok: false}));
-        element._path = 'src/test.cpp';
-        MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
-        return showDialogSpy.lastCall.returnValue.then(() => {
-          MockInteractions.tap(element.$.restoreDialog.shadowRoot
-              .querySelector('gr-button[primary]'));
-          flushAsynchronousOperations();
-  
-          assert.isTrue(restoreStub.called);
-          assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
-          return restoreStub.lastCall.returnValue.then(() => {
-            assert.isFalse(navStub.called);
-            assert.isFalse(closeDialogSpy.called);
-          });
-        });
-      });
-  
-      test('cancel', () => {
-        element._path = 'src/test.cpp';
-        MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
-        return showDialogSpy.lastCall.returnValue.then(() => {
-          MockInteractions.tap(element.$.restoreDialog.shadowRoot
-              .querySelector('gr-button'));
-          assert.isFalse(navStub.called);
-          assert.isTrue(closeDialogSpy.called);
-          assert.equal(element._path, 'src/test.cpp');
-        });
-      });
-    });
-  
-    test('openOpenDialog', done => {
-      element.openOpenDialog('test/path.cpp')
-          .then(() => {
-            assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
-            assert.equal(
-                element.$.openDialog.querySelector('gr-autocomplete').text,
-                'test/path.cpp');
-            done();
-          });
-    });
-  
-    test('_getDialogFromEvent', () => {
-      const spy = sandbox.spy(element, '_getDialogFromEvent');
-      element.addEventListener('tap', element._getDialogFromEvent);
-  
-      MockInteractions.tap(element.$.openDialog);
-      flushAsynchronousOperations();
-      assert.equal(spy.lastCall.returnValue.id, 'openDialog');
-  
-      MockInteractions.tap(element.$.deleteDialog);
-      flushAsynchronousOperations();
-      assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
-  
-      MockInteractions.tap(
-          element.$.deleteDialog.querySelector('gr-autocomplete'));
-      flushAsynchronousOperations();
-      assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
-  
-      MockInteractions.tap(element);
-      flushAsynchronousOperations();
-      assert.notOk(spy.lastCall.returnValue);
     });
   });
+
+  suite('delete button CUJ', () => {
+    let navStub;
+    let deleteStub;
+    let deleteAutocomplete;
+
+    setup(() => {
+      navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      deleteStub = sandbox.stub(element.$.restAPI, 'deleteFileInChangeEdit');
+      deleteAutocomplete =
+          element.$.deleteDialog.querySelector('gr-autocomplete');
+    });
+
+    test('delete', () => {
+      deleteStub.returns(Promise.resolve({ok: true}));
+      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.deleteDialog.disabled);
+        assert.isFalse(queryStub.called);
+        deleteAutocomplete.noDebounce = true;
+        deleteAutocomplete.text = 'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isFalse(element.$.deleteDialog.disabled);
+        MockInteractions.tap(element.$.deleteDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(deleteStub.called);
+
+        return deleteStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.isTrue(navStub.called);
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('delete fails', () => {
+      deleteStub.returns(Promise.resolve({ok: false}));
+      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.deleteDialog.disabled);
+        assert.isFalse(queryStub.called);
+        deleteAutocomplete.noDebounce = true;
+        deleteAutocomplete.text = 'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isFalse(element.$.deleteDialog.disabled);
+        MockInteractions.tap(element.$.deleteDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(deleteStub.called);
+
+        return deleteStub.lastCall.returnValue.then(() => {
+          assert.isFalse(navStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.deleteDialog.disabled);
+        element.$.deleteDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        assert.isFalse(element.$.deleteDialog.disabled);
+        MockInteractions.tap(element.$.deleteDialog.shadowRoot
+            .querySelector('gr-button'));
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+      });
+    });
+  });
+
+  suite('rename button CUJ', () => {
+    let navStub;
+    let renameStub;
+    let renameAutocomplete;
+    const inputSelector = PolymerElement ?
+      '.newPathIronInput' :
+      '.newPathInput';
+
+    setup(() => {
+      navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      renameStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
+      renameAutocomplete =
+          element.$.renameDialog.querySelector('gr-autocomplete');
+    });
+
+    test('rename', () => {
+      renameStub.returns(Promise.resolve({ok: true}));
+      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.renameDialog.disabled);
+        assert.isFalse(queryStub.called);
+        renameAutocomplete.noDebounce = true;
+        renameAutocomplete.text = 'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isTrue(element.$.renameDialog.disabled);
+
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
+            'src/test.newPath';
+
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(element.$.renameDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(renameStub.called);
+
+        return renameStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.isTrue(navStub.called);
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('rename fails', () => {
+      renameStub.returns(Promise.resolve({ok: false}));
+      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.renameDialog.disabled);
+        assert.isFalse(queryStub.called);
+        renameAutocomplete.noDebounce = true;
+        renameAutocomplete.text = 'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isTrue(element.$.renameDialog.disabled);
+
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
+            'src/test.newPath';
+
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(element.$.renameDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(renameStub.called);
+
+        return renameStub.lastCall.returnValue.then(() => {
+          assert.isFalse(navStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.renameDialog.disabled);
+        element.$.renameDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
+            'src/test.newPath';
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(element.$.renameDialog.shadowRoot
+            .querySelector('gr-button'));
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._newPath, 'src/test.newPath');
+      });
+    });
+  });
+
+  suite('restore button CUJ', () => {
+    let navStub;
+    let restoreStub;
+
+    setup(() => {
+      navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      restoreStub = sandbox.stub(element.$.restAPI, 'restoreFileInChangeEdit');
+    });
+
+    test('restore hidden by default', () => {
+      assert.isTrue(element.shadowRoot
+          .querySelector('#restore').classList.contains('invisible'));
+    });
+
+    test('restore', () => {
+      restoreStub.returns(Promise.resolve({ok: true}));
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(element.$.restoreDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(restoreStub.called);
+        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+        return restoreStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.isTrue(navStub.called);
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('restore fails', () => {
+      restoreStub.returns(Promise.resolve({ok: false}));
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(element.$.restoreDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(restoreStub.called);
+        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+        return restoreStub.lastCall.returnValue.then(() => {
+          assert.isFalse(navStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(element.$.restoreDialog.shadowRoot
+            .querySelector('gr-button'));
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+      });
+    });
+  });
+
+  test('openOpenDialog', done => {
+    element.openOpenDialog('test/path.cpp')
+        .then(() => {
+          assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
+          assert.equal(
+              element.$.openDialog.querySelector('gr-autocomplete').text,
+              'test/path.cpp');
+          done();
+        });
+  });
+
+  test('_getDialogFromEvent', () => {
+    const spy = sandbox.spy(element, '_getDialogFromEvent');
+    element.addEventListener('tap', element._getDialogFromEvent);
+
+    MockInteractions.tap(element.$.openDialog);
+    flushAsynchronousOperations();
+    assert.equal(spy.lastCall.returnValue.id, 'openDialog');
+
+    MockInteractions.tap(element.$.deleteDialog);
+    flushAsynchronousOperations();
+    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
+
+    MockInteractions.tap(
+        element.$.deleteDialog.querySelector('gr-autocomplete'));
+    flushAsynchronousOperations();
+    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
+
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.notOk(spy.lastCall.returnValue);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
index d59fcf7..10bff3c 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
@@ -14,56 +14,65 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrEditFileControls extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-edit-file-controls'; }
-    /**
-     * Fired when an action in the overflow menu is tapped.
-     *
-     * @event file-action-tap
-     */
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-dropdown/gr-dropdown.js';
+import '../gr-edit-constants.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-edit-file-controls_html.js';
 
-    static get properties() {
-      return {
-        filePath: String,
-        _allFileActions: {
-          type: Array,
-          value: () => Object.values(GrEditConstants.Actions),
-        },
-        _fileActions: {
-          type: Array,
-          computed: '_computeFileActions(_allFileActions)',
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrEditFileControls extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _handleActionTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this._dispatchFileAction(e.detail.id, this.filePath);
-    }
+  static get is() { return 'gr-edit-file-controls'; }
+  /**
+   * Fired when an action in the overflow menu is tapped.
+   *
+   * @event file-action-tap
+   */
 
-    _dispatchFileAction(action, path) {
-      this.dispatchEvent(new CustomEvent(
-          'file-action-tap',
-          {detail: {action, path}, bubbles: true, composed: true}));
-    }
-
-    _computeFileActions(actions) {
-      // TODO(kaspern): conditionally disable some actions based on file status.
-      return actions.map(action => {
-        return {
-          name: action.label,
-          id: action.id,
-        };
-      });
-    }
+  static get properties() {
+    return {
+      filePath: String,
+      _allFileActions: {
+        type: Array,
+        value: () => Object.values(GrEditConstants.Actions),
+      },
+      _fileActions: {
+        type: Array,
+        computed: '_computeFileActions(_allFileActions)',
+      },
+    };
   }
 
-  customElements.define(GrEditFileControls.is, GrEditFileControls);
-})();
+  _handleActionTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this._dispatchFileAction(e.detail.id, this.filePath);
+  }
+
+  _dispatchFileAction(action, path) {
+    this.dispatchEvent(new CustomEvent(
+        'file-action-tap',
+        {detail: {action, path}, bubbles: true, composed: true}));
+  }
+
+  _computeFileActions(actions) {
+    // TODO(kaspern): conditionally disable some actions based on file status.
+    return actions.map(action => {
+      return {
+        name: action.label,
+        id: action.id,
+      };
+    });
+  }
+}
+
+customElements.define(GrEditFileControls.is, GrEditFileControls);
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.js
index f6c7803..7a7ba5d 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
-<link rel="import" href="../gr-edit-constants.html">
-
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-edit-file-controls">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         align-items: center;
@@ -49,13 +41,5 @@
         }
       }
     </style>
-    <gr-dropdown
-        id="actions"
-        items="[[_fileActions]]"
-        down-arrow
-        vertical-offset="20"
-        on-tap-item="_handleActionTap"
-        link>Actions</gr-dropdown>
-  </template>
-  <script src="gr-edit-file-controls.js"></script>
-</dom-module>
+    <gr-dropdown id="actions" items="[[_fileActions]]" down-arrow="" vertical-offset="20" on-tap-item="_handleActionTap" link="">Actions</gr-dropdown>
+`;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
index 392a105..d694226 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
@@ -18,17 +18,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-edit-file-controls</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel="import" href="../gr-edit-constants.html">
-<link rel="import" href="gr-edit-file-controls.html">
+<script type="module" src="../gr-edit-constants.js"></script>
+<script type="module" src="./gr-edit-file-controls.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-edit-constants.js';
+import './gr-edit-file-controls.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,76 +42,79 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-edit-file-controls tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let fileActionHandler;
-  
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      fileActionHandler = sandbox.stub();
-      element.addEventListener('file-action-tap', fileActionHandler);
-    });
-  
-    teardown(() => { sandbox.restore(); });
-  
-    test('open tap emits event', () => {
-      const actions = element.$.actions;
-      element.filePath = 'foo';
-      actions._open();
-      flushAsynchronousOperations();
-  
-      MockInteractions.tap(actions.shadowRoot
-          .querySelector('li [data-id="open"]'));
-      assert.isTrue(fileActionHandler.called);
-      assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-          {action: GrEditConstants.Actions.OPEN.id, path: 'foo'});
-    });
-  
-    test('delete tap emits event', () => {
-      const actions = element.$.actions;
-      element.filePath = 'foo';
-      actions._open();
-      flushAsynchronousOperations();
-  
-      MockInteractions.tap(actions.shadowRoot
-          .querySelector('li [data-id="delete"]'));
-      assert.isTrue(fileActionHandler.called);
-      assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-          {action: GrEditConstants.Actions.DELETE.id, path: 'foo'});
-    });
-  
-    test('restore tap emits event', () => {
-      const actions = element.$.actions;
-      element.filePath = 'foo';
-      actions._open();
-      flushAsynchronousOperations();
-  
-      MockInteractions.tap(actions.shadowRoot
-          .querySelector('li [data-id="restore"]'));
-      assert.isTrue(fileActionHandler.called);
-      assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-          {action: GrEditConstants.Actions.RESTORE.id, path: 'foo'});
-    });
-  
-    test('rename tap emits event', () => {
-      const actions = element.$.actions;
-      element.filePath = 'foo';
-      actions._open();
-      flushAsynchronousOperations();
-  
-      MockInteractions.tap(actions.shadowRoot
-          .querySelector('li [data-id="rename"]'));
-      assert.isTrue(fileActionHandler.called);
-      assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-          {action: GrEditConstants.Actions.RENAME.id, path: 'foo'});
-    });
-  
-    test('computed properties', () => {
-      assert.equal(element._allFileActions.length, 4);
-    });
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-edit-constants.js';
+import './gr-edit-file-controls.js';
+suite('gr-edit-file-controls tests', () => {
+  let element;
+  let sandbox;
+  let fileActionHandler;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    fileActionHandler = sandbox.stub();
+    element.addEventListener('file-action-tap', fileActionHandler);
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('open tap emits event', () => {
+    const actions = element.$.actions;
+    element.filePath = 'foo';
+    actions._open();
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(actions.shadowRoot
+        .querySelector('li [data-id="open"]'));
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
+        {action: GrEditConstants.Actions.OPEN.id, path: 'foo'});
+  });
+
+  test('delete tap emits event', () => {
+    const actions = element.$.actions;
+    element.filePath = 'foo';
+    actions._open();
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(actions.shadowRoot
+        .querySelector('li [data-id="delete"]'));
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
+        {action: GrEditConstants.Actions.DELETE.id, path: 'foo'});
+  });
+
+  test('restore tap emits event', () => {
+    const actions = element.$.actions;
+    element.filePath = 'foo';
+    actions._open();
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(actions.shadowRoot
+        .querySelector('li [data-id="restore"]'));
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
+        {action: GrEditConstants.Actions.RESTORE.id, path: 'foo'});
+  });
+
+  test('rename tap emits event', () => {
+    const actions = element.$.actions;
+    element.filePath = 'foo';
+    actions._open();
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(actions.shadowRoot
+        .querySelector('li [data-id="rename"]'));
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
+        {action: GrEditConstants.Actions.RENAME.id, path: 'foo'});
+  });
+
+  test('computed properties', () => {
+    assert.equal(element._allFileActions.length, 4);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
index 64158d0..2303005 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
@@ -14,257 +14,277 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const RESTORED_MESSAGE = 'Content restored from a previous edit.';
-  const SAVING_MESSAGE = 'Saving changes...';
-  const SAVED_MESSAGE = 'All changes saved';
-  const SAVE_FAILED_MSG = 'Failed to save changes';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-editable-label/gr-editable-label.js';
+import '../../shared/gr-fixed-panel/gr-fixed-panel.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-storage/gr-storage.js';
+import '../gr-default-editor/gr-default-editor.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-editor-view_html.js';
 
-  const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
+const RESTORED_MESSAGE = 'Content restored from a previous edit.';
+const SAVING_MESSAGE = 'Saving changes...';
+const SAVED_MESSAGE = 'All changes saved';
+const SAVE_FAILED_MSG = 'Failed to save changes';
+
+const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @appliesMixin Gerrit.PathListMixin
+ * @extends Polymer.Element
+ */
+class GrEditorView extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+  Gerrit.PatchSetBehavior,
+  Gerrit.PathListBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-editor-view'; }
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @appliesMixin Gerrit.PathListMixin
-   * @extends Polymer.Element
+   * Fired to notify the user of
+   *
+   * @event show-alert
    */
-  class GrEditorView extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-    Gerrit.PatchSetBehavior,
-    Gerrit.PathListBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-editor-view'; }
+
+  static get properties() {
+    return {
     /**
-     * Fired when the title of the page should change.
-     *
-     * @event title-change
+     * URL params passed from the router.
      */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
 
-    /**
-     * Fired to notify the user of
-     *
-     * @event show-alert
-     */
-
-    static get properties() {
-      return {
-      /**
-       * URL params passed from the router.
-       */
-        params: {
-          type: Object,
-          observer: '_paramsChanged',
-        },
-
-        _change: Object,
-        _changeEditDetail: Object,
-        _changeNum: String,
-        _patchNum: String,
-        _path: String,
-        _type: String,
-        _content: String,
-        _newContent: String,
-        _saving: {
-          type: Boolean,
-          value: false,
-        },
-        _successfulSave: {
-          type: Boolean,
-          value: false,
-        },
-        _saveDisabled: {
-          type: Boolean,
-          value: true,
-          computed: '_computeSaveDisabled(_content, _newContent, _saving)',
-        },
-        _prefs: Object,
-        _lineNum: Number,
-      };
-    }
-
-    get keyBindings() {
-      return {
-        'ctrl+s meta+s': '_handleSaveShortcut',
-      };
-    }
-
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('content-change',
-          e => this._handleContentChange(e));
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this._getEditPrefs().then(prefs => { this._prefs = prefs; });
-    }
-
-    get storageKey() {
-      return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
-    }
-
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    }
-
-    _getEditPrefs() {
-      return this.$.restAPI.getEditPreferences();
-    }
-
-    _paramsChanged(value) {
-      if (value.view !== Gerrit.Nav.View.EDIT) {
-        return;
-      }
-
-      this._changeNum = value.changeNum;
-      this._path = value.path;
-      this._patchNum = value.patchNum || this.EDIT_NAME;
-      this._lineNum = value.lineNum;
-
-      // NOTE: This may be called before attachment (e.g. while parentElement is
-      // null). Fire title-change in an async so that, if attachment to the DOM
-      // has been queued, the event can bubble up to the handler in gr-app.
-      this.async(() => {
-        const title = `Editing ${this.computeTruncatedPath(this._path)}`;
-        this.fire('title-change', {title});
-      });
-
-      const promises = [];
-
-      promises.push(this._getChangeDetail(this._changeNum));
-      promises.push(
-          this._getFileData(this._changeNum, this._path, this._patchNum));
-      return Promise.all(promises);
-    }
-
-    _getChangeDetail(changeNum) {
-      return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
-        this._change = change;
-      });
-    }
-
-    _handlePathChanged(e) {
-      const path = e.detail;
-      if (path === this._path) {
-        return Promise.resolve();
-      }
-      return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
-          this._path, path).then(res => {
-        if (!res.ok) { return; }
-
-        this._successfulSave = true;
-        this._viewEditInChangeView();
-      });
-    }
-
-    _viewEditInChangeView() {
-      const patch = this._successfulSave ? this.EDIT_NAME : this._patchNum;
-      Gerrit.Nav.navigateToChange(this._change, patch, null,
-          patch !== this.EDIT_NAME);
-    }
-
-    _getFileData(changeNum, path, patchNum) {
-      const storedContent =
-            this.$.storage.getEditableContentItem(this.storageKey);
-
-      return this.$.restAPI.getFileContent(changeNum, path, patchNum)
-          .then(res => {
-            if (storedContent && storedContent.message &&
-                storedContent.message !== res.content) {
-              this.dispatchEvent(new CustomEvent('show-alert', {
-                detail: {message: RESTORED_MESSAGE},
-                bubbles: true,
-                composed: true,
-              }));
-
-              this._newContent = storedContent.message;
-            } else {
-              this._newContent = res.content || '';
-            }
-            this._content = res.content || '';
-
-            // A non-ok response may result if the file does not yet exist.
-            // The `type` field of the response is only valid when the file
-            // already exists.
-            if (res.ok && res.type) {
-              this._type = res.type;
-            } else {
-              this._type = '';
-            }
-          });
-    }
-
-    _saveEdit() {
-      this._saving = true;
-      this._showAlert(SAVING_MESSAGE);
-      this.$.storage.eraseEditableContentItem(this.storageKey);
-      return this.$.restAPI.saveChangeEdit(this._changeNum, this._path,
-          this._newContent).then(res => {
-        this._saving = false;
-        this._showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
-        if (!res.ok) { return; }
-
-        this._content = this._newContent;
-        this._successfulSave = true;
-      });
-    }
-
-    _showAlert(message) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {message},
-        bubbles: true,
-        composed: true,
-      }));
-    }
-
-    _computeSaveDisabled(content, newContent, saving) {
-      // Polymer 2: check for undefined
-      if ([
-        content,
-        newContent,
-        saving,
-      ].some(arg => arg === undefined)) {
-        return true;
-      }
-
-      if (saving) {
-        return true;
-      }
-      return content === newContent;
-    }
-
-    _handleCloseTap() {
-      // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
-      this._viewEditInChangeView();
-    }
-
-    _handleContentChange(e) {
-      this.debounce('store', () => {
-        const content = e.detail.value;
-        if (content) {
-          this.set('_newContent', e.detail.value);
-          this.$.storage.setEditableContentItem(this.storageKey, content);
-        } else {
-          this.$.storage.eraseEditableContentItem(this.storageKey);
-        }
-      }, STORAGE_DEBOUNCE_INTERVAL_MS);
-    }
-
-    _handleSaveShortcut(e) {
-      e.preventDefault();
-      if (!this._saveDisabled) {
-        this._saveEdit();
-      }
-    }
+      _change: Object,
+      _changeEditDetail: Object,
+      _changeNum: String,
+      _patchNum: String,
+      _path: String,
+      _type: String,
+      _content: String,
+      _newContent: String,
+      _saving: {
+        type: Boolean,
+        value: false,
+      },
+      _successfulSave: {
+        type: Boolean,
+        value: false,
+      },
+      _saveDisabled: {
+        type: Boolean,
+        value: true,
+        computed: '_computeSaveDisabled(_content, _newContent, _saving)',
+      },
+      _prefs: Object,
+      _lineNum: Number,
+    };
   }
 
-  customElements.define(GrEditorView.is, GrEditorView);
-})();
+  get keyBindings() {
+    return {
+      'ctrl+s meta+s': '_handleSaveShortcut',
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('content-change',
+        e => this._handleContentChange(e));
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getEditPrefs().then(prefs => { this._prefs = prefs; });
+  }
+
+  get storageKey() {
+    return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getEditPrefs() {
+    return this.$.restAPI.getEditPreferences();
+  }
+
+  _paramsChanged(value) {
+    if (value.view !== Gerrit.Nav.View.EDIT) {
+      return;
+    }
+
+    this._changeNum = value.changeNum;
+    this._path = value.path;
+    this._patchNum = value.patchNum || this.EDIT_NAME;
+    this._lineNum = value.lineNum;
+
+    // NOTE: This may be called before attachment (e.g. while parentElement is
+    // null). Fire title-change in an async so that, if attachment to the DOM
+    // has been queued, the event can bubble up to the handler in gr-app.
+    this.async(() => {
+      const title = `Editing ${this.computeTruncatedPath(this._path)}`;
+      this.fire('title-change', {title});
+    });
+
+    const promises = [];
+
+    promises.push(this._getChangeDetail(this._changeNum));
+    promises.push(
+        this._getFileData(this._changeNum, this._path, this._patchNum));
+    return Promise.all(promises);
+  }
+
+  _getChangeDetail(changeNum) {
+    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+      this._change = change;
+    });
+  }
+
+  _handlePathChanged(e) {
+    const path = e.detail;
+    if (path === this._path) {
+      return Promise.resolve();
+    }
+    return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
+        this._path, path).then(res => {
+      if (!res.ok) { return; }
+
+      this._successfulSave = true;
+      this._viewEditInChangeView();
+    });
+  }
+
+  _viewEditInChangeView() {
+    const patch = this._successfulSave ? this.EDIT_NAME : this._patchNum;
+    Gerrit.Nav.navigateToChange(this._change, patch, null,
+        patch !== this.EDIT_NAME);
+  }
+
+  _getFileData(changeNum, path, patchNum) {
+    const storedContent =
+          this.$.storage.getEditableContentItem(this.storageKey);
+
+    return this.$.restAPI.getFileContent(changeNum, path, patchNum)
+        .then(res => {
+          if (storedContent && storedContent.message &&
+              storedContent.message !== res.content) {
+            this.dispatchEvent(new CustomEvent('show-alert', {
+              detail: {message: RESTORED_MESSAGE},
+              bubbles: true,
+              composed: true,
+            }));
+
+            this._newContent = storedContent.message;
+          } else {
+            this._newContent = res.content || '';
+          }
+          this._content = res.content || '';
+
+          // A non-ok response may result if the file does not yet exist.
+          // The `type` field of the response is only valid when the file
+          // already exists.
+          if (res.ok && res.type) {
+            this._type = res.type;
+          } else {
+            this._type = '';
+          }
+        });
+  }
+
+  _saveEdit() {
+    this._saving = true;
+    this._showAlert(SAVING_MESSAGE);
+    this.$.storage.eraseEditableContentItem(this.storageKey);
+    return this.$.restAPI.saveChangeEdit(this._changeNum, this._path,
+        this._newContent).then(res => {
+      this._saving = false;
+      this._showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
+      if (!res.ok) { return; }
+
+      this._content = this._newContent;
+      this._successfulSave = true;
+    });
+  }
+
+  _showAlert(message) {
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {message},
+      bubbles: true,
+      composed: true,
+    }));
+  }
+
+  _computeSaveDisabled(content, newContent, saving) {
+    // Polymer 2: check for undefined
+    if ([
+      content,
+      newContent,
+      saving,
+    ].some(arg => arg === undefined)) {
+      return true;
+    }
+
+    if (saving) {
+      return true;
+    }
+    return content === newContent;
+  }
+
+  _handleCloseTap() {
+    // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
+    this._viewEditInChangeView();
+  }
+
+  _handleContentChange(e) {
+    this.debounce('store', () => {
+      const content = e.detail.value;
+      if (content) {
+        this.set('_newContent', e.detail.value);
+        this.$.storage.setEditableContentItem(this.storageKey, content);
+      } else {
+        this.$.storage.eraseEditableContentItem(this.storageKey);
+      }
+    }, STORAGE_DEBOUNCE_INTERVAL_MS);
+  }
+
+  _handleSaveShortcut(e) {
+    e.preventDefault();
+    if (!this._saveDisabled) {
+      this._saveEdit();
+    }
+  }
+}
+
+customElements.define(GrEditorView.is, GrEditorView);
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.js
index 1ae74e1..72d29dd 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.js
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.js
@@ -1,39 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
-<link rel="import" href="../../shared/gr-fixed-panel/gr-fixed-panel.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../gr-default-editor/gr-default-editor.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-editor-view">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         background-color: var(--view-background-color);
@@ -93,28 +76,16 @@
         }
       }
     </style>
-    <gr-fixed-panel keep-on-scroll>
+    <gr-fixed-panel keep-on-scroll="">
       <header>
         <span class="controlGroup">
           <span>Edit mode</span>
           <span class="separator"></span>
-          <gr-editable-label
-              label-text="File path"
-              value="[[_path]]"
-              placeholder="File path..."
-              on-changed="_handlePathChanged"></gr-editable-label>
+          <gr-editable-label label-text="File path" value="[[_path]]" placeholder="File path..." on-changed="_handlePathChanged"></gr-editable-label>
         </span>
         <span class="controlGroup rightControls">
-          <gr-button
-              id="close"
-              link
-              on-click="_handleCloseTap">Close</gr-button>
-          <gr-button
-              id="save"
-              disabled$="[[_saveDisabled]]"
-              primary
-              link
-              on-click="_saveEdit">Save</gr-button>
+          <gr-button id="close" link="" on-click="_handleCloseTap">Close</gr-button>
+          <gr-button id="save" disabled\$="[[_saveDisabled]]" primary="" link="" on-click="_saveEdit">Save</gr-button>
         </span>
       </header>
     </gr-fixed-panel>
@@ -129,6 +100,4 @@
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
-  </template>
-  <script src="gr-editor-view.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
index 1d264bc..8c0d491 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
@@ -18,16 +18,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editor-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel="import" href="gr-editor-view.html">
+<script type="module" src="./gr-editor-view.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-editor-view.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,380 +40,382 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-editor-view tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let savePathStub;
-    let saveFileStub;
-    let changeDetailStub;
-    let navigateStub;
-    const mockParams = {
-      changeNum: '42',
-      path: 'foo/bar.baz',
-      patchNum: 'edit',
-    };
-  
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getEditPreferences() { return Promise.resolve({}); },
-      });
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      savePathStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
-      saveFileStub = sandbox.stub(element.$.restAPI, 'saveChangeEdit');
-      changeDetailStub = sandbox.stub(element.$.restAPI, 'getDiffChangeDetail');
-      navigateStub = sandbox.stub(element, '_viewEditInChangeView');
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-editor-view.js';
+suite('gr-editor-view tests', () => {
+  let element;
+  let sandbox;
+  let savePathStub;
+  let saveFileStub;
+  let changeDetailStub;
+  let navigateStub;
+  const mockParams = {
+    changeNum: '42',
+    path: 'foo/bar.baz',
+    patchNum: 'edit',
+  };
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getEditPreferences() { return Promise.resolve({}); },
     });
-  
-    teardown(() => { sandbox.restore(); });
-  
-    suite('_paramsChanged', () => {
-      test('incorrect view returns immediately', () => {
-        element._paramsChanged(
-            Object.assign({}, mockParams, {view: Gerrit.Nav.View.DIFF}));
-        assert.notOk(element._changeNum);
-      });
-  
-      test('good params proceed', () => {
-        changeDetailStub.returns(Promise.resolve({}));
-        const fileStub = sandbox.stub(element, '_getFileData', () => {
-          element._content = 'text';
-          element._newContent = 'text';
-          element._type = 'application/octet-stream';
-        });
-  
-        const promises = element._paramsChanged(
-            Object.assign({}, mockParams, {view: Gerrit.Nav.View.EDIT}));
-  
-        flushAsynchronousOperations();
-        assert.equal(element._changeNum, mockParams.changeNum);
-        assert.equal(element._path, mockParams.path);
-        assert.deepEqual(changeDetailStub.lastCall.args[0],
-            mockParams.changeNum);
-        assert.deepEqual(fileStub.lastCall.args,
-            [mockParams.changeNum, mockParams.path, mockParams.patchNum]);
-  
-        return promises.then(() => {
-          assert.equal(element._content, 'text');
-          assert.equal(element._newContent, 'text');
-          assert.equal(element._type, 'application/octet-stream');
-        });
-      });
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    savePathStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
+    saveFileStub = sandbox.stub(element.$.restAPI, 'saveChangeEdit');
+    changeDetailStub = sandbox.stub(element.$.restAPI, 'getDiffChangeDetail');
+    navigateStub = sandbox.stub(element, '_viewEditInChangeView');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  suite('_paramsChanged', () => {
+    test('incorrect view returns immediately', () => {
+      element._paramsChanged(
+          Object.assign({}, mockParams, {view: Gerrit.Nav.View.DIFF}));
+      assert.notOk(element._changeNum);
     });
-  
-    test('edit file path', () => {
-      element._changeNum = mockParams.changeNum;
-      element._path = mockParams.path;
-      savePathStub.onFirstCall().returns(Promise.resolve({}));
-      savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
-  
-      // Calling with the same path should not navigate.
-      return element._handlePathChanged({detail: mockParams.path}).then(() => {
-        assert.isFalse(savePathStub.called);
-        // !ok response
-        element._handlePathChanged({detail: 'newPath'}).then(() => {
-          assert.isTrue(savePathStub.called);
-          assert.isFalse(navigateStub.called);
-          // ok response
-          element._handlePathChanged({detail: 'newPath'}).then(() => {
-            assert.isTrue(navigateStub.called);
-            assert.isTrue(element._successfulSave);
-          });
-        });
+
+    test('good params proceed', () => {
+      changeDetailStub.returns(Promise.resolve({}));
+      const fileStub = sandbox.stub(element, '_getFileData', () => {
+        element._content = 'text';
+        element._newContent = 'text';
+        element._type = 'application/octet-stream';
       });
-    });
-  
-    test('reacts to content-change event', () => {
-      const storeStub = sandbox.spy(element.$.storage, 'setEditableContentItem');
-      element._newContent = 'test';
-      element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
-        bubbles: true, composed: true,
-        detail: {value: 'new content value'},
-      }));
-      element.flushDebouncer('store');
+
+      const promises = element._paramsChanged(
+          Object.assign({}, mockParams, {view: Gerrit.Nav.View.EDIT}));
+
       flushAsynchronousOperations();
-  
-      assert.equal(element._newContent, 'new content value');
-      assert.isTrue(storeStub.called);
-      assert.equal(storeStub.lastCall.args[1], 'new content value');
-    });
-  
-    suite('edit file content', () => {
-      const originalText = 'file text';
-      const newText = 'file text changed';
-  
-      setup(() => {
-        element._changeNum = mockParams.changeNum;
-        element._path = mockParams.path;
-        element._content = originalText;
-        element._newContent = originalText;
-        flushAsynchronousOperations();
-      });
-  
-      test('initial load', () => {
-        assert.equal(element.$.file.fileContent, originalText);
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-      });
-  
-      test('file modification and save, !ok response', () => {
-        const saveSpy = sandbox.spy(element, '_saveEdit');
-        const eraseStub = sandbox.stub(element.$.storage,
-            'eraseEditableContentItem');
-        const alertStub = sandbox.stub(element, '_showAlert');
-        saveFileStub.returns(Promise.resolve({ok: false}));
-        element._newContent = newText;
-        flushAsynchronousOperations();
-  
-        assert.isFalse(element.$.save.hasAttribute('disabled'));
-        assert.isFalse(element._saving);
-  
-        MockInteractions.tap(element.$.save);
-        assert.isTrue(saveSpy.called);
-        assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-        assert.isTrue(element._saving);
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-  
-        return saveSpy.lastCall.returnValue.then(() => {
-          assert.isTrue(saveFileStub.called);
-          assert.isTrue(eraseStub.called);
-          assert.isFalse(element._saving);
-          assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
-          assert.deepEqual(saveFileStub.lastCall.args,
-              [mockParams.changeNum, mockParams.path, newText]);
-          assert.isFalse(navigateStub.called);
-          assert.isFalse(element.$.save.hasAttribute('disabled'));
-          assert.notEqual(element._content, element._newContent);
-        });
-      });
-  
-      test('file modification and save', () => {
-        const saveSpy = sandbox.spy(element, '_saveEdit');
-        const alertStub = sandbox.stub(element, '_showAlert');
-        saveFileStub.returns(Promise.resolve({ok: true}));
-        element._newContent = newText;
-        flushAsynchronousOperations();
-  
-        assert.isFalse(element._saving);
-        assert.isFalse(element.$.save.hasAttribute('disabled'));
-  
-        MockInteractions.tap(element.$.save);
-        assert.isTrue(saveSpy.called);
-        assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-        assert.isTrue(element._saving);
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-  
-        return saveSpy.lastCall.returnValue.then(() => {
-          assert.isTrue(saveFileStub.called);
-          assert.isFalse(element._saving);
-          assert.equal(alertStub.lastCall.args[0], 'All changes saved');
-          assert.isFalse(navigateStub.called);
-          assert.isTrue(element.$.save.hasAttribute('disabled'));
-          assert.equal(element._content, element._newContent);
-          assert.isTrue(element._successfulSave);
-        });
-      });
-  
-      test('file modification and close', () => {
-        const closeSpy = sandbox.spy(element, '_handleCloseTap');
-        element._newContent = newText;
-        flushAsynchronousOperations();
-  
-        assert.isFalse(element.$.save.hasAttribute('disabled'));
-  
-        MockInteractions.tap(element.$.close);
-        assert.isTrue(closeSpy.called);
-        assert.isFalse(saveFileStub.called);
-        assert.isTrue(navigateStub.called);
-      });
-    });
-  
-    suite('_getFileData', () => {
-      setup(() => {
-        element._newContent = 'initial';
-        element._content = 'initial';
-        element._type = 'initial';
-        sandbox.stub(element.$.storage, 'getEditableContentItem').returns(null);
-      });
-  
-      test('res.ok', () => {
-        sandbox.stub(element.$.restAPI, 'getFileContent')
-            .returns(Promise.resolve({
-              ok: true,
-              type: 'text/javascript',
-              content: 'new content',
-            }));
-  
-        // Ensure no data is set with a bad response.
-        return element._getFileData('1', 'test/path', 'edit').then(() => {
-          assert.equal(element._newContent, 'new content');
-          assert.equal(element._content, 'new content');
-          assert.equal(element._type, 'text/javascript');
-        });
-      });
-  
-      test('!res.ok', () => {
-        sandbox.stub(element.$.restAPI, 'getFileContent')
-            .returns(Promise.resolve({}));
-  
-        // Ensure no data is set with a bad response.
-        return element._getFileData('1', 'test/path', 'edit').then(() => {
-          assert.equal(element._newContent, '');
-          assert.equal(element._content, '');
-          assert.equal(element._type, '');
-        });
-      });
-  
-      test('content is undefined', () => {
-        sandbox.stub(element.$.restAPI, 'getFileContent')
-            .returns(Promise.resolve({
-              ok: true,
-              type: 'text/javascript',
-            }));
-  
-        return element._getFileData('1', 'test/path', 'edit').then(() => {
-          assert.equal(element._newContent, '');
-          assert.equal(element._content, '');
-          assert.equal(element._type, 'text/javascript');
-        });
-      });
-  
-      test('content and type is undefined', () => {
-        sandbox.stub(element.$.restAPI, 'getFileContent')
-            .returns(Promise.resolve({
-              ok: true,
-            }));
-  
-        return element._getFileData('1', 'test/path', 'edit').then(() => {
-          assert.equal(element._newContent, '');
-          assert.equal(element._content, '');
-          assert.equal(element._type, '');
-        });
-      });
-    });
-  
-    test('_showAlert', done => {
-      element.addEventListener('show-alert', e => {
-        assert.deepEqual(e.detail, {message: 'test message'});
-        assert.isTrue(e.bubbles);
-        done();
-      });
-  
-      element._showAlert('test message');
-    });
-  
-    test('_viewEditInChangeView respects _patchNum', () => {
-      navigateStub.restore();
-      const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-      element._patchNum = element.EDIT_NAME;
-      element._viewEditInChangeView();
-      assert.equal(navStub.lastCall.args[1], element.EDIT_NAME);
-      element._patchNum = '1';
-      element._viewEditInChangeView();
-      assert.equal(navStub.lastCall.args[1], '1');
-      element._successfulSave = true;
-      element._viewEditInChangeView();
-      assert.equal(navStub.lastCall.args[1], element.EDIT_NAME);
-    });
-  
-    suite('keyboard shortcuts', () => {
-      // Used as the spy on the handler for each entry in keyBindings.
-      let handleSpy;
-  
-      suite('_handleSaveShortcut', () => {
-        let saveStub;
-        setup(() => {
-          handleSpy = sandbox.spy(element, '_handleSaveShortcut');
-          saveStub = sandbox.stub(element, '_saveEdit');
-        });
-  
-        test('save enabled', () => {
-          element._content = '';
-          element._newContent = '_test';
-          MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-          flushAsynchronousOperations();
-  
-          assert.isTrue(handleSpy.calledOnce);
-          assert.isTrue(saveStub.calledOnce);
-  
-          MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-          flushAsynchronousOperations();
-  
-          assert.equal(handleSpy.callCount, 2);
-          assert.equal(saveStub.callCount, 2);
-        });
-  
-        test('save disabled', () => {
-          MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-          flushAsynchronousOperations();
-  
-          assert.isTrue(handleSpy.calledOnce);
-          assert.isFalse(saveStub.called);
-  
-          MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-          flushAsynchronousOperations();
-  
-          assert.equal(handleSpy.callCount, 2);
-          assert.isFalse(saveStub.called);
-        });
-      });
-    });
-  
-    suite('gr-storage caching', () => {
-      test('local edit exists', () => {
-        sandbox.stub(element.$.storage, 'getEditableContentItem')
-            .returns({message: 'pending edit'});
-        sandbox.stub(element.$.restAPI, 'getFileContent')
-            .returns(Promise.resolve({
-              ok: true,
-              type: 'text/javascript',
-              content: 'old content',
-            }));
-  
-        const alertStub = sandbox.stub();
-        element.addEventListener('show-alert', alertStub);
-  
-        return element._getFileData(1, 'test', 1).then(() => {
-          flushAsynchronousOperations();
-  
-          assert.isTrue(alertStub.called);
-          assert.equal(element._newContent, 'pending edit');
-          assert.equal(element._content, 'old content');
-          assert.equal(element._type, 'text/javascript');
-        });
-      });
-  
-      test('local edit exists, is same as remote edit', () => {
-        sandbox.stub(element.$.storage, 'getEditableContentItem')
-            .returns({message: 'pending edit'});
-        sandbox.stub(element.$.restAPI, 'getFileContent')
-            .returns(Promise.resolve({
-              ok: true,
-              type: 'text/javascript',
-              content: 'pending edit',
-            }));
-  
-        const alertStub = sandbox.stub();
-        element.addEventListener('show-alert', alertStub);
-  
-        return element._getFileData(1, 'test', 1).then(() => {
-          flushAsynchronousOperations();
-  
-          assert.isFalse(alertStub.called);
-          assert.equal(element._newContent, 'pending edit');
-          assert.equal(element._content, 'pending edit');
-          assert.equal(element._type, 'text/javascript');
-        });
-      });
-  
-      test('storage key computation', () => {
-        element._changeNum = 1;
-        element._patchNum = 1;
-        element._path = 'test';
-        assert.equal(element.storageKey, 'c1_ps1_test');
+      assert.equal(element._changeNum, mockParams.changeNum);
+      assert.equal(element._path, mockParams.path);
+      assert.deepEqual(changeDetailStub.lastCall.args[0],
+          mockParams.changeNum);
+      assert.deepEqual(fileStub.lastCall.args,
+          [mockParams.changeNum, mockParams.path, mockParams.patchNum]);
+
+      return promises.then(() => {
+        assert.equal(element._content, 'text');
+        assert.equal(element._newContent, 'text');
+        assert.equal(element._type, 'application/octet-stream');
       });
     });
   });
+
+  test('edit file path', () => {
+    element._changeNum = mockParams.changeNum;
+    element._path = mockParams.path;
+    savePathStub.onFirstCall().returns(Promise.resolve({}));
+    savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
+
+    // Calling with the same path should not navigate.
+    return element._handlePathChanged({detail: mockParams.path}).then(() => {
+      assert.isFalse(savePathStub.called);
+      // !ok response
+      element._handlePathChanged({detail: 'newPath'}).then(() => {
+        assert.isTrue(savePathStub.called);
+        assert.isFalse(navigateStub.called);
+        // ok response
+        element._handlePathChanged({detail: 'newPath'}).then(() => {
+          assert.isTrue(navigateStub.called);
+          assert.isTrue(element._successfulSave);
+        });
+      });
+    });
+  });
+
+  test('reacts to content-change event', () => {
+    const storeStub = sandbox.spy(element.$.storage, 'setEditableContentItem');
+    element._newContent = 'test';
+    element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
+      bubbles: true, composed: true,
+      detail: {value: 'new content value'},
+    }));
+    element.flushDebouncer('store');
+    flushAsynchronousOperations();
+
+    assert.equal(element._newContent, 'new content value');
+    assert.isTrue(storeStub.called);
+    assert.equal(storeStub.lastCall.args[1], 'new content value');
+  });
+
+  suite('edit file content', () => {
+    const originalText = 'file text';
+    const newText = 'file text changed';
+
+    setup(() => {
+      element._changeNum = mockParams.changeNum;
+      element._path = mockParams.path;
+      element._content = originalText;
+      element._newContent = originalText;
+      flushAsynchronousOperations();
+    });
+
+    test('initial load', () => {
+      assert.equal(element.$.file.fileContent, originalText);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+    });
+
+    test('file modification and save, !ok response', () => {
+      const saveSpy = sandbox.spy(element, '_saveEdit');
+      const eraseStub = sandbox.stub(element.$.storage,
+          'eraseEditableContentItem');
+      const alertStub = sandbox.stub(element, '_showAlert');
+      saveFileStub.returns(Promise.resolve({ok: false}));
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+      assert.isFalse(element._saving);
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isTrue(eraseStub.called);
+        assert.isFalse(element._saving);
+        assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
+        assert.deepEqual(saveFileStub.lastCall.args,
+            [mockParams.changeNum, mockParams.path, newText]);
+        assert.isFalse(navigateStub.called);
+        assert.isFalse(element.$.save.hasAttribute('disabled'));
+        assert.notEqual(element._content, element._newContent);
+      });
+    });
+
+    test('file modification and save', () => {
+      const saveSpy = sandbox.spy(element, '_saveEdit');
+      const alertStub = sandbox.stub(element, '_showAlert');
+      saveFileStub.returns(Promise.resolve({ok: true}));
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.isFalse(element._saving);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isFalse(element._saving);
+        assert.equal(alertStub.lastCall.args[0], 'All changes saved');
+        assert.isFalse(navigateStub.called);
+        assert.isTrue(element.$.save.hasAttribute('disabled'));
+        assert.equal(element._content, element._newContent);
+        assert.isTrue(element._successfulSave);
+      });
+    });
+
+    test('file modification and close', () => {
+      const closeSpy = sandbox.spy(element, '_handleCloseTap');
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.close);
+      assert.isTrue(closeSpy.called);
+      assert.isFalse(saveFileStub.called);
+      assert.isTrue(navigateStub.called);
+    });
+  });
+
+  suite('_getFileData', () => {
+    setup(() => {
+      element._newContent = 'initial';
+      element._content = 'initial';
+      element._type = 'initial';
+      sandbox.stub(element.$.storage, 'getEditableContentItem').returns(null);
+    });
+
+    test('res.ok', () => {
+      sandbox.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+            content: 'new content',
+          }));
+
+      // Ensure no data is set with a bad response.
+      return element._getFileData('1', 'test/path', 'edit').then(() => {
+        assert.equal(element._newContent, 'new content');
+        assert.equal(element._content, 'new content');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('!res.ok', () => {
+      sandbox.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({}));
+
+      // Ensure no data is set with a bad response.
+      return element._getFileData('1', 'test/path', 'edit').then(() => {
+        assert.equal(element._newContent, '');
+        assert.equal(element._content, '');
+        assert.equal(element._type, '');
+      });
+    });
+
+    test('content is undefined', () => {
+      sandbox.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+          }));
+
+      return element._getFileData('1', 'test/path', 'edit').then(() => {
+        assert.equal(element._newContent, '');
+        assert.equal(element._content, '');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('content and type is undefined', () => {
+      sandbox.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+          }));
+
+      return element._getFileData('1', 'test/path', 'edit').then(() => {
+        assert.equal(element._newContent, '');
+        assert.equal(element._content, '');
+        assert.equal(element._type, '');
+      });
+    });
+  });
+
+  test('_showAlert', done => {
+    element.addEventListener('show-alert', e => {
+      assert.deepEqual(e.detail, {message: 'test message'});
+      assert.isTrue(e.bubbles);
+      done();
+    });
+
+    element._showAlert('test message');
+  });
+
+  test('_viewEditInChangeView respects _patchNum', () => {
+    navigateStub.restore();
+    const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+    element._patchNum = element.EDIT_NAME;
+    element._viewEditInChangeView();
+    assert.equal(navStub.lastCall.args[1], element.EDIT_NAME);
+    element._patchNum = '1';
+    element._viewEditInChangeView();
+    assert.equal(navStub.lastCall.args[1], '1');
+    element._successfulSave = true;
+    element._viewEditInChangeView();
+    assert.equal(navStub.lastCall.args[1], element.EDIT_NAME);
+  });
+
+  suite('keyboard shortcuts', () => {
+    // Used as the spy on the handler for each entry in keyBindings.
+    let handleSpy;
+
+    suite('_handleSaveShortcut', () => {
+      let saveStub;
+      setup(() => {
+        handleSpy = sandbox.spy(element, '_handleSaveShortcut');
+        saveStub = sandbox.stub(element, '_saveEdit');
+      });
+
+      test('save enabled', () => {
+        element._content = '';
+        element._newContent = '_test';
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
+        flushAsynchronousOperations();
+
+        assert.isTrue(handleSpy.calledOnce);
+        assert.isTrue(saveStub.calledOnce);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
+        flushAsynchronousOperations();
+
+        assert.equal(handleSpy.callCount, 2);
+        assert.equal(saveStub.callCount, 2);
+      });
+
+      test('save disabled', () => {
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
+        flushAsynchronousOperations();
+
+        assert.isTrue(handleSpy.calledOnce);
+        assert.isFalse(saveStub.called);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
+        flushAsynchronousOperations();
+
+        assert.equal(handleSpy.callCount, 2);
+        assert.isFalse(saveStub.called);
+      });
+    });
+  });
+
+  suite('gr-storage caching', () => {
+    test('local edit exists', () => {
+      sandbox.stub(element.$.storage, 'getEditableContentItem')
+          .returns({message: 'pending edit'});
+      sandbox.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+            content: 'old content',
+          }));
+
+      const alertStub = sandbox.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      return element._getFileData(1, 'test', 1).then(() => {
+        flushAsynchronousOperations();
+
+        assert.isTrue(alertStub.called);
+        assert.equal(element._newContent, 'pending edit');
+        assert.equal(element._content, 'old content');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('local edit exists, is same as remote edit', () => {
+      sandbox.stub(element.$.storage, 'getEditableContentItem')
+          .returns({message: 'pending edit'});
+      sandbox.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+            content: 'pending edit',
+          }));
+
+      const alertStub = sandbox.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      return element._getFileData(1, 'test', 1).then(() => {
+        flushAsynchronousOperations();
+
+        assert.isFalse(alertStub.called);
+        assert.equal(element._newContent, 'pending edit');
+        assert.equal(element._content, 'pending edit');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('storage key computation', () => {
+      element._changeNum = 1;
+      element._patchNum = 1;
+      element._path = 'test';
+      assert.equal(element.storageKey, 'c1_ps1_test');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index ea5a180..785e8f9 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -14,531 +14,568 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../scripts/util.js';
 
+import '../scripts/bundled-polymer.js';
+import '../behaviors/base-url-behavior/base-url-behavior.js';
+import '../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../styles/shared-styles.js';
+import '../styles/themes/app-theme.js';
+import './admin/gr-admin-view/gr-admin-view.js';
+import './documentation/gr-documentation-search/gr-documentation-search.js';
+import './change-list/gr-change-list-view/gr-change-list-view.js';
+import './change-list/gr-dashboard-view/gr-dashboard-view.js';
+import './change/gr-change-view/gr-change-view.js';
+import './core/gr-error-manager/gr-error-manager.js';
+import './core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js';
+import './core/gr-main-header/gr-main-header.js';
+import './core/gr-navigation/gr-navigation.js';
+import './core/gr-reporting/gr-reporting.js';
+import './core/gr-router/gr-router.js';
+import './core/gr-smart-search/gr-smart-search.js';
+import './diff/gr-diff-view/gr-diff-view.js';
+import './edit/gr-editor-view/gr-editor-view.js';
+import './plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import './plugins/gr-endpoint-param/gr-endpoint-param.js';
+import './plugins/gr-external-style/gr-external-style.js';
+import './plugins/gr-plugin-host/gr-plugin-host.js';
+import './settings/gr-cla-view/gr-cla-view.js';
+import './settings/gr-registration-dialog/gr-registration-dialog.js';
+import './settings/gr-settings-view/gr-settings-view.js';
+import './shared/gr-fixed-panel/gr-fixed-panel.js';
+import './shared/gr-lib-loader/gr-lib-loader.js';
+import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import moment from 'moment/src/moment.js';
+self.moment = moment;
+import {htmlTemplate} from './gr-app-element_html.js';
+
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @extends Polymer.Element
+ */
+class GrAppElement extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-app-element'; }
   /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @extends Polymer.Element
+   * Fired when the URL location changes.
+   *
+   * @event location-change
    */
-  class GrAppElement extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-app-element'; }
-    /**
-     * Fired when the URL location changes.
-     *
-     * @event location-change
-     */
 
-    static get properties() {
-      return {
+  static get properties() {
+    return {
+    /**
+     * @type {{ query: string, view: string, screen: string }}
+     */
+      params: Object,
+      keyEventTarget: {
+        type: Object,
+        value() { return document.body; },
+      },
+
+      _account: {
+        type: Object,
+        observer: '_accountChanged',
+      },
+
       /**
-       * @type {{ query: string, view: string, screen: string }}
+       * The last time the g key was pressed in milliseconds (or a keydown event
+       * was handled if the key is held down).
+       *
+       * @type {number|null}
        */
-        params: Object,
-        keyEventTarget: {
-          type: Object,
-          value() { return document.body; },
-        },
+      _lastGKeyPressTimestamp: {
+        type: Number,
+        value: null,
+      },
 
-        _account: {
-          type: Object,
-          observer: '_accountChanged',
-        },
+      /**
+       * @type {{ plugin: Object }}
+       */
+      _serverConfig: Object,
+      _version: String,
+      _showChangeListView: Boolean,
+      _showDashboardView: Boolean,
+      _showChangeView: Boolean,
+      _showDiffView: Boolean,
+      _showSettingsView: Boolean,
+      _showAdminView: Boolean,
+      _showCLAView: Boolean,
+      _showEditorView: Boolean,
+      _showPluginScreen: Boolean,
+      _showDocumentationSearch: Boolean,
+      /** @type {?} */
+      _viewState: Object,
+      /** @type {?} */
+      _lastError: Object,
+      _lastSearchPage: String,
+      _path: String,
+      _pluginScreenName: {
+        type: String,
+        computed: '_computePluginScreenName(params)',
+      },
+      _settingsUrl: String,
+      _feedbackUrl: String,
+      // Used to allow searching on mobile
+      mobileSearch: {
+        type: Boolean,
+        value: false,
+      },
 
-        /**
-         * The last time the g key was pressed in milliseconds (or a keydown event
-         * was handled if the key is held down).
-         *
-         * @type {number|null}
-         */
-        _lastGKeyPressTimestamp: {
-          type: Number,
-          value: null,
-        },
+      /**
+       * Other elements in app must open this URL when
+       * user login is required.
+       */
+      _loginUrl: {
+        type: String,
+        value: '/login',
+      },
+    };
+  }
 
-        /**
-         * @type {{ plugin: Object }}
-         */
-        _serverConfig: Object,
-        _version: String,
-        _showChangeListView: Boolean,
-        _showDashboardView: Boolean,
-        _showChangeView: Boolean,
-        _showDiffView: Boolean,
-        _showSettingsView: Boolean,
-        _showAdminView: Boolean,
-        _showCLAView: Boolean,
-        _showEditorView: Boolean,
-        _showPluginScreen: Boolean,
-        _showDocumentationSearch: Boolean,
-        /** @type {?} */
-        _viewState: Object,
-        /** @type {?} */
-        _lastError: Object,
-        _lastSearchPage: String,
-        _path: String,
-        _pluginScreenName: {
-          type: String,
-          computed: '_computePluginScreenName(params)',
-        },
-        _settingsUrl: String,
-        _feedbackUrl: String,
-        // Used to allow searching on mobile
-        mobileSearch: {
-          type: Boolean,
-          value: false,
-        },
+  static get observers() {
+    return [
+      '_viewChanged(params.view)',
+      '_paramsChanged(params.*)',
+    ];
+  }
 
-        /**
-         * Other elements in app must open this URL when
-         * user login is required.
-         */
-        _loginUrl: {
-          type: String,
-          value: '/login',
-        },
-      };
-    }
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
+      [this.Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
+      [this.Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
+      [this.Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
+      [this.Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
+      [this.Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
+    };
+  }
 
-    static get observers() {
-      return [
-        '_viewChanged(params.view)',
-        '_paramsChanged(params.*)',
-      ];
-    }
+  /** @override */
+  created() {
+    super.created();
+    this._bindKeyboardShortcuts();
+    this.addEventListener('page-error',
+        e => this._handlePageError(e));
+    this.addEventListener('title-change',
+        e => this._handleTitleChange(e));
+    this.addEventListener('location-change',
+        e => this._handleLocationChange(e));
+    this.addEventListener('rpc-log',
+        e => this._handleRpcLog(e));
+    this.addEventListener('shortcut-triggered',
+        e => this._handleShortcutTriggered(e));
+  }
 
-    keyboardShortcuts() {
-      return {
-        [this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
-        [this.Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
-        [this.Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
-        [this.Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
-        [this.Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
-        [this.Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
-      };
-    }
+  /** @override */
+  ready() {
+    super.ready();
+    this._updateLoginUrl();
+    this.$.reporting.appStarted();
+    this.$.router.start();
 
-    /** @override */
-    created() {
-      super.created();
-      this._bindKeyboardShortcuts();
-      this.addEventListener('page-error',
-          e => this._handlePageError(e));
-      this.addEventListener('title-change',
-          e => this._handleTitleChange(e));
-      this.addEventListener('location-change',
-          e => this._handleLocationChange(e));
-      this.addEventListener('rpc-log',
-          e => this._handleRpcLog(e));
-      this.addEventListener('shortcut-triggered',
-          e => this._handleShortcutTriggered(e));
-    }
+    this.$.restAPI.getAccount().then(account => {
+      this._account = account;
+    });
+    this.$.restAPI.getConfig().then(config => {
+      this._serverConfig = config;
 
-    /** @override */
-    ready() {
-      super.ready();
-      this._updateLoginUrl();
-      this.$.reporting.appStarted();
-      this.$.router.start();
-
-      this.$.restAPI.getAccount().then(account => {
-        this._account = account;
-      });
-      this.$.restAPI.getConfig().then(config => {
-        this._serverConfig = config;
-
-        if (config && config.gerrit && config.gerrit.report_bug_url) {
-          this._feedbackUrl = config.gerrit.report_bug_url;
-        }
-      });
-      this.$.restAPI.getVersion().then(version => {
-        this._version = version;
-        this._logWelcome();
-      });
-
-      if (window.localStorage.getItem('dark-theme')) {
-        // No need to add the style module to element again as it's imported
-        // by importHref already
-        this.$.libLoader.getDarkTheme();
+      if (config && config.gerrit && config.gerrit.report_bug_url) {
+        this._feedbackUrl = config.gerrit.report_bug_url;
       }
+    });
+    this.$.restAPI.getVersion().then(version => {
+      this._version = version;
+      this._logWelcome();
+    });
 
-      // Note: this is evaluated here to ensure that it only happens after the
-      // router has been initialized. @see Issue 7837
-      this._settingsUrl = Gerrit.Nav.getUrlForSettings();
-
-      this._viewState = {
-        changeView: {
-          changeNum: null,
-          patchRange: null,
-          selectedFileIndex: 0,
-          showReplyDialog: false,
-          diffMode: null,
-          numFilesShown: null,
-          scrollTop: 0,
-        },
-        changeListView: {
-          query: null,
-          offset: 0,
-          selectedChangeIndex: 0,
-        },
-        dashboardView: {
-          selectedChangeIndex: 0,
-        },
-      };
+    if (window.localStorage.getItem('dark-theme')) {
+      // No need to add the style module to element again as it's imported
+      // by importHref already
+      this.$.libLoader.getDarkTheme();
     }
 
-    _bindKeyboardShortcuts() {
-      this.bindShortcut(this.Shortcut.SEND_REPLY,
-          this.DOC_ONLY, 'ctrl+enter', 'meta+enter');
-      this.bindShortcut(this.Shortcut.EMOJI_DROPDOWN,
-          this.DOC_ONLY, ':');
+    // Note: this is evaluated here to ensure that it only happens after the
+    // router has been initialized. @see Issue 7837
+    this._settingsUrl = Gerrit.Nav.getUrlForSettings();
 
-      this.bindShortcut(
-          this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_USER_DASHBOARD, this.GO_KEY, 'i');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_MERGED_CHANGES, this.GO_KEY, 'm');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_ABANDONED_CHANGES, this.GO_KEY, 'a');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_WATCHED_CHANGES, this.GO_KEY, 'w');
+    this._viewState = {
+      changeView: {
+        changeNum: null,
+        patchRange: null,
+        selectedFileIndex: 0,
+        showReplyDialog: false,
+        diffMode: null,
+        numFilesShown: null,
+        scrollTop: 0,
+      },
+      changeListView: {
+        query: null,
+        offset: 0,
+        selectedChangeIndex: 0,
+      },
+      dashboardView: {
+        selectedChangeIndex: 0,
+      },
+    };
+  }
 
-      this.bindShortcut(
-          this.Shortcut.CURSOR_NEXT_CHANGE, 'j');
-      this.bindShortcut(
-          this.Shortcut.CURSOR_PREV_CHANGE, 'k');
-      this.bindShortcut(
-          this.Shortcut.OPEN_CHANGE, 'o');
-      this.bindShortcut(
-          this.Shortcut.NEXT_PAGE, 'n', ']');
-      this.bindShortcut(
-          this.Shortcut.PREV_PAGE, 'p', '[');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_CHANGE_STAR, 's:keyup');
-      this.bindShortcut(
-          this.Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
-      this.bindShortcut(
-          this.Shortcut.EDIT_TOPIC, 't');
+  _bindKeyboardShortcuts() {
+    this.bindShortcut(this.Shortcut.SEND_REPLY,
+        this.DOC_ONLY, 'ctrl+enter', 'meta+enter');
+    this.bindShortcut(this.Shortcut.EMOJI_DROPDOWN,
+        this.DOC_ONLY, ':');
 
-      this.bindShortcut(
-          this.Shortcut.OPEN_REPLY_DIALOG, 'a');
-      this.bindShortcut(
-          this.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-      this.bindShortcut(
-          this.Shortcut.EXPAND_ALL_MESSAGES, 'x');
-      this.bindShortcut(
-          this.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-      this.bindShortcut(
-          this.Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
-      this.bindShortcut(
-          this.Shortcut.UP_TO_DASHBOARD, 'u');
-      this.bindShortcut(
-          this.Shortcut.UP_TO_CHANGE, 'u');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
+    this.bindShortcut(
+        this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
+    this.bindShortcut(
+        this.Shortcut.GO_TO_USER_DASHBOARD, this.GO_KEY, 'i');
+    this.bindShortcut(
+        this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
+    this.bindShortcut(
+        this.Shortcut.GO_TO_MERGED_CHANGES, this.GO_KEY, 'm');
+    this.bindShortcut(
+        this.Shortcut.GO_TO_ABANDONED_CHANGES, this.GO_KEY, 'a');
+    this.bindShortcut(
+        this.Shortcut.GO_TO_WATCHED_CHANGES, this.GO_KEY, 'w');
 
-      this.bindShortcut(
-          this.Shortcut.NEXT_LINE, 'j', 'down');
-      this.bindShortcut(
-          this.Shortcut.PREV_LINE, 'k', 'up');
-      if (this._isCursorManagerSupportMoveToVisibleLine()) {
-        this.bindShortcut(
-            this.Shortcut.VISIBLE_LINE, '.');
-      }
-      this.bindShortcut(
-          this.Shortcut.NEXT_CHUNK, 'n');
-      this.bindShortcut(
-          this.Shortcut.PREV_CHUNK, 'p');
-      this.bindShortcut(
-          this.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
-      this.bindShortcut(
-          this.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-      this.bindShortcut(
-          this.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-      this.bindShortcut(
-          this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
-      this.bindShortcut(
-          this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-          this.DOC_ONLY, 'shift+e');
-      this.bindShortcut(
-          this.Shortcut.LEFT_PANE, 'shift+left');
-      this.bindShortcut(
-          this.Shortcut.RIGHT_PANE, 'shift+right');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-      this.bindShortcut(
-          this.Shortcut.NEW_COMMENT, 'c');
-      this.bindShortcut(
-          this.Shortcut.SAVE_COMMENT,
-          'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
-      this.bindShortcut(
-          this.Shortcut.OPEN_DIFF_PREFS, ',');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
+    this.bindShortcut(
+        this.Shortcut.CURSOR_NEXT_CHANGE, 'j');
+    this.bindShortcut(
+        this.Shortcut.CURSOR_PREV_CHANGE, 'k');
+    this.bindShortcut(
+        this.Shortcut.OPEN_CHANGE, 'o');
+    this.bindShortcut(
+        this.Shortcut.NEXT_PAGE, 'n', ']');
+    this.bindShortcut(
+        this.Shortcut.PREV_PAGE, 'p', '[');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_CHANGE_STAR, 's:keyup');
+    this.bindShortcut(
+        this.Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
+    this.bindShortcut(
+        this.Shortcut.EDIT_TOPIC, 't');
 
-      this.bindShortcut(
-          this.Shortcut.NEXT_FILE, ']');
-      this.bindShortcut(
-          this.Shortcut.PREV_FILE, '[');
-      this.bindShortcut(
-          this.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-      this.bindShortcut(
-          this.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-      this.bindShortcut(
-          this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-      this.bindShortcut(
-          this.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-      this.bindShortcut(
-          this.Shortcut.OPEN_FILE, 'o', 'enter');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
-      this.bindShortcut(
-          this.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_BLAME, 'b');
+    this.bindShortcut(
+        this.Shortcut.OPEN_REPLY_DIALOG, 'a');
+    this.bindShortcut(
+        this.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+    this.bindShortcut(
+        this.Shortcut.EXPAND_ALL_MESSAGES, 'x');
+    this.bindShortcut(
+        this.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+    this.bindShortcut(
+        this.Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
+    this.bindShortcut(
+        this.Shortcut.UP_TO_DASHBOARD, 'u');
+    this.bindShortcut(
+        this.Shortcut.UP_TO_CHANGE, 'u');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
 
+    this.bindShortcut(
+        this.Shortcut.NEXT_LINE, 'j', 'down');
+    this.bindShortcut(
+        this.Shortcut.PREV_LINE, 'k', 'up');
+    if (this._isCursorManagerSupportMoveToVisibleLine()) {
       this.bindShortcut(
-          this.Shortcut.OPEN_FIRST_FILE, ']');
-      this.bindShortcut(
-          this.Shortcut.OPEN_LAST_FILE, '[');
-
-      this.bindShortcut(
-          this.Shortcut.SEARCH, '/');
+          this.Shortcut.VISIBLE_LINE, '.');
     }
+    this.bindShortcut(
+        this.Shortcut.NEXT_CHUNK, 'n');
+    this.bindShortcut(
+        this.Shortcut.PREV_CHUNK, 'p');
+    this.bindShortcut(
+        this.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+    this.bindShortcut(
+        this.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+    this.bindShortcut(
+        this.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+    this.bindShortcut(
+        this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
+    this.bindShortcut(
+        this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+        this.DOC_ONLY, 'shift+e');
+    this.bindShortcut(
+        this.Shortcut.LEFT_PANE, 'shift+left');
+    this.bindShortcut(
+        this.Shortcut.RIGHT_PANE, 'shift+right');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+    this.bindShortcut(
+        this.Shortcut.NEW_COMMENT, 'c');
+    this.bindShortcut(
+        this.Shortcut.SAVE_COMMENT,
+        'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
+    this.bindShortcut(
+        this.Shortcut.OPEN_DIFF_PREFS, ',');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
 
-    _isCursorManagerSupportMoveToVisibleLine() {
-      // This method is a copy-paste from the
-      // method _isIntersectionObserverSupported of gr-cursor-manager.js
-      // It is better share this method with gr-cursor-manager,
-      // but doing it require a lot if changes instead of 1-line copied code
-      return 'IntersectionObserver' in window;
+    this.bindShortcut(
+        this.Shortcut.NEXT_FILE, ']');
+    this.bindShortcut(
+        this.Shortcut.PREV_FILE, '[');
+    this.bindShortcut(
+        this.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+    this.bindShortcut(
+        this.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+    this.bindShortcut(
+        this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+    this.bindShortcut(
+        this.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+    this.bindShortcut(
+        this.Shortcut.OPEN_FILE, 'o', 'enter');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
+    this.bindShortcut(
+        this.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_BLAME, 'b');
+
+    this.bindShortcut(
+        this.Shortcut.OPEN_FIRST_FILE, ']');
+    this.bindShortcut(
+        this.Shortcut.OPEN_LAST_FILE, '[');
+
+    this.bindShortcut(
+        this.Shortcut.SEARCH, '/');
+  }
+
+  _isCursorManagerSupportMoveToVisibleLine() {
+    // This method is a copy-paste from the
+    // method _isIntersectionObserverSupported of gr-cursor-manager.js
+    // It is better share this method with gr-cursor-manager,
+    // but doing it require a lot if changes instead of 1-line copied code
+    return 'IntersectionObserver' in window;
+  }
+
+  _accountChanged(account) {
+    if (!account) { return; }
+
+    // Preferences are cached when a user is logged in; warm them.
+    this.$.restAPI.getPreferences();
+    this.$.restAPI.getDiffPreferences();
+    this.$.restAPI.getEditPreferences();
+    this.$.errorManager.knownAccountId =
+        this._account && this._account._account_id || null;
+  }
+
+  _viewChanged(view) {
+    this.$.errorView.classList.remove('show');
+    this.set('_showChangeListView', view === Gerrit.Nav.View.SEARCH);
+    this.set('_showDashboardView', view === Gerrit.Nav.View.DASHBOARD);
+    this.set('_showChangeView', view === Gerrit.Nav.View.CHANGE);
+    this.set('_showDiffView', view === Gerrit.Nav.View.DIFF);
+    this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS);
+    this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN ||
+        view === Gerrit.Nav.View.GROUP || view === Gerrit.Nav.View.REPO);
+    this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
+    this.set('_showEditorView', view === Gerrit.Nav.View.EDIT);
+    const isPluginScreen = view === Gerrit.Nav.View.PLUGIN_SCREEN;
+    this.set('_showPluginScreen', false);
+    // Navigation within plugin screens does not restamp gr-endpoint-decorator
+    // because _showPluginScreen value does not change. To force restamp,
+    // change _showPluginScreen value between true and false.
+    if (isPluginScreen) {
+      this.async(() => this.set('_showPluginScreen', true), 1);
     }
-
-    _accountChanged(account) {
-      if (!account) { return; }
-
-      // Preferences are cached when a user is logged in; warm them.
-      this.$.restAPI.getPreferences();
-      this.$.restAPI.getDiffPreferences();
-      this.$.restAPI.getEditPreferences();
-      this.$.errorManager.knownAccountId =
-          this._account && this._account._account_id || null;
-    }
-
-    _viewChanged(view) {
-      this.$.errorView.classList.remove('show');
-      this.set('_showChangeListView', view === Gerrit.Nav.View.SEARCH);
-      this.set('_showDashboardView', view === Gerrit.Nav.View.DASHBOARD);
-      this.set('_showChangeView', view === Gerrit.Nav.View.CHANGE);
-      this.set('_showDiffView', view === Gerrit.Nav.View.DIFF);
-      this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS);
-      this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN ||
-          view === Gerrit.Nav.View.GROUP || view === Gerrit.Nav.View.REPO);
-      this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
-      this.set('_showEditorView', view === Gerrit.Nav.View.EDIT);
-      const isPluginScreen = view === Gerrit.Nav.View.PLUGIN_SCREEN;
-      this.set('_showPluginScreen', false);
-      // Navigation within plugin screens does not restamp gr-endpoint-decorator
-      // because _showPluginScreen value does not change. To force restamp,
-      // change _showPluginScreen value between true and false.
-      if (isPluginScreen) {
-        this.async(() => this.set('_showPluginScreen', true), 1);
-      }
-      this.set('_showDocumentationSearch',
-          view === Gerrit.Nav.View.DOCUMENTATION_SEARCH);
-      if (this.params.justRegistered) {
-        this.$.registrationOverlay.open();
-        this.$.registrationDialog.loadData().then(() => {
-          this.$.registrationOverlay.refit();
-        });
-      }
-      this.$.header.unfloat();
-    }
-
-    _handleShortcutTriggered(event) {
-      const {event: e, goKey} = event.detail;
-      // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
-      let key = `${e.key}:${e.type}`;
-      if (goKey) key = 'g+' + key;
-      if (e.shiftKey) key = 'shift+' + key;
-      if (e.ctrlKey) key = 'ctrl+' + key;
-      if (e.metaKey) key = 'meta+' + key;
-      if (e.altKey) key = 'alt+' + key;
-      this.$.reporting.reportInteraction('shortcut-triggered', {
-        key,
-        from: event.path && event.path[0]
-          && event.path[0].nodeName || 'unknown',
+    this.set('_showDocumentationSearch',
+        view === Gerrit.Nav.View.DOCUMENTATION_SEARCH);
+    if (this.params.justRegistered) {
+      this.$.registrationOverlay.open();
+      this.$.registrationDialog.loadData().then(() => {
+        this.$.registrationOverlay.refit();
       });
     }
+    this.$.header.unfloat();
+  }
 
-    _handlePageError(e) {
-      const props = [
-        '_showChangeListView',
-        '_showDashboardView',
-        '_showChangeView',
-        '_showDiffView',
-        '_showSettingsView',
-        '_showAdminView',
-      ];
-      for (const showProp of props) {
-        this.set(showProp, false);
-      }
+  _handleShortcutTriggered(event) {
+    const {event: e, goKey} = event.detail;
+    // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
+    let key = `${e.key}:${e.type}`;
+    if (goKey) key = 'g+' + key;
+    if (e.shiftKey) key = 'shift+' + key;
+    if (e.ctrlKey) key = 'ctrl+' + key;
+    if (e.metaKey) key = 'meta+' + key;
+    if (e.altKey) key = 'alt+' + key;
+    this.$.reporting.reportInteraction('shortcut-triggered', {
+      key,
+      from: event.path && event.path[0]
+        && event.path[0].nodeName || 'unknown',
+    });
+  }
 
-      this.$.errorView.classList.add('show');
-      const response = e.detail.response;
-      const err = {text: [response.status, response.statusText].join(' ')};
-      if (response.status === 404) {
-        err.emoji = '¯\\_(ツ)_/¯';
+  _handlePageError(e) {
+    const props = [
+      '_showChangeListView',
+      '_showDashboardView',
+      '_showChangeView',
+      '_showDiffView',
+      '_showSettingsView',
+      '_showAdminView',
+    ];
+    for (const showProp of props) {
+      this.set(showProp, false);
+    }
+
+    this.$.errorView.classList.add('show');
+    const response = e.detail.response;
+    const err = {text: [response.status, response.statusText].join(' ')};
+    if (response.status === 404) {
+      err.emoji = '¯\\_(ツ)_/¯';
+      this._lastError = err;
+    } else {
+      err.emoji = 'o_O';
+      response.text().then(text => {
+        err.moreInfo = text;
         this._lastError = err;
-      } else {
-        err.emoji = 'o_O';
-        response.text().then(text => {
-          err.moreInfo = text;
-          this._lastError = err;
-        });
-      }
-    }
-
-    _handleLocationChange(e) {
-      this._updateLoginUrl();
-
-      const hash = e.detail.hash.substring(1);
-      let pathname = e.detail.pathname;
-      if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
-        pathname += '@' + hash;
-      }
-      this.set('_path', pathname);
-    }
-
-    _updateLoginUrl() {
-      const baseUrl = this.getBaseUrl();
-      if (baseUrl) {
-        // Strip the canonical path from the path since needing canonical in
-        // the path is uneeded and breaks the url.
-        this._loginUrl = baseUrl + '/login/' + encodeURIComponent(
-            '/' + window.location.pathname.substring(baseUrl.length) +
-            window.location.search +
-            window.location.hash);
-      } else {
-        this._loginUrl = '/login/' + encodeURIComponent(
-            window.location.pathname +
-            window.location.search +
-            window.location.hash);
-      }
-    }
-
-    _paramsChanged(paramsRecord) {
-      const params = paramsRecord.base;
-      const viewsToCheck = [Gerrit.Nav.View.SEARCH, Gerrit.Nav.View.DASHBOARD];
-      if (viewsToCheck.includes(params.view)) {
-        this.set('_lastSearchPage', location.pathname);
-      }
-    }
-
-    _handleTitleChange(e) {
-      if (e.detail.title) {
-        document.title = e.detail.title + ' · Gerrit Code Review';
-      } else {
-        document.title = '';
-      }
-    }
-
-    _showKeyboardShortcuts(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      this.$.keyboardShortcuts.open();
-    }
-
-    _handleKeyboardShortcutDialogClose() {
-      this.$.keyboardShortcuts.close();
-    }
-
-    _handleAccountDetailUpdate(e) {
-      this.$.mainHeader.reload();
-      if (this.params.view === Gerrit.Nav.View.SETTINGS) {
-        this.shadowRoot.querySelector('gr-settings-view').reloadAccountDetail();
-      }
-    }
-
-    _handleRegistrationDialogClose(e) {
-      this.params.justRegistered = false;
-      this.$.registrationOverlay.close();
-    }
-
-    _goToOpenedChanges() {
-      Gerrit.Nav.navigateToStatusSearch('open');
-    }
-
-    _goToUserDashboard() {
-      Gerrit.Nav.navigateToUserDashboard();
-    }
-
-    _goToMergedChanges() {
-      Gerrit.Nav.navigateToStatusSearch('merged');
-    }
-
-    _goToAbandonedChanges() {
-      Gerrit.Nav.navigateToStatusSearch('abandoned');
-    }
-
-    _goToWatchedChanges() {
-      // The query is hardcoded, and doesn't respect custom menu entries
-      Gerrit.Nav.navigateToSearchQuery('is:watched is:open');
-    }
-
-    _computePluginScreenName({plugin, screen}) {
-      if (!plugin || !screen) return '';
-      return `${plugin}-screen-${screen}`;
-    }
-
-    _logWelcome() {
-      console.group('Runtime Info');
-      console.log('Gerrit UI (PolyGerrit)');
-      console.log(`Gerrit Server Version: ${this._version}`);
-      if (window.VERSION_INFO) {
-        console.log(`UI Version Info: ${window.VERSION_INFO}`);
-      }
-      if (this._feedbackUrl) {
-        console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
-      }
-      console.groupEnd();
-    }
-
-    /**
-     * Intercept RPC log events emitted by REST API interfaces.
-     * Note: the REST API interface cannot use gr-reporting directly because
-     * that would create a cyclic dependency.
-     */
-    _handleRpcLog(e) {
-      this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl,
-          e.detail.elapsed);
-    }
-
-    _mobileSearchToggle(e) {
-      this.mobileSearch = !this.mobileSearch;
-    }
-
-    getThemeEndpoint() {
-      // For now, we only have dark mode and light mode
-      return window.localStorage.getItem('dark-theme') ?
-        'app-theme-dark' :
-        'app-theme-light';
+      });
     }
   }
 
-  customElements.define(GrAppElement.is, GrAppElement);
-})();
+  _handleLocationChange(e) {
+    this._updateLoginUrl();
+
+    const hash = e.detail.hash.substring(1);
+    let pathname = e.detail.pathname;
+    if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
+      pathname += '@' + hash;
+    }
+    this.set('_path', pathname);
+  }
+
+  _updateLoginUrl() {
+    const baseUrl = this.getBaseUrl();
+    if (baseUrl) {
+      // Strip the canonical path from the path since needing canonical in
+      // the path is uneeded and breaks the url.
+      this._loginUrl = baseUrl + '/login/' + encodeURIComponent(
+          '/' + window.location.pathname.substring(baseUrl.length) +
+          window.location.search +
+          window.location.hash);
+    } else {
+      this._loginUrl = '/login/' + encodeURIComponent(
+          window.location.pathname +
+          window.location.search +
+          window.location.hash);
+    }
+  }
+
+  _paramsChanged(paramsRecord) {
+    const params = paramsRecord.base;
+    const viewsToCheck = [Gerrit.Nav.View.SEARCH, Gerrit.Nav.View.DASHBOARD];
+    if (viewsToCheck.includes(params.view)) {
+      this.set('_lastSearchPage', location.pathname);
+    }
+  }
+
+  _handleTitleChange(e) {
+    if (e.detail.title) {
+      document.title = e.detail.title + ' · Gerrit Code Review';
+    } else {
+      document.title = '';
+    }
+  }
+
+  _showKeyboardShortcuts(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    this.$.keyboardShortcuts.open();
+  }
+
+  _handleKeyboardShortcutDialogClose() {
+    this.$.keyboardShortcuts.close();
+  }
+
+  _handleAccountDetailUpdate(e) {
+    this.$.mainHeader.reload();
+    if (this.params.view === Gerrit.Nav.View.SETTINGS) {
+      this.shadowRoot.querySelector('gr-settings-view').reloadAccountDetail();
+    }
+  }
+
+  _handleRegistrationDialogClose(e) {
+    this.params.justRegistered = false;
+    this.$.registrationOverlay.close();
+  }
+
+  _goToOpenedChanges() {
+    Gerrit.Nav.navigateToStatusSearch('open');
+  }
+
+  _goToUserDashboard() {
+    Gerrit.Nav.navigateToUserDashboard();
+  }
+
+  _goToMergedChanges() {
+    Gerrit.Nav.navigateToStatusSearch('merged');
+  }
+
+  _goToAbandonedChanges() {
+    Gerrit.Nav.navigateToStatusSearch('abandoned');
+  }
+
+  _goToWatchedChanges() {
+    // The query is hardcoded, and doesn't respect custom menu entries
+    Gerrit.Nav.navigateToSearchQuery('is:watched is:open');
+  }
+
+  _computePluginScreenName({plugin, screen}) {
+    if (!plugin || !screen) return '';
+    return `${plugin}-screen-${screen}`;
+  }
+
+  _logWelcome() {
+    console.group('Runtime Info');
+    console.log('Gerrit UI (PolyGerrit)');
+    console.log(`Gerrit Server Version: ${this._version}`);
+    if (window.VERSION_INFO) {
+      console.log(`UI Version Info: ${window.VERSION_INFO}`);
+    }
+    if (this._feedbackUrl) {
+      console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
+    }
+    console.groupEnd();
+  }
+
+  /**
+   * Intercept RPC log events emitted by REST API interfaces.
+   * Note: the REST API interface cannot use gr-reporting directly because
+   * that would create a cyclic dependency.
+   */
+  _handleRpcLog(e) {
+    this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl,
+        e.detail.elapsed);
+  }
+
+  _mobileSearchToggle(e) {
+    this.mobileSearch = !this.mobileSearch;
+  }
+
+  getThemeEndpoint() {
+    // For now, we only have dark mode and light mode
+    return window.localStorage.getItem('dark-theme') ?
+      'app-theme-dark' :
+      'app-theme-light';
+  }
+}
+
+customElements.define(GrAppElement.is, GrAppElement);
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.js b/polygerrit-ui/app/elements/gr-app-element_html.js
index 62f2967..3951d9d 100644
--- a/polygerrit-ui/app/elements/gr-app-element_html.js
+++ b/polygerrit-ui/app/elements/gr-app-element_html.js
@@ -1,54 +1,22 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<script src="/bower_components/moment/moment.js"></script>
-<script src="../scripts/util.js"></script>
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../styles/shared-styles.html">
-<link rel="import" href="../styles/themes/app-theme.html">
-<link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
-<link rel="import" href="./documentation/gr-documentation-search/gr-documentation-search.html">
-<link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
-<link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
-<link rel="import" href="./change/gr-change-view/gr-change-view.html">
-<link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
-<link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
-<link rel="import" href="./core/gr-main-header/gr-main-header.html">
-<link rel="import" href="./core/gr-navigation/gr-navigation.html">
-<link rel="import" href="./core/gr-reporting/gr-reporting.html">
-<link rel="import" href="./core/gr-router/gr-router.html">
-<link rel="import" href="./core/gr-smart-search/gr-smart-search.html">
-<link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
-<link rel="import" href="./edit/gr-editor-view/gr-editor-view.html">
-<link rel="import" href="./plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="./plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
-<link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
-<link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
-<link rel="import" href="./settings/gr-registration-dialog/gr-registration-dialog.html">
-<link rel="import" href="./settings/gr-settings-view/gr-settings-view.html">
-<link rel="import" href="./shared/gr-fixed-panel/gr-fixed-panel.html">
-<link rel="import" href="./shared/gr-lib-loader/gr-lib-loader.html">
-<link rel="import" href="./shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-app-element">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         background-color: var(--background-color-tertiary);
@@ -126,56 +94,33 @@
     </style>
     <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
     <gr-fixed-panel id="header">
-      <gr-main-header
-          id="mainHeader"
-          search-query="{{params.query}}"
-          on-mobile-search="_mobileSearchToggle"
-          login-url="[[_loginUrl]]"
-      >
+      <gr-main-header id="mainHeader" search-query="{{params.query}}" on-mobile-search="_mobileSearchToggle" login-url="[[_loginUrl]]">
       </gr-main-header>
     </gr-fixed-panel>
     <main>
-      <gr-smart-search
-          id="search"
-          search-query="{{params.query}}"
-          hidden="[[!mobileSearch]]">
+      <gr-smart-search id="search" search-query="{{params.query}}" hidden="[[!mobileSearch]]">
       </gr-smart-search>
       <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
-        <gr-change-list-view
-            params="[[params]]"
-            account="[[_account]]"
-            view-state="{{_viewState.changeListView}}"></gr-change-list-view>
+        <gr-change-list-view params="[[params]]" account="[[_account]]" view-state="{{_viewState.changeListView}}"></gr-change-list-view>
       </template>
       <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
-        <gr-dashboard-view
-            account="[[_account]]"
-            params="[[params]]"
-            view-state="{{_viewState.dashboardView}}"></gr-dashboard-view>
+        <gr-dashboard-view account="[[_account]]" params="[[params]]" view-state="{{_viewState.dashboardView}}"></gr-dashboard-view>
       </template>
       <template is="dom-if" if="[[_showChangeView]]" restamp="true">
-        <gr-change-view
-            params="[[params]]"
-            view-state="{{_viewState.changeView}}"
-            back-page="[[_lastSearchPage]]"></gr-change-view>
+        <gr-change-view params="[[params]]" view-state="{{_viewState.changeView}}" back-page="[[_lastSearchPage]]"></gr-change-view>
       </template>
       <template is="dom-if" if="[[_showEditorView]]" restamp="true">
-        <gr-editor-view
-            params="[[params]]"></gr-editor-view>
+        <gr-editor-view params="[[params]]"></gr-editor-view>
       </template>
       <template is="dom-if" if="[[_showDiffView]]" restamp="true">
-          <gr-diff-view
-              params="[[params]]"
-              change-view-state="{{_viewState.changeView}}"></gr-diff-view>
+          <gr-diff-view params="[[params]]" change-view-state="{{_viewState.changeView}}"></gr-diff-view>
         </template>
       <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
-        <gr-settings-view
-            params="[[params]]"
-            on-account-detail-update="_handleAccountDetailUpdate">
+        <gr-settings-view params="[[params]]" on-account-detail-update="_handleAccountDetailUpdate">
         </gr-settings-view>
       </template>
       <template is="dom-if" if="[[_showAdminView]]" restamp="true">
-        <gr-admin-view path="[[_path]]"
-            params=[[params]]></gr-admin-view>
+        <gr-admin-view path="[[_path]]" params="[[params]]"></gr-admin-view>
       </template>
       <template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
         <gr-endpoint-decorator name="[[_pluginScreenName]]">
@@ -186,8 +131,7 @@
         <gr-cla-view></gr-cla-view>
       </template>
       <template is="dom-if" if="[[_showDocumentationSearch]]" restamp="true">
-        <gr-documentation-search
-            params="[[params]]">
+        <gr-documentation-search params="[[params]]">
         </gr-documentation-search>
       </template>
       <div id="errorView" class="errorView">
@@ -198,32 +142,23 @@
     </main>
     <footer r="contentinfo">
       <div>
-        Powered by <a href="https://www.gerritcodereview.com/" rel="noopener"
-        target="_blank">Gerrit Code Review</a>
+        Powered by <a href="https://www.gerritcodereview.com/" rel="noopener" target="_blank">Gerrit Code Review</a>
         ([[_version]])
         <gr-endpoint-decorator name="footer-left"></gr-endpoint-decorator>
       </div>
       <div>
         <template is="dom-if" if="[[_feedbackUrl]]">
-          <a class="feedback"
-              href$="[[_feedbackUrl]]"
-              rel="noopener"
-              target="_blank">Report bug</a> |
+          <a class="feedback" href\$="[[_feedbackUrl]]" rel="noopener" target="_blank">Report bug</a> |
         </template>
-        Press &ldquo;?&rdquo; for keyboard shortcuts
+        Press “?” for keyboard shortcuts
         <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
       </div>
     </footer>
-    <gr-overlay id="keyboardShortcuts" with-backdrop>
-      <gr-keyboard-shortcuts-dialog
-          on-close="_handleKeyboardShortcutDialogClose"></gr-keyboard-shortcuts-dialog>
+    <gr-overlay id="keyboardShortcuts" with-backdrop="">
+      <gr-keyboard-shortcuts-dialog on-close="_handleKeyboardShortcutDialogClose"></gr-keyboard-shortcuts-dialog>
     </gr-overlay>
-    <gr-overlay id="registrationOverlay" with-backdrop>
-      <gr-registration-dialog
-          id="registrationDialog"
-          settings-url="[[_settingsUrl]]"
-          on-account-detail-update="_handleAccountDetailUpdate"
-          on-close="_handleRegistrationDialogClose">
+    <gr-overlay id="registrationOverlay" with-backdrop="">
+      <gr-registration-dialog id="registrationDialog" settings-url="[[_settingsUrl]]" on-account-detail-update="_handleAccountDetailUpdate" on-close="_handleRegistrationDialogClose">
       </gr-registration-dialog>
     </gr-overlay>
     <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
@@ -231,12 +166,9 @@
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
     <gr-router id="router"></gr-router>
-    <gr-plugin-host id="plugins"
-        config="[[_serverConfig]]">
+    <gr-plugin-host id="plugins" config="[[_serverConfig]]">
     </gr-plugin-host>
     <gr-lib-loader id="libLoader"></gr-lib-loader>
     <gr-external-style id="externalStyleForAll" name="app-theme"></gr-external-style>
     <gr-external-style id="externalStyleForTheme" name="[[getThemeEndpoint()]]"></gr-external-style>
-  </template>
-  <script src="gr-app-element.js" crossorigin="anonymous"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
new file mode 100644
index 0000000..1483f7a
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -0,0 +1 @@
+<script src='./gr-app.js' type='module'></script>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index da54ac4..ac5a04d 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,15 +14,38 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+/* TODO(taoalpha): Remove once all legacyUndefinedCheck removed. */
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+import './gr-app-init.js';
 
-  /** @extends Polymer.Element */
-  class GrApp extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-app'; }
-  }
+import './font-roboto-local-loader.js';
+import '../scripts/bundled-polymer.js';
+import 'polymer-resin/standalone/polymer-resin.js';
+import '../behaviors/safe-types-behavior/safe-types-behavior.js';
+import './gr-app-element.js';
+import './change-list/gr-embed-dashboard/gr-embed-dashboard.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-app_html.js';
 
-  customElements.define(GrApp.is, GrApp);
-})();
+security.polymer_resin.install({
+  allowedIdentifierPrefixes: [''],
+  reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
+  safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
+});
+
+/** @extends Polymer.Element */
+class GrApp extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-app'; }
+}
+
+customElements.define(GrApp.is, GrApp);
diff --git a/polygerrit-ui/app/elements/gr-app_html.js b/polygerrit-ui/app/elements/gr-app_html.js
index 2a28bc1..fcf773f 100644
--- a/polygerrit-ui/app/elements/gr-app_html.js
+++ b/polygerrit-ui/app/elements/gr-app_html.js
@@ -1,38 +1,21 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<script src="gr-app-init.js"></script>
-<script src="./font-roboto-local-loader.js" type="module" />
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/polymer-resin/standalone/polymer-resin.html">
-<!-- TODO(taoalpha): Remove once all legacyUndefinedCheck removed. -->
-<link rel="import" href="../behaviors/safe-types-behavior/safe-types-behavior.html">
-<script>
-  security.polymer_resin.install({
-    allowedIdentifierPrefixes: [''],
-    reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
-    safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
-  });
-</script>
-
-<link rel="import" href="./gr-app-element.html">
-<dom-module id="gr-app">
-  <template>
+export const htmlTemplate = html`
     <gr-app-element id="app-element"></gr-app-element>
-  </template>
-  <script src="gr-app.js" crossorigin="anonymous"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
index 47a8e06..447aae4 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../test/test-pre-setup.js"></script>
-<link rel="import" href="../test/common-test-setup.html"/>
-<link rel="import" href="gr-app.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../test/test-pre-setup.js"></script>
+<script type="module" src="../test/common-test-setup.js"></script>
+<script type="module" src="./gr-app.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../test/test-pre-setup.js';
+import '../test/common-test-setup.js';
+import './gr-app.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,74 +40,76 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-app tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let element;
+<script type="module">
+import '../test/test-pre-setup.js';
+import '../test/common-test-setup.js';
+import './gr-app.js';
+suite('gr-app tests', () => {
+  let sandbox;
+  let element;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-reporting', {
-        appStarted: sandbox.stub(),
-      });
-      stub('gr-account-dropdown', {
-        _getTopContent: sinon.stub(),
-      });
-      stub('gr-router', {
-        start: sandbox.stub(),
-      });
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve({}); },
-        getAccountCapabilities() { return Promise.resolve({}); },
-        getConfig() {
-          return Promise.resolve({
-            plugin: {},
-            auth: {
-              auth_type: undefined,
-            },
-          });
-        },
-        getPreferences() { return Promise.resolve({my: []}); },
-        getDiffPreferences() { return Promise.resolve({}); },
-        getEditPreferences() { return Promise.resolve({}); },
-        getVersion() { return Promise.resolve(42); },
-        probePath() { return Promise.resolve(42); },
-      });
-
-      element = fixture('basic');
-      flush(done);
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-reporting', {
+      appStarted: sandbox.stub(),
+    });
+    stub('gr-account-dropdown', {
+      _getTopContent: sinon.stub(),
+    });
+    stub('gr-router', {
+      start: sandbox.stub(),
+    });
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve({}); },
+      getAccountCapabilities() { return Promise.resolve({}); },
+      getConfig() {
+        return Promise.resolve({
+          plugin: {},
+          auth: {
+            auth_type: undefined,
+          },
+        });
+      },
+      getPreferences() { return Promise.resolve({my: []}); },
+      getDiffPreferences() { return Promise.resolve({}); },
+      getEditPreferences() { return Promise.resolve({}); },
+      getVersion() { return Promise.resolve(42); },
+      probePath() { return Promise.resolve(42); },
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    element = fixture('basic');
+    flush(done);
+  });
 
-    const appElement = () => element.$['app-element'];
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('reporting', () => {
-      assert.isTrue(appElement().$.reporting.appStarted.calledOnce);
-    });
+  const appElement = () => element.$['app-element'];
 
-    test('reporting called before router start', () => {
-      const element = appElement();
-      const appStartedStub = element.$.reporting.appStarted;
-      const routerStartStub = element.$.router.start;
-      sinon.assert.callOrder(appStartedStub, routerStartStub);
-    });
+  test('reporting', () => {
+    assert.isTrue(appElement().$.reporting.appStarted.calledOnce);
+  });
 
-    test('passes config to gr-plugin-host', () => {
-      const config = appElement().$.restAPI.getConfig;
-      return config.lastCall.returnValue.then(config => {
-        assert.deepEqual(appElement().$.plugins.config, config);
-      });
-    });
+  test('reporting called before router start', () => {
+    const element = appElement();
+    const appStartedStub = element.$.reporting.appStarted;
+    const routerStartStub = element.$.router.start;
+    sinon.assert.callOrder(appStartedStub, routerStartStub);
+  });
 
-    test('_paramsChanged sets search page', () => {
-      appElement()._paramsChanged({base: {view: Gerrit.Nav.View.CHANGE}});
-      assert.notOk(appElement()._lastSearchPage);
-      appElement()._paramsChanged({base: {view: Gerrit.Nav.View.SEARCH}});
-      assert.ok(appElement()._lastSearchPage);
+  test('passes config to gr-plugin-host', () => {
+    const config = appElement().$.restAPI.getConfig;
+    return config.lastCall.returnValue.then(config => {
+      assert.deepEqual(appElement().$.plugins.config, config);
     });
   });
+
+  test('_paramsChanged sets search page', () => {
+    appElement()._paramsChanged({base: {view: Gerrit.Nav.View.CHANGE}});
+    assert.notOk(appElement()._lastSearchPage);
+    appElement()._paramsChanged({base: {view: Gerrit.Nav.View.SEARCH}});
+    assert.ok(appElement()._lastSearchPage);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.html b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.html
deleted file mode 100644
index 756c435..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.html
+++ /dev/null
@@ -1,18 +0,0 @@
-<!--
-@license
-Copyright (C) 2018 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.
--->
-
-<script src="gr-admin-api.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
index f10f922..21e46b8 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
@@ -19,54 +19,63 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-api</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="gr-admin-api.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../shared/gr-js-api-interface/gr-js-api-interface.js"></script>
+<script type="module" src="./gr-admin-api.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import './gr-admin-api.js';
+void(0);
+</script>
 
-<script>
-  suite('gr-admin-api tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let adminApi;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import './gr-admin-api.js';
+suite('gr-admin-api tests', () => {
+  let sandbox;
+  let adminApi;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      Gerrit._loadPlugins([]);
-      adminApi = plugin.admin();
-    });
-
-    teardown(() => {
-      adminApi = null;
-      sandbox.restore();
-    });
-
-    test('exists', () => {
-      assert.isOk(adminApi);
-    });
-
-    test('addMenuLink', () => {
-      adminApi.addMenuLink('text', 'url');
-      const links = adminApi.getMenuLinks();
-      assert.equal(links.length, 1);
-      assert.deepEqual(links[0], {text: 'text', url: 'url', capability: null});
-    });
-
-    test('addMenuLinkWithCapability', () => {
-      adminApi.addMenuLink('text', 'url', 'capability');
-      const links = adminApi.getMenuLinks();
-      assert.equal(links.length, 1);
-      assert.deepEqual(links[0],
-          {text: 'text', url: 'url', capability: 'capability'});
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    let plugin;
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    Gerrit._loadPlugins([]);
+    adminApi = plugin.admin();
   });
+
+  teardown(() => {
+    adminApi = null;
+    sandbox.restore();
+  });
+
+  test('exists', () => {
+    assert.isOk(adminApi);
+  });
+
+  test('addMenuLink', () => {
+    adminApi.addMenuLink('text', 'url');
+    const links = adminApi.getMenuLinks();
+    assert.equal(links.length, 1);
+    assert.deepEqual(links[0], {text: 'text', url: 'url', capability: null});
+  });
+
+  test('addMenuLinkWithCapability', () => {
+    adminApi.addMenuLink('text', 'url', 'capability');
+    const links = adminApi.getMenuLinks();
+    assert.equal(links.length, 1);
+    assert.deepEqual(links[0],
+        {text: 'text', url: 'url', capability: 'capability'});
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
deleted file mode 100644
index ece8677..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
+++ /dev/null
@@ -1,22 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-attribute-helper">
-  <script src="gr-attribute-helper.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
index 0cff8e9..09620ef 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
@@ -14,6 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../scripts/bundled-polymer.js';
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-attribute-helper">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
 (function(window) {
   'use strict';
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
index 2dfd036..cfb51f0 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
@@ -19,28 +19,37 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-attribute-helper</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-attribute-helper.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-attribute-helper.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-attribute-helper.js';
+void(0);
+</script>
 
 <dom-element id="some-element">
-  <script>
-    Polymer({
-      is: 'some-element',
-      properties: {
-        fooBar: {
-          type: Object,
-          notify: true,
-        },
-      },
-    });
-  </script>
+  <script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-attribute-helper.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+Polymer({
+  is: 'some-element',
+  properties: {
+    fooBar: {
+      type: Object,
+      notify: true,
+    },
+  },
+});
+</script>
 
 </dom-element>
 
@@ -50,54 +59,56 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-attribute-helper tests', async () => {
-    await readyToTest();
-    let element;
-    let instance;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-attribute-helper.js';
+suite('gr-attribute-helper tests', () => {
+  let element;
+  let instance;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      instance = new GrAttributeHelper(element);
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('resolved on value change from undefined', () => {
-      const promise = instance.get('fooBar').then(value => {
-        assert.equal(value, 'foo! bar!');
-      });
-      element.fooBar = 'foo! bar!';
-      return promise;
-    });
-
-    test('resolves to current attribute value', () => {
-      element.fooBar = 'foo-foo-bar';
-      const promise = instance.get('fooBar').then(value => {
-        assert.equal(value, 'foo-foo-bar');
-      });
-      element.fooBar = 'no bar';
-      return promise;
-    });
-
-    test('bind', () => {
-      const stub = sandbox.stub();
-      element.fooBar = 'bar foo';
-      const unbind = instance.bind('fooBar', stub);
-      element.fooBar = 'partridge in a foo tree';
-      element.fooBar = 'five gold bars';
-      assert.equal(stub.callCount, 3);
-      assert.deepEqual(stub.args[0], ['bar foo']);
-      assert.deepEqual(stub.args[1], ['partridge in a foo tree']);
-      assert.deepEqual(stub.args[2], ['five gold bars']);
-      stub.reset();
-      unbind();
-      instance.fooBar = 'ladies dancing';
-      assert.isFalse(stub.called);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    instance = new GrAttributeHelper(element);
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('resolved on value change from undefined', () => {
+    const promise = instance.get('fooBar').then(value => {
+      assert.equal(value, 'foo! bar!');
+    });
+    element.fooBar = 'foo! bar!';
+    return promise;
+  });
+
+  test('resolves to current attribute value', () => {
+    element.fooBar = 'foo-foo-bar';
+    const promise = instance.get('fooBar').then(value => {
+      assert.equal(value, 'foo-foo-bar');
+    });
+    element.fooBar = 'no bar';
+    return promise;
+  });
+
+  test('bind', () => {
+    const stub = sandbox.stub();
+    element.fooBar = 'bar foo';
+    const unbind = instance.bind('fooBar', stub);
+    element.fooBar = 'partridge in a foo tree';
+    element.fooBar = 'five gold bars';
+    assert.equal(stub.callCount, 3);
+    assert.deepEqual(stub.args[0], ['bar foo']);
+    assert.deepEqual(stub.args[1], ['partridge in a foo tree']);
+    assert.deepEqual(stub.args[2], ['five gold bars']);
+    stub.reset();
+    unbind();
+    instance.fooBar = 'ladies dancing';
+    assert.isFalse(stub.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html
deleted file mode 100644
index dd532e1..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html
+++ /dev/null
@@ -1,22 +0,0 @@
-<!--
-@license
-Copyright (C) 2018 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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-change-metadata-api">
-  <script src="gr-change-metadata-api.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
index 80abf23..daf48f0 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
@@ -14,6 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../scripts/bundled-polymer.js';
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-change-metadata-api">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
 (function(window) {
   'use strict';
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html
deleted file mode 100644
index 8b9000f..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html
+++ /dev/null
@@ -1,22 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-dom-hooks">
-  <script src="gr-dom-hooks.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
index fb9adb5..9497493 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
@@ -14,6 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../scripts/bundled-polymer.js';
+
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-dom-hooks">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
 (function(window) {
   'use strict';
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
index 524b1b9..8e23b0d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
@@ -19,16 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dom-hooks</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-dom-hooks.html"/>
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-dom-hooks.js"></script>
+<script type="module" src="../../shared/gr-js-api-interface/gr-js-api-interface.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-dom-hooks.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,131 +42,134 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-dom-hooks tests', async () => {
-    await readyToTest();
-    const PUBLIC_METHODS =[
-      'onAttached',
-      'onDetached',
-      'getLastAttached',
-      'getAllAttached',
-      'getModuleName',
-    ];
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-dom-hooks.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+suite('gr-dom-hooks tests', () => {
+  const PUBLIC_METHODS =[
+    'onAttached',
+    'onDetached',
+    'getLastAttached',
+    'getAllAttached',
+    'getModuleName',
+  ];
 
-    let instance;
-    let sandbox;
-    let hook;
-    let hookInternal;
+  let instance;
+  let sandbox;
+  let hook;
+  let hookInternal;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      instance = new GrDomHooksManager(plugin);
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    let plugin;
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    instance = new GrDomHooksManager(plugin);
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('placeholder', () => {
+    setup(()=>{
+      sandbox.stub(GrDomHook.prototype, '_createPlaceholder');
+      hookInternal = instance.getDomHook('foo-bar');
+      hook = hookInternal.getPublicAPI();
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('public hook API has only public methods', () => {
+      assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
     });
 
-    suite('placeholder', () => {
-      setup(()=>{
-        sandbox.stub(GrDomHook.prototype, '_createPlaceholder');
-        hookInternal = instance.getDomHook('foo-bar');
-        hook = hookInternal.getPublicAPI();
-      });
-
-      test('public hook API has only public methods', () => {
-        assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
-      });
-
-      test('registers placeholder class', () => {
-        assert.isTrue(hookInternal._createPlaceholder.calledWithExactly(
-            'testplugin-autogenerated-foo-bar'));
-      });
-
-      test('getModuleName()', () => {
-        const hookName = Object.keys(instance._hooks).pop();
-        assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
-        assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
-      });
+    test('registers placeholder class', () => {
+      assert.isTrue(hookInternal._createPlaceholder.calledWithExactly(
+          'testplugin-autogenerated-foo-bar'));
     });
 
-    suite('custom element', () => {
-      setup(() => {
-        hookInternal = instance.getDomHook('foo-bar', 'my-el');
-        hook = hookInternal.getPublicAPI();
-      });
-
-      test('public hook API has only public methods', () => {
-        assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
-      });
-
-      test('getModuleName()', () => {
-        const hookName = Object.keys(instance._hooks).pop();
-        assert.equal(hookName, 'foo-bar my-el');
-        assert.equal(hook.getModuleName(), 'my-el');
-      });
-
-      test('onAttached', () => {
-        const onAttachedSpy = sandbox.spy();
-        hook.onAttached(onAttachedSpy);
-        const [el1, el2] = [
-          document.createElement(hook.getModuleName()),
-          document.createElement(hook.getModuleName()),
-        ];
-        hookInternal.handleInstanceAttached(el1);
-        hookInternal.handleInstanceAttached(el2);
-        assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
-        assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
-      });
-
-      test('onDetached', () => {
-        const onDetachedSpy = sandbox.spy();
-        hook.onDetached(onDetachedSpy);
-        const [el1, el2] = [
-          document.createElement(hook.getModuleName()),
-          document.createElement(hook.getModuleName()),
-        ];
-        hookInternal.handleInstanceDetached(el1);
-        assert.isTrue(onDetachedSpy.firstCall.calledWithExactly(el1));
-        hookInternal.handleInstanceDetached(el2);
-        assert.isTrue(onDetachedSpy.secondCall.calledWithExactly(el2));
-      });
-
-      test('getAllAttached', () => {
-        const [el1, el2] = [
-          document.createElement(hook.getModuleName()),
-          document.createElement(hook.getModuleName()),
-        ];
-        el1.textContent = 'one';
-        el2.textContent = 'two';
-        hookInternal.handleInstanceAttached(el1);
-        hookInternal.handleInstanceAttached(el2);
-        assert.deepEqual([el1, el2], hook.getAllAttached());
-        hookInternal.handleInstanceDetached(el1);
-        assert.deepEqual([el2], hook.getAllAttached());
-      });
-
-      test('getLastAttached', () => {
-        const beforeAttachedPromise = hook.getLastAttached().then(
-            el => assert.strictEqual(el1, el));
-        const [el1, el2] = [
-          document.createElement(hook.getModuleName()),
-          document.createElement(hook.getModuleName()),
-        ];
-        el1.textContent = 'one';
-        el2.textContent = 'two';
-        hookInternal.handleInstanceAttached(el1);
-        hookInternal.handleInstanceAttached(el2);
-        const afterAttachedPromise = hook.getLastAttached().then(
-            el => assert.strictEqual(el2, el));
-        return Promise.all([
-          beforeAttachedPromise,
-          afterAttachedPromise,
-        ]);
-      });
+    test('getModuleName()', () => {
+      const hookName = Object.keys(instance._hooks).pop();
+      assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
+      assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
     });
   });
+
+  suite('custom element', () => {
+    setup(() => {
+      hookInternal = instance.getDomHook('foo-bar', 'my-el');
+      hook = hookInternal.getPublicAPI();
+    });
+
+    test('public hook API has only public methods', () => {
+      assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
+    });
+
+    test('getModuleName()', () => {
+      const hookName = Object.keys(instance._hooks).pop();
+      assert.equal(hookName, 'foo-bar my-el');
+      assert.equal(hook.getModuleName(), 'my-el');
+    });
+
+    test('onAttached', () => {
+      const onAttachedSpy = sandbox.spy();
+      hook.onAttached(onAttachedSpy);
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      hookInternal.handleInstanceAttached(el1);
+      hookInternal.handleInstanceAttached(el2);
+      assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
+      assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
+    });
+
+    test('onDetached', () => {
+      const onDetachedSpy = sandbox.spy();
+      hook.onDetached(onDetachedSpy);
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      hookInternal.handleInstanceDetached(el1);
+      assert.isTrue(onDetachedSpy.firstCall.calledWithExactly(el1));
+      hookInternal.handleInstanceDetached(el2);
+      assert.isTrue(onDetachedSpy.secondCall.calledWithExactly(el2));
+    });
+
+    test('getAllAttached', () => {
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      el1.textContent = 'one';
+      el2.textContent = 'two';
+      hookInternal.handleInstanceAttached(el1);
+      hookInternal.handleInstanceAttached(el2);
+      assert.deepEqual([el1, el2], hook.getAllAttached());
+      hookInternal.handleInstanceDetached(el1);
+      assert.deepEqual([el2], hook.getAllAttached());
+    });
+
+    test('getLastAttached', () => {
+      const beforeAttachedPromise = hook.getLastAttached().then(
+          el => assert.strictEqual(el1, el));
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      el1.textContent = 'one';
+      el2.textContent = 'two';
+      hookInternal.handleInstanceAttached(el1);
+      hookInternal.handleInstanceAttached(el2);
+      const afterAttachedPromise = hook.getLastAttached().then(
+          el => assert.strictEqual(el2, el));
+      return Promise.all([
+        beforeAttachedPromise,
+        afterAttachedPromise,
+      ]);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
index 1c10642..e1624b9 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
@@ -14,155 +14,163 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const INIT_PROPERTIES_TIMEOUT_MS = 10000;
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {importHref} from '../../../scripts/import-href.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-endpoint-decorator_html.js';
 
-  /** @extends Polymer.Element */
-  class GrEndpointDecorator extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-endpoint-decorator'; }
+const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
-    static get properties() {
-      return {
-        name: String,
-        /** @type {!Map} */
-        _domHooks: {
-          type: Map,
-          value() { return new Map(); },
-        },
-        /**
-         * This map prevents importing the same endpoint twice.
-         * Without caching, if a plugin is loaded after the loaded plugins
-         * callback fires, it will be imported twice and appear twice on the page.
-         *
-         * @type {!Map}
-         */
-        _initializedPlugins: {
-          type: Map,
-          value() { return new Map(); },
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrEndpointDecorator extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /** @override */
-    detached() {
-      super.detached();
-      for (const [el, domHook] of this._domHooks) {
-        domHook.handleInstanceDetached(el);
-      }
-    }
+  static get is() { return 'gr-endpoint-decorator'; }
 
-    /**
-     * @suppress {checkTypes}
-     */
-    _import(url) {
-      return new Promise((resolve, reject) => {
-        Polymer.importHref(url, resolve, reject);
-      });
-    }
+  static get properties() {
+    return {
+      name: String,
+      /** @type {!Map} */
+      _domHooks: {
+        type: Map,
+        value() { return new Map(); },
+      },
+      /**
+       * This map prevents importing the same endpoint twice.
+       * Without caching, if a plugin is loaded after the loaded plugins
+       * callback fires, it will be imported twice and appear twice on the page.
+       *
+       * @type {!Map}
+       */
+      _initializedPlugins: {
+        type: Map,
+        value() { return new Map(); },
+      },
+    };
+  }
 
-    _initDecoration(name, plugin) {
-      const el = document.createElement(name);
-      return this._initProperties(el, plugin,
-          this.getContentChildren().find(
-              el => el.nodeName !== 'GR-ENDPOINT-PARAM'))
-          .then(el => this._appendChild(el));
-    }
-
-    _initReplacement(name, plugin) {
-      this.getContentChildNodes()
-          .filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
-          .forEach(node => node.remove());
-      const el = document.createElement(name);
-      return this._initProperties(el, plugin).then(
-          el => this._appendChild(el));
-    }
-
-    _getEndpointParams() {
-      return Array.from(
-          Polymer.dom(this).querySelectorAll('gr-endpoint-param'));
-    }
-
-    /**
-     * @param {!Element} el
-     * @param {!Object} plugin
-     * @param {!Element=} opt_content
-     * @return {!Promise<Element>}
-     */
-    _initProperties(el, plugin, opt_content) {
-      el.plugin = plugin;
-      if (opt_content) {
-        el.content = opt_content;
-      }
-      const expectProperties = this._getEndpointParams().map(paramEl => {
-        const helper = plugin.attributeHelper(paramEl);
-        const paramName = paramEl.getAttribute('name');
-        return helper.get('value').then(
-            value => helper.bind('value',
-                value => plugin.attributeHelper(el).set(paramName, value))
-        );
-      });
-      let timeoutId;
-      const timeout = new Promise(
-          resolve => timeoutId = setTimeout(() => {
-            console.warn(
-                'Timeout waiting for endpoint properties initialization: ' +
-              `plugin ${plugin.getPluginName()}, endpoint ${this.name}`);
-          }, INIT_PROPERTIES_TIMEOUT_MS));
-      return Promise.race([timeout, Promise.all(expectProperties)])
-          .then(() => {
-            clearTimeout(timeoutId);
-            return el;
-          });
-    }
-
-    _appendChild(el) {
-      return Polymer.dom(this.root).appendChild(el);
-    }
-
-    _initModule({moduleName, plugin, type, domHook}) {
-      const name = plugin.getPluginName() + '.' + moduleName;
-      if (this._initializedPlugins.get(name)) {
-        return;
-      }
-      let initPromise;
-      switch (type) {
-        case 'decorate':
-          initPromise = this._initDecoration(moduleName, plugin);
-          break;
-        case 'replace':
-          initPromise = this._initReplacement(moduleName, plugin);
-          break;
-      }
-      if (!initPromise) {
-        console.warn('Unable to initialize module ' + name);
-      }
-      this._initializedPlugins.set(name, true);
-      initPromise.then(el => {
-        domHook.handleInstanceAttached(el);
-        this._domHooks.set(el, domHook);
-      });
-    }
-
-    /** @override */
-    ready() {
-      super.ready();
-      Gerrit._endpoints.onNewEndpoint(this.name, this._initModule.bind(this));
-      Gerrit.awaitPluginsLoaded()
-          .then(() => Promise.all(
-              Gerrit._endpoints.getPlugins(this.name).map(
-                  pluginUrl => this._import(pluginUrl)))
-          )
-          .then(() =>
-            Gerrit._endpoints
-                .getDetails(this.name)
-                .forEach(this._initModule, this)
-          );
+  /** @override */
+  detached() {
+    super.detached();
+    for (const [el, domHook] of this._domHooks) {
+      domHook.handleInstanceDetached(el);
     }
   }
 
-  customElements.define(GrEndpointDecorator.is, GrEndpointDecorator);
-})();
+  /**
+   * @suppress {checkTypes}
+   */
+  _import(url) {
+    return new Promise((resolve, reject) => {
+      importHref(url, resolve, reject);
+    });
+  }
+
+  _initDecoration(name, plugin) {
+    const el = document.createElement(name);
+    return this._initProperties(el, plugin,
+        this.getContentChildren().find(
+            el => el.nodeName !== 'GR-ENDPOINT-PARAM'))
+        .then(el => this._appendChild(el));
+  }
+
+  _initReplacement(name, plugin) {
+    this.getContentChildNodes()
+        .filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
+        .forEach(node => node.remove());
+    const el = document.createElement(name);
+    return this._initProperties(el, plugin).then(
+        el => this._appendChild(el));
+  }
+
+  _getEndpointParams() {
+    return Array.from(
+        dom(this).querySelectorAll('gr-endpoint-param'));
+  }
+
+  /**
+   * @param {!Element} el
+   * @param {!Object} plugin
+   * @param {!Element=} opt_content
+   * @return {!Promise<Element>}
+   */
+  _initProperties(el, plugin, opt_content) {
+    el.plugin = plugin;
+    if (opt_content) {
+      el.content = opt_content;
+    }
+    const expectProperties = this._getEndpointParams().map(paramEl => {
+      const helper = plugin.attributeHelper(paramEl);
+      const paramName = paramEl.getAttribute('name');
+      return helper.get('value').then(
+          value => helper.bind('value',
+              value => plugin.attributeHelper(el).set(paramName, value))
+      );
+    });
+    let timeoutId;
+    const timeout = new Promise(
+        resolve => timeoutId = setTimeout(() => {
+          console.warn(
+              'Timeout waiting for endpoint properties initialization: ' +
+            `plugin ${plugin.getPluginName()}, endpoint ${this.name}`);
+        }, INIT_PROPERTIES_TIMEOUT_MS));
+    return Promise.race([timeout, Promise.all(expectProperties)])
+        .then(() => {
+          clearTimeout(timeoutId);
+          return el;
+        });
+  }
+
+  _appendChild(el) {
+    return dom(this.root).appendChild(el);
+  }
+
+  _initModule({moduleName, plugin, type, domHook}) {
+    const name = plugin.getPluginName() + '.' + moduleName;
+    if (this._initializedPlugins.get(name)) {
+      return;
+    }
+    let initPromise;
+    switch (type) {
+      case 'decorate':
+        initPromise = this._initDecoration(moduleName, plugin);
+        break;
+      case 'replace':
+        initPromise = this._initReplacement(moduleName, plugin);
+        break;
+    }
+    if (!initPromise) {
+      console.warn('Unable to initialize module ' + name);
+    }
+    this._initializedPlugins.set(name, true);
+    initPromise.then(el => {
+      domHook.handleInstanceAttached(el);
+      this._domHooks.set(el, domHook);
+    });
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    Gerrit._endpoints.onNewEndpoint(this.name, this._initModule.bind(this));
+    Gerrit.awaitPluginsLoaded()
+        .then(() => Promise.all(
+            Gerrit._endpoints.getPlugins(this.name).map(
+                pluginUrl => this._import(pluginUrl)))
+        )
+        .then(() =>
+          Gerrit._endpoints
+              .getDetails(this.name)
+              .forEach(this._initModule, this)
+        );
+  }
+}
+
+customElements.define(GrEndpointDecorator.is, GrEndpointDecorator);
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.js
index 1b27c0a..1644c07 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.js
@@ -1,26 +1,21 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-
-<dom-module id="gr-endpoint-decorator">
-  <template>
+export const htmlTemplate = html`
     <slot></slot>
-  </template>
-  <script src="gr-endpoint-decorator.js"></script>
-</dom-module>
\ No newline at end of file
+`;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
index e0d91fa..fcac174 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
@@ -19,16 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-endpoint-decorator</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-endpoint-decorator.html">
-<link rel="import" href="../gr-endpoint-param/gr-endpoint-param.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-endpoint-decorator.js"></script>
+<script type="module" src="../gr-endpoint-param/gr-endpoint-param.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-endpoint-decorator.js';
+import '../gr-endpoint-param/gr-endpoint-param.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -46,149 +52,153 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-endpoint-decorator', async () => {
-    await readyToTest();
-    let container;
-    let sandbox;
-    let plugin;
-    let decorationHook;
-    let replacementHook;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-endpoint-decorator.js';
+import '../gr-endpoint-param/gr-endpoint-param.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-endpoint-decorator', () => {
+  let container;
+  let sandbox;
+  let plugin;
+  let decorationHook;
+  let replacementHook;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-endpoint-decorator', {
-        _import: sandbox.stub().returns(Promise.resolve()),
-      });
-      Gerrit._testOnly_resetPlugins();
-      container = fixture('basic');
-      Gerrit.install(p => plugin = p, '0.1', 'http://some/plugin/url.html');
-      // Decoration
-      decorationHook = plugin.registerCustomComponent('first', 'some-module');
-      // Replacement
-      replacementHook = plugin.registerCustomComponent(
-          'second', 'other-module', {replace: true});
-      // Mimic all plugins loaded.
-      Gerrit._loadPlugins([]);
-      flush(done);
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-endpoint-decorator', {
+      _import: sandbox.stub().returns(Promise.resolve()),
     });
+    Gerrit._testOnly_resetPlugins();
+    container = fixture('basic');
+    Gerrit.install(p => plugin = p, '0.1', 'http://some/plugin/url.html');
+    // Decoration
+    decorationHook = plugin.registerCustomComponent('first', 'some-module');
+    // Replacement
+    replacementHook = plugin.registerCustomComponent(
+        'second', 'other-module', {replace: true});
+    // Mimic all plugins loaded.
+    Gerrit._loadPlugins([]);
+    flush(done);
+  });
 
-    teardown(() => {
-      sandbox.restore();
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('imports plugin-provided modules into endpoints', () => {
+    const endpoints =
+        Array.from(container.querySelectorAll('gr-endpoint-decorator'));
+    assert.equal(endpoints.length, 3);
+    endpoints.forEach(element => {
+      assert.isTrue(
+          element._import.calledWith(new URL('http://some/plugin/url.html')));
     });
+  });
 
-    test('imports plugin-provided modules into endpoints', () => {
-      const endpoints =
-          Array.from(container.querySelectorAll('gr-endpoint-decorator'));
-      assert.equal(endpoints.length, 3);
-      endpoints.forEach(element => {
-        assert.isTrue(
-            element._import.calledWith(new URL('http://some/plugin/url.html')));
-      });
-    });
+  test('decoration', () => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="first"]');
+    const modules = Array.from(dom(element.root).children).filter(
+        element => element.nodeName === 'SOME-MODULE');
+    assert.equal(modules.length, 1);
+    const [module] = modules;
+    assert.isOk(module);
+    assert.equal(module['someparam'], 'barbar');
+    return decorationHook.getLastAttached().then(element => {
+      assert.strictEqual(element, module);
+    })
+        .then(() => {
+          element.remove();
+          assert.equal(decorationHook.getAllAttached().length, 0);
+        });
+  });
 
-    test('decoration', () => {
+  test('replacement', () => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="second"]');
+    const module = Array.from(dom(element.root).children).find(
+        element => element.nodeName === 'OTHER-MODULE');
+    assert.isOk(module);
+    assert.equal(module['someparam'], 'foofoo');
+    return replacementHook.getLastAttached()
+        .then(element => {
+          assert.strictEqual(element, module);
+        })
+        .then(() => {
+          element.remove();
+          assert.equal(replacementHook.getAllAttached().length, 0);
+        });
+  });
+
+  test('late registration', done => {
+    plugin.registerCustomComponent('banana', 'noob-noob');
+    flush(() => {
       const element =
-          container.querySelector('gr-endpoint-decorator[name="first"]');
-      const modules = Array.from(Polymer.dom(element.root).children).filter(
-          element => element.nodeName === 'SOME-MODULE');
-      assert.equal(modules.length, 1);
-      const [module] = modules;
+          container.querySelector('gr-endpoint-decorator[name="banana"]');
+      const module = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'NOOB-NOOB');
       assert.isOk(module);
-      assert.equal(module['someparam'], 'barbar');
-      return decorationHook.getLastAttached().then(element => {
-        assert.strictEqual(element, module);
-      })
-          .then(() => {
-            element.remove();
-            assert.equal(decorationHook.getAllAttached().length, 0);
-          });
+      done();
     });
+  });
 
-    test('replacement', () => {
+  test('two modules', done => {
+    plugin.registerCustomComponent('banana', 'mod-one');
+    plugin.registerCustomComponent('banana', 'mod-two');
+    flush(() => {
       const element =
-          container.querySelector('gr-endpoint-decorator[name="second"]');
-      const module = Array.from(Polymer.dom(element.root).children).find(
-          element => element.nodeName === 'OTHER-MODULE');
-      assert.isOk(module);
-      assert.equal(module['someparam'], 'foofoo');
-      return replacementHook.getLastAttached()
-          .then(element => {
-            assert.strictEqual(element, module);
-          })
-          .then(() => {
-            element.remove();
-            assert.equal(replacementHook.getAllAttached().length, 0);
-          });
+          container.querySelector('gr-endpoint-decorator[name="banana"]');
+      const module1 = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'MOD-ONE');
+      assert.isOk(module1);
+      const module2 = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'MOD-TWO');
+      assert.isOk(module2);
+      done();
     });
+  });
 
-    test('late registration', done => {
-      plugin.registerCustomComponent('banana', 'noob-noob');
+  test('late param setup', done => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="banana"]');
+    const param = dom(element).querySelector('gr-endpoint-param');
+    param['value'] = undefined;
+    plugin.registerCustomComponent('banana', 'noob-noob');
+    flush(() => {
+      let module = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'NOOB-NOOB');
+      // Module waits for param to be defined.
+      assert.isNotOk(module);
+      const value = {abc: 'def'};
+      param.value = value;
       flush(() => {
-        const element =
-            container.querySelector('gr-endpoint-decorator[name="banana"]');
-        const module = Array.from(Polymer.dom(element.root).children).find(
+        module = Array.from(dom(element.root).children).find(
             element => element.nodeName === 'NOOB-NOOB');
         assert.isOk(module);
-        done();
-      });
-    });
-
-    test('two modules', done => {
-      plugin.registerCustomComponent('banana', 'mod-one');
-      plugin.registerCustomComponent('banana', 'mod-two');
-      flush(() => {
-        const element =
-            container.querySelector('gr-endpoint-decorator[name="banana"]');
-        const module1 = Array.from(Polymer.dom(element.root).children).find(
-            element => element.nodeName === 'MOD-ONE');
-        assert.isOk(module1);
-        const module2 = Array.from(Polymer.dom(element.root).children).find(
-            element => element.nodeName === 'MOD-TWO');
-        assert.isOk(module2);
-        done();
-      });
-    });
-
-    test('late param setup', done => {
-      const element =
-          container.querySelector('gr-endpoint-decorator[name="banana"]');
-      const param = Polymer.dom(element).querySelector('gr-endpoint-param');
-      param['value'] = undefined;
-      plugin.registerCustomComponent('banana', 'noob-noob');
-      flush(() => {
-        let module = Array.from(Polymer.dom(element.root).children).find(
-            element => element.nodeName === 'NOOB-NOOB');
-        // Module waits for param to be defined.
-        assert.isNotOk(module);
-        const value = {abc: 'def'};
-        param.value = value;
-        flush(() => {
-          module = Array.from(Polymer.dom(element.root).children).find(
-              element => element.nodeName === 'NOOB-NOOB');
-          assert.isOk(module);
-          assert.strictEqual(module['someParam'], value);
-          done();
-        });
-      });
-    });
-
-    test('param is bound', done => {
-      const element =
-          container.querySelector('gr-endpoint-decorator[name="banana"]');
-      const param = Polymer.dom(element).querySelector('gr-endpoint-param');
-      const value1 = {abc: 'def'};
-      const value2 = {def: 'abc'};
-      param.value = value1;
-      plugin.registerCustomComponent('banana', 'noob-noob');
-      flush(() => {
-        const module = Array.from(Polymer.dom(element.root).children).find(
-            element => element.nodeName === 'NOOB-NOOB');
-        assert.strictEqual(module['someParam'], value1);
-        param.value = value2;
-        assert.strictEqual(module['someParam'], value2);
+        assert.strictEqual(module['someParam'], value);
         done();
       });
     });
   });
+
+  test('param is bound', done => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="banana"]');
+    const param = dom(element).querySelector('gr-endpoint-param');
+    const value1 = {abc: 'def'};
+    const value2 = {def: 'abc'};
+    param.value = value1;
+    plugin.registerCustomComponent('banana', 'noob-noob');
+    flush(() => {
+      const module = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'NOOB-NOOB');
+      assert.strictEqual(module['someParam'], value1);
+      param.value = value2;
+      assert.strictEqual(module['someParam'], value2);
+      done();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html
deleted file mode 100644
index 6a5b558..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html
+++ /dev/null
@@ -1,22 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-endpoint-param">
-  <script src="gr-endpoint-param.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
index bcad7f9..9574391 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
@@ -14,41 +14,43 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrEndpointParam extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-endpoint-param'; }
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 
-    static get properties() {
-      return {
-        name: String,
-        value: {
-          type: Object,
-          notify: true,
-          observer: '_valueChanged',
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrEndpointParam extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get is() { return 'gr-endpoint-param'; }
 
-    _valueChanged(newValue, oldValue) {
-      /* In polymer 2 the following change was made:
-      "Property change notifications (property-changed events) aren't fired when
-      the value changes as a result of a binding from the host"
-      (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
-      To workaround this problem, we fire the event from the observer.
-      In some cases this fire the event twice, but our code is
-      ready for it.
-      */
-      const detail = {
-        value: newValue,
-      };
-      this.dispatchEvent(new CustomEvent('value-changed', {detail}));
-    }
+  static get properties() {
+    return {
+      name: String,
+      value: {
+        type: Object,
+        notify: true,
+        observer: '_valueChanged',
+      },
+    };
   }
 
-  customElements.define(GrEndpointParam.is, GrEndpointParam);
-})();
+  _valueChanged(newValue, oldValue) {
+    /* In polymer 2 the following change was made:
+    "Property change notifications (property-changed events) aren't fired when
+    the value changes as a result of a binding from the host"
+    (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
+    To workaround this problem, we fire the event from the observer.
+    In some cases this fire the event twice, but our code is
+    ready for it.
+    */
+    const detail = {
+      value: newValue,
+    };
+    this.dispatchEvent(new CustomEvent('value-changed', {detail}));
+  }
+}
+
+customElements.define(GrEndpointParam.is, GrEndpointParam);
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html
deleted file mode 100644
index 15db861..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html
+++ /dev/null
@@ -1,23 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-
-<dom-module id="gr-event-helper">
-  <script src="gr-event-helper.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
index 481a467..66d42d3 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
@@ -14,6 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-event-helper">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
 (function(window) {
   'use strict';
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
index bb08cfb..8eebe33 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
@@ -19,33 +19,42 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-event-helper</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-event-helper.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-event-helper.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-event-helper.js';
+void(0);
+</script>
 
 <dom-element id="some-element">
-  <script>
-    Polymer({
-      is: 'some-element',
+  <script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-event-helper.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+Polymer({
+  is: 'some-element',
 
-      properties: {
-        fooBar: {
-          type: Object,
-          notify: true,
-        },
-      },
+  properties: {
+    fooBar: {
+      type: Object,
+      notify: true,
+    },
+  },
 
-      behaviors: [
-        Gerrit.FireBehavior,
-      ],
-    });
-  </script>
+  behaviors: [
+    Gerrit.FireBehavior,
+  ],
+});
+</script>
 
 </dom-element>
 
@@ -55,85 +64,88 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-event-helper tests', async () => {
-    await readyToTest();
-    let element;
-    let instance;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-event-helper.js';
+import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
+suite('gr-event-helper tests', () => {
+  let element;
+  let instance;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      instance = new GrEventHelper(element);
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('onTap()', done => {
-      instance.onTap(() => {
-        done();
-      });
-      MockInteractions.tap(element);
-    });
-
-    test('onTap() cancel', () => {
-      const tapStub = sandbox.stub();
-      Polymer.Gestures.addListener(element.parentElement, 'tap', tapStub);
-      instance.onTap(() => false);
-      MockInteractions.tap(element);
-      flushAsynchronousOperations();
-      assert.isFalse(tapStub.called);
-    });
-
-    test('onClick() cancel', () => {
-      const tapStub = sandbox.stub();
-      element.parentElement.addEventListener('click', tapStub);
-      instance.onTap(() => false);
-      MockInteractions.tap(element);
-      flushAsynchronousOperations();
-      assert.isFalse(tapStub.called);
-    });
-
-    test('captureTap()', done => {
-      instance.captureTap(() => {
-        done();
-      });
-      MockInteractions.tap(element);
-    });
-
-    test('captureClick()', done => {
-      instance.captureClick(() => {
-        done();
-      });
-      MockInteractions.tap(element);
-    });
-
-    test('captureTap() cancels tap()', () => {
-      const tapStub = sandbox.stub();
-      Polymer.Gestures.addListener(element.parentElement, 'tap', tapStub);
-      instance.captureTap(() => false);
-      MockInteractions.tap(element);
-      flushAsynchronousOperations();
-      assert.isFalse(tapStub.called);
-    });
-
-    test('captureClick() cancels click()', () => {
-      const tapStub = sandbox.stub();
-      element.addEventListener('click', tapStub);
-      instance.captureTap(() => false);
-      MockInteractions.tap(element);
-      flushAsynchronousOperations();
-      assert.isFalse(tapStub.called);
-    });
-
-    test('on()', done => {
-      instance.on('foo', () => {
-        done();
-      });
-      element.fire('foo');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    instance = new GrEventHelper(element);
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('onTap()', done => {
+    instance.onTap(() => {
+      done();
+    });
+    MockInteractions.tap(element);
+  });
+
+  test('onTap() cancel', () => {
+    const tapStub = sandbox.stub();
+    addListener(element.parentElement, 'tap', tapStub);
+    instance.onTap(() => false);
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('onClick() cancel', () => {
+    const tapStub = sandbox.stub();
+    element.parentElement.addEventListener('click', tapStub);
+    instance.onTap(() => false);
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('captureTap()', done => {
+    instance.captureTap(() => {
+      done();
+    });
+    MockInteractions.tap(element);
+  });
+
+  test('captureClick()', done => {
+    instance.captureClick(() => {
+      done();
+    });
+    MockInteractions.tap(element);
+  });
+
+  test('captureTap() cancels tap()', () => {
+    const tapStub = sandbox.stub();
+    addListener(element.parentElement, 'tap', tapStub);
+    instance.captureTap(() => false);
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('captureClick() cancels click()', () => {
+    const tapStub = sandbox.stub();
+    element.addEventListener('click', tapStub);
+    instance.captureTap(() => false);
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('on()', done => {
+    instance.on('foo', () => {
+      done();
+    });
+    element.fire('foo');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
index 7e239f9..ba4dd58 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
@@ -14,84 +14,92 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrExternalStyle extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-external-style'; }
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {importHref} from '../../../scripts/import-href.js';
+import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-external-style_html.js';
 
-    static get properties() {
-      return {
-        name: String,
-        _urlsImported: {
-          type: Array,
-          value() { return []; },
-        },
-        _stylesApplied: {
-          type: Array,
-          value() { return []; },
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrExternalStyle extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _importHref(url, resolve, reject) {
-      // It is impossible to mock es6-module imported function.
-      // The _importHref function is mocked in test.
-      Polymer.importHref(url, resolve, reject);
-    }
+  static get is() { return 'gr-external-style'; }
 
-    /**
-     * @suppress {checkTypes}
-     */
-    _import(url) {
-      if (this._urlsImported.includes(url)) { return Promise.resolve(); }
-      this._urlsImported.push(url);
-      return new Promise((resolve, reject) => {
-        this._importHref(url, resolve, reject);
-      });
-    }
-
-    _applyStyle(name) {
-      if (this._stylesApplied.includes(name)) { return; }
-      this._stylesApplied.push(name);
-
-      const s = document.createElement('style');
-      s.setAttribute('include', name);
-      const cs = document.createElement('custom-style');
-      cs.appendChild(s);
-      // When using Shadow DOM <custom-style> must be added to the <body>.
-      // Within <gr-external-style> itself the styles would have no effect.
-      const topEl = document.getElementsByTagName('body')[0];
-      topEl.insertBefore(cs, topEl.firstChild);
-      Polymer.updateStyles();
-    }
-
-    _importAndApply() {
-      Promise.all(Gerrit._endpoints.getPlugins(this.name).map(
-          pluginUrl => this._import(pluginUrl))
-      ).then(() => {
-        const moduleNames = Gerrit._endpoints.getModules(this.name);
-        for (const name of moduleNames) {
-          this._applyStyle(name);
-        }
-      });
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      this._importAndApply();
-    }
-
-    /** @override */
-    ready() {
-      super.ready();
-      Gerrit.awaitPluginsLoaded().then(() => this._importAndApply());
-    }
+  static get properties() {
+    return {
+      name: String,
+      _urlsImported: {
+        type: Array,
+        value() { return []; },
+      },
+      _stylesApplied: {
+        type: Array,
+        value() { return []; },
+      },
+    };
   }
 
-  customElements.define(GrExternalStyle.is, GrExternalStyle);
-})();
+  _importHref(url, resolve, reject) {
+    // It is impossible to mock es6-module imported function.
+    // The _importHref function is mocked in test.
+    importHref(url, resolve, reject);
+  }
+
+  /**
+   * @suppress {checkTypes}
+   */
+  _import(url) {
+    if (this._urlsImported.includes(url)) { return Promise.resolve(); }
+    this._urlsImported.push(url);
+    return new Promise((resolve, reject) => {
+      this._importHref(url, resolve, reject);
+    });
+  }
+
+  _applyStyle(name) {
+    if (this._stylesApplied.includes(name)) { return; }
+    this._stylesApplied.push(name);
+
+    const s = document.createElement('style');
+    s.setAttribute('include', name);
+    const cs = document.createElement('custom-style');
+    cs.appendChild(s);
+    // When using Shadow DOM <custom-style> must be added to the <body>.
+    // Within <gr-external-style> itself the styles would have no effect.
+    const topEl = document.getElementsByTagName('body')[0];
+    topEl.insertBefore(cs, topEl.firstChild);
+    updateStyles();
+  }
+
+  _importAndApply() {
+    Promise.all(Gerrit._endpoints.getPlugins(this.name).map(
+        pluginUrl => this._import(pluginUrl))
+    ).then(() => {
+      const moduleNames = Gerrit._endpoints.getModules(this.name);
+      for (const name of moduleNames) {
+        this._applyStyle(name);
+      }
+    });
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._importAndApply();
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    Gerrit.awaitPluginsLoaded().then(() => this._importAndApply());
+  }
+}
+
+customElements.define(GrExternalStyle.is, GrExternalStyle);
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.js
index 6a55349..1644c07 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.js
@@ -1,26 +1,21 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-
-<dom-module id="gr-external-style">
-  <template>
+export const htmlTemplate = html`
     <slot></slot>
-  </template>
-  <script src="gr-external-style.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
index 808de43..7b76f63 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
@@ -19,13 +19,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-external-style</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-external-style.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-external-style.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,98 +33,100 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-external-style integration tests', async () => {
-    await readyToTest();
-    const TEST_URL = 'http://some/plugin/url.html';
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-external-style.js';
+suite('gr-external-style integration tests', () => {
+  const TEST_URL = 'http://some/plugin/url.html';
 
-    let sandbox;
-    let element;
-    let plugin;
-    let importHrefStub;
+  let sandbox;
+  let element;
+  let plugin;
+  let importHrefStub;
 
-    const installPlugin = () => {
-      if (plugin) { return; }
-      Gerrit.install(p => {
-        plugin = p;
-      }, '0.1', TEST_URL);
-    };
+  const installPlugin = () => {
+    if (plugin) { return; }
+    Gerrit.install(p => {
+      plugin = p;
+    }, '0.1', TEST_URL);
+  };
 
-    const createElement = () => {
-      element = fixture('basic');
-      sandbox.spy(element, '_applyStyle');
-    };
+  const createElement = () => {
+    element = fixture('basic');
+    sandbox.spy(element, '_applyStyle');
+  };
 
-    /**
-     * Installs the plugin, creates the element, registers style module.
-     */
-    const lateRegister = () => {
-      installPlugin();
-      createElement();
-      plugin.registerStyleModule('foo', 'some-module');
-    };
+  /**
+   * Installs the plugin, creates the element, registers style module.
+   */
+  const lateRegister = () => {
+    installPlugin();
+    createElement();
+    plugin.registerStyleModule('foo', 'some-module');
+  };
 
-    /**
-     * Installs the plugin, registers style module, creates the element.
-     */
-    const earlyRegister = () => {
-      installPlugin();
-      plugin.registerStyleModule('foo', 'some-module');
-      createElement();
-    };
+  /**
+   * Installs the plugin, registers style module, creates the element.
+   */
+  const earlyRegister = () => {
+    installPlugin();
+    plugin.registerStyleModule('foo', 'some-module');
+    createElement();
+  };
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      importHrefStub = sandbox.stub().callsArg(1);
-      stub('gr-external-style', {
-        _importHref: (url, resolve, reject) => {
-          importHrefStub(url, resolve, reject);
-        },
-      });
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    importHrefStub = sandbox.stub().callsArg(1);
+    stub('gr-external-style', {
+      _importHref: (url, resolve, reject) => {
+        importHrefStub(url, resolve, reject);
+      },
     });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('imports plugin-provided module', async () => {
-      lateRegister();
-      await new Promise(flush);
-      assert.isTrue(importHrefStub.calledWith(new URL(TEST_URL)));
-    });
-
-    test('applies plugin-provided styles', async () => {
-      lateRegister();
-      await new Promise(flush);
-      assert.isTrue(element._applyStyle.calledWith('some-module'));
-    });
-
-    test('does not double import', async () => {
-      earlyRegister();
-      await new Promise(flush);
-      plugin.registerStyleModule('foo', 'some-module');
-      await new Promise(flush);
-      const urlsImported =
-          element._urlsImported.filter(url => url.toString() === TEST_URL);
-      assert.strictEqual(urlsImported.length, 1);
-    });
-
-    test('does not double apply', async () => {
-      earlyRegister();
-      await new Promise(flush);
-      plugin.registerStyleModule('foo', 'some-module');
-      await new Promise(flush);
-      const stylesApplied =
-          element._stylesApplied.filter(name => name === 'some-module');
-      assert.strictEqual(stylesApplied.length, 1);
-    });
-
-    test('loads and applies preloaded modules', async () => {
-      earlyRegister();
-      await new Promise(flush);
-      assert.isTrue(importHrefStub.calledWith(new URL(TEST_URL)));
-      assert.isTrue(element._applyStyle.calledWith('some-module'));
-    });
+    sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('imports plugin-provided module', async () => {
+    lateRegister();
+    await new Promise(flush);
+    assert.isTrue(importHrefStub.calledWith(new URL(TEST_URL)));
+  });
+
+  test('applies plugin-provided styles', async () => {
+    lateRegister();
+    await new Promise(flush);
+    assert.isTrue(element._applyStyle.calledWith('some-module'));
+  });
+
+  test('does not double import', async () => {
+    earlyRegister();
+    await new Promise(flush);
+    plugin.registerStyleModule('foo', 'some-module');
+    await new Promise(flush);
+    const urlsImported =
+        element._urlsImported.filter(url => url.toString() === TEST_URL);
+    assert.strictEqual(urlsImported.length, 1);
+  });
+
+  test('does not double apply', async () => {
+    earlyRegister();
+    await new Promise(flush);
+    plugin.registerStyleModule('foo', 'some-module');
+    await new Promise(flush);
+    const stylesApplied =
+        element._stylesApplied.filter(name => name === 'some-module');
+    assert.strictEqual(stylesApplied.length, 1);
+  });
+
+  test('loads and applies preloaded modules', async () => {
+    earlyRegister();
+    await new Promise(flush);
+    assert.isTrue(importHrefStub.calledWith(new URL(TEST_URL)));
+    assert.isTrue(element._applyStyle.calledWith('some-module'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
deleted file mode 100644
index f277899..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
+++ /dev/null
@@ -1,24 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-
-<dom-module id="gr-plugin-host">
-  <script src="gr-plugin-host.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
index da050fb..1236f97 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
@@ -14,59 +14,63 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrPluginHost extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-plugin-host'; }
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 
-    static get properties() {
-      return {
-        config: {
-          type: Object,
-          observer: '_configChanged',
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrPluginHost extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get is() { return 'gr-plugin-host'; }
 
-    _configChanged(config) {
-      const plugins = config.plugin;
-      const htmlPlugins = (plugins.html_resource_paths || []);
-      const jsPlugins =
-          this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins);
-      const shouldLoadTheme = config.default_theme &&
-            !Gerrit._isPluginPreloaded('preloaded:gerrit-theme');
-      const themeToLoad =
-            shouldLoadTheme ? [config.default_theme] : [];
-
-      // Theme should be loaded first if has one to have better UX
-      const pluginsPending =
-          themeToLoad.concat(jsPlugins, htmlPlugins);
-
-      const pluginOpts = {};
-
-      if (shouldLoadTheme) {
-        // Theme needs to be loaded synchronous.
-        pluginOpts[config.default_theme] = {sync: true};
-      }
-
-      Gerrit._loadPlugins(pluginsPending, pluginOpts);
-    }
-
-    /**
-     * Omit .js plugins that have .html counterparts.
-     * For example, if plugin provides foo.js and foo.html, skip foo.js.
-     */
-    _handleMigrations(jsPlugins, htmlPlugins) {
-      return jsPlugins.filter(url => {
-        const counterpart = url.replace(/\.js$/, '.html');
-        return !htmlPlugins.includes(counterpart);
-      });
-    }
+  static get properties() {
+    return {
+      config: {
+        type: Object,
+        observer: '_configChanged',
+      },
+    };
   }
 
-  customElements.define(GrPluginHost.is, GrPluginHost);
-})();
+  _configChanged(config) {
+    const plugins = config.plugin;
+    const htmlPlugins = (plugins.html_resource_paths || []);
+    const jsPlugins =
+        this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins);
+    const shouldLoadTheme = config.default_theme &&
+          !Gerrit._isPluginPreloaded('preloaded:gerrit-theme');
+    const themeToLoad =
+          shouldLoadTheme ? [config.default_theme] : [];
+
+    // Theme should be loaded first if has one to have better UX
+    const pluginsPending =
+        themeToLoad.concat(jsPlugins, htmlPlugins);
+
+    const pluginOpts = {};
+
+    if (shouldLoadTheme) {
+      // Theme needs to be loaded synchronous.
+      pluginOpts[config.default_theme] = {sync: true};
+    }
+
+    Gerrit._loadPlugins(pluginsPending, pluginOpts);
+  }
+
+  /**
+   * Omit .js plugins that have .html counterparts.
+   * For example, if plugin provides foo.js and foo.html, skip foo.js.
+   */
+  _handleMigrations(jsPlugins, htmlPlugins) {
+    return jsPlugins.filter(url => {
+      const counterpart = url.replace(/\.js$/, '.html');
+      return !htmlPlugins.includes(counterpart);
+    });
+  }
+}
+
+customElements.define(GrPluginHost.is, GrPluginHost);
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
index defd242..894c8b4 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-host</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-plugin-host.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-plugin-host.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-plugin-host.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,62 +40,64 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-plugin-host tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-plugin-host.js';
+suite('gr-plugin-host tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(document.body, 'appendChild');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('load plugins should be called', () => {
-      sandbox.stub(Gerrit, '_loadPlugins');
-      element.config = {
-        plugin: {
-          html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
-          js_resource_paths: ['plugins/42'],
-        },
-      };
-      assert.isTrue(Gerrit._loadPlugins.calledOnce);
-      assert.isTrue(Gerrit._loadPlugins.calledWith([
-        'plugins/42', 'plugins/foo/bar', 'plugins/baz',
-      ], {}));
-    });
-
-    test('theme plugins should be loaded if enabled', () => {
-      sandbox.stub(Gerrit, '_loadPlugins');
-      element.config = {
-        default_theme: 'gerrit-theme.html',
-        plugin: {
-          html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
-          js_resource_paths: ['plugins/42'],
-        },
-      };
-      assert.isTrue(Gerrit._loadPlugins.calledOnce);
-      assert.isTrue(Gerrit._loadPlugins.calledWith([
-        'gerrit-theme.html', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
-      ], {'gerrit-theme.html': {sync: true}}));
-    });
-
-    test('skip theme if preloaded', () => {
-      sandbox.stub(Gerrit, '_isPluginPreloaded')
-          .withArgs('preloaded:gerrit-theme')
-          .returns(true);
-      sandbox.stub(Gerrit, '_loadPlugins');
-      element.config = {
-        default_theme: '/oof',
-        plugin: {},
-      };
-      assert.isTrue(Gerrit._loadPlugins.calledOnce);
-      assert.isTrue(Gerrit._loadPlugins.calledWith([], {}));
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(document.body, 'appendChild');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('load plugins should be called', () => {
+    sandbox.stub(Gerrit, '_loadPlugins');
+    element.config = {
+      plugin: {
+        html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
+        js_resource_paths: ['plugins/42'],
+      },
+    };
+    assert.isTrue(Gerrit._loadPlugins.calledOnce);
+    assert.isTrue(Gerrit._loadPlugins.calledWith([
+      'plugins/42', 'plugins/foo/bar', 'plugins/baz',
+    ], {}));
+  });
+
+  test('theme plugins should be loaded if enabled', () => {
+    sandbox.stub(Gerrit, '_loadPlugins');
+    element.config = {
+      default_theme: 'gerrit-theme.html',
+      plugin: {
+        html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
+        js_resource_paths: ['plugins/42'],
+      },
+    };
+    assert.isTrue(Gerrit._loadPlugins.calledOnce);
+    assert.isTrue(Gerrit._loadPlugins.calledWith([
+      'gerrit-theme.html', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
+    ], {'gerrit-theme.html': {sync: true}}));
+  });
+
+  test('skip theme if preloaded', () => {
+    sandbox.stub(Gerrit, '_isPluginPreloaded')
+        .withArgs('preloaded:gerrit-theme')
+        .returns(true);
+    sandbox.stub(Gerrit, '_loadPlugins');
+    element.config = {
+      default_theme: '/oof',
+      plugin: {},
+    };
+    assert.isTrue(Gerrit._loadPlugins.calledOnce);
+    assert.isTrue(Gerrit._loadPlugins.calledWith([], {}));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
index 30bf6c8..db44cea 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
@@ -14,13 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../scripts/bundled-polymer.js';
+
+import '../../shared/gr-overlay/gr-overlay.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-plugin-popup_html.js';
+
 (function(window) {
   'use strict';
 
   /** @extends Polymer.Element */
-  class GrPluginPopup extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
+  class GrPluginPopup extends GestureEventListeners(
+      LegacyElementMixin(
+          PolymerElement)) {
+    static get template() { return htmlTemplate; }
+
     static get is() { return 'gr-plugin-popup'; }
 
     get opened() {
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.js
index d084445..779cbad 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.js
@@ -1,31 +1,26 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-
-<dom-module id="gr-plugin-popup">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
-    <gr-overlay id="overlay" with-backdrop>
+    <gr-overlay id="overlay" with-backdrop="">
       <slot></slot>
     </gr-overlay>
-  </template>
-  <script src="gr-plugin-popup.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
index 1617cd5..00bbd52 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-popup</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-plugin-popup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-plugin-popup.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-plugin-popup.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,39 +40,41 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-plugin-popup tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-plugin-popup.js';
+suite('gr-plugin-popup tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      stub('gr-overlay', {
-        open: sandbox.stub().returns(Promise.resolve()),
-        close: sandbox.stub(),
-      });
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('exists', () => {
-      assert.isOk(element);
-    });
-
-    test('open uses open() from gr-overlay', done => {
-      element.open().then(() => {
-        assert.isTrue(element.$.overlay.open.called);
-        done();
-      });
-    });
-
-    test('close uses close() from gr-overlay', () => {
-      element.close();
-      assert.isTrue(element.$.overlay.close.called);
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    stub('gr-overlay', {
+      open: sandbox.stub().returns(Promise.resolve()),
+      close: sandbox.stub(),
     });
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('exists', () => {
+    assert.isOk(element);
+  });
+
+  test('open uses open() from gr-overlay', done => {
+    element.open().then(() => {
+      assert.isTrue(element.$.overlay.open.called);
+      done();
+    });
+  });
+
+  test('close uses close() from gr-overlay', () => {
+    element.close();
+    assert.isTrue(element.$.overlay.close.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
deleted file mode 100644
index a8bb06b..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
+++ /dev/null
@@ -1,23 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="gr-plugin-popup.html">
-
-<dom-module id="gr-popup-interface">
-  <script src="gr-popup-interface.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
index c3588a1..e9d3e36 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
@@ -14,6 +14,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../scripts/bundled-polymer.js';
+
+import './gr-plugin-popup.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-popup-interface">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
 (function(window) {
   'use strict';
 
@@ -35,7 +47,7 @@
   }
 
   GrPopupInterface.prototype._getElement = function() {
-    return Polymer.dom(this._popup);
+    return dom(this._popup);
   };
 
   /**
@@ -52,12 +64,12 @@
               .then(hookEl => {
                 const popup = document.createElement('gr-plugin-popup');
                 if (this._moduleName) {
-                  const el = Polymer.dom(popup).appendChild(
+                  const el = dom(popup).appendChild(
                       document.createElement(this._moduleName));
                   el.plugin = this.plugin;
                 }
-                this._popup = Polymer.dom(hookEl).appendChild(popup);
-                Polymer.dom.flush();
+                this._popup = dom(hookEl).appendChild(popup);
+                flush();
                 return this._popup.open().then(() => this);
               });
     }
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
index c1593b7..aeef29a 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
@@ -19,16 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-popup-interface</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-popup-interface.html"/>
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-popup-interface.js"></script>
+<script type="module" src="../../shared/gr-js-api-interface/gr-js-api-interface.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-popup-interface.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+void(0);
+</script>
 
 <test-fixture id="container">
   <template>
@@ -40,85 +46,94 @@
   <template>
     <div id="barfoo">some test module</div>
   </template>
-  <script>
-    Polymer({is: 'gr-user-test-popup'});
-  </script>
+  <script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-popup-interface.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+Polymer({is: 'gr-user-test-popup'});
+</script>
 </dom-module>
 
-<script>
-  suite('gr-popup-interface tests', async () => {
-    await readyToTest();
-    let container;
-    let instance;
-    let plugin;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-popup-interface.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-popup-interface tests', () => {
+  let container;
+  let instance;
+  let plugin;
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    container = fixture('container');
+    sandbox.stub(plugin, 'hook').returns({
+      getLastAttached() {
+        return Promise.resolve(container);
+      },
+    });
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('manual', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      container = fixture('container');
-      sandbox.stub(plugin, 'hook').returns({
-        getLastAttached() {
-          return Promise.resolve(container);
-        },
+      instance = new GrPopupInterface(plugin);
+    });
+
+    test('open', done => {
+      instance.open().then(api => {
+        assert.strictEqual(api, instance);
+        const manual = document.createElement('div');
+        manual.id = 'foobar';
+        manual.innerHTML = 'manual content';
+        api._getElement().appendChild(manual);
+        flushAsynchronousOperations();
+        assert.equal(
+            container.querySelector('#foobar').textContent, 'manual content');
+        done();
       });
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('manual', () => {
-      setup(() => {
-        instance = new GrPopupInterface(plugin);
-      });
-
-      test('open', done => {
-        instance.open().then(api => {
-          assert.strictEqual(api, instance);
-          const manual = document.createElement('div');
-          manual.id = 'foobar';
-          manual.innerHTML = 'manual content';
-          api._getElement().appendChild(manual);
-          flushAsynchronousOperations();
-          assert.equal(
-              container.querySelector('#foobar').textContent, 'manual content');
-          done();
-        });
-      });
-
-      test('close', done => {
-        instance.open().then(api => {
-          assert.isTrue(api._getElement().node.opened);
-          api.close();
-          assert.isFalse(api._getElement().node.opened);
-          done();
-        });
-      });
-    });
-
-    suite('components', () => {
-      setup(() => {
-        instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
-      });
-
-      test('open', done => {
-        instance.open().then(api => {
-          assert.isNotNull(
-              Polymer.dom(container).querySelector('gr-user-test-popup'));
-          done();
-        });
-      });
-
-      test('close', done => {
-        instance.open().then(api => {
-          assert.isTrue(api._getElement().node.opened);
-          api.close();
-          assert.isFalse(api._getElement().node.opened);
-          done();
-        });
+    test('close', done => {
+      instance.open().then(api => {
+        assert.isTrue(api._getElement().node.opened);
+        api.close();
+        assert.isFalse(api._getElement().node.opened);
+        done();
       });
     });
   });
+
+  suite('components', () => {
+    setup(() => {
+      instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
+    });
+
+    test('open', done => {
+      instance.open().then(api => {
+        assert.isNotNull(
+            dom(container).querySelector('gr-user-test-popup'));
+        done();
+      });
+    });
+
+    test('close', done => {
+      instance.open().then(api => {
+        assert.isTrue(api._getElement().node.opened);
+        api.close();
+        assert.isFalse(api._getElement().node.opened);
+        done();
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
index 593c1e0..f9a2bdf 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
@@ -1,35 +1,35 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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 '../../../scripts/bundled-polymer.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../admin/gr-repo-command/gr-repo-command.html">
-
-<dom-module id="gr-plugin-repo-command">
-  <template>
+import '../../admin/gr-repo-command/gr-repo-command.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+Polymer({
+  _template: html`
     <gr-repo-command title="[[title]]">
     </gr-repo-command>
-  </template>
-  <script>
-    Polymer({
-      is: 'gr-plugin-repo-command',
-      properties: {
-        title: String,
-        repoName: String,
-        config: Object,
-      },
-    });
-  </script>
-</dom-module>
+`,
+
+  is: 'gr-plugin-repo-command',
+
+  properties: {
+    title: String,
+    repoName: String,
+    config: Object,
+  },
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html
deleted file mode 100644
index b3f6aec..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html
+++ /dev/null
@@ -1,23 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="gr-plugin-repo-command.html">
-
-<dom-module id="gr-repo-api">
-  <script src="gr-repo-api.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
index b59cce6..6c1a3c8 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
@@ -14,6 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../scripts/bundled-polymer.js';
+
+import './gr-plugin-repo-command.js';
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-repo-api">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
 (function(window) {
   'use strict';
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
index adc1de5..c177715 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
@@ -19,16 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-api</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="gr-repo-api.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../gr-endpoint-decorator/gr-endpoint-decorator.js"></script>
+<script type="module" src="./gr-repo-api.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import './gr-repo-api.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,52 +43,55 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-api tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let repoApi;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import './gr-repo-api.js';
+suite('gr-repo-api tests', () => {
+  let sandbox;
+  let repoApi;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      Gerrit._loadPlugins([]);
-      repoApi = plugin.project();
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    let plugin;
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    Gerrit._loadPlugins([]);
+    repoApi = plugin.project();
+  });
 
-    teardown(() => {
-      repoApi = null;
-      sandbox.restore();
-    });
+  teardown(() => {
+    repoApi = null;
+    sandbox.restore();
+  });
 
-    test('exists', () => {
-      assert.isOk(repoApi);
-    });
+  test('exists', () => {
+    assert.isOk(repoApi);
+  });
 
-    test('works', done => {
-      const attachedStub = sandbox.stub();
-      const tapStub = sandbox.stub();
-      repoApi
-          .createCommand('foo', attachedStub)
-          .onTap(tapStub);
-      const element = fixture('basic');
-      flush(() => {
-        assert.isTrue(attachedStub.called);
-        const pluginCommand = element.shadowRoot
-            .querySelector('gr-plugin-repo-command');
-        assert.isOk(pluginCommand);
-        const command = pluginCommand.shadowRoot
-            .querySelector('gr-repo-command');
-        assert.isOk(command);
-        assert.equal(command.title, 'foo');
-        assert.isFalse(tapStub.called);
-        MockInteractions.tap(command.shadowRoot
-            .querySelector('gr-button'));
-        assert.isTrue(tapStub.called);
-        done();
-      });
+  test('works', done => {
+    const attachedStub = sandbox.stub();
+    const tapStub = sandbox.stub();
+    repoApi
+        .createCommand('foo', attachedStub)
+        .onTap(tapStub);
+    const element = fixture('basic');
+    flush(() => {
+      assert.isTrue(attachedStub.called);
+      const pluginCommand = element.shadowRoot
+          .querySelector('gr-plugin-repo-command');
+      assert.isOk(pluginCommand);
+      const command = pluginCommand.shadowRoot
+          .querySelector('gr-repo-command');
+      assert.isOk(command);
+      assert.equal(command.title, 'foo');
+      assert.isFalse(tapStub.called);
+      MockInteractions.tap(command.shadowRoot
+          .querySelector('gr-button'));
+      assert.isTrue(tapStub.called);
+      done();
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html
deleted file mode 100644
index 999ecfa..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html
+++ /dev/null
@@ -1,25 +0,0 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Settings
-
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../settings/gr-settings-view/gr-settings-item.html">
-<link rel="import" href="../../settings/gr-settings-view/gr-settings-menu-item.html">
-
-<dom-module id="gr-settings-api">
-  <script src="gr-settings-api.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
index 5ed4c1a..a8bfccdd 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
@@ -14,6 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../settings/gr-settings-view/gr-settings-item.js';
+import '../../settings/gr-settings-view/gr-settings-menu-item.js';
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-settings-api">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
 (function(window) {
   'use strict';
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
index 2efd182..b0dbc3c 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
@@ -19,16 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-api</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="gr-settings-api.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../gr-endpoint-decorator/gr-endpoint-decorator.js"></script>
+<script type="module" src="./gr-settings-api.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import './gr-settings-api.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -39,51 +45,54 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-settings-api tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let settingsApi;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import './gr-settings-api.js';
+suite('gr-settings-api tests', () => {
+  let sandbox;
+  let settingsApi;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      Gerrit._loadPlugins([]);
-      settingsApi = plugin.settings();
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    let plugin;
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    Gerrit._loadPlugins([]);
+    settingsApi = plugin.settings();
+  });
 
-    teardown(() => {
-      settingsApi = null;
-      sandbox.restore();
-    });
+  teardown(() => {
+    settingsApi = null;
+    sandbox.restore();
+  });
 
-    test('exists', () => {
-      assert.isOk(settingsApi);
-    });
+  test('exists', () => {
+    assert.isOk(settingsApi);
+  });
 
-    test('works', done => {
-      settingsApi
-          .title('foo')
-          .token('bar')
-          .module('some-settings-screen')
-          .build();
-      const element = fixture('basic');
-      flush(() => {
-        const [menuItemEl, itemEl] = element;
-        const menuItem = menuItemEl.shadowRoot
-            .querySelector('gr-settings-menu-item');
-        assert.isOk(menuItem);
-        assert.equal(menuItem.title, 'foo');
-        assert.equal(menuItem.href, '#x/testplugin/bar');
-        const item = itemEl.shadowRoot
-            .querySelector('gr-settings-item');
-        assert.isOk(item);
-        assert.equal(item.title, 'foo');
-        assert.equal(item.anchor, 'x/testplugin/bar');
-        done();
-      });
+  test('works', done => {
+    settingsApi
+        .title('foo')
+        .token('bar')
+        .module('some-settings-screen')
+        .build();
+    const element = fixture('basic');
+    flush(() => {
+      const [menuItemEl, itemEl] = element;
+      const menuItem = menuItemEl.shadowRoot
+          .querySelector('gr-settings-menu-item');
+      assert.isOk(menuItem);
+      assert.equal(menuItem.title, 'foo');
+      assert.equal(menuItem.href, '#x/testplugin/bar');
+      const item = itemEl.shadowRoot
+          .querySelector('gr-settings-item');
+      assert.isOk(item);
+      assert.equal(item.title, 'foo');
+      assert.equal(item.anchor, 'x/testplugin/bar');
+      done();
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.html b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.html
deleted file mode 100644
index 74b87c8..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.html
+++ /dev/null
@@ -1,18 +0,0 @@
-<!--
-@license
-Copyright (C) 2019 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.
--->
-
-<script src="gr-styles-api.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html
index feb59fe..a69db8d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html
@@ -19,31 +19,75 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-api</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="gr-styles-api.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../shared/gr-js-api-interface/gr-js-api-interface.js"></script>
+<script type="module" src="./gr-styles-api.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import './gr-styles-api.js';
+void(0);
+</script>
 
 <dom-module id="gr-style-test-element">
   <template>
     <div id="wrapper"></div>
   </template>
-  <script>
-    Polymer({is: 'gr-style-test-element'});
-  </script>
+  <script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import './gr-styles-api.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+Polymer({is: 'gr-style-test-element'});
+</script>
 </dom-module>
 
-<script>
-  suite('gr-styles-api tests', async () => {
-    await readyToTest();
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import './gr-styles-api.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-styles-api tests', () => {
+  let sandbox;
+  let stylesApi;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    let plugin;
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    Gerrit._loadPlugins([]);
+    stylesApi = plugin.styles();
+  });
+
+  teardown(() => {
+    stylesApi = null;
+    sandbox.restore();
+  });
+
+  test('exists', () => {
+    assert.isOk(stylesApi);
+  });
+
+  test('css', () => {
+    const styleObject = stylesApi.css('background: red');
+    assert.isDefined(styleObject);
+  });
+
+  suite('GrStyleObject tests', () => {
     let sandbox;
     let stylesApi;
+    let displayInlineStyle;
+    let displayNoneStyle;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
@@ -52,133 +96,104 @@
           'http://test.com/plugins/testplugin/static/test.js');
       Gerrit._loadPlugins([]);
       stylesApi = plugin.styles();
+      displayInlineStyle = stylesApi.css('display: inline');
+      displayNoneStyle = stylesApi.css('display: none');
     });
 
     teardown(() => {
+      displayInlineStyle = null;
+      displayNoneStyle = null;
       stylesApi = null;
       sandbox.restore();
     });
 
-    test('exists', () => {
-      assert.isOk(stylesApi);
+    function createNestedElements(parentElement) {
+      /* parentElement
+      *  |--- element1
+      *  |--- element2
+      *       |--- element3
+      **/
+      const element1 = document.createElement('div');
+      const element2 = document.createElement('div');
+      const element3 = document.createElement('div');
+      dom(parentElement).appendChild(element1);
+      dom(parentElement).appendChild(element2);
+      dom(element2).appendChild(element3);
+
+      return [element1, element2, element3];
+    }
+
+    test('getClassName  - body level elements', () => {
+      const bodyLevelElements = createNestedElements(document.body);
+
+      testGetClassName(bodyLevelElements);
     });
 
-    test('css', () => {
-      const styleObject = stylesApi.css('background: red');
-      assert.isDefined(styleObject);
+    test('getClassName  - elements inside polymer element', () => {
+      const polymerElement = document.createElement('gr-style-test-element');
+      dom(document.body).appendChild(polymerElement);
+      const contentElements = createNestedElements(polymerElement.$.wrapper);
+
+      testGetClassName(contentElements);
     });
 
-    suite('GrStyleObject tests', () => {
-      let sandbox;
-      let stylesApi;
-      let displayInlineStyle;
-      let displayNoneStyle;
+    function testGetClassName(elements) {
+      assertAllElementsHaveDefaultStyle(elements);
 
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-        let plugin;
-        Gerrit.install(p => { plugin = p; }, '0.1',
-            'http://test.com/plugins/testplugin/static/test.js');
-        Gerrit._loadPlugins([]);
-        stylesApi = plugin.styles();
-        displayInlineStyle = stylesApi.css('display: inline');
-        displayNoneStyle = stylesApi.css('display: none');
-      });
+      const className1 = displayInlineStyle.getClassName(elements[0]);
+      const className2 = displayNoneStyle.getClassName(elements[1]);
+      const className3 = displayInlineStyle.getClassName(elements[2]);
 
-      teardown(() => {
-        displayInlineStyle = null;
-        displayNoneStyle = null;
-        stylesApi = null;
-        sandbox.restore();
-      });
+      assert.notEqual(className2, className1);
+      assert.equal(className3, className1);
 
-      function createNestedElements(parentElement) {
-        /* parentElement
-        *  |--- element1
-        *  |--- element2
-        *       |--- element3
-        **/
-        const element1 = document.createElement('div');
-        const element2 = document.createElement('div');
-        const element3 = document.createElement('div');
-        Polymer.dom(parentElement).appendChild(element1);
-        Polymer.dom(parentElement).appendChild(element2);
-        Polymer.dom(element2).appendChild(element3);
+      assertAllElementsHaveDefaultStyle(elements);
 
-        return [element1, element2, element3];
+      elements[0].classList.add(className1);
+      elements[1].classList.add(className2);
+      elements[2].classList.add(className1);
+
+      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
+    }
+
+    test('apply - body level elements', () => {
+      const bodyLevelElements = createNestedElements(document.body);
+
+      testApply(bodyLevelElements);
+    });
+
+    test('apply - elements inside polymer element', () => {
+      const polymerElement = document.createElement('gr-style-test-element');
+      dom(document.body).appendChild(polymerElement);
+      const contentElements = createNestedElements(polymerElement.$.wrapper);
+
+      testApply(contentElements);
+    });
+
+    function testApply(elements) {
+      assertAllElementsHaveDefaultStyle(elements);
+      displayInlineStyle.apply(elements[0]);
+      displayNoneStyle.apply(elements[1]);
+      displayInlineStyle.apply(elements[2]);
+      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
+    }
+
+    function assertAllElementsHaveDefaultStyle(elements) {
+      for (const element of elements) {
+        assert.equal(getComputedStyle(element).getPropertyValue('display'),
+            'block');
       }
+    }
 
-      test('getClassName  - body level elements', () => {
-        const bodyLevelElements = createNestedElements(document.body);
-
-        testGetClassName(bodyLevelElements);
-      });
-
-      test('getClassName  - elements inside polymer element', () => {
-        const polymerElement = document.createElement('gr-style-test-element');
-        Polymer.dom(document.body).appendChild(polymerElement);
-        const contentElements = createNestedElements(polymerElement.$.wrapper);
-
-        testGetClassName(contentElements);
-      });
-
-      function testGetClassName(elements) {
-        assertAllElementsHaveDefaultStyle(elements);
-
-        const className1 = displayInlineStyle.getClassName(elements[0]);
-        const className2 = displayNoneStyle.getClassName(elements[1]);
-        const className3 = displayInlineStyle.getClassName(elements[2]);
-
-        assert.notEqual(className2, className1);
-        assert.equal(className3, className1);
-
-        assertAllElementsHaveDefaultStyle(elements);
-
-        elements[0].classList.add(className1);
-        elements[1].classList.add(className2);
-        elements[2].classList.add(className1);
-
-        assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
-      }
-
-      test('apply - body level elements', () => {
-        const bodyLevelElements = createNestedElements(document.body);
-
-        testApply(bodyLevelElements);
-      });
-
-      test('apply - elements inside polymer element', () => {
-        const polymerElement = document.createElement('gr-style-test-element');
-        Polymer.dom(document.body).appendChild(polymerElement);
-        const contentElements = createNestedElements(polymerElement.$.wrapper);
-
-        testApply(contentElements);
-      });
-
-      function testApply(elements) {
-        assertAllElementsHaveDefaultStyle(elements);
-        displayInlineStyle.apply(elements[0]);
-        displayNoneStyle.apply(elements[1]);
-        displayInlineStyle.apply(elements[2]);
-        assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
-      }
-
-      function assertAllElementsHaveDefaultStyle(elements) {
-        for (const element of elements) {
-          assert.equal(getComputedStyle(element).getPropertyValue('display'),
-              'block');
+    function assertDisplayPropertyValues(elements, expectedDisplayValues) {
+      for (const key in elements) {
+        if (elements.hasOwnProperty(key)) {
+          assert.equal(
+              getComputedStyle(elements[key]).getPropertyValue('display'),
+              expectedDisplayValues[key]);
         }
       }
-
-      function assertDisplayPropertyValues(elements, expectedDisplayValues) {
-        for (const key in elements) {
-          if (elements.hasOwnProperty(key)) {
-            assert.equal(
-                getComputedStyle(elements[key]).getPropertyValue('display'),
-                expectedDisplayValues[key]);
-          }
-        }
-      }
-    });
+    }
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
index f0eacd2..411a7c8 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
@@ -1,24 +1,25 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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 '../../../scripts/bundled-polymer.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-custom-plugin-header">
-  <template>
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+Polymer({
+  _template: html`
     <style>
       img {
         width: 1em;
@@ -30,17 +31,15 @@
       }
     </style>
     <span>
-      <img src="[[logoUrl]]" hidden$="[[!logoUrl]]">
+      <img src="[[logoUrl]]" hidden\$="[[!logoUrl]]">
       <span class="title">[[title]]</span>
     </span>
-  </template>
-  <script>
-    Polymer({
-      is: 'gr-custom-plugin-header',
-      properties: {
-        logoUrl: String,
-        title: String,
-      },
-    });
-  </script>
-</dom-module>
+`,
+
+  is: 'gr-custom-plugin-header',
+
+  properties: {
+    logoUrl: String,
+    title: String,
+  },
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html
deleted file mode 100644
index ef1c9d4..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html
+++ /dev/null
@@ -1,23 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="gr-custom-plugin-header.html">
-
-<dom-module id="gr-theme-api">
-  <script src="gr-theme-api.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
index d145f52f..8da680b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
@@ -14,6 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../scripts/bundled-polymer.js';
+
+import './gr-custom-plugin-header.js';
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-theme-api">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
 (function(window) {
   'use strict';
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
index 80853f5..6428f90 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
@@ -19,16 +19,22 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-theme-api</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="gr-theme-api.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../gr-endpoint-decorator/gr-endpoint-decorator.js"></script>
+<script type="module" src="./gr-theme-api.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import './gr-theme-api.js';
+void(0);
+</script>
 
 <test-fixture id="header-title">
   <template>
@@ -38,50 +44,53 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-theme-api tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let theme;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import './gr-theme-api.js';
+suite('gr-theme-api tests', () => {
+  let sandbox;
+  let theme;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    let plugin;
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    theme = plugin.theme();
+  });
+
+  teardown(() => {
+    theme = null;
+    sandbox.restore();
+  });
+
+  test('exists', () => {
+    assert.isOk(theme);
+  });
+
+  suite('header-title', () => {
+    let customHeader;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      theme = plugin.theme();
-    });
-
-    teardown(() => {
-      theme = null;
-      sandbox.restore();
-    });
-
-    test('exists', () => {
-      assert.isOk(theme);
-    });
-
-    suite('header-title', () => {
-      let customHeader;
-
-      setup(() => {
-        fixture('header-title');
-        stub('gr-custom-plugin-header', {
-          /** @override */
-          ready() { customHeader = this; },
-        });
-        Gerrit._loadPlugins([]);
+      fixture('header-title');
+      stub('gr-custom-plugin-header', {
+        /** @override */
+        ready() { customHeader = this; },
       });
+      Gerrit._loadPlugins([]);
+    });
 
-      test('sets logo and title', done => {
-        theme.setHeaderLogoAndTitle('foo.jpg', 'bar');
-        flush(() => {
-          assert.isNotNull(customHeader);
-          assert.equal(customHeader.logoUrl, 'foo.jpg');
-          assert.equal(customHeader.title, 'bar');
-          done();
-        });
+    test('sets logo and title', done => {
+      theme.setHeaderLogoAndTitle('foo.jpg', 'bar');
+      flush(() => {
+        assert.isNotNull(customHeader);
+        assert.equal(customHeader.logoUrl, 'foo.jpg');
+        assert.equal(customHeader.title, 'bar');
+        done();
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index 7bf641d..c425318 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -14,195 +14,208 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
+import '@polymer/iron-input/iron-input.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../shared/gr-avatar/gr-avatar.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-info_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrAccountInfo extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-account-info'; }
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when account details are changed.
+   *
+   * @event account-detail-update
    */
-  class GrAccountInfo extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-account-info'; }
-    /**
-     * Fired when account details are changed.
-     *
-     * @event account-detail-update
-     */
 
-    static get properties() {
-      return {
-        usernameMutable: {
-          type: Boolean,
-          notify: true,
-          computed: '_computeUsernameMutable(_serverConfig, _account.username)',
-        },
-        nameMutable: {
-          type: Boolean,
-          notify: true,
-          computed: '_computeNameMutable(_serverConfig)',
-        },
-        hasUnsavedChanges: {
-          type: Boolean,
-          notify: true,
-          computed: '_computeHasUnsavedChanges(_hasNameChange, ' +
-            '_hasUsernameChange, _hasStatusChange)',
-        },
+  static get properties() {
+    return {
+      usernameMutable: {
+        type: Boolean,
+        notify: true,
+        computed: '_computeUsernameMutable(_serverConfig, _account.username)',
+      },
+      nameMutable: {
+        type: Boolean,
+        notify: true,
+        computed: '_computeNameMutable(_serverConfig)',
+      },
+      hasUnsavedChanges: {
+        type: Boolean,
+        notify: true,
+        computed: '_computeHasUnsavedChanges(_hasNameChange, ' +
+          '_hasUsernameChange, _hasStatusChange)',
+      },
 
-        _hasNameChange: Boolean,
-        _hasUsernameChange: Boolean,
-        _hasStatusChange: Boolean,
-        _loading: {
-          type: Boolean,
-          value: false,
-        },
-        _saving: {
-          type: Boolean,
-          value: false,
-        },
-        /** @type {?} */
-        _account: Object,
-        _serverConfig: Object,
-        _username: {
-          type: String,
-          observer: '_usernameChanged',
-        },
-        _avatarChangeUrl: {
-          type: String,
-          value: '',
-        },
-      };
+      _hasNameChange: Boolean,
+      _hasUsernameChange: Boolean,
+      _hasStatusChange: Boolean,
+      _loading: {
+        type: Boolean,
+        value: false,
+      },
+      _saving: {
+        type: Boolean,
+        value: false,
+      },
+      /** @type {?} */
+      _account: Object,
+      _serverConfig: Object,
+      _username: {
+        type: String,
+        observer: '_usernameChanged',
+      },
+      _avatarChangeUrl: {
+        type: String,
+        value: '',
+      },
+    };
+  }
+
+  static get observers() {
+    return [
+      '_nameChanged(_account.name)',
+      '_statusChanged(_account.status)',
+    ];
+  }
+
+  loadData() {
+    const promises = [];
+
+    this._loading = true;
+
+    promises.push(this.$.restAPI.getConfig().then(config => {
+      this._serverConfig = config;
+    }));
+
+    promises.push(this.$.restAPI.getAccount().then(account => {
+      this._hasNameChange = false;
+      this._hasUsernameChange = false;
+      this._hasStatusChange = false;
+      // Provide predefined value for username to trigger computation of
+      // username mutability.
+      account.username = account.username || '';
+      this._account = account;
+      this._username = account.username;
+    }));
+
+    promises.push(this.$.restAPI.getAvatarChangeUrl().then(url => {
+      this._avatarChangeUrl = url;
+    }));
+
+    return Promise.all(promises).then(() => {
+      this._loading = false;
+    });
+  }
+
+  save() {
+    if (!this.hasUnsavedChanges) {
+      return Promise.resolve();
     }
 
-    static get observers() {
-      return [
-        '_nameChanged(_account.name)',
-        '_statusChanged(_account.status)',
-      ];
+    this._saving = true;
+    // Set only the fields that have changed.
+    // Must be done in sequence to avoid race conditions (@see Issue 5721)
+    return this._maybeSetName()
+        .then(this._maybeSetUsername.bind(this))
+        .then(this._maybeSetStatus.bind(this))
+        .then(() => {
+          this._hasNameChange = false;
+          this._hasStatusChange = false;
+          this._saving = false;
+          this.fire('account-detail-update');
+        });
+  }
+
+  _maybeSetName() {
+    return this._hasNameChange && this.nameMutable ?
+      this.$.restAPI.setAccountName(this._account.name) :
+      Promise.resolve();
+  }
+
+  _maybeSetUsername() {
+    return this._hasUsernameChange && this.usernameMutable ?
+      this.$.restAPI.setAccountUsername(this._username) :
+      Promise.resolve();
+  }
+
+  _maybeSetStatus() {
+    return this._hasStatusChange ?
+      this.$.restAPI.setAccountStatus(this._account.status) :
+      Promise.resolve();
+  }
+
+  _computeHasUnsavedChanges(nameChanged, usernameChanged, statusChanged) {
+    return nameChanged || usernameChanged || statusChanged;
+  }
+
+  _computeUsernameMutable(config, username) {
+    // Polymer 2: check for undefined
+    if ([
+      config,
+      username,
+    ].some(arg => arg === undefined)) {
+      return undefined;
     }
 
-    loadData() {
-      const promises = [];
+    // Username may not be changed once it is set.
+    return config.auth.editable_account_fields.includes('USER_NAME') &&
+        !username;
+  }
 
-      this._loading = true;
+  _computeNameMutable(config) {
+    return config.auth.editable_account_fields.includes('FULL_NAME');
+  }
 
-      promises.push(this.$.restAPI.getConfig().then(config => {
-        this._serverConfig = config;
-      }));
+  _statusChanged() {
+    if (this._loading) { return; }
+    this._hasStatusChange = true;
+  }
 
-      promises.push(this.$.restAPI.getAccount().then(account => {
-        this._hasNameChange = false;
-        this._hasUsernameChange = false;
-        this._hasStatusChange = false;
-        // Provide predefined value for username to trigger computation of
-        // username mutability.
-        account.username = account.username || '';
-        this._account = account;
-        this._username = account.username;
-      }));
+  _usernameChanged() {
+    if (this._loading || !this._account) { return; }
+    this._hasUsernameChange =
+        (this._account.username || '') !== (this._username || '');
+  }
 
-      promises.push(this.$.restAPI.getAvatarChangeUrl().then(url => {
-        this._avatarChangeUrl = url;
-      }));
+  _nameChanged() {
+    if (this._loading) { return; }
+    this._hasNameChange = true;
+  }
 
-      return Promise.all(promises).then(() => {
-        this._loading = false;
-      });
-    }
-
-    save() {
-      if (!this.hasUnsavedChanges) {
-        return Promise.resolve();
-      }
-
-      this._saving = true;
-      // Set only the fields that have changed.
-      // Must be done in sequence to avoid race conditions (@see Issue 5721)
-      return this._maybeSetName()
-          .then(this._maybeSetUsername.bind(this))
-          .then(this._maybeSetStatus.bind(this))
-          .then(() => {
-            this._hasNameChange = false;
-            this._hasStatusChange = false;
-            this._saving = false;
-            this.fire('account-detail-update');
-          });
-    }
-
-    _maybeSetName() {
-      return this._hasNameChange && this.nameMutable ?
-        this.$.restAPI.setAccountName(this._account.name) :
-        Promise.resolve();
-    }
-
-    _maybeSetUsername() {
-      return this._hasUsernameChange && this.usernameMutable ?
-        this.$.restAPI.setAccountUsername(this._username) :
-        Promise.resolve();
-    }
-
-    _maybeSetStatus() {
-      return this._hasStatusChange ?
-        this.$.restAPI.setAccountStatus(this._account.status) :
-        Promise.resolve();
-    }
-
-    _computeHasUnsavedChanges(nameChanged, usernameChanged, statusChanged) {
-      return nameChanged || usernameChanged || statusChanged;
-    }
-
-    _computeUsernameMutable(config, username) {
-      // Polymer 2: check for undefined
-      if ([
-        config,
-        username,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      // Username may not be changed once it is set.
-      return config.auth.editable_account_fields.includes('USER_NAME') &&
-          !username;
-    }
-
-    _computeNameMutable(config) {
-      return config.auth.editable_account_fields.includes('FULL_NAME');
-    }
-
-    _statusChanged() {
-      if (this._loading) { return; }
-      this._hasStatusChange = true;
-    }
-
-    _usernameChanged() {
-      if (this._loading || !this._account) { return; }
-      this._hasUsernameChange =
-          (this._account.username || '') !== (this._username || '');
-    }
-
-    _nameChanged() {
-      if (this._loading) { return; }
-      this._hasNameChange = true;
-    }
-
-    _handleKeydown(e) {
-      if (e.keyCode === 13) { // Enter
-        e.stopPropagation();
-        this.save();
-      }
-    }
-
-    _hideAvatarChangeUrl(avatarChangeUrl) {
-      if (!avatarChangeUrl) {
-        return 'hide';
-      }
-
-      return '';
+  _handleKeydown(e) {
+    if (e.keyCode === 13) { // Enter
+      e.stopPropagation();
+      this.save();
     }
   }
 
-  customElements.define(GrAccountInfo.is, GrAccountInfo);
-})();
+  _hideAvatarChangeUrl(avatarChangeUrl) {
+    if (!avatarChangeUrl) {
+      return 'hide';
+    }
+
+    return '';
+  }
+}
+
+customElements.define(GrAccountInfo.is, GrAccountInfo);
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js
index e685030..6e37c25 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js
@@ -1,34 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-
-<dom-module id="gr-account-info">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       gr-avatar {
         height: 120px;
@@ -47,14 +35,13 @@
       <section>
         <span class="title"></span>
         <span class="value">
-          <gr-avatar account="[[_account]]"
-              image-size="120"></gr-avatar>
+          <gr-avatar account="[[_account]]" image-size="120"></gr-avatar>
         </span>
       </section>
-      <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
+      <section class\$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
         <span class="title"></span>
         <span class="value">
-          <a href$="[[_avatarChangeUrl]]">
+          <a href\$="[[_avatarChangeUrl]]">
             Change avatar
           </a>
         </span>
@@ -70,68 +57,35 @@
       <section>
         <span class="title">Registered</span>
         <span class="value">
-          <gr-date-formatter
-              has-tooltip
-              date-str="[[_account.registered_on]]"></gr-date-formatter>
+          <gr-date-formatter has-tooltip="" date-str="[[_account.registered_on]]"></gr-date-formatter>
         </span>
       </section>
       <section id="usernameSection">
         <span class="title">Username</span>
-        <span
-            hidden$="[[usernameMutable]]"
-            class="value">[[_username]]</span>
-        <span
-            hidden$="[[!usernameMutable]]"
-            class="value">
-          <iron-input
-              on-keydown="_handleKeydown"
-              bind-value="{{_username}}">
-            <input
-                is="iron-input"
-                id="usernameInput"
-                disabled="[[_saving]]"
-                on-keydown="_handleKeydown"
-                bind-value="{{_username}}">
+        <span hidden\$="[[usernameMutable]]" class="value">[[_username]]</span>
+        <span hidden\$="[[!usernameMutable]]" class="value">
+          <iron-input on-keydown="_handleKeydown" bind-value="{{_username}}">
+            <input is="iron-input" id="usernameInput" disabled="[[_saving]]" on-keydown="_handleKeydown" bind-value="{{_username}}">
           </iron-input>
         </span>
       </section>
       <section id="nameSection">
         <span class="title">Full name</span>
-        <span
-            hidden$="[[nameMutable]]"
-            class="value">[[_account.name]]</span>
-        <span
-            hidden$="[[!nameMutable]]"
-            class="value">
-          <iron-input
-              on-keydown="_handleKeydown"
-              bind-value="{{_account.name}}">
-            <input
-                is="iron-input"
-                id="nameInput"
-                disabled="[[_saving]]"
-                on-keydown="_handleKeydown"
-                bind-value="{{_account.name}}">
+        <span hidden\$="[[nameMutable]]" class="value">[[_account.name]]</span>
+        <span hidden\$="[[!nameMutable]]" class="value">
+          <iron-input on-keydown="_handleKeydown" bind-value="{{_account.name}}">
+            <input is="iron-input" id="nameInput" disabled="[[_saving]]" on-keydown="_handleKeydown" bind-value="{{_account.name}}">
           </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Status (e.g. "Vacation")</span>
         <span class="value">
-          <iron-input
-              on-keydown="_handleKeydown"
-              bind-value="{{_account.status}}">
-            <input
-                is="iron-input"
-                id="statusInput"
-                disabled="[[_saving]]"
-                on-keydown="_handleKeydown"
-                bind-value="{{_account.status}}">
+          <iron-input on-keydown="_handleKeydown" bind-value="{{_account.status}}">
+            <input is="iron-input" id="statusInput" disabled="[[_saving]]" on-keydown="_handleKeydown" bind-value="{{_account.status}}">
           </iron-input>
         </span>
       </section>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-account-info.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
index 80c7399..640ae37 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-info</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-account-info.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-account-info.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-account-info.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,310 +40,313 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-account-info tests', async () => {
-    await readyToTest();
-    let element;
-    let account;
-    let config;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-account-info.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-account-info tests', () => {
+  let element;
+  let account;
+  let config;
+  let sandbox;
 
-    function valueOf(title) {
-      const sections = Polymer.dom(element.root).querySelectorAll('section');
-      let titleEl;
-      for (let i = 0; i < sections.length; i++) {
-        titleEl = sections[i].querySelector('.title');
-        if (titleEl.textContent === title) {
-          return sections[i].querySelector('.value');
-        }
+  function valueOf(title) {
+    const sections = dom(element.root).querySelectorAll('section');
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl.textContent === title) {
+        return sections[i].querySelector('.value');
       }
     }
+  }
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      account = {
-        _account_id: 123,
-        name: 'user name',
-        email: 'user@email',
-        username: 'user username',
-        registered: '2000-01-01 00:00:00.000000000',
-      };
-      config = {auth: {editable_account_fields: []}};
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    account = {
+      _account_id: 123,
+      name: 'user name',
+      email: 'user@email',
+      username: 'user username',
+      registered: '2000-01-01 00:00:00.000000000',
+    };
+    config = {auth: {editable_account_fields: []}};
 
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(account); },
-        getConfig() { return Promise.resolve(config); },
-        getPreferences() {
-          return Promise.resolve({time_format: 'HHMM_12'});
-        },
-      });
-      element = fixture('basic');
-      // Allow the element to render.
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(account); },
+      getConfig() { return Promise.resolve(config); },
+      getPreferences() {
+        return Promise.resolve({time_format: 'HHMM_12'});
+      },
     });
+    element = fixture('basic');
+    // Allow the element to render.
+    element.loadData().then(() => { flush(done); });
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('basic account info render', () => {
-      assert.isFalse(element._loading);
+  test('basic account info render', () => {
+    assert.isFalse(element._loading);
 
-      assert.equal(valueOf('ID').textContent, account._account_id);
-      assert.equal(valueOf('Email').textContent, account.email);
-      assert.equal(valueOf('Username').textContent, account.username);
-    });
+    assert.equal(valueOf('ID').textContent, account._account_id);
+    assert.equal(valueOf('Email').textContent, account.email);
+    assert.equal(valueOf('Username').textContent, account.username);
+  });
 
-    test('full name render (immutable)', () => {
-      const section = element.$.nameSection;
-      const displaySpan = section.querySelectorAll('.value')[0];
-      const inputSpan = section.querySelectorAll('.value')[1];
+  test('full name render (immutable)', () => {
+    const section = element.$.nameSection;
+    const displaySpan = section.querySelectorAll('.value')[0];
+    const inputSpan = section.querySelectorAll('.value')[1];
 
-      assert.isFalse(element.nameMutable);
-      assert.isFalse(displaySpan.hasAttribute('hidden'));
-      assert.equal(displaySpan.textContent, account.name);
-      assert.isTrue(inputSpan.hasAttribute('hidden'));
-    });
+    assert.isFalse(element.nameMutable);
+    assert.isFalse(displaySpan.hasAttribute('hidden'));
+    assert.equal(displaySpan.textContent, account.name);
+    assert.isTrue(inputSpan.hasAttribute('hidden'));
+  });
 
-    test('full name render (mutable)', () => {
+  test('full name render (mutable)', () => {
+    element.set('_serverConfig',
+        {auth: {editable_account_fields: ['FULL_NAME']}});
+
+    const section = element.$.nameSection;
+    const displaySpan = section.querySelectorAll('.value')[0];
+    const inputSpan = section.querySelectorAll('.value')[1];
+
+    assert.isTrue(element.nameMutable);
+    assert.isTrue(displaySpan.hasAttribute('hidden'));
+    assert.equal(element.$.nameInput.bindValue, account.name);
+    assert.isFalse(inputSpan.hasAttribute('hidden'));
+  });
+
+  test('username render (immutable)', () => {
+    const section = element.$.usernameSection;
+    const displaySpan = section.querySelectorAll('.value')[0];
+    const inputSpan = section.querySelectorAll('.value')[1];
+
+    assert.isFalse(element.usernameMutable);
+    assert.isFalse(displaySpan.hasAttribute('hidden'));
+    assert.equal(displaySpan.textContent, account.username);
+    assert.isTrue(inputSpan.hasAttribute('hidden'));
+  });
+
+  test('username render (mutable)', () => {
+    element.set('_serverConfig',
+        {auth: {editable_account_fields: ['USER_NAME']}});
+    element.set('_account.username', '');
+    element.set('_username', '');
+
+    const section = element.$.usernameSection;
+    const displaySpan = section.querySelectorAll('.value')[0];
+    const inputSpan = section.querySelectorAll('.value')[1];
+
+    assert.isTrue(element.usernameMutable);
+    assert.isTrue(displaySpan.hasAttribute('hidden'));
+    assert.equal(element.$.usernameInput.bindValue, account.username);
+    assert.isFalse(inputSpan.hasAttribute('hidden'));
+  });
+
+  suite('account info edit', () => {
+    let nameChangedSpy;
+    let usernameChangedSpy;
+    let statusChangedSpy;
+    let nameStub;
+    let usernameStub;
+    let statusStub;
+
+    setup(() => {
+      nameChangedSpy = sandbox.spy(element, '_nameChanged');
+      usernameChangedSpy = sandbox.spy(element, '_usernameChanged');
+      statusChangedSpy = sandbox.spy(element, '_statusChanged');
       element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME']}});
+          {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
 
-      const section = element.$.nameSection;
-      const displaySpan = section.querySelectorAll('.value')[0];
-      const inputSpan = section.querySelectorAll('.value')[1];
+      nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
+          name => Promise.resolve());
+      usernameStub = sandbox.stub(element.$.restAPI, 'setAccountUsername',
+          username => Promise.resolve());
+      statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
+          status => Promise.resolve());
+    });
 
+    test('name', done => {
       assert.isTrue(element.nameMutable);
-      assert.isTrue(displaySpan.hasAttribute('hidden'));
-      assert.equal(element.$.nameInput.bindValue, account.name);
-      assert.isFalse(inputSpan.hasAttribute('hidden'));
-    });
+      assert.isFalse(element.hasUnsavedChanges);
 
-    test('username render (immutable)', () => {
-      const section = element.$.usernameSection;
-      const displaySpan = section.querySelectorAll('.value')[0];
-      const inputSpan = section.querySelectorAll('.value')[1];
+      element.set('_account.name', 'new name');
 
-      assert.isFalse(element.usernameMutable);
-      assert.isFalse(displaySpan.hasAttribute('hidden'));
-      assert.equal(displaySpan.textContent, account.username);
-      assert.isTrue(inputSpan.hasAttribute('hidden'));
-    });
+      assert.isTrue(nameChangedSpy.called);
+      assert.isFalse(statusChangedSpy.called);
+      assert.isTrue(element.hasUnsavedChanges);
 
-    test('username render (mutable)', () => {
-      element.set('_serverConfig',
-          {auth: {editable_account_fields: ['USER_NAME']}});
-      element.set('_account.username', '');
-      element.set('_username', '');
-
-      const section = element.$.usernameSection;
-      const displaySpan = section.querySelectorAll('.value')[0];
-      const inputSpan = section.querySelectorAll('.value')[1];
-
-      assert.isTrue(element.usernameMutable);
-      assert.isTrue(displaySpan.hasAttribute('hidden'));
-      assert.equal(element.$.usernameInput.bindValue, account.username);
-      assert.isFalse(inputSpan.hasAttribute('hidden'));
-    });
-
-    suite('account info edit', () => {
-      let nameChangedSpy;
-      let usernameChangedSpy;
-      let statusChangedSpy;
-      let nameStub;
-      let usernameStub;
-      let statusStub;
-
-      setup(() => {
-        nameChangedSpy = sandbox.spy(element, '_nameChanged');
-        usernameChangedSpy = sandbox.spy(element, '_usernameChanged');
-        statusChangedSpy = sandbox.spy(element, '_statusChanged');
-        element.set('_serverConfig',
-            {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
-
-        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
-            name => Promise.resolve());
-        usernameStub = sandbox.stub(element.$.restAPI, 'setAccountUsername',
-            username => Promise.resolve());
-        statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-            status => Promise.resolve());
-      });
-
-      test('name', done => {
-        assert.isTrue(element.nameMutable);
-        assert.isFalse(element.hasUnsavedChanges);
-
-        element.set('_account.name', 'new name');
-
-        assert.isTrue(nameChangedSpy.called);
-        assert.isFalse(statusChangedSpy.called);
-        assert.isTrue(element.hasUnsavedChanges);
-
-        element.save().then(() => {
-          assert.isFalse(usernameStub.called);
-          assert.isTrue(nameStub.called);
-          assert.isFalse(statusStub.called);
-          nameStub.lastCall.returnValue.then(() => {
-            assert.equal(nameStub.lastCall.args[0], 'new name');
-            done();
-          });
-        });
-      });
-
-      test('username', done => {
-        element.set('_account.username', '');
-        element._hasUsernameChange = false;
-        assert.isTrue(element.usernameMutable);
-
-        element.set('_username', 'new username');
-
-        assert.isTrue(usernameChangedSpy.called);
-        assert.isFalse(statusChangedSpy.called);
-        assert.isTrue(element.hasUnsavedChanges);
-
-        element.save().then(() => {
-          assert.isTrue(usernameStub.called);
-          assert.isFalse(nameStub.called);
-          assert.isFalse(statusStub.called);
-          usernameStub.lastCall.returnValue.then(() => {
-            assert.equal(usernameStub.lastCall.args[0], 'new username');
-            done();
-          });
-        });
-      });
-
-      test('status', done => {
-        assert.isFalse(element.hasUnsavedChanges);
-
-        element.set('_account.status', 'new status');
-
-        assert.isFalse(nameChangedSpy.called);
-        assert.isTrue(statusChangedSpy.called);
-        assert.isTrue(element.hasUnsavedChanges);
-
-        element.save().then(() => {
-          assert.isFalse(usernameStub.called);
-          assert.isTrue(statusStub.called);
-          assert.isFalse(nameStub.called);
-          statusStub.lastCall.returnValue.then(() => {
-            assert.equal(statusStub.lastCall.args[0], 'new status');
-            done();
-          });
-        });
-      });
-    });
-
-    suite('edit name and status', () => {
-      let nameChangedSpy;
-      let statusChangedSpy;
-      let nameStub;
-      let statusStub;
-
-      setup(() => {
-        nameChangedSpy = sandbox.spy(element, '_nameChanged');
-        statusChangedSpy = sandbox.spy(element, '_statusChanged');
-        element.set('_serverConfig',
-            {auth: {editable_account_fields: ['FULL_NAME']}});
-
-        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
-            name => Promise.resolve());
-        statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-            status => Promise.resolve());
-        sandbox.stub(element.$.restAPI, 'setAccountUsername',
-            username => Promise.resolve());
-      });
-
-      test('set name and status', done => {
-        assert.isTrue(element.nameMutable);
-        assert.isFalse(element.hasUnsavedChanges);
-
-        element.set('_account.name', 'new name');
-
-        assert.isTrue(nameChangedSpy.called);
-
-        element.set('_account.status', 'new status');
-
-        assert.isTrue(statusChangedSpy.called);
-
-        assert.isTrue(element.hasUnsavedChanges);
-
-        element.save().then(() => {
-          assert.isTrue(statusStub.called);
-          assert.isTrue(nameStub.called);
-
+      element.save().then(() => {
+        assert.isFalse(usernameStub.called);
+        assert.isTrue(nameStub.called);
+        assert.isFalse(statusStub.called);
+        nameStub.lastCall.returnValue.then(() => {
           assert.equal(nameStub.lastCall.args[0], 'new name');
-
-          assert.equal(statusStub.lastCall.args[0], 'new status');
-
           done();
         });
       });
     });
 
-    suite('set status but read name', () => {
-      let statusChangedSpy;
-      let statusStub;
+    test('username', done => {
+      element.set('_account.username', '');
+      element._hasUsernameChange = false;
+      assert.isTrue(element.usernameMutable);
 
-      setup(() => {
-        statusChangedSpy = sandbox.spy(element, '_statusChanged');
-        element.set('_serverConfig',
-            {auth: {editable_account_fields: []}});
+      element.set('_username', 'new username');
 
-        statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-            status => Promise.resolve());
-      });
+      assert.isTrue(usernameChangedSpy.called);
+      assert.isFalse(statusChangedSpy.called);
+      assert.isTrue(element.hasUnsavedChanges);
 
-      test('read full name but set status', done => {
-        const section = element.$.nameSection;
-        const displaySpan = section.querySelectorAll('.value')[0];
-        const inputSpan = section.querySelectorAll('.value')[1];
-
-        assert.isFalse(element.nameMutable);
-
-        assert.isFalse(element.hasUnsavedChanges);
-
-        assert.isFalse(displaySpan.hasAttribute('hidden'));
-        assert.equal(displaySpan.textContent, account.name);
-        assert.isTrue(inputSpan.hasAttribute('hidden'));
-
-        element.set('_account.status', 'new status');
-
-        assert.isTrue(statusChangedSpy.called);
-
-        assert.isTrue(element.hasUnsavedChanges);
-
-        element.save().then(() => {
-          assert.isTrue(statusStub.called);
-          statusStub.lastCall.returnValue.then(() => {
-            assert.equal(statusStub.lastCall.args[0], 'new status');
-            done();
-          });
+      element.save().then(() => {
+        assert.isTrue(usernameStub.called);
+        assert.isFalse(nameStub.called);
+        assert.isFalse(statusStub.called);
+        usernameStub.lastCall.returnValue.then(() => {
+          assert.equal(usernameStub.lastCall.args[0], 'new username');
+          done();
         });
       });
     });
 
-    test('_usernameChanged compares usernames with loose equality', () => {
-      element._account = {};
-      element._username = '';
-      element._hasUsernameChange = false;
-      element._loading = false;
-      // _usernameChanged is an observer, but call it here after setting
-      // _hasUsernameChange in the test to force recomputation.
-      element._usernameChanged();
-      flushAsynchronousOperations();
+    test('status', done => {
+      assert.isFalse(element.hasUnsavedChanges);
 
-      assert.isFalse(element._hasUsernameChange);
+      element.set('_account.status', 'new status');
 
-      element.set('_username', 'test');
-      flushAsynchronousOperations();
+      assert.isFalse(nameChangedSpy.called);
+      assert.isTrue(statusChangedSpy.called);
+      assert.isTrue(element.hasUnsavedChanges);
 
-      assert.isTrue(element._hasUsernameChange);
-    });
-
-    test('_hideAvatarChangeUrl', () => {
-      assert.equal(element._hideAvatarChangeUrl(''), 'hide');
-
-      assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
+      element.save().then(() => {
+        assert.isFalse(usernameStub.called);
+        assert.isTrue(statusStub.called);
+        assert.isFalse(nameStub.called);
+        statusStub.lastCall.returnValue.then(() => {
+          assert.equal(statusStub.lastCall.args[0], 'new status');
+          done();
+        });
+      });
     });
   });
+
+  suite('edit name and status', () => {
+    let nameChangedSpy;
+    let statusChangedSpy;
+    let nameStub;
+    let statusStub;
+
+    setup(() => {
+      nameChangedSpy = sandbox.spy(element, '_nameChanged');
+      statusChangedSpy = sandbox.spy(element, '_statusChanged');
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: ['FULL_NAME']}});
+
+      nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
+          name => Promise.resolve());
+      statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
+          status => Promise.resolve());
+      sandbox.stub(element.$.restAPI, 'setAccountUsername',
+          username => Promise.resolve());
+    });
+
+    test('set name and status', done => {
+      assert.isTrue(element.nameMutable);
+      assert.isFalse(element.hasUnsavedChanges);
+
+      element.set('_account.name', 'new name');
+
+      assert.isTrue(nameChangedSpy.called);
+
+      element.set('_account.status', 'new status');
+
+      assert.isTrue(statusChangedSpy.called);
+
+      assert.isTrue(element.hasUnsavedChanges);
+
+      element.save().then(() => {
+        assert.isTrue(statusStub.called);
+        assert.isTrue(nameStub.called);
+
+        assert.equal(nameStub.lastCall.args[0], 'new name');
+
+        assert.equal(statusStub.lastCall.args[0], 'new status');
+
+        done();
+      });
+    });
+  });
+
+  suite('set status but read name', () => {
+    let statusChangedSpy;
+    let statusStub;
+
+    setup(() => {
+      statusChangedSpy = sandbox.spy(element, '_statusChanged');
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: []}});
+
+      statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
+          status => Promise.resolve());
+    });
+
+    test('read full name but set status', done => {
+      const section = element.$.nameSection;
+      const displaySpan = section.querySelectorAll('.value')[0];
+      const inputSpan = section.querySelectorAll('.value')[1];
+
+      assert.isFalse(element.nameMutable);
+
+      assert.isFalse(element.hasUnsavedChanges);
+
+      assert.isFalse(displaySpan.hasAttribute('hidden'));
+      assert.equal(displaySpan.textContent, account.name);
+      assert.isTrue(inputSpan.hasAttribute('hidden'));
+
+      element.set('_account.status', 'new status');
+
+      assert.isTrue(statusChangedSpy.called);
+
+      assert.isTrue(element.hasUnsavedChanges);
+
+      element.save().then(() => {
+        assert.isTrue(statusStub.called);
+        statusStub.lastCall.returnValue.then(() => {
+          assert.equal(statusStub.lastCall.args[0], 'new status');
+          done();
+        });
+      });
+    });
+  });
+
+  test('_usernameChanged compares usernames with loose equality', () => {
+    element._account = {};
+    element._username = '';
+    element._hasUsernameChange = false;
+    element._loading = false;
+    // _usernameChanged is an observer, but call it here after setting
+    // _hasUsernameChange in the test to force recomputation.
+    element._usernameChanged();
+    flushAsynchronousOperations();
+
+    assert.isFalse(element._hasUsernameChange);
+
+    element.set('_username', 'test');
+    flushAsynchronousOperations();
+
+    assert.isTrue(element._hasUsernameChange);
+  });
+
+  test('_hideAvatarChangeUrl', () => {
+    assert.equal(element._hideAvatarChangeUrl(''), 'hide');
+
+    assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
index 67dc0c4..18a0419 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
@@ -14,46 +14,56 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @extends Polymer.Element
-   */
-  class GrAgreementsList extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-agreements-list'; }
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-agreements-list_html.js';
 
-    static get properties() {
-      return {
-        _agreements: Array,
-      };
-    }
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @extends Polymer.Element
+ */
+class GrAgreementsList extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this.loadData();
-    }
+  static get is() { return 'gr-agreements-list'; }
 
-    loadData() {
-      return this.$.restAPI.getAccountAgreements().then(agreements => {
-        this._agreements = agreements;
-      });
-    }
-
-    getUrl() {
-      return this.getBaseUrl() + '/settings/new-agreement';
-    }
-
-    getUrlBase(item) {
-      return this.getBaseUrl() + '/' + item;
-    }
+  static get properties() {
+    return {
+      _agreements: Array,
+    };
   }
 
-  customElements.define(GrAgreementsList.is, GrAgreementsList);
-})();
+  /** @override */
+  attached() {
+    super.attached();
+    this.loadData();
+  }
+
+  loadData() {
+    return this.$.restAPI.getAccountAgreements().then(agreements => {
+      this._agreements = agreements;
+    });
+  }
+
+  getUrl() {
+    return this.getBaseUrl() + '/settings/new-agreement';
+  }
+
+  getUrlBase(item) {
+    return this.getBaseUrl() + '/' + item;
+  }
+}
+
+customElements.define(GrAgreementsList.is, GrAgreementsList);
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.js
index 74d92d3..4bd9365 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-agreements-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       #agreements .nameColumn {
         min-width: 15em;
@@ -47,7 +41,7 @@
           <template is="dom-repeat" items="[[_agreements]]">
             <tr>
               <td class="nameColumn">
-                <a href$="[[getUrlBase(item.url)]]" rel="external">
+                <a href\$="[[getUrlBase(item.url)]]" rel="external">
                   [[item.name]]
                 </a>
               </td>
@@ -56,9 +50,7 @@
           </template>
         </tbody>
       </table>
-      <a href$="[[getUrl()]]">New Contributor Agreement</a>
+      <a href\$="[[getUrl()]]">New Contributor Agreement</a>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-agreements-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
index 39b8663..4aa917b 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-agreements-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-agreements-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-agreements-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,38 +40,41 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-agreements-list tests', async () => {
-    await readyToTest();
-    let element;
-    let agreements;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-agreements-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-agreements-list tests', () => {
+  let element;
+  let agreements;
 
-    setup(done => {
-      agreements = [{
-        url: 'some url',
-        description: 'Agreements 1 description',
-        name: 'Agreements 1',
-      }];
+  setup(done => {
+    agreements = [{
+      url: 'some url',
+      description: 'Agreements 1 description',
+      name: 'Agreements 1',
+    }];
 
-      stub('gr-rest-api-interface', {
-        getAccountAgreements() { return Promise.resolve(agreements); },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getAccountAgreements() { return Promise.resolve(agreements); },
     });
 
-    test('renders', () => {
-      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+    element = fixture('basic');
 
-      assert.equal(rows.length, 1);
-
-      const nameCells = Array.from(rows).map(row =>
-        row.querySelectorAll('td')[0].textContent.trim()
-      );
-
-      assert.equal(nameCells[0], 'Agreements 1');
-    });
+    element.loadData().then(() => { flush(done); });
   });
+
+  test('renders', () => {
+    const rows = dom(element.root).querySelectorAll('tbody tr');
+
+    assert.equal(rows.length, 1);
+
+    const nameCells = Array.from(rows).map(row =>
+      row.querySelectorAll('td')[0].textContent.trim()
+    );
+
+    assert.equal(nameCells[0], 'Agreements 1');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
index 8521126..85c34a4 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
@@ -14,72 +14,85 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
 
-  /**
-   * @appliesMixin Gerrit.ChangeTableMixin
-   * @extends Polymer.Element
-   */
-  class GrChangeTableEditor extends Polymer.mixinBehaviors( [
-    Gerrit.ChangeTableBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-change-table-editor'; }
+import '../../../scripts/bundled-polymer.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import '../../../styles/gr-form-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-table-editor_html.js';
 
-    static get properties() {
-      return {
-        displayedColumns: {
-          type: Array,
-          notify: true,
-        },
-        showNumber: {
-          type: Boolean,
-          notify: true,
-        },
-      };
-    }
+/**
+ * @appliesMixin Gerrit.ChangeTableMixin
+ * @extends Polymer.Element
+ */
+class GrChangeTableEditor extends mixinBehaviors( [
+  Gerrit.ChangeTableBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Get the list of enabled column names from whichever checkboxes are
-     * checked (excluding the number checkbox).
-     *
-     * @return {!Array<string>}
-     */
-    _getDisplayedColumns() {
-      return Array.from(Polymer.dom(this.root)
-          .querySelectorAll('.checkboxContainer input:not([name=number])'))
-          .filter(checkbox => checkbox.checked)
-          .map(checkbox => checkbox.name);
-    }
+  static get is() { return 'gr-change-table-editor'; }
 
-    /**
-     * Handle a click on a checkbox container and relay the click to the checkbox it
-     * contains.
-     */
-    _handleCheckboxContainerClick(e) {
-      const checkbox = Polymer.dom(e.target).querySelector('input');
-      if (!checkbox) { return; }
-      checkbox.click();
-    }
-
-    /**
-     * Handle a click on the number checkbox and update the showNumber property
-     * accordingly.
-     */
-    _handleNumberCheckboxClick(e) {
-      this.showNumber = Polymer.dom(e).rootTarget.checked;
-    }
-
-    /**
-     * Handle a click on a displayed column checkboxes (excluding number) and
-     * update the displayedColumns property accordingly.
-     */
-    _handleTargetClick(e) {
-      this.set('displayedColumns', this._getDisplayedColumns());
-    }
+  static get properties() {
+    return {
+      displayedColumns: {
+        type: Array,
+        notify: true,
+      },
+      showNumber: {
+        type: Boolean,
+        notify: true,
+      },
+    };
   }
 
-  customElements.define(GrChangeTableEditor.is, GrChangeTableEditor);
-})();
+  /**
+   * Get the list of enabled column names from whichever checkboxes are
+   * checked (excluding the number checkbox).
+   *
+   * @return {!Array<string>}
+   */
+  _getDisplayedColumns() {
+    return Array.from(dom(this.root)
+        .querySelectorAll('.checkboxContainer input:not([name=number])'))
+        .filter(checkbox => checkbox.checked)
+        .map(checkbox => checkbox.name);
+  }
+
+  /**
+   * Handle a click on a checkbox container and relay the click to the checkbox it
+   * contains.
+   */
+  _handleCheckboxContainerClick(e) {
+    const checkbox = dom(e.target).querySelector('input');
+    if (!checkbox) { return; }
+    checkbox.click();
+  }
+
+  /**
+   * Handle a click on the number checkbox and update the showNumber property
+   * accordingly.
+   */
+  _handleNumberCheckboxClick(e) {
+    this.showNumber = dom(e).rootTarget.checked;
+  }
+
+  /**
+   * Handle a click on a displayed column checkboxes (excluding number) and
+   * update the displayedColumns property accordingly.
+   */
+  _handleTargetClick(e) {
+    this.set('displayedColumns', this._getDisplayedColumns());
+  }
+}
+
+customElements.define(GrChangeTableEditor.is, GrChangeTableEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.js
index 09a9dbc..7aa785c 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-
-<dom-module id="gr-change-table-editor">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -56,31 +49,19 @@
         <tbody>
           <tr>
             <td>Number</td>
-            <td class="checkboxContainer"
-                on-click="_handleCheckboxContainerClick">
-              <input
-                  type="checkbox"
-                  name="number"
-                  on-click="_handleNumberCheckboxClick"
-                  checked$="[[showNumber]]">
+            <td class="checkboxContainer" on-click="_handleCheckboxContainerClick">
+              <input type="checkbox" name="number" on-click="_handleNumberCheckboxClick" checked\$="[[showNumber]]">
             </td>
           </tr>
           <template is="dom-repeat" items="[[columnNames]]">
             <tr>
               <td>[[item]]</td>
-              <td class="checkboxContainer"
-                  on-click="_handleCheckboxContainerClick">
-                <input
-                    type="checkbox"
-                    name="[[item]]"
-                    on-click="_handleTargetClick"
-                    checked$="[[!isColumnHidden(item, displayedColumns)]]">
+              <td class="checkboxContainer" on-click="_handleCheckboxContainerClick">
+                <input type="checkbox" name="[[item]]" on-click="_handleTargetClick" checked\$="[[!isColumnHidden(item, displayedColumns)]]">
               </td>
             </tr>
           </template>
         </tbody>
       </table>
     </div>
-  </template>
-  <script src="gr-change-table-editor.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
index 460d6bc..1d60384 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-change-table-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-change-table-editor.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-change-table-editor.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,138 +40,140 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-table-editor tests', async () => {
-    await readyToTest();
-    let element;
-    let columns;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-change-table-editor.js';
+suite('gr-change-table-editor tests', () => {
+  let element;
+  let columns;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
 
-      columns = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Repo',
-        'Branch',
-        'Updated',
-      ];
+    columns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Updated',
+    ];
 
-      element.set('displayedColumns', columns);
-      element.showNumber = false;
-      flushAsynchronousOperations();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('renders', () => {
-      const rows = element.shadowRoot
-          .querySelector('tbody').querySelectorAll('tr');
-      let tds;
-
-      // The `+ 1` is for the number column, which isn't included in the change
-      // table behavior's list.
-      assert.equal(rows.length, element.columnNames.length + 1);
-      for (let i = 0; i < columns.length; i++) {
-        tds = rows[i + 1].querySelectorAll('td');
-        assert.equal(tds[0].textContent, columns[i]);
-      }
-    });
-
-    test('hide item', () => {
-      const checkbox = element.shadowRoot
-          .querySelector('table tr:nth-child(2) input');
-      const isChecked = checkbox.checked;
-      const displayedLength = element.displayedColumns.length;
-      assert.isTrue(isChecked);
-
-      MockInteractions.tap(checkbox);
-      flushAsynchronousOperations();
-
-      assert.equal(element.displayedColumns.length, displayedLength - 1);
-    });
-
-    test('show item', () => {
-      element.set('displayedColumns', [
-        'Status',
-        'Owner',
-        'Assignee',
-        'Repo',
-        'Branch',
-        'Updated',
-      ]);
-      flushAsynchronousOperations();
-      const checkbox = element.shadowRoot
-          .querySelector('table tr:nth-child(2) input');
-      const isChecked = checkbox.checked;
-      const displayedLength = element.displayedColumns.length;
-      assert.isFalse(isChecked);
-      assert.equal(element.shadowRoot
-          .querySelector('table').style.display, '');
-
-      MockInteractions.tap(checkbox);
-      flushAsynchronousOperations();
-
-      assert.equal(element.displayedColumns.length,
-          displayedLength + 1);
-    });
-
-    test('_getDisplayedColumns', () => {
-      assert.deepEqual(element._getDisplayedColumns(), columns);
-      MockInteractions.tap(
-          element.shadowRoot
-              .querySelector('.checkboxContainer input[name=Assignee]'));
-      assert.deepEqual(element._getDisplayedColumns(),
-          columns.filter(c => c !== 'Assignee'));
-    });
-
-    test('_handleCheckboxContainerClick relayes taps to checkboxes', () => {
-      sandbox.stub(element, '_handleNumberCheckboxClick');
-      sandbox.stub(element, '_handleTargetClick');
-
-      MockInteractions.tap(
-          element.shadowRoot
-              .querySelector('table tr:first-of-type .checkboxContainer'));
-      assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-      assert.isFalse(element._handleTargetClick.called);
-
-      MockInteractions.tap(
-          element.shadowRoot
-              .querySelector('table tr:last-of-type .checkboxContainer'));
-      assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-      assert.isTrue(element._handleTargetClick.calledOnce);
-    });
-
-    test('_handleNumberCheckboxClick', () => {
-      sandbox.spy(element, '_handleNumberCheckboxClick');
-
-      MockInteractions
-          .tap(element.shadowRoot
-              .querySelector('.checkboxContainer input[name=number]'));
-      assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-      assert.isTrue(element.showNumber);
-
-      MockInteractions
-          .tap(element.shadowRoot
-              .querySelector('.checkboxContainer input[name=number]'));
-      assert.isTrue(element._handleNumberCheckboxClick.calledTwice);
-      assert.isFalse(element.showNumber);
-    });
-
-    test('_handleTargetClick', () => {
-      sandbox.spy(element, '_handleTargetClick');
-      assert.include(element.displayedColumns, 'Assignee');
-      MockInteractions
-          .tap(element.shadowRoot
-              .querySelector('.checkboxContainer input[name=Assignee]'));
-      assert.isTrue(element._handleTargetClick.calledOnce);
-      assert.notInclude(element.displayedColumns, 'Assignee');
-    });
+    element.set('displayedColumns', columns);
+    element.showNumber = false;
+    flushAsynchronousOperations();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('tbody').querySelectorAll('tr');
+    let tds;
+
+    // The `+ 1` is for the number column, which isn't included in the change
+    // table behavior's list.
+    assert.equal(rows.length, element.columnNames.length + 1);
+    for (let i = 0; i < columns.length; i++) {
+      tds = rows[i + 1].querySelectorAll('td');
+      assert.equal(tds[0].textContent, columns[i]);
+    }
+  });
+
+  test('hide item', () => {
+    const checkbox = element.shadowRoot
+        .querySelector('table tr:nth-child(2) input');
+    const isChecked = checkbox.checked;
+    const displayedLength = element.displayedColumns.length;
+    assert.isTrue(isChecked);
+
+    MockInteractions.tap(checkbox);
+    flushAsynchronousOperations();
+
+    assert.equal(element.displayedColumns.length, displayedLength - 1);
+  });
+
+  test('show item', () => {
+    element.set('displayedColumns', [
+      'Status',
+      'Owner',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Updated',
+    ]);
+    flushAsynchronousOperations();
+    const checkbox = element.shadowRoot
+        .querySelector('table tr:nth-child(2) input');
+    const isChecked = checkbox.checked;
+    const displayedLength = element.displayedColumns.length;
+    assert.isFalse(isChecked);
+    assert.equal(element.shadowRoot
+        .querySelector('table').style.display, '');
+
+    MockInteractions.tap(checkbox);
+    flushAsynchronousOperations();
+
+    assert.equal(element.displayedColumns.length,
+        displayedLength + 1);
+  });
+
+  test('_getDisplayedColumns', () => {
+    assert.deepEqual(element._getDisplayedColumns(), columns);
+    MockInteractions.tap(
+        element.shadowRoot
+            .querySelector('.checkboxContainer input[name=Assignee]'));
+    assert.deepEqual(element._getDisplayedColumns(),
+        columns.filter(c => c !== 'Assignee'));
+  });
+
+  test('_handleCheckboxContainerClick relayes taps to checkboxes', () => {
+    sandbox.stub(element, '_handleNumberCheckboxClick');
+    sandbox.stub(element, '_handleTargetClick');
+
+    MockInteractions.tap(
+        element.shadowRoot
+            .querySelector('table tr:first-of-type .checkboxContainer'));
+    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
+    assert.isFalse(element._handleTargetClick.called);
+
+    MockInteractions.tap(
+        element.shadowRoot
+            .querySelector('table tr:last-of-type .checkboxContainer'));
+    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
+    assert.isTrue(element._handleTargetClick.calledOnce);
+  });
+
+  test('_handleNumberCheckboxClick', () => {
+    sandbox.spy(element, '_handleNumberCheckboxClick');
+
+    MockInteractions
+        .tap(element.shadowRoot
+            .querySelector('.checkboxContainer input[name=number]'));
+    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
+    assert.isTrue(element.showNumber);
+
+    MockInteractions
+        .tap(element.shadowRoot
+            .querySelector('.checkboxContainer input[name=number]'));
+    assert.isTrue(element._handleNumberCheckboxClick.calledTwice);
+    assert.isFalse(element.showNumber);
+  });
+
+  test('_handleTargetClick', () => {
+    sandbox.spy(element, '_handleTargetClick');
+    assert.include(element.displayedColumns, 'Assignee');
+    MockInteractions
+        .tap(element.shadowRoot
+            .querySelector('.checkboxContainer input[name=Assignee]'));
+    assert.isTrue(element._handleTargetClick.calledOnce);
+    assert.notInclude(element.displayedColumns, 'Assignee');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
index cff1d54..373ac63 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
@@ -14,151 +14,164 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
-   */
-  class GrClaView extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-cla-view'; }
+import '@polymer/iron-input/iron-input.js';
+import '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-cla-view_html.js';
 
-    static get properties() {
-      return {
-        _groups: Object,
-        /** @type {?} */
-        _serverConfig: Object,
-        _agreementsText: String,
-        _agreementName: String,
-        _signedAgreements: Array,
-        _showAgreements: {
-          type: Boolean,
-          value: false,
-        },
-        _agreementsUrl: String,
-      };
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrClaView extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-cla-view'; }
+
+  static get properties() {
+    return {
+      _groups: Object,
+      /** @type {?} */
+      _serverConfig: Object,
+      _agreementsText: String,
+      _agreementName: String,
+      _signedAgreements: Array,
+      _showAgreements: {
+        type: Boolean,
+        value: false,
+      },
+      _agreementsUrl: String,
+    };
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.loadData();
+
+    this.fire('title-change', {title: 'New Contributor Agreement'});
+  }
+
+  loadData() {
+    const promises = [];
+    promises.push(this.$.restAPI.getConfig(true).then(config => {
+      this._serverConfig = config;
+    }));
+
+    promises.push(this.$.restAPI.getAccountGroups().then(groups => {
+      this._groups = groups.sort((a, b) => a.name.localeCompare(b.name));
+    }));
+
+    promises.push(this.$.restAPI.getAccountAgreements().then(agreements => {
+      this._signedAgreements = agreements || [];
+    }));
+
+    return Promise.all(promises);
+  }
+
+  _getAgreementsUrl(configUrl) {
+    let url;
+    if (!configUrl) {
+      return '';
+    }
+    if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
+      url = configUrl;
+    } else {
+      url = this.getBaseUrl() + '/' + configUrl;
     }
 
-    /** @override */
-    attached() {
-      super.attached();
+    return url;
+  }
+
+  _handleShowAgreement(e) {
+    this._agreementName = e.target.getAttribute('data-name');
+    this._agreementsUrl =
+        this._getAgreementsUrl(e.target.getAttribute('data-url'));
+    this._showAgreements = true;
+  }
+
+  _handleSaveAgreements(e) {
+    this._createToast('Agreement saving...');
+
+    const name = this._agreementName;
+    return this.$.restAPI.saveAccountAgreement({name}).then(res => {
+      let message = 'Agreement failed to be submitted, please try again';
+      if (res.status === 200) {
+        message = 'Agreement has been successfully submited.';
+      }
+      this._createToast(message);
       this.loadData();
+      this._agreementsText = '';
+      this._showAgreements = false;
+    });
+  }
 
-      this.fire('title-change', {title: 'New Contributor Agreement'});
-    }
+  _createToast(message) {
+    this.dispatchEvent(new CustomEvent(
+        'show-alert', {detail: {message}, bubbles: true, composed: true}));
+  }
 
-    loadData() {
-      const promises = [];
-      promises.push(this.$.restAPI.getConfig(true).then(config => {
-        this._serverConfig = config;
-      }));
+  _computeShowAgreementsClass(agreements) {
+    return agreements ? 'show' : '';
+  }
 
-      promises.push(this.$.restAPI.getAccountGroups().then(groups => {
-        this._groups = groups.sort((a, b) => a.name.localeCompare(b.name));
-      }));
-
-      promises.push(this.$.restAPI.getAccountAgreements().then(agreements => {
-        this._signedAgreements = agreements || [];
-      }));
-
-      return Promise.all(promises);
-    }
-
-    _getAgreementsUrl(configUrl) {
-      let url;
-      if (!configUrl) {
-        return '';
+  _disableAgreements(item, groups, signedAgreements) {
+    if (!groups) return false;
+    for (const group of groups) {
+      if ((item && item.auto_verify_group &&
+          item.auto_verify_group.id === group.id) ||
+          signedAgreements.find(i => i.name === item.name)) {
+        return true;
       }
-      if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
-        url = configUrl;
-      } else {
-        url = this.getBaseUrl() + '/' + configUrl;
+    }
+    return false;
+  }
+
+  _hideAgreements(item, groups, signedAgreements) {
+    return this._disableAgreements(item, groups, signedAgreements) ?
+      '' : 'hide';
+  }
+
+  _disableAgreementsText(text) {
+    return text.toLowerCase() === 'i agree' ? false : true;
+  }
+
+  // This checks for auto_verify_group,
+  // if specified it returns 'hideAgreementsTextBox' which
+  // then hides the text box and submit button.
+  _computeHideAgreementClass(name, config) {
+    if (!config) return '';
+    for (const key in config) {
+      if (!config.hasOwnProperty(key)) {
+        continue;
       }
-
-      return url;
-    }
-
-    _handleShowAgreement(e) {
-      this._agreementName = e.target.getAttribute('data-name');
-      this._agreementsUrl =
-          this._getAgreementsUrl(e.target.getAttribute('data-url'));
-      this._showAgreements = true;
-    }
-
-    _handleSaveAgreements(e) {
-      this._createToast('Agreement saving...');
-
-      const name = this._agreementName;
-      return this.$.restAPI.saveAccountAgreement({name}).then(res => {
-        let message = 'Agreement failed to be submitted, please try again';
-        if (res.status === 200) {
-          message = 'Agreement has been successfully submited.';
-        }
-        this._createToast(message);
-        this.loadData();
-        this._agreementsText = '';
-        this._showAgreements = false;
-      });
-    }
-
-    _createToast(message) {
-      this.dispatchEvent(new CustomEvent(
-          'show-alert', {detail: {message}, bubbles: true, composed: true}));
-    }
-
-    _computeShowAgreementsClass(agreements) {
-      return agreements ? 'show' : '';
-    }
-
-    _disableAgreements(item, groups, signedAgreements) {
-      if (!groups) return false;
-      for (const group of groups) {
-        if ((item && item.auto_verify_group &&
-            item.auto_verify_group.id === group.id) ||
-            signedAgreements.find(i => i.name === item.name)) {
-          return true;
-        }
-      }
-      return false;
-    }
-
-    _hideAgreements(item, groups, signedAgreements) {
-      return this._disableAgreements(item, groups, signedAgreements) ?
-        '' : 'hide';
-    }
-
-    _disableAgreementsText(text) {
-      return text.toLowerCase() === 'i agree' ? false : true;
-    }
-
-    // This checks for auto_verify_group,
-    // if specified it returns 'hideAgreementsTextBox' which
-    // then hides the text box and submit button.
-    _computeHideAgreementClass(name, config) {
-      if (!config) return '';
-      for (const key in config) {
-        if (!config.hasOwnProperty(key)) {
+      for (const prop in config[key]) {
+        if (!config[key].hasOwnProperty(prop)) {
           continue;
         }
-        for (const prop in config[key]) {
-          if (!config[key].hasOwnProperty(prop)) {
-            continue;
-          }
-          if (name === config[key].name &&
-              !config[key].auto_verify_group) {
-            return 'hideAgreementsTextBox';
-          }
+        if (name === config[key].name &&
+            !config[key].auto_verify_group) {
+          return 'hideAgreementsTextBox';
         }
       }
     }
   }
+}
 
-  customElements.define(GrClaView.is, GrClaView);
-})();
+customElements.define(GrClaView.is, GrClaView);
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js
index fb5d64f..2c2fca0 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js
@@ -1,31 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-cla-view">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       h1 {
         margin-bottom: var(--spacing-m);
@@ -74,36 +65,26 @@
       <h3>Select an agreement type:</h3>
       <template is="dom-repeat" items="[[_serverConfig.auth.contributor_agreements]]">
         <span class="contributorAgreementButton">
-          <input id$="claNewAgreementsInput[[item.name]]"
-              name="claNewAgreementsRadio"
-              type="radio"
-              data-name$="[[item.name]]"
-              data-url$="[[item.url]]"
-              on-click="_handleShowAgreement"
-              disabled$="[[_disableAgreements(item, _groups, _signedAgreements)]]">
+          <input id\$="claNewAgreementsInput[[item.name]]" name="claNewAgreementsRadio" type="radio" data-name\$="[[item.name]]" data-url\$="[[item.url]]" on-click="_handleShowAgreement" disabled\$="[[_disableAgreements(item, _groups, _signedAgreements)]]">
           <label id="claNewAgreementsLabel">[[item.name]]</label>
         </span>
-        <div class$="alreadySubmittedText [[_hideAgreements(item, _groups, _signedAgreements)]]">
+        <div class\$="alreadySubmittedText [[_hideAgreements(item, _groups, _signedAgreements)]]">
           Agreement already submitted.
         </div>
         <div class="agreementsUrl">
           [[item.description]]
         </div>
       </template>
-      <div id="claNewAgreement" class$="[[_computeShowAgreementsClass(_showAgreements)]]">
+      <div id="claNewAgreement" class\$="[[_computeShowAgreementsClass(_showAgreements)]]">
         <h3 class="smallHeading">Review the agreement:</h3>
         <div id="agreementsUrl" class="agreementsUrl">
-          <a href$="[[_agreementsUrl]]" target="blank" rel="noopener">
+          <a href\$="[[_agreementsUrl]]" target="blank" rel="noopener">
             Please review the agreement.</a>
         </div>
-        <div class$="agreementsTextBox [[_computeHideAgreementClass(_agreementName, _serverConfig.auth.contributor_agreements)]]">
+        <div class\$="agreementsTextBox [[_computeHideAgreementClass(_agreementName, _serverConfig.auth.contributor_agreements)]]">
           <h3 class="smallHeading">Complete the agreement:</h3>
-          <iron-input bind-value="{{_agreementsText}}"
-                      placeholder="Enter 'I agree' here">
-            <input id="input-agreements"
-                   is="iron-input"
-                   bind-value="{{_agreementsText}}"
-                   placeholder="Enter 'I agree' here">
+          <iron-input bind-value="{{_agreementsText}}" placeholder="Enter 'I agree' here">
+            <input id="input-agreements" is="iron-input" bind-value="{{_agreementsText}}" placeholder="Enter 'I agree' here">
           </iron-input>
           <gr-button on-click="_handleSaveAgreements" disabled="[[_disableAgreementsText(_agreementsText)]]">
             Submit
@@ -112,6 +93,4 @@
       </div>
     </main>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-cla-view.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
index d40d36d..50f82e2 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-cla-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-cla-view.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-cla-view.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-cla-view.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,162 +40,165 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-cla-view tests', async () => {
-    await readyToTest();
-    let element;
-    const signedAgreements = [{
-      name: 'CLA',
-      description: 'Contributor License Agreement',
-      url: 'static/cla.html',
-    }];
-    const auth = {
-      name: 'Individual',
-      description: 'test-description',
-      url: 'static/cla_individual.html',
-      auto_verify_group: {
-        url: '#/admin/groups/uuid-e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-        options: {
-          visible_to_all: true,
-        },
-        group_id: 20,
-        owner: 'CLA Accepted - Individual',
-        owner_id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-        created_on: '2017-07-31 15:11:04.000000000',
-        id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-        name: 'CLA Accepted - Individual',
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-cla-view.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-cla-view tests', () => {
+  let element;
+  const signedAgreements = [{
+    name: 'CLA',
+    description: 'Contributor License Agreement',
+    url: 'static/cla.html',
+  }];
+  const auth = {
+    name: 'Individual',
+    description: 'test-description',
+    url: 'static/cla_individual.html',
+    auto_verify_group: {
+      url: '#/admin/groups/uuid-e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+      options: {
+        visible_to_all: true,
       },
-    };
-
-    const auth2 = {
-      name: 'Individual2',
-      description: 'test-description2',
-      url: 'static/cla_individual2.html',
-      auto_verify_group: {
-        url: '#/admin/groups/uuid-bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-        options: {},
-        group_id: 21,
-        owner: 'CLA Accepted - Individual2',
-        owner_id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-        created_on: '2017-07-31 15:25:42.000000000',
-        id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-        name: 'CLA Accepted - Individual2',
-      },
-    };
-
-    const auth3 = {
-      name: 'CLA',
-      description: 'Contributor License Agreement',
-      url: 'static/cla_individual.html',
-    };
-
-    const config = {
-      auth: {
-        use_contributor_agreements: true,
-        contributor_agreements: [
-          {
-            name: 'Individual',
-            description: 'test-description',
-            url: 'static/cla_individual.html',
-          },
-          {
-            name: 'CLA',
-            description: 'Contributor License Agreement',
-            url: 'static/cla.html',
-          }],
-      },
-    };
-    const config2 = {
-      auth: {
-        use_contributor_agreements: true,
-        contributor_agreements: [
-          {
-            name: 'Individual2',
-            description: 'test-description2',
-            url: 'static/cla_individual2.html',
-          },
-        ],
-      },
-    };
-    const groups = [{
-      options: {visible_to_all: true},
+      group_id: 20,
+      owner: 'CLA Accepted - Individual',
+      owner_id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+      created_on: '2017-07-31 15:11:04.000000000',
       id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-      group_id: 3,
       name: 'CLA Accepted - Individual',
     },
-    ];
+  };
 
-    setup(done => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve(config); },
-        getAccountGroups() { return Promise.resolve(groups); },
-        getAccountAgreements() { return Promise.resolve(signedAgreements); },
-      });
-      element = fixture('basic');
-      element.loadData().then(() => { flush(done); });
-    });
+  const auth2 = {
+    name: 'Individual2',
+    description: 'test-description2',
+    url: 'static/cla_individual2.html',
+    auto_verify_group: {
+      url: '#/admin/groups/uuid-bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      options: {},
+      group_id: 21,
+      owner: 'CLA Accepted - Individual2',
+      owner_id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      created_on: '2017-07-31 15:25:42.000000000',
+      id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      name: 'CLA Accepted - Individual2',
+    },
+  };
 
-    test('renders as expected with signed agreement', () => {
-      const agreementSections = Polymer.dom(element.root)
-          .querySelectorAll('.contributorAgreementButton');
-      const agreementSubmittedTexts = Polymer.dom(element.root)
-          .querySelectorAll('.alreadySubmittedText');
-      assert.equal(agreementSections.length, 2);
-      assert.isFalse(agreementSections[0].querySelector('input').disabled);
-      assert.equal(getComputedStyle(agreementSubmittedTexts[0]).display,
-          'none');
-      assert.isTrue(agreementSections[1].querySelector('input').disabled);
-      assert.notEqual(getComputedStyle(agreementSubmittedTexts[1]).display,
-          'none');
-    });
+  const auth3 = {
+    name: 'CLA',
+    description: 'Contributor License Agreement',
+    url: 'static/cla_individual.html',
+  };
 
-    test('_disableAgreements', () => {
-      // In the auto verify group and have not yet signed agreement
-      assert.isTrue(
-          element._disableAgreements(auth, groups, signedAgreements));
-      // Not in the auto verify group and have not yet signed agreement
-      assert.isFalse(
-          element._disableAgreements(auth2, groups, signedAgreements));
-      // Not in the auto verify group, have signed agreement
-      assert.isTrue(
-          element._disableAgreements(auth3, groups, signedAgreements));
-      // Make sure the undefined check works
-      assert.isFalse(
-          element._disableAgreements(auth, undefined, signedAgreements));
-    });
+  const config = {
+    auth: {
+      use_contributor_agreements: true,
+      contributor_agreements: [
+        {
+          name: 'Individual',
+          description: 'test-description',
+          url: 'static/cla_individual.html',
+        },
+        {
+          name: 'CLA',
+          description: 'Contributor License Agreement',
+          url: 'static/cla.html',
+        }],
+    },
+  };
+  const config2 = {
+    auth: {
+      use_contributor_agreements: true,
+      contributor_agreements: [
+        {
+          name: 'Individual2',
+          description: 'test-description2',
+          url: 'static/cla_individual2.html',
+        },
+      ],
+    },
+  };
+  const groups = [{
+    options: {visible_to_all: true},
+    id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+    group_id: 3,
+    name: 'CLA Accepted - Individual',
+  },
+  ];
 
-    test('_hideAgreements', () => {
-      // Not in the auto verify group and have not yet signed agreement
-      assert.equal(
-          element._hideAgreements(auth, groups, signedAgreements), '');
-      // In the auto verify group
-      assert.equal(
-          element._hideAgreements(auth2, groups, signedAgreements), 'hide');
-      // Not in the auto verify group, have signed agreement
-      assert.equal(
-          element._hideAgreements(auth3, groups, signedAgreements), '');
+  setup(done => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve(config); },
+      getAccountGroups() { return Promise.resolve(groups); },
+      getAccountAgreements() { return Promise.resolve(signedAgreements); },
     });
-
-    test('_disableAgreementsText', () => {
-      assert.isFalse(element._disableAgreementsText('I AGREE'));
-      assert.isTrue(element._disableAgreementsText('I DO NOT AGREE'));
-    });
-
-    test('_computeHideAgreementClass', () => {
-      assert.equal(
-          element._computeHideAgreementClass(
-              auth.name, config.auth.contributor_agreements),
-          'hideAgreementsTextBox');
-      assert.isUndefined(
-          element._computeHideAgreementClass(
-              auth.name, config2.auth.contributor_agreements));
-    });
-
-    test('_getAgreementsUrl', () => {
-      assert.equal(element._getAgreementsUrl(
-          'http://test.org/test.html'), 'http://test.org/test.html');
-      assert.equal(element._getAgreementsUrl(
-          'test_cla.html'), '/test_cla.html');
-    });
+    element = fixture('basic');
+    element.loadData().then(() => { flush(done); });
   });
+
+  test('renders as expected with signed agreement', () => {
+    const agreementSections = dom(element.root)
+        .querySelectorAll('.contributorAgreementButton');
+    const agreementSubmittedTexts = dom(element.root)
+        .querySelectorAll('.alreadySubmittedText');
+    assert.equal(agreementSections.length, 2);
+    assert.isFalse(agreementSections[0].querySelector('input').disabled);
+    assert.equal(getComputedStyle(agreementSubmittedTexts[0]).display,
+        'none');
+    assert.isTrue(agreementSections[1].querySelector('input').disabled);
+    assert.notEqual(getComputedStyle(agreementSubmittedTexts[1]).display,
+        'none');
+  });
+
+  test('_disableAgreements', () => {
+    // In the auto verify group and have not yet signed agreement
+    assert.isTrue(
+        element._disableAgreements(auth, groups, signedAgreements));
+    // Not in the auto verify group and have not yet signed agreement
+    assert.isFalse(
+        element._disableAgreements(auth2, groups, signedAgreements));
+    // Not in the auto verify group, have signed agreement
+    assert.isTrue(
+        element._disableAgreements(auth3, groups, signedAgreements));
+    // Make sure the undefined check works
+    assert.isFalse(
+        element._disableAgreements(auth, undefined, signedAgreements));
+  });
+
+  test('_hideAgreements', () => {
+    // Not in the auto verify group and have not yet signed agreement
+    assert.equal(
+        element._hideAgreements(auth, groups, signedAgreements), '');
+    // In the auto verify group
+    assert.equal(
+        element._hideAgreements(auth2, groups, signedAgreements), 'hide');
+    // Not in the auto verify group, have signed agreement
+    assert.equal(
+        element._hideAgreements(auth3, groups, signedAgreements), '');
+  });
+
+  test('_disableAgreementsText', () => {
+    assert.isFalse(element._disableAgreementsText('I AGREE'));
+    assert.isTrue(element._disableAgreementsText('I DO NOT AGREE'));
+  });
+
+  test('_computeHideAgreementClass', () => {
+    assert.equal(
+        element._computeHideAgreementClass(
+            auth.name, config.auth.contributor_agreements),
+        'hideAgreementsTextBox');
+    assert.isUndefined(
+        element._computeHideAgreementClass(
+            auth.name, config2.auth.contributor_agreements));
+  });
+
+  test('_getAgreementsUrl', () => {
+    assert.equal(element._getAgreementsUrl(
+        'http://test.org/test.html'), 'http://test.org/test.html');
+    assert.equal(element._getAgreementsUrl(
+        'test_cla.html'), '/test_cla.html');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
index 9523136..2a7ac06 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
@@ -14,76 +14,86 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrEditPreferences extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-edit-preferences'; }
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-edit-preferences_html.js';
 
-    static get properties() {
-      return {
-        hasUnsavedChanges: {
-          type: Boolean,
-          notify: true,
-          value: false,
-        },
+/** @extends Polymer.Element */
+class GrEditPreferences extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-        /** @type {?} */
-        editPrefs: Object,
-      };
-    }
+  static get is() { return 'gr-edit-preferences'; }
 
-    loadData() {
-      return this.$.restAPI.getEditPreferences().then(prefs => {
-        this.editPrefs = prefs;
-      });
-    }
+  static get properties() {
+    return {
+      hasUnsavedChanges: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
 
-    _handleEditPrefsChanged() {
-      this.hasUnsavedChanges = true;
-    }
-
-    _handleEditSyntaxHighlightingChanged() {
-      this.set('editPrefs.syntax_highlighting',
-          this.$.editSyntaxHighlighting.checked);
-      this._handleEditPrefsChanged();
-    }
-
-    _handleEditShowTabsChanged() {
-      this.set('editPrefs.show_tabs', this.$.editShowTabs.checked);
-      this._handleEditPrefsChanged();
-    }
-
-    _handleMatchBracketsChanged() {
-      this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
-      this._handleEditPrefsChanged();
-    }
-
-    _handleEditLineWrappingChanged() {
-      this.set('editPrefs.line_wrapping', this.$.editShowLineWrapping.checked);
-      this._handleEditPrefsChanged();
-    }
-
-    _handleIndentWithTabsChanged() {
-      this.set('editPrefs.indent_with_tabs', this.$.showIndentWithTabs.checked);
-      this._handleEditPrefsChanged();
-    }
-
-    _handleAutoCloseBracketsChanged() {
-      this.set('editPrefs.auto_close_brackets',
-          this.$.showAutoCloseBrackets.checked);
-      this._handleEditPrefsChanged();
-    }
-
-    save() {
-      return this.$.restAPI.saveEditPreferences(this.editPrefs).then(res => {
-        this.hasUnsavedChanges = false;
-      });
-    }
+      /** @type {?} */
+      editPrefs: Object,
+    };
   }
 
-  customElements.define(GrEditPreferences.is, GrEditPreferences);
-})();
+  loadData() {
+    return this.$.restAPI.getEditPreferences().then(prefs => {
+      this.editPrefs = prefs;
+    });
+  }
+
+  _handleEditPrefsChanged() {
+    this.hasUnsavedChanges = true;
+  }
+
+  _handleEditSyntaxHighlightingChanged() {
+    this.set('editPrefs.syntax_highlighting',
+        this.$.editSyntaxHighlighting.checked);
+    this._handleEditPrefsChanged();
+  }
+
+  _handleEditShowTabsChanged() {
+    this.set('editPrefs.show_tabs', this.$.editShowTabs.checked);
+    this._handleEditPrefsChanged();
+  }
+
+  _handleMatchBracketsChanged() {
+    this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
+    this._handleEditPrefsChanged();
+  }
+
+  _handleEditLineWrappingChanged() {
+    this.set('editPrefs.line_wrapping', this.$.editShowLineWrapping.checked);
+    this._handleEditPrefsChanged();
+  }
+
+  _handleIndentWithTabsChanged() {
+    this.set('editPrefs.indent_with_tabs', this.$.showIndentWithTabs.checked);
+    this._handleEditPrefsChanged();
+  }
+
+  _handleAutoCloseBracketsChanged() {
+    this.set('editPrefs.auto_close_brackets',
+        this.$.showAutoCloseBrackets.checked);
+    this._handleEditPrefsChanged();
+  }
+
+  save() {
+    return this.$.restAPI.saveEditPreferences(this.editPrefs).then(res => {
+      this.hasUnsavedChanges = false;
+    });
+  }
+}
+
+customElements.define(GrEditPreferences.is, GrEditPreferences);
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.js
index 80440c7..de22dbb 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-edit-preferences">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -34,128 +27,63 @@
       <section>
         <span class="title">Tab width</span>
         <span class="value">
-          <iron-input
-              type="number"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{editPrefs.tab_size}}"
-              on-keypress="_handleEditPrefsChanged"
-              on-change="_handleEditPrefsChanged">
-            <input
-                is="iron-input"
-                type="number"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{editPrefs.tab_size}}"
-                on-keypress="_handleEditPrefsChanged"
-                on-change="_handleEditPrefsChanged">
+          <iron-input type="number" prevent-invalid-input="" allowed-pattern="[0-9]" bind-value="{{editPrefs.tab_size}}" on-keypress="_handleEditPrefsChanged" on-change="_handleEditPrefsChanged">
+            <input is="iron-input" type="number" prevent-invalid-input="" allowed-pattern="[0-9]" bind-value="{{editPrefs.tab_size}}" on-keypress="_handleEditPrefsChanged" on-change="_handleEditPrefsChanged">
           </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Columns</span>
         <span class="value">
-          <iron-input
-              type="number"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{editPrefs.line_length}}"
-              on-keypress="_handleEditPrefsChanged"
-              on-change="_handleEditPrefsChanged">
-            <input
-                is="iron-input"
-                type="number"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{editPrefs.line_length}}"
-                on-keypress="_handleEditPrefsChanged"
-                on-change="_handleEditPrefsChanged">
+          <iron-input type="number" prevent-invalid-input="" allowed-pattern="[0-9]" bind-value="{{editPrefs.line_length}}" on-keypress="_handleEditPrefsChanged" on-change="_handleEditPrefsChanged">
+            <input is="iron-input" type="number" prevent-invalid-input="" allowed-pattern="[0-9]" bind-value="{{editPrefs.line_length}}" on-keypress="_handleEditPrefsChanged" on-change="_handleEditPrefsChanged">
           </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Indent unit</span>
         <span class="value">
-          <iron-input
-              type="number"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{editPrefs.indent_unit}}"
-              on-keypress="_handleEditPrefsChanged"
-              on-change="_handleEditPrefsChanged">
-            <input
-                is="iron-input"
-                type="number"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{editPrefs.indent_unit}}"
-                on-keypress="_handleEditPrefsChanged"
-                on-change="_handleEditPrefsChanged">
+          <iron-input type="number" prevent-invalid-input="" allowed-pattern="[0-9]" bind-value="{{editPrefs.indent_unit}}" on-keypress="_handleEditPrefsChanged" on-change="_handleEditPrefsChanged">
+            <input is="iron-input" type="number" prevent-invalid-input="" allowed-pattern="[0-9]" bind-value="{{editPrefs.indent_unit}}" on-keypress="_handleEditPrefsChanged" on-change="_handleEditPrefsChanged">
           </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Syntax highlighting</span>
         <span class="value">
-          <input
-              id="editSyntaxHighlighting"
-              type="checkbox"
-              checked$="[[editPrefs.syntax_highlighting]]"
-              on-change="_handleEditSyntaxHighlightingChanged">
+          <input id="editSyntaxHighlighting" type="checkbox" checked\$="[[editPrefs.syntax_highlighting]]" on-change="_handleEditSyntaxHighlightingChanged">
         </span>
       </section>
       <section>
         <span class="title">Show tabs</span>
         <span class="value">
-          <input
-              id="editShowTabs"
-              type="checkbox"
-              checked$="[[editPrefs.show_tabs]]"
-              on-change="_handleEditShowTabsChanged">
+          <input id="editShowTabs" type="checkbox" checked\$="[[editPrefs.show_tabs]]" on-change="_handleEditShowTabsChanged">
         </span>
       </section>
       <section>
         <span class="title">Match brackets</span>
         <span class="value">
-          <input
-              id="showMatchBrackets"
-              type="checkbox"
-              checked$="[[editPrefs.match_brackets]]"
-              on-change="_handleMatchBracketsChanged">
+          <input id="showMatchBrackets" type="checkbox" checked\$="[[editPrefs.match_brackets]]" on-change="_handleMatchBracketsChanged">
         </span>
       </section>
       <section>
         <span class="title">Line wrapping</span>
         <span class="value">
-          <input
-              id="editShowLineWrapping"
-              type="checkbox"
-              checked$="[[editPrefs.line_wrapping]]"
-              on-change="_handleEditLineWrappingChanged">
+          <input id="editShowLineWrapping" type="checkbox" checked\$="[[editPrefs.line_wrapping]]" on-change="_handleEditLineWrappingChanged">
         </span>
       </section>
       <section>
         <span class="title">Indent with tabs</span>
         <span class="value">
-          <input
-              id="showIndentWithTabs"
-              type="checkbox"
-              checked$="[[editPrefs.indent_with_tabs]]"
-              on-change="_handleIndentWithTabsChanged">
+          <input id="showIndentWithTabs" type="checkbox" checked\$="[[editPrefs.indent_with_tabs]]" on-change="_handleIndentWithTabsChanged">
         </span>
       </section>
       <section>
         <span class="title">Auto close brackets</span>
         <span class="value">
-          <input
-              id="showAutoCloseBrackets"
-              type="checkbox"
-              checked$="[[editPrefs.auto_close_brackets]]"
-              on-change="_handleAutoCloseBracketsChanged">
+          <input id="showAutoCloseBrackets" type="checkbox" checked\$="[[editPrefs.auto_close_brackets]]" on-change="_handleAutoCloseBracketsChanged">
         </span>
       </section>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-edit-preferences.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
index 3c99977..b73b14f 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-edit-preferences</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-edit-preferences.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-edit-preferences.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-edit-preferences.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,95 +40,97 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-edit-preferences tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let editPreferences;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-edit-preferences.js';
+suite('gr-edit-preferences tests', () => {
+  let element;
+  let sandbox;
+  let editPreferences;
 
-    function valueOf(title, fieldsetid) {
-      const sections = element.$[fieldsetid].querySelectorAll('section');
-      let titleEl;
-      for (let i = 0; i < sections.length; i++) {
-        titleEl = sections[i].querySelector('.title');
-        if (titleEl.textContent.trim() === title) {
-          return sections[i].querySelector('.value');
-        }
+  function valueOf(title, fieldsetid) {
+    const sections = element.$[fieldsetid].querySelectorAll('section');
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl.textContent.trim() === title) {
+        return sections[i].querySelector('.value');
       }
     }
+  }
 
-    setup(() => {
-      editPreferences = {
-        auto_close_brackets: false,
-        cursor_blink_rate: 0,
-        hide_line_numbers: false,
-        hide_top_menu: false,
-        indent_unit: 2,
-        indent_with_tabs: false,
-        key_map_type: 'DEFAULT',
-        line_length: 100,
-        line_wrapping: false,
-        match_brackets: true,
-        show_base: false,
-        show_tabs: true,
-        show_whitespace_errors: true,
-        syntax_highlighting: true,
-        tab_size: 8,
-        theme: 'DEFAULT',
-      };
+  setup(() => {
+    editPreferences = {
+      auto_close_brackets: false,
+      cursor_blink_rate: 0,
+      hide_line_numbers: false,
+      hide_top_menu: false,
+      indent_unit: 2,
+      indent_with_tabs: false,
+      key_map_type: 'DEFAULT',
+      line_length: 100,
+      line_wrapping: false,
+      match_brackets: true,
+      show_base: false,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      tab_size: 8,
+      theme: 'DEFAULT',
+    };
 
-      stub('gr-rest-api-interface', {
-        getEditPreferences() {
-          return Promise.resolve(editPreferences);
-        },
-      });
-
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      return element.loadData();
+    stub('gr-rest-api-interface', {
+      getEditPreferences() {
+        return Promise.resolve(editPreferences);
+      },
     });
 
-    teardown(() => { sandbox.restore(); });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    return element.loadData();
+  });
 
-    test('renders', () => {
-      // Rendered with the expected preferences selected.
-      assert.equal(valueOf('Tab width', 'editPreferences')
-          .firstElementChild.bindValue, editPreferences.tab_size);
-      assert.equal(valueOf('Columns', 'editPreferences')
-          .firstElementChild.bindValue, editPreferences.line_length);
-      assert.equal(valueOf('Indent unit', 'editPreferences')
-          .firstElementChild.bindValue, editPreferences.indent_unit);
-      assert.equal(valueOf('Syntax highlighting', 'editPreferences')
-          .firstElementChild.checked, editPreferences.syntax_highlighting);
-      assert.equal(valueOf('Show tabs', 'editPreferences')
-          .firstElementChild.checked, editPreferences.show_tabs);
-      assert.equal(valueOf('Match brackets', 'editPreferences')
-          .firstElementChild.checked, editPreferences.match_brackets);
-      assert.equal(valueOf('Line wrapping', 'editPreferences')
-          .firstElementChild.checked, editPreferences.line_wrapping);
-      assert.equal(valueOf('Indent with tabs', 'editPreferences')
-          .firstElementChild.checked, editPreferences.indent_with_tabs);
-      assert.equal(valueOf('Auto close brackets', 'editPreferences')
-          .firstElementChild.checked, editPreferences.auto_close_brackets);
+  teardown(() => { sandbox.restore(); });
 
+  test('renders', () => {
+    // Rendered with the expected preferences selected.
+    assert.equal(valueOf('Tab width', 'editPreferences')
+        .firstElementChild.bindValue, editPreferences.tab_size);
+    assert.equal(valueOf('Columns', 'editPreferences')
+        .firstElementChild.bindValue, editPreferences.line_length);
+    assert.equal(valueOf('Indent unit', 'editPreferences')
+        .firstElementChild.bindValue, editPreferences.indent_unit);
+    assert.equal(valueOf('Syntax highlighting', 'editPreferences')
+        .firstElementChild.checked, editPreferences.syntax_highlighting);
+    assert.equal(valueOf('Show tabs', 'editPreferences')
+        .firstElementChild.checked, editPreferences.show_tabs);
+    assert.equal(valueOf('Match brackets', 'editPreferences')
+        .firstElementChild.checked, editPreferences.match_brackets);
+    assert.equal(valueOf('Line wrapping', 'editPreferences')
+        .firstElementChild.checked, editPreferences.line_wrapping);
+    assert.equal(valueOf('Indent with tabs', 'editPreferences')
+        .firstElementChild.checked, editPreferences.indent_with_tabs);
+    assert.equal(valueOf('Auto close brackets', 'editPreferences')
+        .firstElementChild.checked, editPreferences.auto_close_brackets);
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('save changes', () => {
+    sandbox.stub(element.$.restAPI, 'saveEditPreferences')
+        .returns(Promise.resolve());
+    const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
+        .firstElementChild;
+    showTabsCheckbox.checked = false;
+    element._handleEditShowTabsChanged();
+
+    assert.isTrue(element.hasUnsavedChanges);
+
+    // Save the change.
+    return element.save().then(() => {
       assert.isFalse(element.hasUnsavedChanges);
     });
-
-    test('save changes', () => {
-      sandbox.stub(element.$.restAPI, 'saveEditPreferences')
-          .returns(Promise.resolve());
-      const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
-          .firstElementChild;
-      showTabsCheckbox.checked = false;
-      element._handleEditShowTabsChanged();
-
-      assert.isTrue(element.hasUnsavedChanges);
-
-      // Save the change.
-      return element.save().then(() => {
-        assert.isFalse(element.hasUnsavedChanges);
-      });
-    });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
index c60568c..fc97079 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
@@ -14,89 +14,99 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrEmailEditor extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-email-editor'; }
+import '@polymer/iron-input/iron-input.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-email-editor_html.js';
 
-    static get properties() {
-      return {
-        hasUnsavedChanges: {
-          type: Boolean,
-          notify: true,
-          value: false,
-        },
+/** @extends Polymer.Element */
+class GrEmailEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-        _emails: Array,
-        _emailsToRemove: {
-          type: Array,
-          value() { return []; },
-        },
-        /** @type {?string} */
-        _newPreferred: {
-          type: String,
-          value: null,
-        },
-      };
+  static get is() { return 'gr-email-editor'; }
+
+  static get properties() {
+    return {
+      hasUnsavedChanges: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+
+      _emails: Array,
+      _emailsToRemove: {
+        type: Array,
+        value() { return []; },
+      },
+      /** @type {?string} */
+      _newPreferred: {
+        type: String,
+        value: null,
+      },
+    };
+  }
+
+  loadData() {
+    return this.$.restAPI.getAccountEmails().then(emails => {
+      this._emails = emails;
+    });
+  }
+
+  save() {
+    const promises = [];
+
+    for (const emailObj of this._emailsToRemove) {
+      promises.push(this.$.restAPI.deleteAccountEmail(emailObj.email));
     }
 
-    loadData() {
-      return this.$.restAPI.getAccountEmails().then(emails => {
-        this._emails = emails;
-      });
+    if (this._newPreferred) {
+      promises.push(this.$.restAPI.setPreferredAccountEmail(
+          this._newPreferred));
     }
 
-    save() {
-      const promises = [];
+    return Promise.all(promises).then(() => {
+      this._emailsToRemove = [];
+      this._newPreferred = null;
+      this.hasUnsavedChanges = false;
+    });
+  }
 
-      for (const emailObj of this._emailsToRemove) {
-        promises.push(this.$.restAPI.deleteAccountEmail(emailObj.email));
-      }
+  _handleDeleteButton(e) {
+    const index = parseInt(dom(e).localTarget
+        .getAttribute('data-index'), 10);
+    const email = this._emails[index];
+    this.push('_emailsToRemove', email);
+    this.splice('_emails', index, 1);
+    this.hasUnsavedChanges = true;
+  }
 
-      if (this._newPreferred) {
-        promises.push(this.$.restAPI.setPreferredAccountEmail(
-            this._newPreferred));
-      }
-
-      return Promise.all(promises).then(() => {
-        this._emailsToRemove = [];
-        this._newPreferred = null;
-        this.hasUnsavedChanges = false;
-      });
-    }
-
-    _handleDeleteButton(e) {
-      const index = parseInt(Polymer.dom(e).localTarget
-          .getAttribute('data-index'), 10);
-      const email = this._emails[index];
-      this.push('_emailsToRemove', email);
-      this.splice('_emails', index, 1);
-      this.hasUnsavedChanges = true;
-    }
-
-    _handlePreferredControlClick(e) {
-      if (e.target.classList.contains('preferredControl')) {
-        e.target.firstElementChild.click();
-      }
-    }
-
-    _handlePreferredChange(e) {
-      const preferred = e.target.value;
-      for (let i = 0; i < this._emails.length; i++) {
-        if (preferred === this._emails[i].email) {
-          this.set(['_emails', i, 'preferred'], true);
-          this._newPreferred = preferred;
-          this.hasUnsavedChanges = true;
-        } else if (this._emails[i].preferred) {
-          this.set(['_emails', i, 'preferred'], false);
-        }
-      }
+  _handlePreferredControlClick(e) {
+    if (e.target.classList.contains('preferredControl')) {
+      e.target.firstElementChild.click();
     }
   }
 
-  customElements.define(GrEmailEditor.is, GrEmailEditor);
-})();
+  _handlePreferredChange(e) {
+    const preferred = e.target.value;
+    for (let i = 0; i < this._emails.length; i++) {
+      if (preferred === this._emails[i].email) {
+        this.set(['_emails', i, 'preferred'], true);
+        this._newPreferred = preferred;
+        this.hasUnsavedChanges = true;
+      } else if (this._emails[i].preferred) {
+        this.set(['_emails', i, 'preferred'], false);
+      }
+    }
+  }
+}
+
+customElements.define(GrEmailEditor.is, GrEmailEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.js
index 041b2a7..b02df3c 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-email-editor">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -65,29 +59,12 @@
             <tr>
               <td class="emailColumn">[[item.email]]</td>
               <td class="preferredControl" on-click="_handlePreferredControlClick">
-                <iron-input
-                    class="preferredRadio"
-                    type="radio"
-                    on-change="_handlePreferredChange"
-                    name="preferred"
-                    bind-value="[[item.email]]"
-                    checked$="[[item.preferred]]">
-                  <input
-                      is="iron-input"
-                      class="preferredRadio"
-                      type="radio"
-                      on-change="_handlePreferredChange"
-                      name="preferred"
-                      value="[[item.email]]"
-                      checked$="[[item.preferred]]">
+                <iron-input class="preferredRadio" type="radio" on-change="_handlePreferredChange" name="preferred" bind-value="[[item.email]]" checked\$="[[item.preferred]]">
+                  <input is="iron-input" class="preferredRadio" type="radio" on-change="_handlePreferredChange" name="preferred" value="[[item.email]]" checked\$="[[item.preferred]]">
                 </iron-input>
               </td>
               <td>
-                <gr-button
-                    data-index$="[[index]]"
-                    on-click="_handleDeleteButton"
-                    disabled="[[item.preferred]]"
-                    class="remove-button">Delete</gr-button>
+                <gr-button data-index\$="[[index]]" on-click="_handleDeleteButton" disabled="[[item.preferred]]" class="remove-button">Delete</gr-button>
               </td>
             </tr>
           </template>
@@ -95,6 +72,4 @@
       </table>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-email-editor.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
index ecb108d..196f8a9 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-email-editor</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-email-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-email-editor.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-email-editor.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,121 +40,123 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-email-editor tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-email-editor.js';
+suite('gr-email-editor tests', () => {
+  let element;
 
-    setup(done => {
-      const emails = [
-        {email: 'email@one.com'},
-        {email: 'email@two.com', preferred: true},
-        {email: 'email@three.com'},
-      ];
+  setup(done => {
+    const emails = [
+      {email: 'email@one.com'},
+      {email: 'email@two.com', preferred: true},
+      {email: 'email@three.com'},
+    ];
 
-      stub('gr-rest-api-interface', {
-        getAccountEmails() { return Promise.resolve(emails); },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(flush(done));
+    stub('gr-rest-api-interface', {
+      getAccountEmails() { return Promise.resolve(emails); },
     });
 
-    test('renders', () => {
-      const rows = element.shadowRoot
-          .querySelector('table').querySelectorAll('tbody tr');
+    element = fixture('basic');
 
-      assert.equal(rows.length, 3);
+    element.loadData().then(flush(done));
+  });
 
-      assert.isFalse(rows[0].querySelector('input[type=radio]').checked);
-      assert.isNotOk(rows[0].querySelector('gr-button').disabled);
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('table').querySelectorAll('tbody tr');
 
-      assert.isTrue(rows[1].querySelector('input[type=radio]').checked);
-      assert.isOk(rows[1].querySelector('gr-button').disabled);
+    assert.equal(rows.length, 3);
 
-      assert.isFalse(rows[2].querySelector('input[type=radio]').checked);
-      assert.isNotOk(rows[2].querySelector('gr-button').disabled);
+    assert.isFalse(rows[0].querySelector('input[type=radio]').checked);
+    assert.isNotOk(rows[0].querySelector('gr-button').disabled);
 
-      assert.isFalse(element.hasUnsavedChanges);
-    });
+    assert.isTrue(rows[1].querySelector('input[type=radio]').checked);
+    assert.isOk(rows[1].querySelector('gr-button').disabled);
 
-    test('edit preferred', () => {
-      const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
-      const radios = element.shadowRoot
-          .querySelector('table').querySelectorAll('input[type=radio]');
+    assert.isFalse(rows[2].querySelector('input[type=radio]').checked);
+    assert.isNotOk(rows[2].querySelector('gr-button').disabled);
 
-      assert.isFalse(element.hasUnsavedChanges);
-      assert.isNotOk(element._newPreferred);
-      assert.equal(element._emailsToRemove.length, 0);
-      assert.equal(element._emails.length, 3);
-      assert.isNotOk(radios[0].checked);
-      assert.isOk(radios[1].checked);
-      assert.isFalse(preferredChangedSpy.called);
+    assert.isFalse(element.hasUnsavedChanges);
+  });
 
-      radios[0].click();
+  test('edit preferred', () => {
+    const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
+    const radios = element.shadowRoot
+        .querySelector('table').querySelectorAll('input[type=radio]');
 
-      assert.isTrue(element.hasUnsavedChanges);
-      assert.isOk(element._newPreferred);
-      assert.equal(element._emailsToRemove.length, 0);
-      assert.equal(element._emails.length, 3);
-      assert.isOk(radios[0].checked);
-      assert.isNotOk(radios[1].checked);
-      assert.isTrue(preferredChangedSpy.called);
-    });
+    assert.isFalse(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
+    assert.isNotOk(radios[0].checked);
+    assert.isOk(radios[1].checked);
+    assert.isFalse(preferredChangedSpy.called);
 
-    test('delete email', () => {
-      const buttons = element.shadowRoot
-          .querySelector('table').querySelectorAll('gr-button');
+    radios[0].click();
 
-      assert.isFalse(element.hasUnsavedChanges);
-      assert.isNotOk(element._newPreferred);
-      assert.equal(element._emailsToRemove.length, 0);
-      assert.equal(element._emails.length, 3);
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
+    assert.isOk(radios[0].checked);
+    assert.isNotOk(radios[1].checked);
+    assert.isTrue(preferredChangedSpy.called);
+  });
 
-      buttons[2].click();
+  test('delete email', () => {
+    const buttons = element.shadowRoot
+        .querySelector('table').querySelectorAll('gr-button');
 
-      assert.isTrue(element.hasUnsavedChanges);
-      assert.isNotOk(element._newPreferred);
-      assert.equal(element._emailsToRemove.length, 1);
-      assert.equal(element._emails.length, 2);
+    assert.isFalse(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
 
-      assert.equal(element._emailsToRemove[0].email, 'email@three.com');
-    });
+    buttons[2].click();
 
-    test('save changes', done => {
-      const deleteEmailStub =
-          sinon.stub(element.$.restAPI, 'deleteAccountEmail');
-      const setPreferredStub = sinon.stub(element.$.restAPI,
-          'setPreferredAccountEmail');
-      const rows = element.shadowRoot
-          .querySelector('table').querySelectorAll('tbody tr');
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 1);
+    assert.equal(element._emails.length, 2);
 
-      assert.isFalse(element.hasUnsavedChanges);
-      assert.isNotOk(element._newPreferred);
-      assert.equal(element._emailsToRemove.length, 0);
-      assert.equal(element._emails.length, 3);
+    assert.equal(element._emailsToRemove[0].email, 'email@three.com');
+  });
 
-      // Delete the first email and set the last as preferred.
-      rows[0].querySelector('gr-button').click();
-      rows[2].querySelector('input[type=radio]').click();
+  test('save changes', done => {
+    const deleteEmailStub =
+        sinon.stub(element.$.restAPI, 'deleteAccountEmail');
+    const setPreferredStub = sinon.stub(element.$.restAPI,
+        'setPreferredAccountEmail');
+    const rows = element.shadowRoot
+        .querySelector('table').querySelectorAll('tbody tr');
 
-      assert.isTrue(element.hasUnsavedChanges);
-      assert.equal(element._newPreferred, 'email@three.com');
-      assert.equal(element._emailsToRemove.length, 1);
-      assert.equal(element._emailsToRemove[0].email, 'email@one.com');
-      assert.equal(element._emails.length, 2);
+    assert.isFalse(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
 
-      // Save the changes.
-      element.save().then(() => {
-        assert.equal(deleteEmailStub.callCount, 1);
-        assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
+    // Delete the first email and set the last as preferred.
+    rows[0].querySelector('gr-button').click();
+    rows[2].querySelector('input[type=radio]').click();
 
-        assert.isTrue(setPreferredStub.called);
-        assert.equal(setPreferredStub.getCall(0).args[0], 'email@three.com');
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.equal(element._newPreferred, 'email@three.com');
+    assert.equal(element._emailsToRemove.length, 1);
+    assert.equal(element._emailsToRemove[0].email, 'email@one.com');
+    assert.equal(element._emails.length, 2);
 
-        done();
-      });
+    // Save the changes.
+    element.save().then(() => {
+      assert.equal(deleteEmailStub.callCount, 1);
+      assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
+
+      assert.isTrue(setPreferredStub.called);
+      assert.equal(setPreferredStub.getCall(0).args[0], 'email@three.com');
+
+      done();
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
index 9f04915..90631c7 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
@@ -14,100 +14,113 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrGpgEditor extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-gpg-editor'; }
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/gr-form-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-gpg-editor_html.js';
 
-    static get properties() {
-      return {
-        hasUnsavedChanges: {
-          type: Boolean,
-          value: false,
-          notify: true,
-        },
-        _keys: Array,
-        /** @type {?} */
-        _keyToView: Object,
-        _newKey: {
-          type: String,
-          value: '',
-        },
-        _keysToRemove: {
-          type: Array,
-          value() { return []; },
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrGpgEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    loadData() {
-      this._keys = [];
-      return this.$.restAPI.getAccountGPGKeys().then(keys => {
-        if (!keys) {
-          return;
-        }
-        this._keys = Object.keys(keys)
-            .map(key => {
-              const gpgKey = keys[key];
-              gpgKey.id = key;
-              return gpgKey;
-            });
-      });
-    }
+  static get is() { return 'gr-gpg-editor'; }
 
-    save() {
-      const promises = this._keysToRemove.map(key => {
-        this.$.restAPI.deleteAccountGPGKey(key.id);
-      });
-
-      return Promise.all(promises).then(() => {
-        this._keysToRemove = [];
-        this.hasUnsavedChanges = false;
-      });
-    }
-
-    _showKey(e) {
-      const el = Polymer.dom(e).localTarget;
-      const index = parseInt(el.getAttribute('data-index'), 10);
-      this._keyToView = this._keys[index];
-      this.$.viewKeyOverlay.open();
-    }
-
-    _closeOverlay() {
-      this.$.viewKeyOverlay.close();
-    }
-
-    _handleDeleteKey(e) {
-      const el = Polymer.dom(e).localTarget;
-      const index = parseInt(el.getAttribute('data-index'), 10);
-      this.push('_keysToRemove', this._keys[index]);
-      this.splice('_keys', index, 1);
-      this.hasUnsavedChanges = true;
-    }
-
-    _handleAddKey() {
-      this.$.addButton.disabled = true;
-      this.$.newKey.disabled = true;
-      return this.$.restAPI.addAccountGPGKey({add: [this._newKey.trim()]})
-          .then(key => {
-            this.$.newKey.disabled = false;
-            this._newKey = '';
-            this.loadData();
-          })
-          .catch(() => {
-            this.$.addButton.disabled = false;
-            this.$.newKey.disabled = false;
-          });
-    }
-
-    _computeAddButtonDisabled(newKey) {
-      return !newKey.length;
-    }
+  static get properties() {
+    return {
+      hasUnsavedChanges: {
+        type: Boolean,
+        value: false,
+        notify: true,
+      },
+      _keys: Array,
+      /** @type {?} */
+      _keyToView: Object,
+      _newKey: {
+        type: String,
+        value: '',
+      },
+      _keysToRemove: {
+        type: Array,
+        value() { return []; },
+      },
+    };
   }
 
-  customElements.define(GrGpgEditor.is, GrGpgEditor);
-})();
+  loadData() {
+    this._keys = [];
+    return this.$.restAPI.getAccountGPGKeys().then(keys => {
+      if (!keys) {
+        return;
+      }
+      this._keys = Object.keys(keys)
+          .map(key => {
+            const gpgKey = keys[key];
+            gpgKey.id = key;
+            return gpgKey;
+          });
+    });
+  }
+
+  save() {
+    const promises = this._keysToRemove.map(key => {
+      this.$.restAPI.deleteAccountGPGKey(key.id);
+    });
+
+    return Promise.all(promises).then(() => {
+      this._keysToRemove = [];
+      this.hasUnsavedChanges = false;
+    });
+  }
+
+  _showKey(e) {
+    const el = dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    this._keyToView = this._keys[index];
+    this.$.viewKeyOverlay.open();
+  }
+
+  _closeOverlay() {
+    this.$.viewKeyOverlay.close();
+  }
+
+  _handleDeleteKey(e) {
+    const el = dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    this.push('_keysToRemove', this._keys[index]);
+    this.splice('_keys', index, 1);
+    this.hasUnsavedChanges = true;
+  }
+
+  _handleAddKey() {
+    this.$.addButton.disabled = true;
+    this.$.newKey.disabled = true;
+    return this.$.restAPI.addAccountGPGKey({add: [this._newKey.trim()]})
+        .then(key => {
+          this.$.newKey.disabled = false;
+          this._newKey = '';
+          this.loadData();
+        })
+        .catch(() => {
+          this.$.addButton.disabled = false;
+          this.$.newKey.disabled = false;
+        });
+  }
+
+  _computeAddButtonDisabled(newKey) {
+    return !newKey.length;
+  }
+}
+
+customElements.define(GrGpgEditor.is, GrGpgEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.js
index 7b8a191..3ec4642 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.js
@@ -1,31 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-gpg-editor">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -81,29 +72,20 @@
                   </template>
                 </td>
                 <td class="keyHeader">
-                  <gr-button
-                      on-click="_showKey"
-                      data-index$="[[index]]"
-                      link>Click to View</gr-button>
+                  <gr-button on-click="_showKey" data-index\$="[[index]]" link="">Click to View</gr-button>
                 </td>
                 <td>
-                  <gr-copy-clipboard
-                      has-tooltip
-                      button-title="Copy GPG public key to clipboard"
-                      hide-input
-                      text="[[key.key]]">
+                  <gr-copy-clipboard has-tooltip="" button-title="Copy GPG public key to clipboard" hide-input="" text="[[key.key]]">
                   </gr-copy-clipboard>
                 </td>
                 <td>
-                  <gr-button
-                      data-index$="[[index]]"
-                      on-click="_handleDeleteKey">Delete</gr-button>
+                  <gr-button data-index\$="[[index]]" on-click="_handleDeleteKey">Delete</gr-button>
                 </td>
               </tr>
             </template>
           </tbody>
         </table>
-        <gr-overlay id="viewKeyOverlay" with-backdrop>
+        <gr-overlay id="viewKeyOverlay" with-backdrop="">
           <fieldset>
             <section>
               <span class="title">Status</span>
@@ -114,32 +96,19 @@
               <span class="value">[[_keyToView.key]]</span>
             </section>
           </fieldset>
-          <gr-button
-              class="closeButton"
-              on-click="_closeOverlay">Close</gr-button>
+          <gr-button class="closeButton" on-click="_closeOverlay">Close</gr-button>
         </gr-overlay>
-        <gr-button
-            on-click="save"
-            disabled$="[[!hasUnsavedChanges]]">Save changes</gr-button>
+        <gr-button on-click="save" disabled\$="[[!hasUnsavedChanges]]">Save changes</gr-button>
       </fieldset>
       <fieldset>
         <section>
           <span class="title">New GPG key</span>
           <span class="value">
-            <iron-autogrow-textarea
-                id="newKey"
-                autocomplete="on"
-                bind-value="{{_newKey}}"
-                placeholder="New GPG Key"></iron-autogrow-textarea>
+            <iron-autogrow-textarea id="newKey" autocomplete="on" bind-value="{{_newKey}}" placeholder="New GPG Key"></iron-autogrow-textarea>
           </span>
         </section>
-        <gr-button
-            id="addButton"
-            disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-            on-click="_handleAddKey">Add new GPG key</gr-button>
+        <gr-button id="addButton" disabled\$="[[_computeAddButtonDisabled(_newKey)]]" on-click="_handleAddKey">Add new GPG key</gr-button>
       </fieldset>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-gpg-editor.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
index 08c36fe..5c95222 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-gpg-editor</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-gpg-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-gpg-editor.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-gpg-editor.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,163 +40,166 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-gpg-editor tests', async () => {
-    await readyToTest();
-    let element;
-    let keys;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-gpg-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-gpg-editor tests', () => {
+  let element;
+  let keys;
 
-    setup(done => {
-      const fingerprint1 = '0192 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
-      const fingerprint2 = '0196 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
-      keys = {
-        AFC8A49B: {
-          fingerprint: fingerprint1,
-          user_ids: [
-            'John Doe john.doe@example.com',
-          ],
-          key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-               '\nVersion: BCPG v1.52\n\t<key 1>',
-          status: 'TRUSTED',
-          problems: [],
-        },
-        AED9B59C: {
-          fingerprint: fingerprint2,
-          user_ids: [
-            'Gerrit gerrit@example.com',
-          ],
-          key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-               '\nVersion: BCPG v1.52\n\t<key 2>',
-          status: 'TRUSTED',
-          problems: [],
-        },
-      };
+  setup(done => {
+    const fingerprint1 = '0192 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
+    const fingerprint2 = '0196 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
+    keys = {
+      AFC8A49B: {
+        fingerprint: fingerprint1,
+        user_ids: [
+          'John Doe john.doe@example.com',
+        ],
+        key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+             '\nVersion: BCPG v1.52\n\t<key 1>',
+        status: 'TRUSTED',
+        problems: [],
+      },
+      AED9B59C: {
+        fingerprint: fingerprint2,
+        user_ids: [
+          'Gerrit gerrit@example.com',
+        ],
+        key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+             '\nVersion: BCPG v1.52\n\t<key 2>',
+        status: 'TRUSTED',
+        problems: [],
+      },
+    };
 
-      stub('gr-rest-api-interface', {
-        getAccountGPGKeys() { return Promise.resolve(keys); },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getAccountGPGKeys() { return Promise.resolve(keys); },
     });
 
-    test('renders', () => {
-      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+    element = fixture('basic');
 
-      assert.equal(rows.length, 2);
+    element.loadData().then(() => { flush(done); });
+  });
 
-      let cells = rows[0].querySelectorAll('td');
-      assert.equal(cells[0].textContent, 'AFC8A49B');
+  test('renders', () => {
+    const rows = dom(element.root).querySelectorAll('tbody tr');
 
-      cells = rows[1].querySelectorAll('td');
-      assert.equal(cells[0].textContent, 'AED9B59C');
-    });
+    assert.equal(rows.length, 2);
 
-    test('remove key', done => {
-      const lastKey = keys[Object.keys(keys)[1]];
+    let cells = rows[0].querySelectorAll('td');
+    assert.equal(cells[0].textContent, 'AFC8A49B');
 
-      const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountGPGKey',
-          () => Promise.resolve());
+    cells = rows[1].querySelectorAll('td');
+    assert.equal(cells[0].textContent, 'AED9B59C');
+  });
 
+  test('remove key', done => {
+    const lastKey = keys[Object.keys(keys)[1]];
+
+    const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountGPGKey',
+        () => Promise.resolve());
+
+    assert.equal(element._keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+
+    // Get the delete button for the last row.
+    const button = dom(element.root).querySelector(
+        'tbody tr:last-of-type td:nth-child(6) gr-button');
+
+    MockInteractions.tap(button);
+
+    assert.equal(element._keys.length, 1);
+    assert.equal(element._keysToRemove.length, 1);
+    assert.equal(element._keysToRemove[0], lastKey);
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isFalse(saveStub.called);
+
+    element.save().then(() => {
+      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
       assert.equal(element._keysToRemove.length, 0);
       assert.isFalse(element.hasUnsavedChanges);
-
-      // Get the delete button for the last row.
-      const button = Polymer.dom(element.root).querySelector(
-          'tbody tr:last-of-type td:nth-child(6) gr-button');
-
-      MockInteractions.tap(button);
-
-      assert.equal(element._keys.length, 1);
-      assert.equal(element._keysToRemove.length, 1);
-      assert.equal(element._keysToRemove[0], lastKey);
-      assert.isTrue(element.hasUnsavedChanges);
-      assert.isFalse(saveStub.called);
-
-      element.save().then(() => {
-        assert.isTrue(saveStub.called);
-        assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
-        assert.equal(element._keysToRemove.length, 0);
-        assert.isFalse(element.hasUnsavedChanges);
-        done();
-      });
-    });
-
-    test('show key', () => {
-      const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
-
-      // Get the show button for the last row.
-      const button = Polymer.dom(element.root).querySelector(
-          'tbody tr:last-of-type td:nth-child(4) gr-button');
-
-      MockInteractions.tap(button);
-
-      assert.equal(element._keyToView, keys[Object.keys(keys)[1]]);
-      assert.isTrue(openSpy.called);
-    });
-
-    test('add key', done => {
-      const newKeyString =
-          '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-          '\nVersion: BCPG v1.52\n\t<key 3>';
-      const newKeyObject = {
-        ADE8A59B: {
-          fingerprint: '0194 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B',
-          user_ids: [
-            'John john@example.com',
-          ],
-          key: newKeyString,
-          status: 'TRUSTED',
-          problems: [],
-        },
-      };
-
-      const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
-          () => Promise.resolve(newKeyObject));
-
-      element._newKey = newKeyString;
-
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-
-      element._handleAddKey().then(() => {
-        assert.isTrue(element.$.addButton.disabled);
-        assert.isFalse(element.$.newKey.disabled);
-        assert.equal(element._keys.length, 2);
-        done();
-      });
-
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isTrue(element.$.newKey.disabled);
-
-      assert.isTrue(addStub.called);
-      assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
-    });
-
-    test('add invalid key', done => {
-      const newKeyString = 'not even close to valid';
-
-      const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
-          () => Promise.reject(new Error('error')));
-
-      element._newKey = newKeyString;
-
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-
-      element._handleAddKey().then(() => {
-        assert.isFalse(element.$.addButton.disabled);
-        assert.isFalse(element.$.newKey.disabled);
-        assert.equal(element._keys.length, 2);
-        done();
-      });
-
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isTrue(element.$.newKey.disabled);
-
-      assert.isTrue(addStub.called);
-      assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+      done();
     });
   });
+
+  test('show key', () => {
+    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+
+    // Get the show button for the last row.
+    const button = dom(element.root).querySelector(
+        'tbody tr:last-of-type td:nth-child(4) gr-button');
+
+    MockInteractions.tap(button);
+
+    assert.equal(element._keyToView, keys[Object.keys(keys)[1]]);
+    assert.isTrue(openSpy.called);
+  });
+
+  test('add key', done => {
+    const newKeyString =
+        '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+        '\nVersion: BCPG v1.52\n\t<key 3>';
+    const newKeyObject = {
+      ADE8A59B: {
+        fingerprint: '0194 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B',
+        user_ids: [
+          'John john@example.com',
+        ],
+        key: newKeyString,
+        status: 'TRUSTED',
+        problems: [],
+      },
+    };
+
+    const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
+        () => Promise.resolve(newKeyObject));
+
+    element._newKey = newKeyString;
+
+    assert.isFalse(element.$.addButton.disabled);
+    assert.isFalse(element.$.newKey.disabled);
+
+    element._handleAddKey().then(() => {
+      assert.isTrue(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+      assert.equal(element._keys.length, 2);
+      done();
+    });
+
+    assert.isTrue(element.$.addButton.disabled);
+    assert.isTrue(element.$.newKey.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+  });
+
+  test('add invalid key', done => {
+    const newKeyString = 'not even close to valid';
+
+    const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
+        () => Promise.reject(new Error('error')));
+
+    element._newKey = newKeyString;
+
+    assert.isFalse(element.$.addButton.disabled);
+    assert.isFalse(element.$.newKey.disabled);
+
+    element._handleAddKey().then(() => {
+      assert.isFalse(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+      assert.equal(element._keys.length, 2);
+      done();
+    });
+
+    assert.isTrue(element.$.addButton.disabled);
+    assert.isTrue(element.$.newKey.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
index c7b5faa..01739cd 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
@@ -14,39 +14,48 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrGroupList extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-group-list'; }
+import '../../../styles/shared-styles.js';
+import '../../../styles/gr-form-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-group-list_html.js';
 
-    static get properties() {
-      return {
-        _groups: Array,
-      };
-    }
+/** @extends Polymer.Element */
+class GrGroupList extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    loadData() {
-      return this.$.restAPI.getAccountGroups().then(groups => {
-        this._groups = groups.sort((a, b) => a.name.localeCompare(b.name));
-      });
-    }
+  static get is() { return 'gr-group-list'; }
 
-    _computeVisibleToAll(group) {
-      return group.options.visible_to_all ? 'Yes' : 'No';
-    }
-
-    _computeGroupPath(group) {
-      if (!group || !group.id) { return; }
-
-      // Group ID is already encoded from the API
-      // Decode it here to match with our router encoding behavior
-      return Gerrit.Nav.getUrlForGroup(decodeURIComponent(group.id));
-    }
+  static get properties() {
+    return {
+      _groups: Array,
+    };
   }
 
-  customElements.define(GrGroupList.is, GrGroupList);
-})();
+  loadData() {
+    return this.$.restAPI.getAccountGroups().then(groups => {
+      this._groups = groups.sort((a, b) => a.name.localeCompare(b.name));
+    });
+  }
+
+  _computeVisibleToAll(group) {
+    return group.options.visible_to_all ? 'Yes' : 'No';
+  }
+
+  _computeGroupPath(group) {
+    if (!group || !group.id) { return; }
+
+    // Group ID is already encoded from the API
+    // Decode it here to match with our router encoding behavior
+    return Gerrit.Nav.getUrlForGroup(decodeURIComponent(group.id));
+  }
+}
+
+customElements.define(GrGroupList.is, GrGroupList);
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.js
index e51294d..ddacd31 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-group-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -52,7 +46,7 @@
           <template is="dom-repeat" items="[[_groups]]">
             <tr>
               <td class="nameColumn">
-                <a href$="[[_computeGroupPath(item)]]">
+                <a href\$="[[_computeGroupPath(item)]]">
                   [[item.name]]
                 </a>
               </td>
@@ -64,6 +58,4 @@
       </table>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-group-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
index 10b67ec..205b413 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-group-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-group-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-group-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,81 +40,84 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-group-list tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let element;
-    let groups;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-group-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-group-list tests', () => {
+  let sandbox;
+  let element;
+  let groups;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      groups = [{
-        url: 'some url',
-        options: {},
-        description: 'Group 1 description',
-        group_id: 1,
-        owner: 'Administrators',
-        owner_id: '123',
-        id: 'abc',
-        name: 'Group 1',
-      }, {
-        options: {visible_to_all: true},
-        id: '456',
-        name: 'Group 2',
-      }, {
-        options: {},
-        id: '789',
-        name: 'Group 3',
-      }];
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    groups = [{
+      url: 'some url',
+      options: {},
+      description: 'Group 1 description',
+      group_id: 1,
+      owner: 'Administrators',
+      owner_id: '123',
+      id: 'abc',
+      name: 'Group 1',
+    }, {
+      options: {visible_to_all: true},
+      id: '456',
+      name: 'Group 2',
+    }, {
+      options: {},
+      id: '789',
+      name: 'Group 3',
+    }];
 
-      stub('gr-rest-api-interface', {
-        getAccountGroups() { return Promise.resolve(groups); },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getAccountGroups() { return Promise.resolve(groups); },
     });
 
-    teardown(() => { sandbox.restore(); });
+    element = fixture('basic');
 
-    test('renders', () => {
-      const rows = Array.from(
-          Polymer.dom(element.root).querySelectorAll('tbody tr'));
-
-      assert.equal(rows.length, 3);
-
-      const nameCells = rows.map(row =>
-        row.querySelectorAll('td a')[0].textContent.trim()
-      );
-
-      assert.equal(nameCells[0], 'Group 1');
-      assert.equal(nameCells[1], 'Group 2');
-      assert.equal(nameCells[2], 'Group 3');
-    });
-
-    test('_computeVisibleToAll', () => {
-      assert.equal(element._computeVisibleToAll(groups[0]), 'No');
-      assert.equal(element._computeVisibleToAll(groups[1]), 'Yes');
-    });
-
-    test('_computeGroupPath', () => {
-      sandbox.stub(Gerrit.Nav, 'getUrlForGroup',
-          () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-      let group = {
-        id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
-      };
-
-      assert.equal(element._computeGroupPath(group),
-          '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-      group = {
-        name: 'admin',
-      };
-
-      assert.isUndefined(element._computeGroupPath(group));
-    });
+    element.loadData().then(() => { flush(done); });
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('renders', () => {
+    const rows = Array.from(
+        dom(element.root).querySelectorAll('tbody tr'));
+
+    assert.equal(rows.length, 3);
+
+    const nameCells = rows.map(row =>
+      row.querySelectorAll('td a')[0].textContent.trim()
+    );
+
+    assert.equal(nameCells[0], 'Group 1');
+    assert.equal(nameCells[1], 'Group 2');
+    assert.equal(nameCells[2], 'Group 3');
+  });
+
+  test('_computeVisibleToAll', () => {
+    assert.equal(element._computeVisibleToAll(groups[0]), 'No');
+    assert.equal(element._computeVisibleToAll(groups[1]), 'Yes');
+  });
+
+  test('_computeGroupPath', () => {
+    sandbox.stub(Gerrit.Nav, 'getUrlForGroup',
+        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+    let group = {
+      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
+    };
+
+    assert.equal(element._computeGroupPath(group),
+        '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+    group = {
+      name: 'admin',
+    };
+
+    assert.isUndefined(element._computeGroupPath(group));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
index efd0c39..02657f8 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
@@ -14,59 +14,70 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrHttpPassword extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-http-password'; }
+import '../../../styles/gr-form-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-http-password_html.js';
 
-    static get properties() {
-      return {
-        _username: String,
-        _generatedPassword: String,
-        _passwordUrl: String,
-      };
-    }
+/** @extends Polymer.Element */
+class GrHttpPassword extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this.loadData();
-    }
+  static get is() { return 'gr-http-password'; }
 
-    loadData() {
-      const promises = [];
-
-      promises.push(this.$.restAPI.getAccount().then(account => {
-        this._username = account.username;
-      }));
-
-      promises.push(this.$.restAPI.getConfig().then(info => {
-        this._passwordUrl = info.auth.http_password_url || null;
-      }));
-
-      return Promise.all(promises);
-    }
-
-    _handleGenerateTap() {
-      this._generatedPassword = 'Generating...';
-      this.$.generatedPasswordOverlay.open();
-      this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
-        this._generatedPassword = newPassword;
-      });
-    }
-
-    _closeOverlay() {
-      this.$.generatedPasswordOverlay.close();
-    }
-
-    _generatedPasswordOverlayClosed() {
-      this._generatedPassword = '';
-    }
+  static get properties() {
+    return {
+      _username: String,
+      _generatedPassword: String,
+      _passwordUrl: String,
+    };
   }
 
-  customElements.define(GrHttpPassword.is, GrHttpPassword);
-})();
+  /** @override */
+  attached() {
+    super.attached();
+    this.loadData();
+  }
+
+  loadData() {
+    const promises = [];
+
+    promises.push(this.$.restAPI.getAccount().then(account => {
+      this._username = account.username;
+    }));
+
+    promises.push(this.$.restAPI.getConfig().then(info => {
+      this._passwordUrl = info.auth.http_password_url || null;
+    }));
+
+    return Promise.all(promises);
+  }
+
+  _handleGenerateTap() {
+    this._generatedPassword = 'Generating...';
+    this.$.generatedPasswordOverlay.open();
+    this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
+      this._generatedPassword = newPassword;
+    });
+  }
+
+  _closeOverlay() {
+    this.$.generatedPasswordOverlay.close();
+  }
+
+  _generatedPasswordOverlayClosed() {
+    this._generatedPassword = '';
+  }
+}
+
+customElements.define(GrHttpPassword.is, GrHttpPassword);
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.js
index 22ba457..b75f56e 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-http-password">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .password {
         font-family: var(--monospace-font-family);
@@ -60,47 +52,33 @@
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
     <div class="gr-form-styles">
-      <div hidden$="[[_passwordUrl]]">
+      <div hidden\$="[[_passwordUrl]]">
         <section>
           <span class="title">Username</span>
           <span class="value">[[_username]]</span>
         </section>
-        <gr-button
-            id="generateButton"
-            on-click="_handleGenerateTap">Generate new password</gr-button>
+        <gr-button id="generateButton" on-click="_handleGenerateTap">Generate new password</gr-button>
       </div>
-      <span hidden$="[[!_passwordUrl]]">
-        <a href$="[[_passwordUrl]]" target="_blank" rel="noopener">
+      <span hidden\$="[[!_passwordUrl]]">
+        <a href\$="[[_passwordUrl]]" target="_blank" rel="noopener">
           Obtain password</a>
         (opens in a new tab)
       </span>
     </div>
-    <gr-overlay
-        id="generatedPasswordOverlay"
-        on-iron-overlay-closed="_generatedPasswordOverlayClosed"
-        with-backdrop>
+    <gr-overlay id="generatedPasswordOverlay" on-iron-overlay-closed="_generatedPasswordOverlayClosed" with-backdrop="">
       <div class="gr-form-styles">
         <section id="generatedPasswordDisplay">
           <span class="title">New Password:</span>
           <span class="value">[[_generatedPassword]]</span>
-          <gr-copy-clipboard
-              has-tooltip
-              button-title="Copy password to clipboard"
-              hide-input
-              text="[[_generatedPassword]]">
+          <gr-copy-clipboard has-tooltip="" button-title="Copy password to clipboard" hide-input="" text="[[_generatedPassword]]">
           </gr-copy-clipboard>
         </section>
         <section id="passwordWarning">
           This password will not be displayed again.<br>
           If you lose it, you will need to generate a new one.
         </section>
-        <gr-button
-            link
-            class="closeButton"
-            on-click="_closeOverlay">Close</gr-button>
+        <gr-button link="" class="closeButton" on-click="_closeOverlay">Close</gr-button>
       </div>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-http-password.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
index 974a0f2..57c8622 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-http-password.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-http-password.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-http-password.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,61 +40,62 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-http-password tests', async () => {
-    await readyToTest();
-    let element;
-    let account;
-    let config;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-http-password.js';
+suite('gr-http-password tests', () => {
+  let element;
+  let account;
+  let config;
 
-    setup(done => {
-      account = {username: 'user name'};
-      config = {auth: {}};
+  setup(done => {
+    account = {username: 'user name'};
+    config = {auth: {}};
 
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(account); },
-        getConfig() { return Promise.resolve(config); },
-      });
-
-      element = fixture('basic');
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(account); },
+      getConfig() { return Promise.resolve(config); },
     });
 
-    test('generate password', () => {
-      const button = element.$.generateButton;
-      const nextPassword = 'the new password';
-      let generateResolve;
-      const generateStub = sinon.stub(element.$.restAPI,
-          'generateAccountHttpPassword', () => new Promise(resolve => {
-            generateResolve = resolve;
-          }));
+    element = fixture('basic');
+    element.loadData().then(() => { flush(done); });
+  });
 
-      assert.isNotOk(element._generatedPassword);
+  test('generate password', () => {
+    const button = element.$.generateButton;
+    const nextPassword = 'the new password';
+    let generateResolve;
+    const generateStub = sinon.stub(element.$.restAPI,
+        'generateAccountHttpPassword', () => new Promise(resolve => {
+          generateResolve = resolve;
+        }));
 
-      MockInteractions.tap(button);
+    assert.isNotOk(element._generatedPassword);
 
-      assert.isTrue(generateStub.called);
-      assert.equal(element._generatedPassword, 'Generating...');
+    MockInteractions.tap(button);
 
-      generateResolve(nextPassword);
+    assert.isTrue(generateStub.called);
+    assert.equal(element._generatedPassword, 'Generating...');
 
-      generateStub.lastCall.returnValue.then(() => {
-        assert.equal(element._generatedPassword, nextPassword);
-      });
-    });
+    generateResolve(nextPassword);
 
-    test('without http_password_url', () => {
-      assert.isNull(element._passwordUrl);
-    });
-
-    test('with http_password_url', done => {
-      config.auth.http_password_url = 'http://example.com/';
-      element.loadData().then(() => {
-        assert.isNotNull(element._passwordUrl);
-        assert.equal(element._passwordUrl, config.auth.http_password_url);
-        done();
-      });
+    generateStub.lastCall.returnValue.then(() => {
+      assert.equal(element._generatedPassword, nextPassword);
     });
   });
 
+  test('without http_password_url', () => {
+    assert.isNull(element._passwordUrl);
+  });
+
+  test('with http_password_url', done => {
+    config.auth.http_password_url = 'http://example.com/';
+    element.loadData().then(() => {
+      assert.isNotNull(element._passwordUrl);
+      assert.equal(element._passwordUrl, config.auth.http_password_url);
+      done();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
index ac4f9e4..57f0e1d 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
@@ -14,95 +14,108 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const AUTH = [
-    'OPENID',
-    'OAUTH',
-  ];
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../../styles/gr-form-styles.js';
+import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-identities_html.js';
 
-  /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @extends Polymer.Element
-   */
-  class GrIdentities extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-identities'; }
+const AUTH = [
+  'OPENID',
+  'OAUTH',
+];
 
-    static get properties() {
-      return {
-        _identities: Object,
-        _idName: String,
-        serverConfig: Object,
-        _showLinkAnotherIdentity: {
-          type: Boolean,
-          computed: '_computeShowLinkAnotherIdentity(serverConfig)',
-        },
-      };
-    }
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @extends Polymer.Element
+ */
+class GrIdentities extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    loadData() {
-      return this.$.restAPI.getExternalIds().then(id => {
-        this._identities = id;
-      });
-    }
+  static get is() { return 'gr-identities'; }
 
-    _computeIdentity(id) {
-      return id && id.startsWith('mailto:') ? '' : id;
-    }
-
-    _computeHideDeleteClass(canDelete) {
-      return canDelete ? 'show' : '';
-    }
-
-    _handleDeleteItemConfirm() {
-      this.$.overlay.close();
-      return this.$.restAPI.deleteAccountIdentity([this._idName])
-          .then(() => { this.loadData(); });
-    }
-
-    _handleConfirmDialogCancel() {
-      this.$.overlay.close();
-    }
-
-    _handleDeleteItem(e) {
-      const name = e.model.get('item.identity');
-      if (!name) { return; }
-      this._idName = name;
-      this.$.overlay.open();
-    }
-
-    _computeIsTrusted(item) {
-      return item ? '' : 'Untrusted';
-    }
-
-    filterIdentities(item) {
-      return !item.identity.startsWith('username:');
-    }
-
-    _computeShowLinkAnotherIdentity(config) {
-      if (config && config.auth &&
-          config.auth.git_basic_auth_policy) {
-        return AUTH.includes(
-            config.auth.git_basic_auth_policy.toUpperCase());
-      }
-
-      return false;
-    }
-
-    _computeLinkAnotherIdentity() {
-      const baseUrl = this.getBaseUrl() || '';
-      let pathname = window.location.pathname;
-      if (baseUrl) {
-        pathname = '/' + pathname.substring(baseUrl.length);
-      }
-      return baseUrl + '/login/' + encodeURIComponent(pathname) + '?link';
-    }
+  static get properties() {
+    return {
+      _identities: Object,
+      _idName: String,
+      serverConfig: Object,
+      _showLinkAnotherIdentity: {
+        type: Boolean,
+        computed: '_computeShowLinkAnotherIdentity(serverConfig)',
+      },
+    };
   }
 
-  customElements.define(GrIdentities.is, GrIdentities);
-})();
+  loadData() {
+    return this.$.restAPI.getExternalIds().then(id => {
+      this._identities = id;
+    });
+  }
+
+  _computeIdentity(id) {
+    return id && id.startsWith('mailto:') ? '' : id;
+  }
+
+  _computeHideDeleteClass(canDelete) {
+    return canDelete ? 'show' : '';
+  }
+
+  _handleDeleteItemConfirm() {
+    this.$.overlay.close();
+    return this.$.restAPI.deleteAccountIdentity([this._idName])
+        .then(() => { this.loadData(); });
+  }
+
+  _handleConfirmDialogCancel() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteItem(e) {
+    const name = e.model.get('item.identity');
+    if (!name) { return; }
+    this._idName = name;
+    this.$.overlay.open();
+  }
+
+  _computeIsTrusted(item) {
+    return item ? '' : 'Untrusted';
+  }
+
+  filterIdentities(item) {
+    return !item.identity.startsWith('username:');
+  }
+
+  _computeShowLinkAnotherIdentity(config) {
+    if (config && config.auth &&
+        config.auth.git_basic_auth_policy) {
+      return AUTH.includes(
+          config.auth.git_basic_auth_policy.toUpperCase());
+    }
+
+    return false;
+  }
+
+  _computeLinkAnotherIdentity() {
+    const baseUrl = this.getBaseUrl() || '';
+    let pathname = window.location.pathname;
+    if (baseUrl) {
+      pathname = '/' + pathname.substring(baseUrl.length);
+    }
+    return baseUrl + '/login/' + encodeURIComponent(pathname) + '?link';
+  }
+}
+
+customElements.define(GrIdentities.is, GrIdentities);
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.js
index 53d74f2..f1424cc 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.js
@@ -1,31 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-identities">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -75,9 +66,7 @@
                 <td class="emailAddressColumn">[[item.email_address]]</td>
                 <td class="identityColumn">[[_computeIdentity(item.identity)]]</td>
                 <td class="deleteColumn">
-                  <gr-button
-                      class$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]"
-                      on-click="_handleDeleteItem">
+                  <gr-button class\$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]" on-click="_handleDeleteItem">
                     Delete
                   </gr-button>
                 </td>
@@ -88,21 +77,14 @@
       </fieldset>
       <template is="dom-if" if="[[_showLinkAnotherIdentity]]">
         <fieldset>
-          <a href$="[[_computeLinkAnotherIdentity()]]">
-            <gr-button id="linkAnotherIdentity" link>Link Another Identity</gr-button>
+          <a href\$="[[_computeLinkAnotherIdentity()]]">
+            <gr-button id="linkAnotherIdentity" link="">Link Another Identity</gr-button>
           </a>
         </fieldset>
       </template>
     </div>
-    <gr-overlay id="overlay" with-backdrop>
-      <gr-confirm-delete-item-dialog
-          class="confirmDialog"
-          on-confirm="_handleDeleteItemConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          item="[[_idName]]"
-          item-type="id"></gr-confirm-delete-item-dialog>
+    <gr-overlay id="overlay" with-backdrop="">
+      <gr-confirm-delete-item-dialog class="confirmDialog" on-confirm="_handleDeleteItemConfirm" on-cancel="_handleConfirmDialogCancel" item="[[_idName]]" item-type="id"></gr-confirm-delete-item-dialog>
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-identities.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
index be73a0c..acf4507 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-identities</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-identities.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-identities.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-identities.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,158 +40,161 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-identities tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    const ids = [
-      {
-        identity: 'username:john',
-        email_address: 'john.doe@example.com',
-        trusted: true,
-      }, {
-        identity: 'gerrit:gerrit',
-        email_address: 'gerrit@example.com',
-      }, {
-        identity: 'mailto:gerrit2@example.com',
-        email_address: 'gerrit2@example.com',
-        trusted: true,
-        can_delete: true,
-      },
-    ];
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-identities.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-identities tests', () => {
+  let element;
+  let sandbox;
+  const ids = [
+    {
+      identity: 'username:john',
+      email_address: 'john.doe@example.com',
+      trusted: true,
+    }, {
+      identity: 'gerrit:gerrit',
+      email_address: 'gerrit@example.com',
+    }, {
+      identity: 'mailto:gerrit2@example.com',
+      email_address: 'gerrit2@example.com',
+      trusted: true,
+      can_delete: true,
+    },
+  ];
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
+  setup(done => {
+    sandbox = sinon.sandbox.create();
 
-      stub('gr-rest-api-interface', {
-        getExternalIds() { return Promise.resolve(ids); },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getExternalIds() { return Promise.resolve(ids); },
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    element = fixture('basic');
 
-    test('renders', () => {
-      const rows = Array.from(
-          Polymer.dom(element.root).querySelectorAll('tbody tr'));
+    element.loadData().then(() => { flush(done); });
+  });
 
-      assert.equal(rows.length, 2);
+  teardown(() => {
+    sandbox.restore();
+  });
 
-      const nameCells = rows.map(row =>
-        row.querySelectorAll('td')[2].textContent
-      );
+  test('renders', () => {
+    const rows = Array.from(
+        dom(element.root).querySelectorAll('tbody tr'));
 
-      assert.equal(nameCells[0], 'gerrit:gerrit');
-      assert.equal(nameCells[1], '');
-    });
+    assert.equal(rows.length, 2);
 
-    test('renders email', () => {
-      const rows = Array.from(
-          Polymer.dom(element.root).querySelectorAll('tbody tr'));
+    const nameCells = rows.map(row =>
+      row.querySelectorAll('td')[2].textContent
+    );
 
-      assert.equal(rows.length, 2);
+    assert.equal(nameCells[0], 'gerrit:gerrit');
+    assert.equal(nameCells[1], '');
+  });
 
-      const nameCells = rows.map(row =>
-        row.querySelectorAll('td')[1].textContent
-      );
+  test('renders email', () => {
+    const rows = Array.from(
+        dom(element.root).querySelectorAll('tbody tr'));
 
-      assert.equal(nameCells[0], 'gerrit@example.com');
-      assert.equal(nameCells[1], 'gerrit2@example.com');
-    });
+    assert.equal(rows.length, 2);
 
-    test('_computeIdentity', () => {
-      assert.equal(
-          element._computeIdentity(ids[0].identity), 'username:john');
-      assert.equal(element._computeIdentity(ids[2].identity), '');
-    });
+    const nameCells = rows.map(row =>
+      row.querySelectorAll('td')[1].textContent
+    );
 
-    test('filterIdentities', () => {
-      assert.isFalse(element.filterIdentities(ids[0]));
+    assert.equal(nameCells[0], 'gerrit@example.com');
+    assert.equal(nameCells[1], 'gerrit2@example.com');
+  });
 
-      assert.isTrue(element.filterIdentities(ids[1]));
-    });
+  test('_computeIdentity', () => {
+    assert.equal(
+        element._computeIdentity(ids[0].identity), 'username:john');
+    assert.equal(element._computeIdentity(ids[2].identity), '');
+  });
 
-    test('delete id', done => {
-      element._idName = 'mailto:gerrit2@example.com';
-      const loadDataStub = sandbox.stub(element, 'loadData');
-      element._handleDeleteItemConfirm().then(() => {
-        assert.isTrue(loadDataStub.called);
-        done();
-      });
-    });
+  test('filterIdentities', () => {
+    assert.isFalse(element.filterIdentities(ids[0]));
 
-    test('_handleDeleteItem opens modal', () => {
-      const deleteBtn =
-          Polymer.dom(element.root).querySelector('.deleteButton');
-      const deleteItem = sandbox.stub(element, '_handleDeleteItem');
-      MockInteractions.tap(deleteBtn);
-      assert.isTrue(deleteItem.called);
-    });
+    assert.isTrue(element.filterIdentities(ids[1]));
+  });
 
-    test('_computeShowLinkAnotherIdentity', () => {
-      let serverConfig;
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'OAUTH',
-        },
-      };
-      assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'OpenID',
-        },
-      };
-      assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'HTTP_LDAP',
-        },
-      };
-      assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'LDAP',
-        },
-      };
-      assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'HTTP',
-        },
-      };
-      assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-      serverConfig = {};
-      assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-    });
-
-    test('_showLinkAnotherIdentity', () => {
-      element.serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'OAUTH',
-        },
-      };
-
-      assert.isTrue(element._showLinkAnotherIdentity);
-
-      element.serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'LDAP',
-        },
-      };
-
-      assert.isFalse(element._showLinkAnotherIdentity);
+  test('delete id', done => {
+    element._idName = 'mailto:gerrit2@example.com';
+    const loadDataStub = sandbox.stub(element, 'loadData');
+    element._handleDeleteItemConfirm().then(() => {
+      assert.isTrue(loadDataStub.called);
+      done();
     });
   });
+
+  test('_handleDeleteItem opens modal', () => {
+    const deleteBtn =
+        dom(element.root).querySelector('.deleteButton');
+    const deleteItem = sandbox.stub(element, '_handleDeleteItem');
+    MockInteractions.tap(deleteBtn);
+    assert.isTrue(deleteItem.called);
+  });
+
+  test('_computeShowLinkAnotherIdentity', () => {
+    let serverConfig;
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'OAUTH',
+      },
+    };
+    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'OpenID',
+      },
+    };
+    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'HTTP_LDAP',
+      },
+    };
+    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'LDAP',
+      },
+    };
+    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'HTTP',
+      },
+    };
+    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {};
+    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
+  });
+
+  test('_showLinkAnotherIdentity', () => {
+    element.serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'OAUTH',
+      },
+    };
+
+    assert.isTrue(element._showLinkAnotherIdentity);
+
+    element.serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'LDAP',
+      },
+    };
+
+    assert.isFalse(element._showLinkAnotherIdentity);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
index 0ee232b..42982fd 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
@@ -14,68 +14,80 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrMenuEditor extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-menu-editor'; }
+import '@polymer/iron-input/iron-input.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import '../../../styles/gr-form-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-menu-editor_html.js';
 
-    static get properties() {
-      return {
-        menuItems: Array,
-        _newName: String,
-        _newUrl: String,
-      };
-    }
+/** @extends Polymer.Element */
+class GrMenuEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _handleMoveUpButton(e) {
-      const index = Number(Polymer.dom(e).localTarget.dataset.index);
-      if (index === 0) { return; }
-      const row = this.menuItems[index];
-      const prev = this.menuItems[index - 1];
-      this.splice('menuItems', index - 1, 2, row, prev);
-    }
+  static get is() { return 'gr-menu-editor'; }
 
-    _handleMoveDownButton(e) {
-      const index = Number(Polymer.dom(e).localTarget.dataset.index);
-      if (index === this.menuItems.length - 1) { return; }
-      const row = this.menuItems[index];
-      const next = this.menuItems[index + 1];
-      this.splice('menuItems', index, 2, next, row);
-    }
-
-    _handleDeleteButton(e) {
-      const index = Number(Polymer.dom(e).localTarget.dataset.index);
-      this.splice('menuItems', index, 1);
-    }
-
-    _handleAddButton() {
-      if (this._computeAddDisabled(this._newName, this._newUrl)) { return; }
-
-      this.splice('menuItems', this.menuItems.length, 0, {
-        name: this._newName,
-        url: this._newUrl,
-        target: '_blank',
-      });
-
-      this._newName = '';
-      this._newUrl = '';
-    }
-
-    _computeAddDisabled(newName, newUrl) {
-      return !newName.length || !newUrl.length;
-    }
-
-    _handleInputKeydown(e) {
-      if (e.keyCode === 13) {
-        e.stopPropagation();
-        this._handleAddButton();
-      }
-    }
+  static get properties() {
+    return {
+      menuItems: Array,
+      _newName: String,
+      _newUrl: String,
+    };
   }
 
-  customElements.define(GrMenuEditor.is, GrMenuEditor);
-})();
+  _handleMoveUpButton(e) {
+    const index = Number(dom(e).localTarget.dataset.index);
+    if (index === 0) { return; }
+    const row = this.menuItems[index];
+    const prev = this.menuItems[index - 1];
+    this.splice('menuItems', index - 1, 2, row, prev);
+  }
+
+  _handleMoveDownButton(e) {
+    const index = Number(dom(e).localTarget.dataset.index);
+    if (index === this.menuItems.length - 1) { return; }
+    const row = this.menuItems[index];
+    const next = this.menuItems[index + 1];
+    this.splice('menuItems', index, 2, next, row);
+  }
+
+  _handleDeleteButton(e) {
+    const index = Number(dom(e).localTarget.dataset.index);
+    this.splice('menuItems', index, 1);
+  }
+
+  _handleAddButton() {
+    if (this._computeAddDisabled(this._newName, this._newUrl)) { return; }
+
+    this.splice('menuItems', this.menuItems.length, 0, {
+      name: this._newName,
+      url: this._newUrl,
+      target: '_blank',
+    });
+
+    this._newName = '';
+    this._newUrl = '';
+  }
+
+  _computeAddDisabled(newName, newUrl) {
+    return !newName.length || !newUrl.length;
+  }
+
+  _handleInputKeydown(e) {
+    if (e.keyCode === 13) {
+      e.stopPropagation();
+      this._handleAddButton();
+    }
+  }
+}
+
+customElements.define(GrMenuEditor.is, GrMenuEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.js
index 46fc165..58b654f 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-
-<dom-module id="gr-menu-editor">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .buttonColumn {
         width: 2em;
@@ -61,25 +53,13 @@
               <td>[[item.name]]</td>
               <td class="urlCell">[[item.url]]</td>
               <td class="buttonColumn">
-                <gr-button
-                    link
-                    data-index$="[[index]]"
-                    on-click="_handleMoveUpButton"
-                    class="moveUpButton">↑</gr-button>
+                <gr-button link="" data-index\$="[[index]]" on-click="_handleMoveUpButton" class="moveUpButton">↑</gr-button>
               </td>
               <td class="buttonColumn">
-                <gr-button
-                    link
-                    data-index$="[[index]]"
-                    on-click="_handleMoveDownButton"
-                    class="moveDownButton">↓</gr-button>
+                <gr-button link="" data-index\$="[[index]]" on-click="_handleMoveDownButton" class="moveDownButton">↓</gr-button>
               </td>
               <td>
-                <gr-button
-                    link
-                    data-index$="[[index]]"
-                    on-click="_handleDeleteButton"
-                    class="remove-button">Delete</gr-button>
+                <gr-button link="" data-index\$="[[index]]" on-click="_handleDeleteButton" class="remove-button">Delete</gr-button>
               </td>
             </tr>
           </template>
@@ -87,43 +67,22 @@
         <tfoot>
           <tr>
             <th>
-              <iron-input
-                  placeholder="New Title"
-                  on-keydown="_handleInputKeydown"
-                  bind-value="{{_newName}}">
-                <input
-                    is="iron-input"
-                    placeholder="New Title"
-                    on-keydown="_handleInputKeydown"
-                    bind-value="{{_newName}}">
+              <iron-input placeholder="New Title" on-keydown="_handleInputKeydown" bind-value="{{_newName}}">
+                <input is="iron-input" placeholder="New Title" on-keydown="_handleInputKeydown" bind-value="{{_newName}}">
               </iron-input>
             </th>
             <th>
-              <iron-input
-                  class="newUrlInput"
-                  placeholder="New URL"
-                  on-keydown="_handleInputKeydown"
-                  bind-value="{{_newUrl}}">
-                <input
-                    class="newUrlInput"
-                    is="iron-input"
-                    placeholder="New URL"
-                    on-keydown="_handleInputKeydown"
-                    bind-value="{{_newUrl}}">
+              <iron-input class="newUrlInput" placeholder="New URL" on-keydown="_handleInputKeydown" bind-value="{{_newUrl}}">
+                <input class="newUrlInput" is="iron-input" placeholder="New URL" on-keydown="_handleInputKeydown" bind-value="{{_newUrl}}">
               </iron-input>
             </th>
             <th></th>
             <th></th>
             <th>
-              <gr-button
-                  link
-                  disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
-                  on-click="_handleAddButton">Add</gr-button>
+              <gr-button link="" disabled\$="[[_computeAddDisabled(_newName, _newUrl)]]" on-click="_handleAddButton">Add</gr-button>
             </th>
           </tr>
         </tfoot>
       </table>
     </div>
-  </template>
-  <script src="gr-menu-editor.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
index a5f2074..930255c 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-menu-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-menu-editor.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-menu-editor.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,146 +40,149 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-menu-editor tests', async () => {
-    await readyToTest();
-    let element;
-    let menu;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-menu-editor.js';
+import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-menu-editor tests', () => {
+  let element;
+  let menu;
 
-    function assertMenuNamesEqual(element, expected) {
-      const names = element.menuItems.map(i => i.name);
-      assert.equal(names.length, expected.length);
-      for (let i = 0; i < names.length; i++) {
-        assert.equal(names[i], expected[i]);
-      }
+  function assertMenuNamesEqual(element, expected) {
+    const names = element.menuItems.map(i => i.name);
+    assert.equal(names.length, expected.length);
+    for (let i = 0; i < names.length; i++) {
+      assert.equal(names[i], expected[i]);
+    }
+  }
+
+  // Click the up/down button (according to direction) for the index'th row.
+  // The index of the first row is 0, corresponding to the array.
+  function move(element, index, direction) {
+    const selector = 'tr:nth-child(' + (index + 1) + ') .move' +
+        direction + 'Button';
+    const button =
+        element.shadowRoot
+            .querySelector('tbody').querySelector(selector)
+            .shadowRoot
+            .querySelector('paper-button');
+    MockInteractions.tap(button);
+  }
+
+  setup(done => {
+    element = fixture('basic');
+    menu = [
+      {url: '/first/url', name: 'first name', target: '_blank'},
+      {url: '/second/url', name: 'second name', target: '_blank'},
+      {url: '/third/url', name: 'third name', target: '_blank'},
+    ];
+    element.set('menuItems', menu);
+    flush$0();
+    flush(done);
+  });
+
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('tbody').querySelectorAll('tr');
+    let tds;
+
+    assert.equal(rows.length, menu.length);
+    for (let i = 0; i < menu.length; i++) {
+      tds = rows[i].querySelectorAll('td');
+      assert.equal(tds[0].textContent, menu[i].name);
+      assert.equal(tds[1].textContent, menu[i].url);
     }
 
-    // Click the up/down button (according to direction) for the index'th row.
-    // The index of the first row is 0, corresponding to the array.
-    function move(element, index, direction) {
-      const selector = 'tr:nth-child(' + (index + 1) + ') .move' +
-          direction + 'Button';
-      const button =
-          element.shadowRoot
-              .querySelector('tbody').querySelector(selector)
-              .shadowRoot
-              .querySelector('paper-button');
-      MockInteractions.tap(button);
-    }
+    assert.isTrue(element._computeAddDisabled(element._newName,
+        element._newUrl));
+  });
 
-    setup(done => {
-      element = fixture('basic');
-      menu = [
-        {url: '/first/url', name: 'first name', target: '_blank'},
-        {url: '/second/url', name: 'second name', target: '_blank'},
-        {url: '/third/url', name: 'third name', target: '_blank'},
-      ];
-      element.set('menuItems', menu);
-      Polymer.dom.flush();
-      flush(done);
-    });
+  test('_computeAddDisabled', () => {
+    assert.isTrue(element._computeAddDisabled('', ''));
+    assert.isTrue(element._computeAddDisabled('name', ''));
+    assert.isTrue(element._computeAddDisabled('', 'url'));
+    assert.isFalse(element._computeAddDisabled('name', 'url'));
+  });
 
-    test('renders', () => {
-      const rows = element.shadowRoot
-          .querySelector('tbody').querySelectorAll('tr');
-      let tds;
+  test('add a new menu item', () => {
+    const newName = 'new name';
+    const newUrl = 'new url';
 
-      assert.equal(rows.length, menu.length);
-      for (let i = 0; i < menu.length; i++) {
-        tds = rows[i].querySelectorAll('td');
-        assert.equal(tds[0].textContent, menu[i].name);
-        assert.equal(tds[1].textContent, menu[i].url);
-      }
+    element._newName = newName;
+    element._newUrl = newUrl;
+    assert.isFalse(element._computeAddDisabled(element._newName,
+        element._newUrl));
 
-      assert.isTrue(element._computeAddDisabled(element._newName,
-          element._newUrl));
-    });
+    const originalMenuLength = element.menuItems.length;
 
-    test('_computeAddDisabled', () => {
-      assert.isTrue(element._computeAddDisabled('', ''));
-      assert.isTrue(element._computeAddDisabled('name', ''));
-      assert.isTrue(element._computeAddDisabled('', 'url'));
-      assert.isFalse(element._computeAddDisabled('name', 'url'));
-    });
+    element._handleAddButton();
 
-    test('add a new menu item', () => {
-      const newName = 'new name';
-      const newUrl = 'new url';
+    assert.equal(element.menuItems.length, originalMenuLength + 1);
+    assert.equal(element.menuItems[element.menuItems.length - 1].name,
+        newName);
+    assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
+  });
 
-      element._newName = newName;
-      element._newUrl = newUrl;
-      assert.isFalse(element._computeAddDisabled(element._newName,
-          element._newUrl));
+  test('move items down', () => {
+    assertMenuNamesEqual(element,
+        ['first name', 'second name', 'third name']);
 
-      const originalMenuLength = element.menuItems.length;
+    // Move the middle item down
+    move(element, 1, 'Down');
+    assertMenuNamesEqual(element,
+        ['first name', 'third name', 'second name']);
 
-      element._handleAddButton();
+    // Moving the bottom item down is a no-op.
+    move(element, 2, 'Down');
+    assertMenuNamesEqual(element,
+        ['first name', 'third name', 'second name']);
+  });
 
-      assert.equal(element.menuItems.length, originalMenuLength + 1);
-      assert.equal(element.menuItems[element.menuItems.length - 1].name,
-          newName);
-      assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
-    });
+  test('move items up', () => {
+    assertMenuNamesEqual(element,
+        ['first name', 'second name', 'third name']);
 
-    test('move items down', () => {
-      assertMenuNamesEqual(element,
-          ['first name', 'second name', 'third name']);
+    // Move the last item up twice to be the first.
+    move(element, 2, 'Up');
+    move(element, 1, 'Up');
+    assertMenuNamesEqual(element,
+        ['third name', 'first name', 'second name']);
 
-      // Move the middle item down
-      move(element, 1, 'Down');
-      assertMenuNamesEqual(element,
-          ['first name', 'third name', 'second name']);
+    // Moving the top item up is a no-op.
+    move(element, 0, 'Up');
+    assertMenuNamesEqual(element,
+        ['third name', 'first name', 'second name']);
+  });
 
-      // Moving the bottom item down is a no-op.
-      move(element, 2, 'Down');
-      assertMenuNamesEqual(element,
-          ['first name', 'third name', 'second name']);
-    });
+  test('remove item', () => {
+    assertMenuNamesEqual(element,
+        ['first name', 'second name', 'third name']);
 
-    test('move items up', () => {
-      assertMenuNamesEqual(element,
-          ['first name', 'second name', 'third name']);
+    // Tap the delete button for the middle item.
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('tbody')
+        .querySelector('tr:nth-child(2) .remove-button')
+        .shadowRoot
+        .querySelector('paper-button'));
 
-      // Move the last item up twice to be the first.
-      move(element, 2, 'Up');
-      move(element, 1, 'Up');
-      assertMenuNamesEqual(element,
-          ['third name', 'first name', 'second name']);
+    assertMenuNamesEqual(element, ['first name', 'third name']);
 
-      // Moving the top item up is a no-op.
-      move(element, 0, 'Up');
-      assertMenuNamesEqual(element,
-          ['third name', 'first name', 'second name']);
-    });
-
-    test('remove item', () => {
-      assertMenuNamesEqual(element,
-          ['first name', 'second name', 'third name']);
-
-      // Tap the delete button for the middle item.
+    // Delete remaining items.
+    for (let i = 0; i < 2; i++) {
       MockInteractions.tap(element.shadowRoot
           .querySelector('tbody')
-          .querySelector('tr:nth-child(2) .remove-button')
+          .querySelector('tr:first-child .remove-button')
           .shadowRoot
           .querySelector('paper-button'));
+    }
+    assertMenuNamesEqual(element, []);
 
-      assertMenuNamesEqual(element, ['first name', 'third name']);
-
-      // Delete remaining items.
-      for (let i = 0; i < 2; i++) {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('tbody')
-            .querySelector('tr:first-child .remove-button')
-            .shadowRoot
-            .querySelector('paper-button'));
-      }
-      assertMenuNamesEqual(element, []);
-
-      // Add item to empty menu.
-      element._newName = 'new name';
-      element._newUrl = 'new url';
-      element._handleAddButton();
-      assertMenuNamesEqual(element, ['new name']);
-    });
+    // Add item to empty menu.
+    element._newName = 'new name';
+    element._newUrl = 'new url';
+    element._handleAddButton();
+    assertMenuNamesEqual(element, ['new name']);
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
index 4bb98d0..c20800f 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
@@ -14,142 +14,155 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '@polymer/iron-input/iron-input.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/gr-form-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-registration-dialog_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrRegistrationDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-registration-dialog'; }
+  /**
+   * Fired when account details are changed.
+   *
+   * @event account-detail-update
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the close button is pressed.
+   *
+   * @event close
    */
-  class GrRegistrationDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-registration-dialog'; }
-    /**
-     * Fired when account details are changed.
-     *
-     * @event account-detail-update
-     */
 
-    /**
-     * Fired when the close button is pressed.
-     *
-     * @event close
-     */
-
-    static get properties() {
-      return {
-        settingsUrl: String,
-        /** @type {?} */
-        _account: {
-          type: Object,
-          value: () => {
-          // Prepopulate possibly undefined fields with values to trigger
-          // computed bindings.
-            return {email: null, name: null, username: null};
-          },
+  static get properties() {
+    return {
+      settingsUrl: String,
+      /** @type {?} */
+      _account: {
+        type: Object,
+        value: () => {
+        // Prepopulate possibly undefined fields with values to trigger
+        // computed bindings.
+          return {email: null, name: null, username: null};
         },
-        _usernameMutable: {
-          type: Boolean,
-          computed: '_computeUsernameMutable(_serverConfig, _account.username)',
-        },
-        _loading: {
-          type: Boolean,
-          value: true,
-          observer: '_loadingChanged',
-        },
-        _saving: {
-          type: Boolean,
-          value: false,
-        },
-        _serverConfig: Object,
-      };
-    }
-
-    /** @override */
-    ready() {
-      super.ready();
-      this._ensureAttribute('role', 'dialog');
-    }
-
-    loadData() {
-      this._loading = true;
-
-      const loadAccount = this.$.restAPI.getAccount().then(account => {
-        // Using Object.assign here allows preservation of the default values
-        // supplied in the value generating function of this._account, unless
-        // they are overridden by properties in the account from the response.
-        this._account = Object.assign({}, this._account, account);
-      });
-
-      const loadConfig = this.$.restAPI.getConfig().then(config => {
-        this._serverConfig = config;
-      });
-
-      return Promise.all([loadAccount, loadConfig]).then(() => {
-        this._loading = false;
-      });
-    }
-
-    _save() {
-      this._saving = true;
-      const promises = [
-        this.$.restAPI.setAccountName(this.$.name.value),
-        this.$.restAPI.setPreferredAccountEmail(this.$.email.value || ''),
-      ];
-
-      if (this._usernameMutable) {
-        promises.push(this.$.restAPI.setAccountUsername(this.$.username.value));
-      }
-
-      return Promise.all(promises).then(() => {
-        this._saving = false;
-        this.fire('account-detail-update');
-      });
-    }
-
-    _handleSave(e) {
-      e.preventDefault();
-      this._save().then(this.close.bind(this));
-    }
-
-    _handleClose(e) {
-      e.preventDefault();
-      this.close();
-    }
-
-    close() {
-      this._saving = true; // disable buttons indefinitely
-      this.fire('close');
-    }
-
-    _computeSaveDisabled(name, email, saving) {
-      return !name || !email || saving;
-    }
-
-    _computeUsernameMutable(config, username) {
-      // Polymer 2: check for undefined
-      if ([
-        config,
-        username,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      return config.auth.editable_account_fields.includes('USER_NAME') &&
-          !username;
-    }
-
-    _computeUsernameClass(usernameMutable) {
-      return usernameMutable ? '' : 'hide';
-    }
-
-    _loadingChanged() {
-      this.classList.toggle('loading', this._loading);
-    }
+      },
+      _usernameMutable: {
+        type: Boolean,
+        computed: '_computeUsernameMutable(_serverConfig, _account.username)',
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+        observer: '_loadingChanged',
+      },
+      _saving: {
+        type: Boolean,
+        value: false,
+      },
+      _serverConfig: Object,
+    };
   }
 
-  customElements.define(GrRegistrationDialog.is, GrRegistrationDialog);
-})();
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'dialog');
+  }
+
+  loadData() {
+    this._loading = true;
+
+    const loadAccount = this.$.restAPI.getAccount().then(account => {
+      // Using Object.assign here allows preservation of the default values
+      // supplied in the value generating function of this._account, unless
+      // they are overridden by properties in the account from the response.
+      this._account = Object.assign({}, this._account, account);
+    });
+
+    const loadConfig = this.$.restAPI.getConfig().then(config => {
+      this._serverConfig = config;
+    });
+
+    return Promise.all([loadAccount, loadConfig]).then(() => {
+      this._loading = false;
+    });
+  }
+
+  _save() {
+    this._saving = true;
+    const promises = [
+      this.$.restAPI.setAccountName(this.$.name.value),
+      this.$.restAPI.setPreferredAccountEmail(this.$.email.value || ''),
+    ];
+
+    if (this._usernameMutable) {
+      promises.push(this.$.restAPI.setAccountUsername(this.$.username.value));
+    }
+
+    return Promise.all(promises).then(() => {
+      this._saving = false;
+      this.fire('account-detail-update');
+    });
+  }
+
+  _handleSave(e) {
+    e.preventDefault();
+    this._save().then(this.close.bind(this));
+  }
+
+  _handleClose(e) {
+    e.preventDefault();
+    this.close();
+  }
+
+  close() {
+    this._saving = true; // disable buttons indefinitely
+    this.fire('close');
+  }
+
+  _computeSaveDisabled(name, email, saving) {
+    return !name || !email || saving;
+  }
+
+  _computeUsernameMutable(config, username) {
+    // Polymer 2: check for undefined
+    if ([
+      config,
+      username,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    return config.auth.editable_account_fields.includes('USER_NAME') &&
+        !username;
+  }
+
+  _computeUsernameClass(usernameMutable) {
+    return usernameMutable ? '' : 'hide';
+  }
+
+  _loadingChanged() {
+    this.classList.toggle('loading', this._loading);
+  }
+}
+
+customElements.define(GrRegistrationDialog.is, GrRegistrationDialog);
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.js
index c289a49..737e6d5 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.js
@@ -1,31 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-registration-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="gr-form-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -85,31 +76,19 @@
         <hr>
         <section>
           <div class="title">Full Name</div>
-          <iron-input
-              bind-value="{{_account.name}}">
-            <input
-                is="iron-input"
-                id="name"
-                bind-value="{{_account.name}}"
-                disabled="[[_saving]]">
+          <iron-input bind-value="{{_account.name}}">
+            <input is="iron-input" id="name" bind-value="{{_account.name}}" disabled="[[_saving]]">
           </iron-input>
         </section>
-        <section class$="[[_computeUsernameClass(_usernameMutable)]]">
+        <section class\$="[[_computeUsernameClass(_usernameMutable)]]">
           <div class="title">Username</div>
-          <iron-input
-              bind-value="{{_account.username}}">
-            <input
-                is="iron-input"
-                id="username"
-                bind-value="{{_account.username}}"
-                disabled="[[_saving]]">
+          <iron-input bind-value="{{_account.username}}">
+            <input is="iron-input" id="username" bind-value="{{_account.username}}" disabled="[[_saving]]">
           </iron-input>
         </section>
         <section>
           <div class="title">Preferred Email</div>
-          <select
-              id="email"
-              disabled="[[_saving]]">
+          <select id="email" disabled="[[_saving]]">
             <option value="[[_account.email]]">[[_account.email]]</option>
             <template is="dom-repeat" items="[[_account.secondary_emails]]">
               <option value="[[item]]">[[item]]</option>
@@ -119,24 +98,13 @@
         <hr>
         <p>
           More configuration options for Gerrit may be found in the
-          <a on-click="close" href$="[[settingsUrl]]">settings</a>.
+          <a on-click="close" href\$="[[settingsUrl]]">settings</a>.
         </p>
       </main>
       <footer>
-        <gr-button
-            id="closeButton"
-            link
-            disabled="[[_saving]]"
-            on-click="_handleClose">Close</gr-button>
-        <gr-button
-            id="saveButton"
-            primary
-            link
-            disabled="[[_computeSaveDisabled(_account.name, _account.email, _saving)]]"
-            on-click="_handleSave">Save</gr-button>
+        <gr-button id="closeButton" link="" disabled="[[_saving]]" on-click="_handleClose">Close</gr-button>
+        <gr-button id="saveButton" primary="" link="" disabled="[[_computeSaveDisabled(_account.name, _account.email, _saving)]]" on-click="_handleSave">Save</gr-button>
       </footer>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-registration-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
index a3be75c..9aaaaa7 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-registration-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-registration-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-registration-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-registration-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -41,149 +46,151 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-registration-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let account;
-    let sandbox;
-    let _listeners;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-registration-dialog.js';
+suite('gr-registration-dialog tests', () => {
+  let element;
+  let account;
+  let sandbox;
+  let _listeners;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      _listeners = {};
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    _listeners = {};
 
-      account = {
-        name: 'name',
-        username: null,
-        email: 'email',
-        secondary_emails: [
-          'email2',
-          'email3',
-        ],
-      };
+    account = {
+      name: 'name',
+      username: null,
+      email: 'email',
+      secondary_emails: [
+        'email2',
+        'email3',
+      ],
+    };
 
-      stub('gr-rest-api-interface', {
-        getAccount() {
-          return Promise.resolve(account);
-        },
-        setAccountName(name) {
-          account.name = name;
-          return Promise.resolve();
-        },
-        setAccountUsername(username) {
-          account.username = username;
-          return Promise.resolve();
-        },
-        setPreferredAccountEmail(email) {
-          account.email = email;
-          return Promise.resolve();
-        },
-        getConfig() {
-          return Promise.resolve(
-              {auth: {editable_account_fields: ['USER_NAME']}});
-        },
-      });
-
-      element = fixture('basic');
-
-      return element.loadData();
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve(account);
+      },
+      setAccountName(name) {
+        account.name = name;
+        return Promise.resolve();
+      },
+      setAccountUsername(username) {
+        account.username = username;
+        return Promise.resolve();
+      },
+      setPreferredAccountEmail(email) {
+        account.email = email;
+        return Promise.resolve();
+      },
+      getConfig() {
+        return Promise.resolve(
+            {auth: {editable_account_fields: ['USER_NAME']}});
+      },
     });
 
-    teardown(() => {
-      sandbox.restore();
-      for (const eventType in _listeners) {
-        if (_listeners.hasOwnProperty(eventType)) {
-          element.removeEventListener(eventType, _listeners[eventType]);
-        }
+    element = fixture('basic');
+
+    return element.loadData();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    for (const eventType in _listeners) {
+      if (_listeners.hasOwnProperty(eventType)) {
+        element.removeEventListener(eventType, _listeners[eventType]);
       }
-    });
-
-    function listen(eventType) {
-      return new Promise(resolve => {
-        _listeners[eventType] = function() { resolve(); };
-        element.addEventListener(eventType, _listeners[eventType]);
-      });
     }
+  });
 
-    function save(opt_action) {
-      const promise = listen('account-detail-update');
-      if (opt_action) {
-        opt_action();
-      } else {
-        MockInteractions.tap(element.$.saveButton);
-      }
-      return promise;
+  function listen(eventType) {
+    return new Promise(resolve => {
+      _listeners[eventType] = function() { resolve(); };
+      element.addEventListener(eventType, _listeners[eventType]);
+    });
+  }
+
+  function save(opt_action) {
+    const promise = listen('account-detail-update');
+    if (opt_action) {
+      opt_action();
+    } else {
+      MockInteractions.tap(element.$.saveButton);
     }
+    return promise;
+  }
 
-    function close(opt_action) {
-      const promise = listen('close');
-      if (opt_action) {
-        opt_action();
-      } else {
-        MockInteractions.tap(element.$.closeButton);
-      }
-      return promise;
+  function close(opt_action) {
+    const promise = listen('close');
+    if (opt_action) {
+      opt_action();
+    } else {
+      MockInteractions.tap(element.$.closeButton);
     }
+    return promise;
+  }
 
-    test('fires the close event on close', done => {
-      close().then(done);
-    });
+  test('fires the close event on close', done => {
+    close().then(done);
+  });
 
-    test('fires the close event on save', done => {
-      close(() => {
-        MockInteractions.tap(element.$.saveButton);
-      }).then(done);
-    });
+  test('fires the close event on save', done => {
+    close(() => {
+      MockInteractions.tap(element.$.saveButton);
+    }).then(done);
+  });
 
-    test('saves account details', done => {
-      flush(() => {
-        element.$.name.value = 'new name';
-        element.$.username.value = 'new username';
-        element.$.email.value = 'email3';
+  test('saves account details', done => {
+    flush(() => {
+      element.$.name.value = 'new name';
+      element.$.username.value = 'new username';
+      element.$.email.value = 'email3';
 
-        // Nothing should be committed yet.
-        assert.equal(account.name, 'name');
-        assert.isNotOk(account.username);
-        assert.equal(account.email, 'email');
+      // Nothing should be committed yet.
+      assert.equal(account.name, 'name');
+      assert.isNotOk(account.username);
+      assert.equal(account.email, 'email');
 
-        // Save and verify new values are committed.
-        save()
-            .then(() => {
-              assert.equal(account.name, 'new name');
-              assert.equal(account.username, 'new username');
-              assert.equal(account.email, 'email3');
-            })
-            .then(done);
-      });
-    });
-
-    test('email select properly populated', done => {
-      element._account = {email: 'foo', secondary_emails: ['bar', 'baz']};
-      flush(() => {
-        assert.equal(element.$.email.value, 'foo');
-        done();
-      });
-    });
-
-    test('save btn disabled', () => {
-      const compute = element._computeSaveDisabled;
-      assert.isTrue(compute('', '', false));
-      assert.isTrue(compute('', 'test', false));
-      assert.isTrue(compute('test', '', false));
-      assert.isTrue(compute('test', 'test', true));
-      assert.isFalse(compute('test', 'test', false));
-    });
-
-    test('_computeUsernameMutable', () => {
-      assert.isTrue(element._computeUsernameMutable(
-          {auth: {editable_account_fields: ['USER_NAME']}}, null));
-      assert.isFalse(element._computeUsernameMutable(
-          {auth: {editable_account_fields: ['USER_NAME']}}, 'abc'));
-      assert.isFalse(element._computeUsernameMutable(
-          {auth: {editable_account_fields: []}}, null));
-      assert.isFalse(element._computeUsernameMutable(
-          {auth: {editable_account_fields: []}}, 'abc'));
+      // Save and verify new values are committed.
+      save()
+          .then(() => {
+            assert.equal(account.name, 'new name');
+            assert.equal(account.username, 'new username');
+            assert.equal(account.email, 'email3');
+          })
+          .then(done);
     });
   });
+
+  test('email select properly populated', done => {
+    element._account = {email: 'foo', secondary_emails: ['bar', 'baz']};
+    flush(() => {
+      assert.equal(element.$.email.value, 'foo');
+      done();
+    });
+  });
+
+  test('save btn disabled', () => {
+    const compute = element._computeSaveDisabled;
+    assert.isTrue(compute('', '', false));
+    assert.isTrue(compute('', 'test', false));
+    assert.isTrue(compute('test', '', false));
+    assert.isTrue(compute('test', 'test', true));
+    assert.isFalse(compute('test', 'test', false));
+  });
+
+  test('_computeUsernameMutable', () => {
+    assert.isTrue(element._computeUsernameMutable(
+        {auth: {editable_account_fields: ['USER_NAME']}}, null));
+    assert.isFalse(element._computeUsernameMutable(
+        {auth: {editable_account_fields: ['USER_NAME']}}, 'abc'));
+    assert.isFalse(element._computeUsernameMutable(
+        {auth: {editable_account_fields: []}}, null));
+    assert.isFalse(element._computeUsernameMutable(
+        {auth: {editable_account_fields: []}}, 'abc'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
index bae1f38..3884a15 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
@@ -14,22 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-settings-item_html.js';
 
-  /** @extends Polymer.Element */
-  class GrSettingsItem extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-settings-item'; }
+/** @extends Polymer.Element */
+class GrSettingsItem extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    static get properties() {
-      return {
-        anchor: String,
-        title: String,
-      };
-    }
+  static get is() { return 'gr-settings-item'; }
+
+  static get properties() {
+    return {
+      anchor: String,
+      title: String,
+    };
   }
+}
 
-  customElements.define(GrSettingsItem.is, GrSettingsItem);
-})();
+customElements.define(GrSettingsItem.is, GrSettingsItem);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js
index 937ee79..accb8c8 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js
@@ -1,24 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-settings-item">
-  <template>
+export const htmlTemplate = html`
     <style>
       :host {
         display: block;
@@ -27,6 +25,4 @@
     </style>
     <h2 id="[[anchor]]">[[title]]</h2>
     <slot></slot>
-  </template>
-  <script src="gr-settings-item.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
index d5a7eb7..5b11516 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
@@ -14,22 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrSettingsMenuItem extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-settings-menu-item'; }
+import '../../../styles/gr-page-nav-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-settings-menu-item_html.js';
 
-    static get properties() {
-      return {
-        href: String,
-        title: String,
-      };
-    }
+/** @extends Polymer.Element */
+class GrSettingsMenuItem extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-settings-menu-item'; }
+
+  static get properties() {
+    return {
+      href: String,
+      title: String,
+    };
   }
+}
 
-  customElements.define(GrSettingsMenuItem.is, GrSettingsMenuItem);
-})();
+customElements.define(GrSettingsMenuItem.is, GrSettingsMenuItem);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.js
index c356e80..5cb129f 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.js
@@ -1,25 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-page-nav-styles.html">
-
-<dom-module id="gr-settings-menu-item">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -27,8 +24,6 @@
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
     <div class="navStyles">
-      <li><a href$="[[href]]">[[title]]</a></li>
+      <li><a href\$="[[href]]">[[title]]</a></li>
     </div>
-  </template>
-  <script src="gr-settings-menu-item.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 78bad8c..733fa56 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -14,456 +14,489 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const PREFS_SECTION_FIELDS = [
-    'changes_per_page',
-    'date_format',
-    'time_format',
-    'email_strategy',
-    'diff_view',
-    'publish_comments_on_push',
-    'work_in_progress_by_default',
-    'default_base_for_merges',
-    'signed_off_by',
-    'email_format',
-    'size_bar_in_change_table',
-    'relative_date_in_change_table',
-  ];
+import '@polymer/iron-input/iron-input.js';
+import '../../../behaviors/docs-url-behavior/docs-url-behavior.js';
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-menu-page-styles.js';
+import '../../../styles/gr-page-nav-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../gr-change-table-editor/gr-change-table-editor.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-diff-preferences/gr-diff-preferences.js';
+import '../../shared/gr-page-nav/gr-page-nav.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import '../gr-account-info/gr-account-info.js';
+import '../gr-agreements-list/gr-agreements-list.js';
+import '../gr-edit-preferences/gr-edit-preferences.js';
+import '../gr-email-editor/gr-email-editor.js';
+import '../gr-gpg-editor/gr-gpg-editor.js';
+import '../gr-group-list/gr-group-list.js';
+import '../gr-http-password/gr-http-password.js';
+import '../gr-identities/gr-identities.js';
+import '../gr-menu-editor/gr-menu-editor.js';
+import '../gr-ssh-editor/gr-ssh-editor.js';
+import '../gr-watched-projects-editor/gr-watched-projects-editor.js';
+import '../../../scripts/util.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-settings-view_html.js';
 
-  const GERRIT_DOCS_BASE_URL = 'https://gerrit-review.googlesource.com/' +
-      'Documentation';
-  const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
-  const ABSOLUTE_URL_PATTERN = /^https?:/;
-  const TRAILING_SLASH_PATTERN = /\/$/;
+const PREFS_SECTION_FIELDS = [
+  'changes_per_page',
+  'date_format',
+  'time_format',
+  'email_strategy',
+  'diff_view',
+  'publish_comments_on_push',
+  'work_in_progress_by_default',
+  'default_base_for_merges',
+  'signed_off_by',
+  'email_format',
+  'size_bar_in_change_table',
+  'relative_date_in_change_table',
+];
 
-  const RELOAD_MESSAGE = 'Reloading...';
+const GERRIT_DOCS_BASE_URL = 'https://gerrit-review.googlesource.com/' +
+    'Documentation';
+const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
+const ABSOLUTE_URL_PATTERN = /^https?:/;
+const TRAILING_SLASH_PATTERN = /\/$/;
 
-  const HTTP_AUTH = [
-    'HTTP',
-    'HTTP_LDAP',
-  ];
+const RELOAD_MESSAGE = 'Reloading...';
+
+const HTTP_AUTH = [
+  'HTTP',
+  'HTTP_LDAP',
+];
+
+/**
+ * @appliesMixin Gerrit.DocsUrlMixin
+ * @appliesMixin Gerrit.ChangeTableMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrSettingsView extends mixinBehaviors( [
+  Gerrit.DocsUrlBehavior,
+  Gerrit.ChangeTableBehavior,
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-settings-view'; }
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
 
   /**
-   * @appliesMixin Gerrit.DocsUrlMixin
-   * @appliesMixin Gerrit.ChangeTableMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired with email confirmation text, or when the page reloads.
+   *
+   * @event show-alert
    */
-  class GrSettingsView extends Polymer.mixinBehaviors( [
-    Gerrit.DocsUrlBehavior,
-    Gerrit.ChangeTableBehavior,
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-settings-view'; }
-    /**
-     * Fired when the title of the page should change.
-     *
-     * @event title-change
-     */
 
-    /**
-     * Fired with email confirmation text, or when the page reloads.
-     *
-     * @event show-alert
-     */
+  static get properties() {
+    return {
+      prefs: {
+        type: Object,
+        value() { return {}; },
+      },
+      params: {
+        type: Object,
+        value() { return {}; },
+      },
+      _accountInfoChanged: Boolean,
+      _changeTableColumnsNotDisplayed: Array,
+      /** @type {?} */
+      _localPrefs: {
+        type: Object,
+        value() { return {}; },
+      },
+      _localChangeTableColumns: {
+        type: Array,
+        value() { return []; },
+      },
+      _localMenu: {
+        type: Array,
+        value() { return []; },
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _changeTableChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _prefsChanged: {
+        type: Boolean,
+        value: false,
+      },
+      /** @type {?} */
+      _diffPrefsChanged: Boolean,
+      /** @type {?} */
+      _editPrefsChanged: Boolean,
+      _menuChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _watchedProjectsChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _keysChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _gpgKeysChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _newEmail: String,
+      _addingEmail: {
+        type: Boolean,
+        value: false,
+      },
+      _lastSentVerificationEmail: {
+        type: String,
+        value: null,
+      },
+      /** @type {?} */
+      _serverConfig: Object,
+      /** @type {?string} */
+      _docsBaseUrl: String,
+      _emailsChanged: Boolean,
 
-    static get properties() {
-      return {
-        prefs: {
-          type: Object,
-          value() { return {}; },
-        },
-        params: {
-          type: Object,
-          value() { return {}; },
-        },
-        _accountInfoChanged: Boolean,
-        _changeTableColumnsNotDisplayed: Array,
-        /** @type {?} */
-        _localPrefs: {
-          type: Object,
-          value() { return {}; },
-        },
-        _localChangeTableColumns: {
-          type: Array,
-          value() { return []; },
-        },
-        _localMenu: {
-          type: Array,
-          value() { return []; },
-        },
-        _loading: {
-          type: Boolean,
-          value: true,
-        },
-        _changeTableChanged: {
-          type: Boolean,
-          value: false,
-        },
-        _prefsChanged: {
-          type: Boolean,
-          value: false,
-        },
-        /** @type {?} */
-        _diffPrefsChanged: Boolean,
-        /** @type {?} */
-        _editPrefsChanged: Boolean,
-        _menuChanged: {
-          type: Boolean,
-          value: false,
-        },
-        _watchedProjectsChanged: {
-          type: Boolean,
-          value: false,
-        },
-        _keysChanged: {
-          type: Boolean,
-          value: false,
-        },
-        _gpgKeysChanged: {
-          type: Boolean,
-          value: false,
-        },
-        _newEmail: String,
-        _addingEmail: {
-          type: Boolean,
-          value: false,
-        },
-        _lastSentVerificationEmail: {
-          type: String,
-          value: null,
-        },
-        /** @type {?} */
-        _serverConfig: Object,
-        /** @type {?string} */
-        _docsBaseUrl: String,
-        _emailsChanged: Boolean,
+      /**
+       * For testing purposes.
+       */
+      _loadingPromise: Object,
 
-        /**
-         * For testing purposes.
-         */
-        _loadingPromise: Object,
+      _showNumber: Boolean,
 
-        _showNumber: Boolean,
+      _isDark: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-        _isDark: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
+  static get observers() {
+    return [
+      '_handlePrefsChanged(_localPrefs.*)',
+      '_handleMenuChanged(_localMenu.splices)',
+      '_handleChangeTableChanged(_localChangeTableColumns, _showNumber)',
+    ];
+  }
 
-    static get observers() {
-      return [
-        '_handlePrefsChanged(_localPrefs.*)',
-        '_handleMenuChanged(_localMenu.splices)',
-        '_handleChangeTableChanged(_localChangeTableColumns, _showNumber)',
-      ];
-    }
+  /** @override */
+  attached() {
+    super.attached();
+    // Polymer 2: anchor tag won't work on shadow DOM
+    // we need to manually calling scrollIntoView when hash changed
+    this.listen(window, 'location-change', '_handleLocationChange');
+    this.fire('title-change', {title: 'Settings'});
 
-    /** @override */
-    attached() {
-      super.attached();
-      // Polymer 2: anchor tag won't work on shadow DOM
-      // we need to manually calling scrollIntoView when hash changed
-      this.listen(window, 'location-change', '_handleLocationChange');
-      this.fire('title-change', {title: 'Settings'});
+    this._isDark = !!window.localStorage.getItem('dark-theme');
 
-      this._isDark = !!window.localStorage.getItem('dark-theme');
+    const promises = [
+      this.$.accountInfo.loadData(),
+      this.$.watchedProjectsEditor.loadData(),
+      this.$.groupList.loadData(),
+      this.$.identities.loadData(),
+      this.$.editPrefs.loadData(),
+      this.$.diffPrefs.loadData(),
+    ];
 
-      const promises = [
-        this.$.accountInfo.loadData(),
-        this.$.watchedProjectsEditor.loadData(),
-        this.$.groupList.loadData(),
-        this.$.identities.loadData(),
-        this.$.editPrefs.loadData(),
-        this.$.diffPrefs.loadData(),
-      ];
-
-      promises.push(this.$.restAPI.getPreferences().then(prefs => {
-        this.prefs = prefs;
-        this._showNumber = !!prefs.legacycid_in_change_table;
-        this._copyPrefs('_localPrefs', 'prefs');
-        this._cloneMenu(prefs.my);
-        this._cloneChangeTableColumns();
-      }));
-
-      promises.push(this.$.restAPI.getConfig().then(config => {
-        this._serverConfig = config;
-        const configPromises = [];
-
-        if (this._serverConfig && this._serverConfig.sshd) {
-          configPromises.push(this.$.sshEditor.loadData());
-        }
-
-        if (this._serverConfig &&
-            this._serverConfig.receive &&
-            this._serverConfig.receive.enable_signed_push) {
-          configPromises.push(this.$.gpgEditor.loadData());
-        }
-
-        configPromises.push(
-            this.getDocsBaseUrl(config, this.$.restAPI)
-                .then(baseUrl => { this._docsBaseUrl = baseUrl; }));
-
-        return Promise.all(configPromises);
-      }));
-
-      if (this.params.emailToken) {
-        promises.push(this.$.restAPI.confirmEmail(this.params.emailToken).then(
-            message => {
-              if (message) {
-                this.fire('show-alert', {message});
-              }
-              this.$.emailEditor.loadData();
-            }));
-      } else {
-        promises.push(this.$.emailEditor.loadData());
-      }
-
-      this._loadingPromise = Promise.all(promises).then(() => {
-        this._loading = false;
-
-        // Handle anchor tag for initial load
-        this._handleLocationChange();
-      });
-    }
-
-    /** @override */
-    detached() {
-      super.detached();
-      this.unlisten(window, 'location-change', '_handleLocationChange');
-    }
-
-    _handleLocationChange() {
-      // Handle anchor tag after dom attached
-      const urlHash = window.location.hash;
-      if (urlHash) {
-        // Use shadowRoot for Polymer 2
-        const elem = (this.shadowRoot || document).querySelector(urlHash);
-        if (elem) {
-          elem.scrollIntoView();
-        }
-      }
-    }
-
-    reloadAccountDetail() {
-      Promise.all([
-        this.$.accountInfo.loadData(),
-        this.$.emailEditor.loadData(),
-      ]);
-    }
-
-    _isLoading() {
-      return this._loading || this._loading === undefined;
-    }
-
-    _copyPrefs(to, from) {
-      for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
-        this.set([to, PREFS_SECTION_FIELDS[i]],
-            this[from][PREFS_SECTION_FIELDS[i]]);
-      }
-    }
-
-    _cloneMenu(prefs) {
-      const menu = [];
-      for (const item of prefs) {
-        menu.push({
-          name: item.name,
-          url: item.url,
-          target: item.target,
-        });
-      }
-      this._localMenu = menu;
-    }
-
-    _cloneChangeTableColumns() {
-      let columns = this.getVisibleColumns(this.prefs.change_table);
-
-      if (columns.length === 0) {
-        columns = this.columnNames;
-        this._changeTableColumnsNotDisplayed = [];
-      } else {
-        this._changeTableColumnsNotDisplayed = this.getComplementColumns(
-            this.prefs.change_table);
-      }
-      this._localChangeTableColumns = columns;
-    }
-
-    _formatChangeTableColumns(changeTableArray) {
-      return changeTableArray.map(item => {
-        return {column: item};
-      });
-    }
-
-    _handleChangeTableChanged() {
-      if (this._isLoading()) { return; }
-      this._changeTableChanged = true;
-    }
-
-    _handlePrefsChanged(prefs) {
-      if (this._isLoading()) { return; }
-      this._prefsChanged = true;
-    }
-
-    _handleRelativeDateInChangeTable() {
-      this.set('_localPrefs.relative_date_in_change_table',
-          this.$.relativeDateInChangeTable.checked);
-    }
-
-    _handleShowSizeBarsInFileListChanged() {
-      this.set('_localPrefs.size_bar_in_change_table',
-          this.$.showSizeBarsInFileList.checked);
-    }
-
-    _handlePublishCommentsOnPushChanged() {
-      this.set('_localPrefs.publish_comments_on_push',
-          this.$.publishCommentsOnPush.checked);
-    }
-
-    _handleWorkInProgressByDefault() {
-      this.set('_localPrefs.work_in_progress_by_default',
-          this.$.workInProgressByDefault.checked);
-    }
-
-    _handleInsertSignedOff() {
-      this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
-    }
-
-    _handleMenuChanged() {
-      if (this._isLoading()) { return; }
-      this._menuChanged = true;
-    }
-
-    _handleSaveAccountInfo() {
-      this.$.accountInfo.save();
-    }
-
-    _handleSavePreferences() {
-      this._copyPrefs('prefs', '_localPrefs');
-
-      return this.$.restAPI.savePreferences(this.prefs).then(() => {
-        this._prefsChanged = false;
-      });
-    }
-
-    _handleSaveChangeTable() {
-      this.set('prefs.change_table', this._localChangeTableColumns);
-      this.set('prefs.legacycid_in_change_table', this._showNumber);
+    promises.push(this.$.restAPI.getPreferences().then(prefs => {
+      this.prefs = prefs;
+      this._showNumber = !!prefs.legacycid_in_change_table;
+      this._copyPrefs('_localPrefs', 'prefs');
+      this._cloneMenu(prefs.my);
       this._cloneChangeTableColumns();
-      return this.$.restAPI.savePreferences(this.prefs).then(() => {
-        this._changeTableChanged = false;
-      });
-    }
+    }));
 
-    _handleSaveDiffPreferences() {
-      this.$.diffPrefs.save();
-    }
+    promises.push(this.$.restAPI.getConfig().then(config => {
+      this._serverConfig = config;
+      const configPromises = [];
 
-    _handleSaveEditPreferences() {
-      this.$.editPrefs.save();
-    }
-
-    _handleSaveMenu() {
-      this.set('prefs.my', this._localMenu);
-      this._cloneMenu(this.prefs.my);
-      return this.$.restAPI.savePreferences(this.prefs).then(() => {
-        this._menuChanged = false;
-      });
-    }
-
-    _handleResetMenuButton() {
-      return this.$.restAPI.getDefaultPreferences().then(data => {
-        if (data && data.my) {
-          this._cloneMenu(data.my);
-        }
-      });
-    }
-
-    _handleSaveWatchedProjects() {
-      this.$.watchedProjectsEditor.save();
-    }
-
-    _computeHeaderClass(changed) {
-      return changed ? 'edited' : '';
-    }
-
-    _handleSaveEmails() {
-      this.$.emailEditor.save();
-    }
-
-    _handleNewEmailKeydown(e) {
-      if (e.keyCode === 13) { // Enter
-        e.stopPropagation();
-        this._handleAddEmailButton();
-      }
-    }
-
-    _isNewEmailValid(newEmail) {
-      return newEmail && newEmail.includes('@');
-    }
-
-    _computeAddEmailButtonEnabled(newEmail, addingEmail) {
-      return this._isNewEmailValid(newEmail) && !addingEmail;
-    }
-
-    _handleAddEmailButton() {
-      if (!this._isNewEmailValid(this._newEmail)) { return; }
-
-      this._addingEmail = true;
-      this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
-        this._addingEmail = false;
-
-        // If it was unsuccessful.
-        if (response.status < 200 || response.status >= 300) { return; }
-
-        this._lastSentVerificationEmail = this._newEmail;
-        this._newEmail = '';
-      });
-    }
-
-    _getFilterDocsLink(docsBaseUrl) {
-      let base = docsBaseUrl;
-      if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
-        base = GERRIT_DOCS_BASE_URL;
+      if (this._serverConfig && this._serverConfig.sshd) {
+        configPromises.push(this.$.sshEditor.loadData());
       }
 
-      // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
-      base = base.replace(TRAILING_SLASH_PATTERN, '');
-
-      return base + GERRIT_DOCS_FILTER_PATH;
-    }
-
-    _handleToggleDark() {
-      if (this._isDark) {
-        window.localStorage.removeItem('dark-theme');
-      } else {
-        window.localStorage.setItem('dark-theme', 'true');
-      }
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {message: RELOAD_MESSAGE},
-        bubbles: true,
-        composed: true,
-      }));
-      this.async(() => {
-        window.location.reload();
-      }, 1);
-    }
-
-    _showHttpAuth(config) {
-      if (config && config.auth &&
-          config.auth.git_basic_auth_policy) {
-        return HTTP_AUTH.includes(
-            config.auth.git_basic_auth_policy.toUpperCase());
+      if (this._serverConfig &&
+          this._serverConfig.receive &&
+          this._serverConfig.receive.enable_signed_push) {
+        configPromises.push(this.$.gpgEditor.loadData());
       }
 
-      return false;
+      configPromises.push(
+          this.getDocsBaseUrl(config, this.$.restAPI)
+              .then(baseUrl => { this._docsBaseUrl = baseUrl; }));
+
+      return Promise.all(configPromises);
+    }));
+
+    if (this.params.emailToken) {
+      promises.push(this.$.restAPI.confirmEmail(this.params.emailToken).then(
+          message => {
+            if (message) {
+              this.fire('show-alert', {message});
+            }
+            this.$.emailEditor.loadData();
+          }));
+    } else {
+      promises.push(this.$.emailEditor.loadData());
+    }
+
+    this._loadingPromise = Promise.all(promises).then(() => {
+      this._loading = false;
+
+      // Handle anchor tag for initial load
+      this._handleLocationChange();
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'location-change', '_handleLocationChange');
+  }
+
+  _handleLocationChange() {
+    // Handle anchor tag after dom attached
+    const urlHash = window.location.hash;
+    if (urlHash) {
+      // Use shadowRoot for Polymer 2
+      const elem = (this.shadowRoot || document).querySelector(urlHash);
+      if (elem) {
+        elem.scrollIntoView();
+      }
     }
   }
 
-  customElements.define(GrSettingsView.is, GrSettingsView);
-})();
+  reloadAccountDetail() {
+    Promise.all([
+      this.$.accountInfo.loadData(),
+      this.$.emailEditor.loadData(),
+    ]);
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _copyPrefs(to, from) {
+    for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
+      this.set([to, PREFS_SECTION_FIELDS[i]],
+          this[from][PREFS_SECTION_FIELDS[i]]);
+    }
+  }
+
+  _cloneMenu(prefs) {
+    const menu = [];
+    for (const item of prefs) {
+      menu.push({
+        name: item.name,
+        url: item.url,
+        target: item.target,
+      });
+    }
+    this._localMenu = menu;
+  }
+
+  _cloneChangeTableColumns() {
+    let columns = this.getVisibleColumns(this.prefs.change_table);
+
+    if (columns.length === 0) {
+      columns = this.columnNames;
+      this._changeTableColumnsNotDisplayed = [];
+    } else {
+      this._changeTableColumnsNotDisplayed = this.getComplementColumns(
+          this.prefs.change_table);
+    }
+    this._localChangeTableColumns = columns;
+  }
+
+  _formatChangeTableColumns(changeTableArray) {
+    return changeTableArray.map(item => {
+      return {column: item};
+    });
+  }
+
+  _handleChangeTableChanged() {
+    if (this._isLoading()) { return; }
+    this._changeTableChanged = true;
+  }
+
+  _handlePrefsChanged(prefs) {
+    if (this._isLoading()) { return; }
+    this._prefsChanged = true;
+  }
+
+  _handleRelativeDateInChangeTable() {
+    this.set('_localPrefs.relative_date_in_change_table',
+        this.$.relativeDateInChangeTable.checked);
+  }
+
+  _handleShowSizeBarsInFileListChanged() {
+    this.set('_localPrefs.size_bar_in_change_table',
+        this.$.showSizeBarsInFileList.checked);
+  }
+
+  _handlePublishCommentsOnPushChanged() {
+    this.set('_localPrefs.publish_comments_on_push',
+        this.$.publishCommentsOnPush.checked);
+  }
+
+  _handleWorkInProgressByDefault() {
+    this.set('_localPrefs.work_in_progress_by_default',
+        this.$.workInProgressByDefault.checked);
+  }
+
+  _handleInsertSignedOff() {
+    this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
+  }
+
+  _handleMenuChanged() {
+    if (this._isLoading()) { return; }
+    this._menuChanged = true;
+  }
+
+  _handleSaveAccountInfo() {
+    this.$.accountInfo.save();
+  }
+
+  _handleSavePreferences() {
+    this._copyPrefs('prefs', '_localPrefs');
+
+    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+      this._prefsChanged = false;
+    });
+  }
+
+  _handleSaveChangeTable() {
+    this.set('prefs.change_table', this._localChangeTableColumns);
+    this.set('prefs.legacycid_in_change_table', this._showNumber);
+    this._cloneChangeTableColumns();
+    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+      this._changeTableChanged = false;
+    });
+  }
+
+  _handleSaveDiffPreferences() {
+    this.$.diffPrefs.save();
+  }
+
+  _handleSaveEditPreferences() {
+    this.$.editPrefs.save();
+  }
+
+  _handleSaveMenu() {
+    this.set('prefs.my', this._localMenu);
+    this._cloneMenu(this.prefs.my);
+    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+      this._menuChanged = false;
+    });
+  }
+
+  _handleResetMenuButton() {
+    return this.$.restAPI.getDefaultPreferences().then(data => {
+      if (data && data.my) {
+        this._cloneMenu(data.my);
+      }
+    });
+  }
+
+  _handleSaveWatchedProjects() {
+    this.$.watchedProjectsEditor.save();
+  }
+
+  _computeHeaderClass(changed) {
+    return changed ? 'edited' : '';
+  }
+
+  _handleSaveEmails() {
+    this.$.emailEditor.save();
+  }
+
+  _handleNewEmailKeydown(e) {
+    if (e.keyCode === 13) { // Enter
+      e.stopPropagation();
+      this._handleAddEmailButton();
+    }
+  }
+
+  _isNewEmailValid(newEmail) {
+    return newEmail && newEmail.includes('@');
+  }
+
+  _computeAddEmailButtonEnabled(newEmail, addingEmail) {
+    return this._isNewEmailValid(newEmail) && !addingEmail;
+  }
+
+  _handleAddEmailButton() {
+    if (!this._isNewEmailValid(this._newEmail)) { return; }
+
+    this._addingEmail = true;
+    this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
+      this._addingEmail = false;
+
+      // If it was unsuccessful.
+      if (response.status < 200 || response.status >= 300) { return; }
+
+      this._lastSentVerificationEmail = this._newEmail;
+      this._newEmail = '';
+    });
+  }
+
+  _getFilterDocsLink(docsBaseUrl) {
+    let base = docsBaseUrl;
+    if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
+      base = GERRIT_DOCS_BASE_URL;
+    }
+
+    // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
+    base = base.replace(TRAILING_SLASH_PATTERN, '');
+
+    return base + GERRIT_DOCS_FILTER_PATH;
+  }
+
+  _handleToggleDark() {
+    if (this._isDark) {
+      window.localStorage.removeItem('dark-theme');
+    } else {
+      window.localStorage.setItem('dark-theme', 'true');
+    }
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {message: RELOAD_MESSAGE},
+      bubbles: true,
+      composed: true,
+    }));
+    this.async(() => {
+      window.location.reload();
+    }, 1);
+  }
+
+  _showHttpAuth(config) {
+    if (config && config.auth &&
+        config.auth.git_basic_auth_policy) {
+      return HTTP_AUTH.includes(
+          config.auth.git_basic_auth_policy.toUpperCase());
+    }
+
+    return false;
+  }
+}
+
+customElements.define(GrSettingsView.is, GrSettingsView);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
index 475a8e2..0f03ec1 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
@@ -1,53 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-
-<link rel="import" href="../../../behaviors/docs-url-behavior/docs-url-behavior.html">
-<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
-
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-menu-page-styles.html">
-<link rel="import" href="../../../styles/gr-page-nav-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../settings/gr-change-table-editor/gr-change-table-editor.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-diff-preferences/gr-diff-preferences.html">
-<link rel="import" href="../../shared/gr-page-nav/gr-page-nav.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../gr-account-info/gr-account-info.html">
-<link rel="import" href="../gr-agreements-list/gr-agreements-list.html">
-<link rel="import" href="../gr-edit-preferences/gr-edit-preferences.html">
-<link rel="import" href="../gr-email-editor/gr-email-editor.html">
-<link rel="import" href="../gr-gpg-editor/gr-gpg-editor.html">
-<link rel="import" href="../gr-group-list/gr-group-list.html">
-<link rel="import" href="../gr-http-password/gr-http-password.html">
-<link rel="import" href="../gr-identities/gr-identities.html">
-<link rel="import" href="../gr-menu-editor/gr-menu-editor.html">
-<link rel="import" href="../gr-ssh-editor/gr-ssh-editor.html">
-<link rel="import" href="../gr-watched-projects-editor/gr-watched-projects-editor.html">
-<script src="../../../scripts/util.js"></script>
-
-<dom-module id="gr-settings-view">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         color: var(--primary-text-color);
@@ -84,8 +53,8 @@
     <style include="gr-page-nav-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
-    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-    <div hidden$="[[_loading]]" hidden>
+    <div class="loading" hidden\$="[[!_loading]]">Loading...</div>
+    <div hidden\$="[[_loading]]" hidden="">
       <gr-page-nav class="navStyles">
         <ul>
           <li><a href="#Profile">Profile</a></li>
@@ -99,10 +68,10 @@
           <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
             <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
           </template>
-          <li hidden$="[[!_serverConfig.sshd]]"><a href="#SSHKeys">
+          <li hidden\$="[[!_serverConfig.sshd]]"><a href="#SSHKeys">
             SSH Keys
           </a></li>
-          <li hidden$="[[!_serverConfig.receive.enable_signed_push]]"><a href="#GPGKeys">
+          <li hidden\$="[[!_serverConfig.receive.enable_signed_push]]"><a href="#GPGKeys">
             GPG Keys
           </a></li>
           <li><a href="#Groups">Groups</a></li>
@@ -121,9 +90,7 @@
         <h1>User Settings</h1>
         <section class="darkToggle">
           <div class="toggle">
-            <paper-toggle-button
-                checked="[[_isDark]]"
-                on-change="_handleToggleDark"></paper-toggle-button>
+            <paper-toggle-button checked="[[_isDark]]" on-change="_handleToggleDark"></paper-toggle-button>
             <div>Dark theme (alpha)</div>
           </div>
           <p>
@@ -132,26 +99,17 @@
             feedback via the link in the app footer is strongly encouraged!
           </p>
         </section>
-        <h2
-            id="Profile"
-            class$="[[_computeHeaderClass(_accountInfoChanged)]]">Profile</h2>
+        <h2 id="Profile" class\$="[[_computeHeaderClass(_accountInfoChanged)]]">Profile</h2>
         <fieldset id="profile">
-          <gr-account-info
-              id="accountInfo"
-              has-unsaved-changes="{{_accountInfoChanged}}"></gr-account-info>
-          <gr-button
-              on-click="_handleSaveAccountInfo"
-              disabled="[[!_accountInfoChanged]]">Save changes</gr-button>
+          <gr-account-info id="accountInfo" has-unsaved-changes="{{_accountInfoChanged}}"></gr-account-info>
+          <gr-button on-click="_handleSaveAccountInfo" disabled="[[!_accountInfoChanged]]">Save changes</gr-button>
         </fieldset>
-        <h2
-            id="Preferences"
-            class$="[[_computeHeaderClass(_prefsChanged)]]">Preferences</h2>
+        <h2 id="Preferences" class\$="[[_computeHeaderClass(_prefsChanged)]]">Preferences</h2>
         <fieldset id="preferences">
           <section>
             <span class="title">Changes per page</span>
             <span class="value">
-              <gr-select
-                  bind-value="{{_localPrefs.changes_per_page}}">
+              <gr-select bind-value="{{_localPrefs.changes_per_page}}">
                 <select>
                   <option value="10">10 rows per page</option>
                   <option value="25">25 rows per page</option>
@@ -164,8 +122,7 @@
           <section>
             <span class="title">Date/time format</span>
             <span class="value">
-              <gr-select
-                  bind-value="{{_localPrefs.date_format}}">
+              <gr-select bind-value="{{_localPrefs.date_format}}">
                 <select>
                   <option value="STD">Jun 3 ; Jun 3, 2016</option>
                   <option value="US">06/03 ; 06/03/16</option>
@@ -174,8 +131,7 @@
                   <option value="UK">03/06 ; 03/06/2016</option>
                 </select>
               </gr-select>
-              <gr-select
-                  bind-value="{{_localPrefs.time_format}}">
+              <gr-select bind-value="{{_localPrefs.time_format}}">
                 <select>
                   <option value="HHMM_12">4:10 PM</option>
                   <option value="HHMM_24">16:10</option>
@@ -186,8 +142,7 @@
           <section>
             <span class="title">Email notifications</span>
             <span class="value">
-              <gr-select
-                  bind-value="{{_localPrefs.email_strategy}}">
+              <gr-select bind-value="{{_localPrefs.email_strategy}}">
                 <select>
                   <option value="CC_ON_OWN_COMMENTS">Every comment</option>
                   <option value="ENABLED">Only comments left by others</option>
@@ -196,11 +151,10 @@
               </gr-select>
             </span>
           </section>
-          <section hidden$="[[!_localPrefs.email_format]]">
+          <section hidden\$="[[!_localPrefs.email_format]]">
             <span class="title">Email format</span>
             <span class="value">
-              <gr-select
-                  bind-value="{{_localPrefs.email_format}}">
+              <gr-select bind-value="{{_localPrefs.email_format}}">
                 <select>
                   <option value="HTML_PLAINTEXT">HTML and plaintext</option>
                   <option value="PLAINTEXT">Plaintext only</option>
@@ -208,11 +162,10 @@
               </gr-select>
             </span>
           </section>
-          <section hidden$="[[!_localPrefs.default_base_for_merges]]">
+          <section hidden\$="[[!_localPrefs.default_base_for_merges]]">
             <span class="title">Default Base For Merges</span>
             <span class="value">
-              <gr-select
-                  bind-value="{{_localPrefs.default_base_for_merges}}">
+              <gr-select bind-value="{{_localPrefs.default_base_for_merges}}">
                 <select>
                   <option value="AUTO_MERGE">Auto Merge</option>
                   <option value="FIRST_PARENT">First Parent</option>
@@ -223,18 +176,13 @@
           <section>
             <span class="title">Show Relative Dates In Changes Table</span>
             <span class="value">
-              <input
-                  id="relativeDateInChangeTable"
-                  type="checkbox"
-                  checked$="[[_localPrefs.relative_date_in_change_table]]"
-                  on-change="_handleRelativeDateInChangeTable">
+              <input id="relativeDateInChangeTable" type="checkbox" checked\$="[[_localPrefs.relative_date_in_change_table]]" on-change="_handleRelativeDateInChangeTable">
             </span>
           </section>
           <section>
             <span class="title">Diff view</span>
             <span class="value">
-              <gr-select
-                  bind-value="{{_localPrefs.diff_view}}">
+              <gr-select bind-value="{{_localPrefs.diff_view}}">
                 <select>
                   <option value="SIDE_BY_SIDE">Side by side</option>
                   <option value="UNIFIED_DIFF">Unified diff</option>
@@ -245,31 +193,19 @@
           <section>
             <span class="title">Show size bars in file list</span>
             <span class="value">
-              <input
-                  id="showSizeBarsInFileList"
-                  type="checkbox"
-                  checked$="[[_localPrefs.size_bar_in_change_table]]"
-                  on-change="_handleShowSizeBarsInFileListChanged">
+              <input id="showSizeBarsInFileList" type="checkbox" checked\$="[[_localPrefs.size_bar_in_change_table]]" on-change="_handleShowSizeBarsInFileListChanged">
             </span>
           </section>
           <section>
             <span class="title">Publish comments on push</span>
             <span class="value">
-              <input
-                  id="publishCommentsOnPush"
-                  type="checkbox"
-                  checked$="[[_localPrefs.publish_comments_on_push]]"
-                  on-change="_handlePublishCommentsOnPushChanged">
+              <input id="publishCommentsOnPush" type="checkbox" checked\$="[[_localPrefs.publish_comments_on_push]]" on-change="_handlePublishCommentsOnPushChanged">
             </span>
           </section>
           <section>
             <span class="title">Set new changes to "work in progress" by default</span>
             <span class="value">
-              <input
-                  id="workInProgressByDefault"
-                  type="checkbox"
-                  checked$="[[_localPrefs.work_in_progress_by_default]]"
-                  on-change="_handleWorkInProgressByDefault">
+              <input id="workInProgressByDefault" type="checkbox" checked\$="[[_localPrefs.work_in_progress_by_default]]" on-change="_handleWorkInProgressByDefault">
             </span>
           </section>
           <section>
@@ -277,132 +213,69 @@
               Insert Signed-off-by Footer For Inline Edit Changes
             </span>
             <span class="value">
-              <input
-                  id="insertSignedOff"
-                  type="checkbox"
-                  checked$="[[_localPrefs.signed_off_by]]"
-                  on-change="_handleInsertSignedOff">
+              <input id="insertSignedOff" type="checkbox" checked\$="[[_localPrefs.signed_off_by]]" on-change="_handleInsertSignedOff">
             </span>
           </section>
-          <gr-button
-              id="savePrefs"
-              on-click="_handleSavePreferences"
-              disabled="[[!_prefsChanged]]">Save changes</gr-button>
+          <gr-button id="savePrefs" on-click="_handleSavePreferences" disabled="[[!_prefsChanged]]">Save changes</gr-button>
         </fieldset>
-        <h2
-            id="DiffPreferences"
-            class$="[[_computeHeaderClass(_diffPrefsChanged)]]">
+        <h2 id="DiffPreferences" class\$="[[_computeHeaderClass(_diffPrefsChanged)]]">
           Diff Preferences
         </h2>
         <fieldset id="diffPreferences">
-          <gr-diff-preferences
-              id="diffPrefs"
-              has-unsaved-changes="{{_diffPrefsChanged}}"></gr-diff-preferences>
-          <gr-button
-              id="saveDiffPrefs"
-              on-click="_handleSaveDiffPreferences"
-              disabled$="[[!_diffPrefsChanged]]">Save changes</gr-button>
+          <gr-diff-preferences id="diffPrefs" has-unsaved-changes="{{_diffPrefsChanged}}"></gr-diff-preferences>
+          <gr-button id="saveDiffPrefs" on-click="_handleSaveDiffPreferences" disabled\$="[[!_diffPrefsChanged]]">Save changes</gr-button>
         </fieldset>
-        <h2
-            id="EditPreferences"
-            class$="[[_computeHeaderClass(_editPrefsChanged)]]">
+        <h2 id="EditPreferences" class\$="[[_computeHeaderClass(_editPrefsChanged)]]">
           Edit Preferences
         </h2>
         <fieldset id="editPreferences">
-          <gr-edit-preferences
-              id="editPrefs"
-              has-unsaved-changes="{{_editPrefsChanged}}"></gr-edit-preferences>
-          <gr-button
-              id="saveEditPrefs"
-              on-click="_handleSaveEditPreferences"
-              disabled$="[[!_editPrefsChanged]]">Save changes</gr-button>
+          <gr-edit-preferences id="editPrefs" has-unsaved-changes="{{_editPrefsChanged}}"></gr-edit-preferences>
+          <gr-button id="saveEditPrefs" on-click="_handleSaveEditPreferences" disabled\$="[[!_editPrefsChanged]]">Save changes</gr-button>
         </fieldset>
-        <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
+        <h2 id="Menu" class\$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
         <fieldset id="menu">
-          <gr-menu-editor
-              menu-items="{{_localMenu}}"></gr-menu-editor>
-          <gr-button
-              id="saveMenu"
-              on-click="_handleSaveMenu"
-              disabled="[[!_menuChanged]]">Save changes</gr-button>
-          <gr-button
-              id="resetMenu"
-              link
-              on-click="_handleResetMenuButton">Reset</gr-button>
+          <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
+          <gr-button id="saveMenu" on-click="_handleSaveMenu" disabled="[[!_menuChanged]]">Save changes</gr-button>
+          <gr-button id="resetMenu" link="" on-click="_handleResetMenuButton">Reset</gr-button>
         </fieldset>
-        <h2 id="ChangeTableColumns"
-            class$="[[_computeHeaderClass(_changeTableChanged)]]">
+        <h2 id="ChangeTableColumns" class\$="[[_computeHeaderClass(_changeTableChanged)]]">
           Change Table Columns
         </h2>
         <fieldset id="changeTableColumns">
-          <gr-change-table-editor
-              show-number="{{_showNumber}}"
-              displayed-columns="{{_localChangeTableColumns}}">
+          <gr-change-table-editor show-number="{{_showNumber}}" displayed-columns="{{_localChangeTableColumns}}">
           </gr-change-table-editor>
-          <gr-button
-              id="saveChangeTable"
-              on-click="_handleSaveChangeTable"
-              disabled="[[!_changeTableChanged]]">Save changes</gr-button>
+          <gr-button id="saveChangeTable" on-click="_handleSaveChangeTable" disabled="[[!_changeTableChanged]]">Save changes</gr-button>
         </fieldset>
-        <h2
-            id="Notifications"
-            class$="[[_computeHeaderClass(_watchedProjectsChanged)]]">
+        <h2 id="Notifications" class\$="[[_computeHeaderClass(_watchedProjectsChanged)]]">
           Notifications
         </h2>
         <fieldset id="watchedProjects">
-          <gr-watched-projects-editor
-              has-unsaved-changes="{{_watchedProjectsChanged}}"
-              id="watchedProjectsEditor"></gr-watched-projects-editor>
-          <gr-button
-              on-click="_handleSaveWatchedProjects"
-              disabled$="[[!_watchedProjectsChanged]]"
-              id="_handleSaveWatchedProjects">Save changes</gr-button>
+          <gr-watched-projects-editor has-unsaved-changes="{{_watchedProjectsChanged}}" id="watchedProjectsEditor"></gr-watched-projects-editor>
+          <gr-button on-click="_handleSaveWatchedProjects" disabled\$="[[!_watchedProjectsChanged]]" id="_handleSaveWatchedProjects">Save changes</gr-button>
         </fieldset>
-        <h2
-            id="EmailAddresses"
-            class$="[[_computeHeaderClass(_emailsChanged)]]">
+        <h2 id="EmailAddresses" class\$="[[_computeHeaderClass(_emailsChanged)]]">
           Email Addresses
         </h2>
         <fieldset id="email">
-          <gr-email-editor
-              id="emailEditor"
-              has-unsaved-changes="{{_emailsChanged}}"></gr-email-editor>
-          <gr-button
-              on-click="_handleSaveEmails"
-              disabled$="[[!_emailsChanged]]">Save changes</gr-button>
+          <gr-email-editor id="emailEditor" has-unsaved-changes="{{_emailsChanged}}"></gr-email-editor>
+          <gr-button on-click="_handleSaveEmails" disabled\$="[[!_emailsChanged]]">Save changes</gr-button>
         </fieldset>
         <fieldset id="newEmail">
           <section>
             <span class="title">New email address</span>
             <span class="value">
-              <iron-input
-                  class="newEmailInput"
-                  bind-value="{{_newEmail}}"
-                  type="text"
-                  on-keydown="_handleNewEmailKeydown"
-                  placeholder="email@example.com">
-                <input
-                    class="newEmailInput"
-                    bind-value="{{_newEmail}}"
-                    is="iron-input"
-                    type="text"
-                    disabled="[[_addingEmail]]"
-                    on-keydown="_handleNewEmailKeydown"
-                    placeholder="email@example.com">
+              <iron-input class="newEmailInput" bind-value="{{_newEmail}}" type="text" on-keydown="_handleNewEmailKeydown" placeholder="email@example.com">
+                <input class="newEmailInput" bind-value="{{_newEmail}}" is="iron-input" type="text" disabled="[[_addingEmail]]" on-keydown="_handleNewEmailKeydown" placeholder="email@example.com">
               </iron-input>
             </span>
           </section>
-          <section
-              id="verificationSentMessage"
-              hidden$="[[!_lastSentVerificationEmail]]">
+          <section id="verificationSentMessage" hidden\$="[[!_lastSentVerificationEmail]]">
             <p>
               A verification email was sent to
               <em>[[_lastSentVerificationEmail]]</em>. Please check your inbox.
             </p>
           </section>
-          <gr-button
-              disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
-              on-click="_handleAddEmailButton">Send verification</gr-button>
+          <gr-button disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]" on-click="_handleAddEmailButton">Send verification</gr-button>
         </fieldset>
         <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
           <div>
@@ -412,21 +285,13 @@
             </fieldset>
           </div>
         </template>
-        <div hidden$="[[!_serverConfig.sshd]]">
-          <h2
-              id="SSHKeys"
-              class$="[[_computeHeaderClass(_keysChanged)]]">SSH keys</h2>
-          <gr-ssh-editor
-              id="sshEditor"
-              has-unsaved-changes="{{_keysChanged}}"></gr-ssh-editor>
+        <div hidden\$="[[!_serverConfig.sshd]]">
+          <h2 id="SSHKeys" class\$="[[_computeHeaderClass(_keysChanged)]]">SSH keys</h2>
+          <gr-ssh-editor id="sshEditor" has-unsaved-changes="{{_keysChanged}}"></gr-ssh-editor>
         </div>
-        <div hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-          <h2
-              id="GPGKeys"
-              class$="[[_computeHeaderClass(_gpgKeysChanged)]]">GPG keys</h2>
-          <gr-gpg-editor
-              id="gpgEditor"
-              has-unsaved-changes="{{_gpgKeysChanged}}"></gr-gpg-editor>
+        <div hidden\$="[[!_serverConfig.receive.enable_signed_push]]">
+          <h2 id="GPGKeys" class\$="[[_computeHeaderClass(_gpgKeysChanged)]]">GPG keys</h2>
+          <gr-gpg-editor id="gpgEditor" has-unsaved-changes="{{_gpgKeysChanged}}"></gr-gpg-editor>
         </div>
         <h2 id="Groups">Groups</h2>
         <fieldset>
@@ -451,9 +316,7 @@
           <p>
             Here are some example Gmail queries that can be used for filters or
             for searching through archived messages. View the
-            <a href$="[[_getFilterDocsLink(_docsBaseUrl)]]"
-                target="_blank"
-                rel="nofollow">Gerrit documentation</a>
+            <a href\$="[[_getFilterDocsLink(_docsBaseUrl)]]" target="_blank" rel="nofollow">Gerrit documentation</a>
             for the complete set of footers.
           </p>
           <table>
@@ -517,6 +380,4 @@
       </main>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-settings-view.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index cd268c6..a014c96 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-settings-view.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-settings-view.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-settings-view.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -41,488 +46,491 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-settings-view tests', async () => {
-    await readyToTest();
-    let element;
-    let account;
-    let preferences;
-    let config;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-settings-view.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-settings-view tests', () => {
+  let element;
+  let account;
+  let preferences;
+  let config;
+  let sandbox;
 
-    function valueOf(title, fieldsetid) {
-      const sections = element.$[fieldsetid].querySelectorAll('section');
-      let titleEl;
-      for (let i = 0; i < sections.length; i++) {
-        titleEl = sections[i].querySelector('.title');
-        if (titleEl.textContent.trim() === title) {
-          return sections[i].querySelector('.value');
-        }
+  function valueOf(title, fieldsetid) {
+    const sections = element.$[fieldsetid].querySelectorAll('section');
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl.textContent.trim() === title) {
+        return sections[i].querySelector('.value');
       }
     }
+  }
 
-    // Because deepEqual isn't behaving in Safari.
-    function assertMenusEqual(actual, expected) {
-      assert.equal(actual.length, expected.length);
-      for (let i = 0; i < actual.length; i++) {
-        assert.equal(actual[i].name, expected[i].name);
-        assert.equal(actual[i].url, expected[i].url);
-      }
+  // Because deepEqual isn't behaving in Safari.
+  function assertMenusEqual(actual, expected) {
+    assert.equal(actual.length, expected.length);
+    for (let i = 0; i < actual.length; i++) {
+      assert.equal(actual[i].name, expected[i].name);
+      assert.equal(actual[i].url, expected[i].url);
     }
+  }
 
-    function stubAddAccountEmail(statusCode) {
-      return sandbox.stub(element.$.restAPI, 'addAccountEmail',
-          () => Promise.resolve({status: statusCode}));
-    }
+  function stubAddAccountEmail(statusCode) {
+    return sandbox.stub(element.$.restAPI, 'addAccountEmail',
+        () => Promise.resolve({status: statusCode}));
+  }
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      account = {
-        _account_id: 123,
-        name: 'user name',
-        email: 'user@email',
-        username: 'user username',
-        registered: '2000-01-01 00:00:00.000000000',
-      };
-      preferences = {
-        changes_per_page: 25,
-        date_format: 'UK',
-        time_format: 'HHMM_12',
-        diff_view: 'UNIFIED_DIFF',
-        email_strategy: 'ENABLED',
-        email_format: 'HTML_PLAINTEXT',
-        default_base_for_merges: 'FIRST_PARENT',
-        relative_date_in_change_table: false,
-        size_bar_in_change_table: true,
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    account = {
+      _account_id: 123,
+      name: 'user name',
+      email: 'user@email',
+      username: 'user username',
+      registered: '2000-01-01 00:00:00.000000000',
+    };
+    preferences = {
+      changes_per_page: 25,
+      date_format: 'UK',
+      time_format: 'HHMM_12',
+      diff_view: 'UNIFIED_DIFF',
+      email_strategy: 'ENABLED',
+      email_format: 'HTML_PLAINTEXT',
+      default_base_for_merges: 'FIRST_PARENT',
+      relative_date_in_change_table: false,
+      size_bar_in_change_table: true,
 
-        my: [
-          {url: '/first/url', name: 'first name', target: '_blank'},
-          {url: '/second/url', name: 'second name', target: '_blank'},
-        ],
-        change_table: [],
-      };
-      config = {auth: {editable_account_fields: []}};
+      my: [
+        {url: '/first/url', name: 'first name', target: '_blank'},
+        {url: '/second/url', name: 'second name', target: '_blank'},
+      ],
+      change_table: [],
+    };
+    config = {auth: {editable_account_fields: []}};
 
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getAccount() { return Promise.resolve(account); },
-        getPreferences() { return Promise.resolve(preferences); },
-        getWatchedProjects() {
-          return Promise.resolve([]);
-        },
-        getAccountEmails() { return Promise.resolve(); },
-        getConfig() { return Promise.resolve(config); },
-        getAccountGroups() { return Promise.resolve([]); },
-      });
-      element = fixture('basic');
-
-      // Allow the element to render.
-      element._loadingPromise.then(done);
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getAccount() { return Promise.resolve(account); },
+      getPreferences() { return Promise.resolve(preferences); },
+      getWatchedProjects() {
+        return Promise.resolve([]);
+      },
+      getAccountEmails() { return Promise.resolve(); },
+      getConfig() { return Promise.resolve(config); },
+      getAccountGroups() { return Promise.resolve([]); },
     });
+    element = fixture('basic');
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    // Allow the element to render.
+    element._loadingPromise.then(done);
+  });
 
-    test('calls the title-change event', () => {
-      const titleChangedStub = sandbox.stub();
+  teardown(() => {
+    sandbox.restore();
+  });
 
-      // Create a new view.
-      const newElement = document.createElement('gr-settings-view');
-      newElement.addEventListener('title-change', titleChangedStub);
+  test('calls the title-change event', () => {
+    const titleChangedStub = sandbox.stub();
 
-      // Attach it to the fixture.
-      const blank = fixture('blank');
-      blank.appendChild(newElement);
+    // Create a new view.
+    const newElement = document.createElement('gr-settings-view');
+    newElement.addEventListener('title-change', titleChangedStub);
 
-      Polymer.dom.flush();
+    // Attach it to the fixture.
+    const blank = fixture('blank');
+    blank.appendChild(newElement);
 
-      assert.isTrue(titleChangedStub.called);
-      assert.equal(titleChangedStub.getCall(0).args[0].detail.title,
-          'Settings');
-    });
+    flush();
 
-    test('user preferences', done => {
-      // Rendered with the expected preferences selected.
-      assert.equal(valueOf('Changes per page', 'preferences')
-          .firstElementChild.bindValue, preferences.changes_per_page);
-      assert.equal(valueOf('Date/time format', 'preferences')
-          .firstElementChild.bindValue, preferences.date_format);
-      assert.equal(valueOf('Date/time format', 'preferences')
-          .lastElementChild.bindValue, preferences.time_format);
-      assert.equal(valueOf('Email notifications', 'preferences')
-          .firstElementChild.bindValue, preferences.email_strategy);
-      assert.equal(valueOf('Email format', 'preferences')
-          .firstElementChild.bindValue, preferences.email_format);
-      assert.equal(valueOf('Default Base For Merges', 'preferences')
-          .firstElementChild.bindValue, preferences.default_base_for_merges);
-      assert.equal(
-          valueOf('Show Relative Dates In Changes Table', 'preferences')
-              .firstElementChild.checked, false);
-      assert.equal(valueOf('Diff view', 'preferences')
-          .firstElementChild.bindValue, preferences.diff_view);
-      assert.equal(valueOf('Show size bars in file list', 'preferences')
-          .firstElementChild.checked, true);
-      assert.equal(valueOf('Publish comments on push', 'preferences')
-          .firstElementChild.checked, false);
-      assert.equal(valueOf(
-          'Set new changes to "work in progress" by default', 'preferences')
-          .firstElementChild.checked, false);
-      assert.equal(valueOf(
-          'Insert Signed-off-by Footer For Inline Edit Changes', 'preferences')
-          .firstElementChild.checked, false);
+    assert.isTrue(titleChangedStub.called);
+    assert.equal(titleChangedStub.getCall(0).args[0].detail.title,
+        'Settings');
+  });
 
-      assert.isFalse(element._prefsChanged);
-      assert.isFalse(element._menuChanged);
+  test('user preferences', done => {
+    // Rendered with the expected preferences selected.
+    assert.equal(valueOf('Changes per page', 'preferences')
+        .firstElementChild.bindValue, preferences.changes_per_page);
+    assert.equal(valueOf('Date/time format', 'preferences')
+        .firstElementChild.bindValue, preferences.date_format);
+    assert.equal(valueOf('Date/time format', 'preferences')
+        .lastElementChild.bindValue, preferences.time_format);
+    assert.equal(valueOf('Email notifications', 'preferences')
+        .firstElementChild.bindValue, preferences.email_strategy);
+    assert.equal(valueOf('Email format', 'preferences')
+        .firstElementChild.bindValue, preferences.email_format);
+    assert.equal(valueOf('Default Base For Merges', 'preferences')
+        .firstElementChild.bindValue, preferences.default_base_for_merges);
+    assert.equal(
+        valueOf('Show Relative Dates In Changes Table', 'preferences')
+            .firstElementChild.checked, false);
+    assert.equal(valueOf('Diff view', 'preferences')
+        .firstElementChild.bindValue, preferences.diff_view);
+    assert.equal(valueOf('Show size bars in file list', 'preferences')
+        .firstElementChild.checked, true);
+    assert.equal(valueOf('Publish comments on push', 'preferences')
+        .firstElementChild.checked, false);
+    assert.equal(valueOf(
+        'Set new changes to "work in progress" by default', 'preferences')
+        .firstElementChild.checked, false);
+    assert.equal(valueOf(
+        'Insert Signed-off-by Footer For Inline Edit Changes', 'preferences')
+        .firstElementChild.checked, false);
 
-      // Change the diff view element.
-      const diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
-      diffSelect.bindValue = 'SIDE_BY_SIDE';
+    assert.isFalse(element._prefsChanged);
+    assert.isFalse(element._menuChanged);
 
-      const publishOnPush =
-          valueOf('Publish comments on push', 'preferences').firstElementChild;
-      diffSelect.fire('change');
+    // Change the diff view element.
+    const diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
+    diffSelect.bindValue = 'SIDE_BY_SIDE';
 
-      MockInteractions.tap(publishOnPush);
-
-      assert.isTrue(element._prefsChanged);
-      assert.isFalse(element._menuChanged);
-
-      stub('gr-rest-api-interface', {
-        savePreferences(prefs) {
-          assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
-          assertMenusEqual(prefs.my, preferences.my);
-          assert.equal(prefs.publish_comments_on_push, true);
-          return Promise.resolve();
-        },
-      });
-
-      // Save the change.
-      element._handleSavePreferences().then(() => {
-        assert.isFalse(element._prefsChanged);
-        assert.isFalse(element._menuChanged);
-        done();
-      });
-    });
-
-    test('publish comments on push', done => {
-      const publishCommentsOnPush =
+    const publishOnPush =
         valueOf('Publish comments on push', 'preferences').firstElementChild;
-      MockInteractions.tap(publishCommentsOnPush);
+    diffSelect.fire('change');
 
-      assert.isFalse(element._menuChanged);
-      assert.isTrue(element._prefsChanged);
+    MockInteractions.tap(publishOnPush);
 
-      stub('gr-rest-api-interface', {
-        savePreferences(prefs) {
-          assert.equal(prefs.publish_comments_on_push, true);
-          return Promise.resolve();
-        },
-      });
+    assert.isTrue(element._prefsChanged);
+    assert.isFalse(element._menuChanged);
 
-      // Save the change.
-      element._handleSavePreferences().then(() => {
-        assert.isFalse(element._prefsChanged);
-        assert.isFalse(element._menuChanged);
-        done();
-      });
+    stub('gr-rest-api-interface', {
+      savePreferences(prefs) {
+        assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
+        assertMenusEqual(prefs.my, preferences.my);
+        assert.equal(prefs.publish_comments_on_push, true);
+        return Promise.resolve();
+      },
     });
 
-    test('set new changes work-in-progress', done => {
-      const newChangesWorkInProgress =
-        valueOf('Set new changes to "work in progress" by default',
-            'preferences').firstElementChild;
-      MockInteractions.tap(newChangesWorkInProgress);
-
+    // Save the change.
+    element._handleSavePreferences().then(() => {
+      assert.isFalse(element._prefsChanged);
       assert.isFalse(element._menuChanged);
-      assert.isTrue(element._prefsChanged);
+      done();
+    });
+  });
 
-      stub('gr-rest-api-interface', {
-        savePreferences(prefs) {
-          assert.equal(prefs.work_in_progress_by_default, true);
-          return Promise.resolve();
-        },
-      });
+  test('publish comments on push', done => {
+    const publishCommentsOnPush =
+      valueOf('Publish comments on push', 'preferences').firstElementChild;
+    MockInteractions.tap(publishCommentsOnPush);
 
-      // Save the change.
-      element._handleSavePreferences().then(() => {
-        assert.isFalse(element._prefsChanged);
-        assert.isFalse(element._menuChanged);
-        done();
-      });
+    assert.isFalse(element._menuChanged);
+    assert.isTrue(element._prefsChanged);
+
+    stub('gr-rest-api-interface', {
+      savePreferences(prefs) {
+        assert.equal(prefs.publish_comments_on_push, true);
+        return Promise.resolve();
+      },
     });
 
-    test('menu', done => {
+    // Save the change.
+    element._handleSavePreferences().then(() => {
+      assert.isFalse(element._prefsChanged);
+      assert.isFalse(element._menuChanged);
+      done();
+    });
+  });
+
+  test('set new changes work-in-progress', done => {
+    const newChangesWorkInProgress =
+      valueOf('Set new changes to "work in progress" by default',
+          'preferences').firstElementChild;
+    MockInteractions.tap(newChangesWorkInProgress);
+
+    assert.isFalse(element._menuChanged);
+    assert.isTrue(element._prefsChanged);
+
+    stub('gr-rest-api-interface', {
+      savePreferences(prefs) {
+        assert.equal(prefs.work_in_progress_by_default, true);
+        return Promise.resolve();
+      },
+    });
+
+    // Save the change.
+    element._handleSavePreferences().then(() => {
+      assert.isFalse(element._prefsChanged);
+      assert.isFalse(element._menuChanged);
+      done();
+    });
+  });
+
+  test('menu', done => {
+    assert.isFalse(element._menuChanged);
+    assert.isFalse(element._prefsChanged);
+
+    assertMenusEqual(element._localMenu, preferences.my);
+
+    const menu = element.$.menu.firstElementChild;
+    let tableRows = dom(menu.root).querySelectorAll('tbody tr');
+    assert.equal(tableRows.length, preferences.my.length);
+
+    // Add a menu item:
+    element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
+    flush();
+
+    tableRows = dom(menu.root).querySelectorAll('tbody tr');
+    assert.equal(tableRows.length, preferences.my.length + 1);
+
+    assert.isTrue(element._menuChanged);
+    assert.isFalse(element._prefsChanged);
+
+    stub('gr-rest-api-interface', {
+      savePreferences(prefs) {
+        assertMenusEqual(prefs.my, element._localMenu);
+        return Promise.resolve();
+      },
+    });
+
+    element._handleSaveMenu().then(() => {
       assert.isFalse(element._menuChanged);
       assert.isFalse(element._prefsChanged);
-
-      assertMenusEqual(element._localMenu, preferences.my);
-
-      const menu = element.$.menu.firstElementChild;
-      let tableRows = Polymer.dom(menu.root).querySelectorAll('tbody tr');
-      assert.equal(tableRows.length, preferences.my.length);
-
-      // Add a menu item:
-      element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
-      Polymer.dom.flush();
-
-      tableRows = Polymer.dom(menu.root).querySelectorAll('tbody tr');
-      assert.equal(tableRows.length, preferences.my.length + 1);
-
-      assert.isTrue(element._menuChanged);
-      assert.isFalse(element._prefsChanged);
-
-      stub('gr-rest-api-interface', {
-        savePreferences(prefs) {
-          assertMenusEqual(prefs.my, element._localMenu);
-          return Promise.resolve();
-        },
-      });
-
-      element._handleSaveMenu().then(() => {
-        assert.isFalse(element._menuChanged);
-        assert.isFalse(element._prefsChanged);
-        assertMenusEqual(element.prefs.my, element._localMenu);
-        done();
-      });
+      assertMenusEqual(element.prefs.my, element._localMenu);
+      done();
     });
+  });
 
-    test('add email validation', () => {
-      assert.isFalse(element._isNewEmailValid('invalid email'));
-      assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
+  test('add email validation', () => {
+    assert.isFalse(element._isNewEmailValid('invalid email'));
+    assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
 
-      assert.isFalse(
-          element._computeAddEmailButtonEnabled('invalid email'), true);
-      assert.isFalse(
-          element._computeAddEmailButtonEnabled('vaguely@valid.email', true));
-      assert.isTrue(
-          element._computeAddEmailButtonEnabled('vaguely@valid.email', false));
+    assert.isFalse(
+        element._computeAddEmailButtonEnabled('invalid email'), true);
+    assert.isFalse(
+        element._computeAddEmailButtonEnabled('vaguely@valid.email', true));
+    assert.isTrue(
+        element._computeAddEmailButtonEnabled('vaguely@valid.email', false));
+  });
+
+  test('add email does not save invalid', () => {
+    const addEmailStub = stubAddAccountEmail(201);
+
+    assert.isFalse(element._addingEmail);
+    assert.isNotOk(element._lastSentVerificationEmail);
+    element._newEmail = 'invalid email';
+
+    element._handleAddEmailButton();
+
+    assert.isFalse(element._addingEmail);
+    assert.isFalse(addEmailStub.called);
+    assert.isNotOk(element._lastSentVerificationEmail);
+
+    assert.isFalse(addEmailStub.called);
+  });
+
+  test('add email does save valid', done => {
+    const addEmailStub = stubAddAccountEmail(201);
+
+    assert.isFalse(element._addingEmail);
+    assert.isNotOk(element._lastSentVerificationEmail);
+    element._newEmail = 'valid@email.com';
+
+    element._handleAddEmailButton();
+
+    assert.isTrue(element._addingEmail);
+    assert.isTrue(addEmailStub.called);
+
+    assert.isTrue(addEmailStub.called);
+    addEmailStub.lastCall.returnValue.then(() => {
+      assert.isOk(element._lastSentVerificationEmail);
+      done();
     });
+  });
 
-    test('add email does not save invalid', () => {
-      const addEmailStub = stubAddAccountEmail(201);
+  test('add email does not set last-email if error', done => {
+    const addEmailStub = stubAddAccountEmail(500);
 
-      assert.isFalse(element._addingEmail);
+    assert.isNotOk(element._lastSentVerificationEmail);
+    element._newEmail = 'valid@email.com';
+
+    element._handleAddEmailButton();
+
+    assert.isTrue(addEmailStub.called);
+    addEmailStub.lastCall.returnValue.then(() => {
       assert.isNotOk(element._lastSentVerificationEmail);
-      element._newEmail = 'invalid email';
-
-      element._handleAddEmailButton();
-
-      assert.isFalse(element._addingEmail);
-      assert.isFalse(addEmailStub.called);
-      assert.isNotOk(element._lastSentVerificationEmail);
-
-      assert.isFalse(addEmailStub.called);
+      done();
     });
+  });
 
-    test('add email does save valid', done => {
-      const addEmailStub = stubAddAccountEmail(201);
+  test('emails are loaded without emailToken', () => {
+    sandbox.stub(element.$.emailEditor, 'loadData');
+    element.params = {};
+    element.attached();
+    assert.isTrue(element.$.emailEditor.loadData.calledOnce);
+  });
 
-      assert.isFalse(element._addingEmail);
-      assert.isNotOk(element._lastSentVerificationEmail);
-      element._newEmail = 'valid@email.com';
+  test('_handleSaveChangeTable', () => {
+    let newColumns = ['Owner', 'Project', 'Branch'];
+    element._localChangeTableColumns = newColumns.slice(0);
+    element._showNumber = false;
+    const cloneStub = sandbox.stub(element, '_cloneChangeTableColumns');
+    element._handleSaveChangeTable();
+    assert.isTrue(cloneStub.calledOnce);
+    assert.deepEqual(element.prefs.change_table, newColumns);
+    assert.isNotOk(element.prefs.legacycid_in_change_table);
 
-      element._handleAddEmailButton();
+    newColumns = ['Size'];
+    element._localChangeTableColumns = newColumns;
+    element._showNumber = true;
+    element._handleSaveChangeTable();
+    assert.isTrue(cloneStub.calledTwice);
+    assert.deepEqual(element.prefs.change_table, newColumns);
+    assert.isTrue(element.prefs.legacycid_in_change_table);
+  });
 
-      assert.isTrue(element._addingEmail);
-      assert.isTrue(addEmailStub.called);
-
-      assert.isTrue(addEmailStub.called);
-      addEmailStub.lastCall.returnValue.then(() => {
-        assert.isOk(element._lastSentVerificationEmail);
-        done();
-      });
-    });
-
-    test('add email does not set last-email if error', done => {
-      const addEmailStub = stubAddAccountEmail(500);
-
-      assert.isNotOk(element._lastSentVerificationEmail);
-      element._newEmail = 'valid@email.com';
-
-      element._handleAddEmailButton();
-
-      assert.isTrue(addEmailStub.called);
-      addEmailStub.lastCall.returnValue.then(() => {
-        assert.isNotOk(element._lastSentVerificationEmail);
-        done();
-      });
-    });
-
-    test('emails are loaded without emailToken', () => {
-      sandbox.stub(element.$.emailEditor, 'loadData');
-      element.params = {};
-      element.attached();
-      assert.isTrue(element.$.emailEditor.loadData.calledOnce);
-    });
-
-    test('_handleSaveChangeTable', () => {
-      let newColumns = ['Owner', 'Project', 'Branch'];
-      element._localChangeTableColumns = newColumns.slice(0);
-      element._showNumber = false;
-      const cloneStub = sandbox.stub(element, '_cloneChangeTableColumns');
-      element._handleSaveChangeTable();
-      assert.isTrue(cloneStub.calledOnce);
-      assert.deepEqual(element.prefs.change_table, newColumns);
-      assert.isNotOk(element.prefs.legacycid_in_change_table);
-
-      newColumns = ['Size'];
-      element._localChangeTableColumns = newColumns;
-      element._showNumber = true;
-      element._handleSaveChangeTable();
-      assert.isTrue(cloneStub.calledTwice);
-      assert.deepEqual(element.prefs.change_table, newColumns);
-      assert.isTrue(element.prefs.legacycid_in_change_table);
-    });
-
-    test('reset menu item back to default', done => {
-      const originalMenu = {
-        my: [
-          {url: '/first/url', name: 'first name', target: '_blank'},
-          {url: '/second/url', name: 'second name', target: '_blank'},
-          {url: '/third/url', name: 'third name', target: '_blank'},
-        ],
-      };
-
-      stub('gr-rest-api-interface', {
-        getDefaultPreferences() { return Promise.resolve(originalMenu); },
-      });
-
-      const updatedMenu = [
+  test('reset menu item back to default', done => {
+    const originalMenu = {
+      my: [
         {url: '/first/url', name: 'first name', target: '_blank'},
         {url: '/second/url', name: 'second name', target: '_blank'},
         {url: '/third/url', name: 'third name', target: '_blank'},
-        {url: '/fourth/url', name: 'fourth name', target: '_blank'},
-      ];
+      ],
+    };
 
-      element.set('_localMenu', updatedMenu);
-
-      element._handleResetMenuButton().then(() => {
-        assertMenusEqual(element._localMenu, originalMenu.my);
-        done();
-      });
+    stub('gr-rest-api-interface', {
+      getDefaultPreferences() { return Promise.resolve(originalMenu); },
     });
 
-    test('test that reset button is called', () => {
-      const overlayOpen = sandbox.stub(element, '_handleResetMenuButton');
+    const updatedMenu = [
+      {url: '/first/url', name: 'first name', target: '_blank'},
+      {url: '/second/url', name: 'second name', target: '_blank'},
+      {url: '/third/url', name: 'third name', target: '_blank'},
+      {url: '/fourth/url', name: 'fourth name', target: '_blank'},
+    ];
 
-      MockInteractions.tap(element.$.resetMenu);
+    element.set('_localMenu', updatedMenu);
 
-      assert.isTrue(overlayOpen.called);
-    });
-
-    test('_showHttpAuth', () => {
-      let serverConfig;
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'HTTP',
-        },
-      };
-
-      assert.isTrue(element._showHttpAuth(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'HTTP_LDAP',
-        },
-      };
-
-      assert.isTrue(element._showHttpAuth(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'LDAP',
-        },
-      };
-
-      assert.isFalse(element._showHttpAuth(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'OAUTH',
-        },
-      };
-
-      assert.isFalse(element._showHttpAuth(serverConfig));
-
-      serverConfig = {};
-
-      assert.isFalse(element._showHttpAuth(serverConfig));
-    });
-
-    suite('_getFilterDocsLink', () => {
-      test('with http: docs base URL', () => {
-        const base = 'http://example.com/';
-        const result = element._getFilterDocsLink(base);
-        assert.equal(result, 'http://example.com/user-notify.html');
-      });
-
-      test('with http: docs base URL without slash', () => {
-        const base = 'http://example.com';
-        const result = element._getFilterDocsLink(base);
-        assert.equal(result, 'http://example.com/user-notify.html');
-      });
-
-      test('with https: docs base URL', () => {
-        const base = 'https://example.com/';
-        const result = element._getFilterDocsLink(base);
-        assert.equal(result, 'https://example.com/user-notify.html');
-      });
-
-      test('without docs base URL', () => {
-        const result = element._getFilterDocsLink(null);
-        assert.equal(result, 'https://gerrit-review.googlesource.com/' +
-            'Documentation/user-notify.html');
-      });
-
-      test('ignores non HTTP links', () => {
-        const base = 'javascript://alert("evil");';
-        const result = element._getFilterDocsLink(base);
-        assert.equal(result, 'https://gerrit-review.googlesource.com/' +
-            'Documentation/user-notify.html');
-      });
-    });
-
-    suite('when email verification token is provided', () => {
-      let resolveConfirm;
-
-      setup(() => {
-        sandbox.stub(element.$.emailEditor, 'loadData');
-        sandbox.stub(
-            element.$.restAPI,
-            'confirmEmail',
-            () => new Promise(resolve => { resolveConfirm = resolve; }));
-        element.params = {emailToken: 'foo'};
-        element.attached();
-      });
-
-      test('it is used to confirm email via rest API', () => {
-        assert.isTrue(element.$.restAPI.confirmEmail.calledOnce);
-        assert.isTrue(element.$.restAPI.confirmEmail.calledWith('foo'));
-      });
-
-      test('emails are not loaded initially', () => {
-        assert.isFalse(element.$.emailEditor.loadData.called);
-      });
-
-      test('user emails are loaded after email confirmed', done => {
-        element._loadingPromise.then(() => {
-          assert.isTrue(element.$.emailEditor.loadData.calledOnce);
-          done();
-        });
-        resolveConfirm();
-      });
-
-      test('show-alert is fired when email is confirmed', done => {
-        sandbox.spy(element, 'fire');
-        element._loadingPromise.then(() => {
-          assert.isTrue(
-              element.fire.calledWith('show-alert', {message: 'bar'}));
-          done();
-        });
-        resolveConfirm('bar');
-      });
+    element._handleResetMenuButton().then(() => {
+      assertMenusEqual(element._localMenu, originalMenu.my);
+      done();
     });
   });
+
+  test('test that reset button is called', () => {
+    const overlayOpen = sandbox.stub(element, '_handleResetMenuButton');
+
+    MockInteractions.tap(element.$.resetMenu);
+
+    assert.isTrue(overlayOpen.called);
+  });
+
+  test('_showHttpAuth', () => {
+    let serverConfig;
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'HTTP',
+      },
+    };
+
+    assert.isTrue(element._showHttpAuth(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'HTTP_LDAP',
+      },
+    };
+
+    assert.isTrue(element._showHttpAuth(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'LDAP',
+      },
+    };
+
+    assert.isFalse(element._showHttpAuth(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'OAUTH',
+      },
+    };
+
+    assert.isFalse(element._showHttpAuth(serverConfig));
+
+    serverConfig = {};
+
+    assert.isFalse(element._showHttpAuth(serverConfig));
+  });
+
+  suite('_getFilterDocsLink', () => {
+    test('with http: docs base URL', () => {
+      const base = 'http://example.com/';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'http://example.com/user-notify.html');
+    });
+
+    test('with http: docs base URL without slash', () => {
+      const base = 'http://example.com';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'http://example.com/user-notify.html');
+    });
+
+    test('with https: docs base URL', () => {
+      const base = 'https://example.com/';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'https://example.com/user-notify.html');
+    });
+
+    test('without docs base URL', () => {
+      const result = element._getFilterDocsLink(null);
+      assert.equal(result, 'https://gerrit-review.googlesource.com/' +
+          'Documentation/user-notify.html');
+    });
+
+    test('ignores non HTTP links', () => {
+      const base = 'javascript://alert("evil");';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'https://gerrit-review.googlesource.com/' +
+          'Documentation/user-notify.html');
+    });
+  });
+
+  suite('when email verification token is provided', () => {
+    let resolveConfirm;
+
+    setup(() => {
+      sandbox.stub(element.$.emailEditor, 'loadData');
+      sandbox.stub(
+          element.$.restAPI,
+          'confirmEmail',
+          () => new Promise(resolve => { resolveConfirm = resolve; }));
+      element.params = {emailToken: 'foo'};
+      element.attached();
+    });
+
+    test('it is used to confirm email via rest API', () => {
+      assert.isTrue(element.$.restAPI.confirmEmail.calledOnce);
+      assert.isTrue(element.$.restAPI.confirmEmail.calledWith('foo'));
+    });
+
+    test('emails are not loaded initially', () => {
+      assert.isFalse(element.$.emailEditor.loadData.called);
+    });
+
+    test('user emails are loaded after email confirmed', done => {
+      element._loadingPromise.then(() => {
+        assert.isTrue(element.$.emailEditor.loadData.calledOnce);
+        done();
+      });
+      resolveConfirm();
+    });
+
+    test('show-alert is fired when email is confirmed', done => {
+      sandbox.spy(element, 'fire');
+      element._loadingPromise.then(() => {
+        assert.isTrue(
+            element.fire.calledWith('show-alert', {message: 'bar'}));
+        done();
+      });
+      resolveConfirm('bar');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
index 44fb48c..814eb7a 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
@@ -14,95 +14,108 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrSshEditor extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-ssh-editor'; }
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/gr-form-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-ssh-editor_html.js';
 
-    static get properties() {
-      return {
-        hasUnsavedChanges: {
-          type: Boolean,
-          value: false,
-          notify: true,
-        },
-        _keys: Array,
-        /** @type {?} */
-        _keyToView: Object,
-        _newKey: {
-          type: String,
-          value: '',
-        },
-        _keysToRemove: {
-          type: Array,
-          value() { return []; },
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrSshEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    loadData() {
-      return this.$.restAPI.getAccountSSHKeys().then(keys => {
-        this._keys = keys;
-      });
-    }
+  static get is() { return 'gr-ssh-editor'; }
 
-    save() {
-      const promises = this._keysToRemove.map(key => {
-        this.$.restAPI.deleteAccountSSHKey(key.seq);
-      });
-
-      return Promise.all(promises).then(() => {
-        this._keysToRemove = [];
-        this.hasUnsavedChanges = false;
-      });
-    }
-
-    _getStatusLabel(isValid) {
-      return isValid ? 'Valid' : 'Invalid';
-    }
-
-    _showKey(e) {
-      const el = Polymer.dom(e).localTarget;
-      const index = parseInt(el.getAttribute('data-index'), 10);
-      this._keyToView = this._keys[index];
-      this.$.viewKeyOverlay.open();
-    }
-
-    _closeOverlay() {
-      this.$.viewKeyOverlay.close();
-    }
-
-    _handleDeleteKey(e) {
-      const el = Polymer.dom(e).localTarget;
-      const index = parseInt(el.getAttribute('data-index'), 10);
-      this.push('_keysToRemove', this._keys[index]);
-      this.splice('_keys', index, 1);
-      this.hasUnsavedChanges = true;
-    }
-
-    _handleAddKey() {
-      this.$.addButton.disabled = true;
-      this.$.newKey.disabled = true;
-      return this.$.restAPI.addAccountSSHKey(this._newKey.trim())
-          .then(key => {
-            this.$.newKey.disabled = false;
-            this._newKey = '';
-            this.push('_keys', key);
-          })
-          .catch(() => {
-            this.$.addButton.disabled = false;
-            this.$.newKey.disabled = false;
-          });
-    }
-
-    _computeAddButtonDisabled(newKey) {
-      return !newKey.length;
-    }
+  static get properties() {
+    return {
+      hasUnsavedChanges: {
+        type: Boolean,
+        value: false,
+        notify: true,
+      },
+      _keys: Array,
+      /** @type {?} */
+      _keyToView: Object,
+      _newKey: {
+        type: String,
+        value: '',
+      },
+      _keysToRemove: {
+        type: Array,
+        value() { return []; },
+      },
+    };
   }
 
-  customElements.define(GrSshEditor.is, GrSshEditor);
-})();
+  loadData() {
+    return this.$.restAPI.getAccountSSHKeys().then(keys => {
+      this._keys = keys;
+    });
+  }
+
+  save() {
+    const promises = this._keysToRemove.map(key => {
+      this.$.restAPI.deleteAccountSSHKey(key.seq);
+    });
+
+    return Promise.all(promises).then(() => {
+      this._keysToRemove = [];
+      this.hasUnsavedChanges = false;
+    });
+  }
+
+  _getStatusLabel(isValid) {
+    return isValid ? 'Valid' : 'Invalid';
+  }
+
+  _showKey(e) {
+    const el = dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    this._keyToView = this._keys[index];
+    this.$.viewKeyOverlay.open();
+  }
+
+  _closeOverlay() {
+    this.$.viewKeyOverlay.close();
+  }
+
+  _handleDeleteKey(e) {
+    const el = dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    this.push('_keysToRemove', this._keys[index]);
+    this.splice('_keys', index, 1);
+    this.hasUnsavedChanges = true;
+  }
+
+  _handleAddKey() {
+    this.$.addButton.disabled = true;
+    this.$.newKey.disabled = true;
+    return this.$.restAPI.addAccountSSHKey(this._newKey.trim())
+        .then(key => {
+          this.$.newKey.disabled = false;
+          this._newKey = '';
+          this.push('_keys', key);
+        })
+        .catch(() => {
+          this.$.addButton.disabled = false;
+          this.$.newKey.disabled = false;
+        });
+  }
+
+  _computeAddButtonDisabled(newKey) {
+    return !newKey.length;
+  }
+}
+
+customElements.define(GrSshEditor.is, GrSshEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.js
index dd02ccd..3cefb90e 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.js
@@ -1,31 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-ssh-editor">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -79,31 +70,20 @@
                 <td class="commentColumn">[[key.comment]]</td>
                 <td>[[_getStatusLabel(key.valid)]]</td>
                 <td>
-                  <gr-button
-                      link
-                      on-click="_showKey"
-                      data-index$="[[index]]"
-                      link>Click to View</gr-button>
+                  <gr-button link="" on-click="_showKey" data-index\$="[[index]]">Click to View</gr-button>
                 </td>
                 <td>
-                  <gr-copy-clipboard
-                      has-tooltip
-                      button-title="Copy SSH public key to clipboard"
-                      hide-input
-                      text="[[key.ssh_public_key]]">
+                  <gr-copy-clipboard has-tooltip="" button-title="Copy SSH public key to clipboard" hide-input="" text="[[key.ssh_public_key]]">
                   </gr-copy-clipboard>
                 </td>
                 <td>
-                  <gr-button
-                      link
-                      data-index$="[[index]]"
-                      on-click="_handleDeleteKey">Delete</gr-button>
+                  <gr-button link="" data-index\$="[[index]]" on-click="_handleDeleteKey">Delete</gr-button>
                 </td>
               </tr>
             </template>
           </tbody>
         </table>
-        <gr-overlay id="viewKeyOverlay" with-backdrop>
+        <gr-overlay id="viewKeyOverlay" with-backdrop="">
           <fieldset>
             <section>
               <span class="title">Algorithm</span>
@@ -118,33 +98,19 @@
               <span class="value">[[_keyToView.comment]]</span>
             </section>
           </fieldset>
-          <gr-button
-              class="closeButton"
-              on-click="_closeOverlay">Close</gr-button>
+          <gr-button class="closeButton" on-click="_closeOverlay">Close</gr-button>
         </gr-overlay>
-        <gr-button
-            on-click="save"
-            disabled$="[[!hasUnsavedChanges]]">Save changes</gr-button>
+        <gr-button on-click="save" disabled\$="[[!hasUnsavedChanges]]">Save changes</gr-button>
       </fieldset>
       <fieldset>
         <section>
           <span class="title">New SSH key</span>
           <span class="value">
-            <iron-autogrow-textarea
-                id="newKey"
-                autocomplete="on"
-                bind-value="{{_newKey}}"
-                placeholder="New SSH Key"></iron-autogrow-textarea>
+            <iron-autogrow-textarea id="newKey" autocomplete="on" bind-value="{{_newKey}}" placeholder="New SSH Key"></iron-autogrow-textarea>
           </span>
         </section>
-        <gr-button
-            id="addButton"
-            link
-            disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-            on-click="_handleAddKey">Add new SSH key</gr-button>
+        <gr-button id="addButton" link="" disabled\$="[[_computeAddButtonDisabled(_newKey)]]" on-click="_handleAddKey">Add new SSH key</gr-button>
       </fieldset>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-ssh-editor.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
index 82d427d..4312d9a 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-ssh-editor</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-ssh-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-ssh-editor.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-ssh-editor.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,149 +40,152 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-ssh-editor tests', async () => {
-    await readyToTest();
-    let element;
-    let keys;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-ssh-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-ssh-editor tests', () => {
+  let element;
+  let keys;
 
-    setup(done => {
-      keys = [{
-        seq: 1,
-        ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
-        encoded_key: '<key 1>',
-        algorithm: 'ssh-rsa',
-        comment: 'comment-one@machine-one',
-        valid: true,
-      }, {
-        seq: 2,
-        ssh_public_key: 'ssh-rsa <key 2> comment-two@machine-two',
-        encoded_key: '<key 2>',
-        algorithm: 'ssh-rsa',
-        comment: 'comment-two@machine-two',
-        valid: true,
-      }];
+  setup(done => {
+    keys = [{
+      seq: 1,
+      ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
+      encoded_key: '<key 1>',
+      algorithm: 'ssh-rsa',
+      comment: 'comment-one@machine-one',
+      valid: true,
+    }, {
+      seq: 2,
+      ssh_public_key: 'ssh-rsa <key 2> comment-two@machine-two',
+      encoded_key: '<key 2>',
+      algorithm: 'ssh-rsa',
+      comment: 'comment-two@machine-two',
+      valid: true,
+    }];
 
-      stub('gr-rest-api-interface', {
-        getAccountSSHKeys() { return Promise.resolve(keys); },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getAccountSSHKeys() { return Promise.resolve(keys); },
     });
 
-    test('renders', () => {
-      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+    element = fixture('basic');
 
-      assert.equal(rows.length, 2);
+    element.loadData().then(() => { flush(done); });
+  });
 
-      let cells = rows[0].querySelectorAll('td');
-      assert.equal(cells[0].textContent, keys[0].comment);
+  test('renders', () => {
+    const rows = dom(element.root).querySelectorAll('tbody tr');
 
-      cells = rows[1].querySelectorAll('td');
-      assert.equal(cells[0].textContent, keys[1].comment);
-    });
+    assert.equal(rows.length, 2);
 
-    test('remove key', done => {
-      const lastKey = keys[1];
+    let cells = rows[0].querySelectorAll('td');
+    assert.equal(cells[0].textContent, keys[0].comment);
 
-      const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey',
-          () => Promise.resolve());
+    cells = rows[1].querySelectorAll('td');
+    assert.equal(cells[0].textContent, keys[1].comment);
+  });
 
+  test('remove key', done => {
+    const lastKey = keys[1];
+
+    const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey',
+        () => Promise.resolve());
+
+    assert.equal(element._keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+
+    // Get the delete button for the last row.
+    const button = dom(element.root).querySelector(
+        'tbody tr:last-of-type td:nth-child(5) gr-button');
+
+    MockInteractions.tap(button);
+
+    assert.equal(element._keys.length, 1);
+    assert.equal(element._keysToRemove.length, 1);
+    assert.equal(element._keysToRemove[0], lastKey);
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isFalse(saveStub.called);
+
+    element.save().then(() => {
+      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.lastCall.args[0], lastKey.seq);
       assert.equal(element._keysToRemove.length, 0);
       assert.isFalse(element.hasUnsavedChanges);
-
-      // Get the delete button for the last row.
-      const button = Polymer.dom(element.root).querySelector(
-          'tbody tr:last-of-type td:nth-child(5) gr-button');
-
-      MockInteractions.tap(button);
-
-      assert.equal(element._keys.length, 1);
-      assert.equal(element._keysToRemove.length, 1);
-      assert.equal(element._keysToRemove[0], lastKey);
-      assert.isTrue(element.hasUnsavedChanges);
-      assert.isFalse(saveStub.called);
-
-      element.save().then(() => {
-        assert.isTrue(saveStub.called);
-        assert.equal(saveStub.lastCall.args[0], lastKey.seq);
-        assert.equal(element._keysToRemove.length, 0);
-        assert.isFalse(element.hasUnsavedChanges);
-        done();
-      });
-    });
-
-    test('show key', () => {
-      const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
-
-      // Get the show button for the last row.
-      const button = Polymer.dom(element.root).querySelector(
-          'tbody tr:last-of-type td:nth-child(3) gr-button');
-
-      MockInteractions.tap(button);
-
-      assert.equal(element._keyToView, keys[1]);
-      assert.isTrue(openSpy.called);
-    });
-
-    test('add key', done => {
-      const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
-      const newKeyObject = {
-        seq: 3,
-        ssh_public_key: newKeyString,
-        encoded_key: '<key 3>',
-        algorithm: 'ssh-rsa',
-        comment: 'comment-three@machine-three',
-        valid: true,
-      };
-
-      const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-          () => Promise.resolve(newKeyObject));
-
-      element._newKey = newKeyString;
-
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-
-      element._handleAddKey().then(() => {
-        assert.isTrue(element.$.addButton.disabled);
-        assert.isFalse(element.$.newKey.disabled);
-        assert.equal(element._keys.length, 3);
-        done();
-      });
-
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isTrue(element.$.newKey.disabled);
-
-      assert.isTrue(addStub.called);
-      assert.equal(addStub.lastCall.args[0], newKeyString);
-    });
-
-    test('add invalid key', done => {
-      const newKeyString = 'not even close to valid';
-
-      const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-          () => Promise.reject(new Error('error')));
-
-      element._newKey = newKeyString;
-
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-
-      element._handleAddKey().then(() => {
-        assert.isFalse(element.$.addButton.disabled);
-        assert.isFalse(element.$.newKey.disabled);
-        assert.equal(element._keys.length, 2);
-        done();
-      });
-
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isTrue(element.$.newKey.disabled);
-
-      assert.isTrue(addStub.called);
-      assert.equal(addStub.lastCall.args[0], newKeyString);
+      done();
     });
   });
+
+  test('show key', () => {
+    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+
+    // Get the show button for the last row.
+    const button = dom(element.root).querySelector(
+        'tbody tr:last-of-type td:nth-child(3) gr-button');
+
+    MockInteractions.tap(button);
+
+    assert.equal(element._keyToView, keys[1]);
+    assert.isTrue(openSpy.called);
+  });
+
+  test('add key', done => {
+    const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
+    const newKeyObject = {
+      seq: 3,
+      ssh_public_key: newKeyString,
+      encoded_key: '<key 3>',
+      algorithm: 'ssh-rsa',
+      comment: 'comment-three@machine-three',
+      valid: true,
+    };
+
+    const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
+        () => Promise.resolve(newKeyObject));
+
+    element._newKey = newKeyString;
+
+    assert.isFalse(element.$.addButton.disabled);
+    assert.isFalse(element.$.newKey.disabled);
+
+    element._handleAddKey().then(() => {
+      assert.isTrue(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+      assert.equal(element._keys.length, 3);
+      done();
+    });
+
+    assert.isTrue(element.$.addButton.disabled);
+    assert.isTrue(element.$.newKey.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.equal(addStub.lastCall.args[0], newKeyString);
+  });
+
+  test('add invalid key', done => {
+    const newKeyString = 'not even close to valid';
+
+    const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
+        () => Promise.reject(new Error('error')));
+
+    element._newKey = newKeyString;
+
+    assert.isFalse(element.$.addButton.disabled);
+    assert.isFalse(element.$.newKey.disabled);
+
+    element._handleAddKey().then(() => {
+      assert.isFalse(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+      assert.equal(element._keys.length, 2);
+      done();
+    });
+
+    assert.isTrue(element.$.addButton.disabled);
+    assert.isTrue(element.$.newKey.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.equal(addStub.lastCall.args[0], newKeyString);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
index df115ca..b8960e8 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
@@ -14,169 +14,181 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const NOTIFICATION_TYPES = [
-    {name: 'Changes', key: 'notify_new_changes'},
-    {name: 'Patches', key: 'notify_new_patch_sets'},
-    {name: 'Comments', key: 'notify_all_comments'},
-    {name: 'Submits', key: 'notify_submitted_changes'},
-    {name: 'Abandons', key: 'notify_abandoned_changes'},
-  ];
+import '@polymer/iron-input/iron-input.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-watched-projects-editor_html.js';
 
-  /** @extends Polymer.Element */
-  class GrWatchedProjectsEditor extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-watched-projects-editor'; }
+const NOTIFICATION_TYPES = [
+  {name: 'Changes', key: 'notify_new_changes'},
+  {name: 'Patches', key: 'notify_new_patch_sets'},
+  {name: 'Comments', key: 'notify_all_comments'},
+  {name: 'Submits', key: 'notify_submitted_changes'},
+  {name: 'Abandons', key: 'notify_abandoned_changes'},
+];
 
-    static get properties() {
-      return {
-        hasUnsavedChanges: {
-          type: Boolean,
-          value: false,
-          notify: true,
+/** @extends Polymer.Element */
+class GrWatchedProjectsEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-watched-projects-editor'; }
+
+  static get properties() {
+    return {
+      hasUnsavedChanges: {
+        type: Boolean,
+        value: false,
+        notify: true,
+      },
+
+      _projects: Array,
+      _projectsToRemove: {
+        type: Array,
+        value() { return []; },
+      },
+      _query: {
+        type: Function,
+        value() {
+          return this._getProjectSuggestions.bind(this);
         },
-
-        _projects: Array,
-        _projectsToRemove: {
-          type: Array,
-          value() { return []; },
-        },
-        _query: {
-          type: Function,
-          value() {
-            return this._getProjectSuggestions.bind(this);
-          },
-        },
-      };
-    }
-
-    loadData() {
-      return this.$.restAPI.getWatchedProjects().then(projs => {
-        this._projects = projs;
-      });
-    }
-
-    save() {
-      let deletePromise;
-      if (this._projectsToRemove.length) {
-        deletePromise = this.$.restAPI.deleteWatchedProjects(
-            this._projectsToRemove);
-      } else {
-        deletePromise = Promise.resolve();
-      }
-
-      return deletePromise
-          .then(() => this.$.restAPI.saveWatchedProjects(this._projects))
-          .then(projects => {
-            this._projects = projects;
-            this._projectsToRemove = [];
-            this.hasUnsavedChanges = false;
-          });
-    }
-
-    _getTypes() {
-      return NOTIFICATION_TYPES;
-    }
-
-    _getTypeCount() {
-      return this._getTypes().length;
-    }
-
-    _computeCheckboxChecked(project, key) {
-      return project.hasOwnProperty(key);
-    }
-
-    _getProjectSuggestions(input) {
-      return this.$.restAPI.getSuggestedProjects(input)
-          .then(response => {
-            const projects = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              projects.push({
-                name: key,
-                value: response[key],
-              });
-            }
-            return projects;
-          });
-    }
-
-    _handleRemoveProject(e) {
-      const el = Polymer.dom(e).localTarget;
-      const index = parseInt(el.getAttribute('data-index'), 10);
-      const project = this._projects[index];
-      this.splice('_projects', index, 1);
-      this.push('_projectsToRemove', project);
-      this.hasUnsavedChanges = true;
-    }
-
-    _canAddProject(project, text, filter) {
-      if ((!project || !project.id) && !text) { return false; }
-
-      // This will only be used if not using the auto complete
-      if (!project && text) { return true; }
-
-      // Check if the project with filter is already in the list. Compare
-      // filters using == to coalesce null and undefined.
-      for (let i = 0; i < this._projects.length; i++) {
-        if (this._projects[i].project === project.id &&
-            this._projects[i].filter == filter) {
-          return false;
-        }
-      }
-
-      return true;
-    }
-
-    _getNewProjectIndex(name, filter) {
-      let i;
-      for (i = 0; i < this._projects.length; i++) {
-        if (this._projects[i].project > name ||
-            (this._projects[i].project === name &&
-                this._projects[i].filter > filter)) {
-          break;
-        }
-      }
-      return i;
-    }
-
-    _handleAddProject() {
-      const newProject = this.$.newProject.value;
-      const newProjectName = this.$.newProject.text;
-      const filter = this.$.newFilter.value || null;
-
-      if (!this._canAddProject(newProject, newProjectName, filter)) { return; }
-
-      const insertIndex = this._getNewProjectIndex(newProjectName, filter);
-
-      this.splice('_projects', insertIndex, 0, {
-        project: newProjectName,
-        filter,
-        _is_local: true,
-      });
-
-      this.$.newProject.clear();
-      this.$.newFilter.bindValue = '';
-      this.hasUnsavedChanges = true;
-    }
-
-    _handleCheckboxChange(e) {
-      const el = Polymer.dom(e).localTarget;
-      const index = parseInt(el.getAttribute('data-index'), 10);
-      const key = el.getAttribute('data-key');
-      const checked = el.checked;
-      this.set(['_projects', index, key], !!checked);
-      this.hasUnsavedChanges = true;
-    }
-
-    _handleNotifCellClick(e) {
-      const checkbox = Polymer.dom(e.target).querySelector('input');
-      if (checkbox) { checkbox.click(); }
-    }
+      },
+    };
   }
 
-  customElements.define(GrWatchedProjectsEditor.is, GrWatchedProjectsEditor);
-})();
+  loadData() {
+    return this.$.restAPI.getWatchedProjects().then(projs => {
+      this._projects = projs;
+    });
+  }
+
+  save() {
+    let deletePromise;
+    if (this._projectsToRemove.length) {
+      deletePromise = this.$.restAPI.deleteWatchedProjects(
+          this._projectsToRemove);
+    } else {
+      deletePromise = Promise.resolve();
+    }
+
+    return deletePromise
+        .then(() => this.$.restAPI.saveWatchedProjects(this._projects))
+        .then(projects => {
+          this._projects = projects;
+          this._projectsToRemove = [];
+          this.hasUnsavedChanges = false;
+        });
+  }
+
+  _getTypes() {
+    return NOTIFICATION_TYPES;
+  }
+
+  _getTypeCount() {
+    return this._getTypes().length;
+  }
+
+  _computeCheckboxChecked(project, key) {
+    return project.hasOwnProperty(key);
+  }
+
+  _getProjectSuggestions(input) {
+    return this.$.restAPI.getSuggestedProjects(input)
+        .then(response => {
+          const projects = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            projects.push({
+              name: key,
+              value: response[key],
+            });
+          }
+          return projects;
+        });
+  }
+
+  _handleRemoveProject(e) {
+    const el = dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    const project = this._projects[index];
+    this.splice('_projects', index, 1);
+    this.push('_projectsToRemove', project);
+    this.hasUnsavedChanges = true;
+  }
+
+  _canAddProject(project, text, filter) {
+    if ((!project || !project.id) && !text) { return false; }
+
+    // This will only be used if not using the auto complete
+    if (!project && text) { return true; }
+
+    // Check if the project with filter is already in the list. Compare
+    // filters using == to coalesce null and undefined.
+    for (let i = 0; i < this._projects.length; i++) {
+      if (this._projects[i].project === project.id &&
+          this._projects[i].filter == filter) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  _getNewProjectIndex(name, filter) {
+    let i;
+    for (i = 0; i < this._projects.length; i++) {
+      if (this._projects[i].project > name ||
+          (this._projects[i].project === name &&
+              this._projects[i].filter > filter)) {
+        break;
+      }
+    }
+    return i;
+  }
+
+  _handleAddProject() {
+    const newProject = this.$.newProject.value;
+    const newProjectName = this.$.newProject.text;
+    const filter = this.$.newFilter.value || null;
+
+    if (!this._canAddProject(newProject, newProjectName, filter)) { return; }
+
+    const insertIndex = this._getNewProjectIndex(newProjectName, filter);
+
+    this.splice('_projects', insertIndex, 0, {
+      project: newProjectName,
+      filter,
+      _is_local: true,
+    });
+
+    this.$.newProject.clear();
+    this.$.newFilter.bindValue = '';
+    this.hasUnsavedChanges = true;
+  }
+
+  _handleCheckboxChange(e) {
+    const el = dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    const key = el.getAttribute('data-key');
+    const checked = el.checked;
+    this.set(['_projects', index, key], !!checked);
+    this.hasUnsavedChanges = true;
+  }
+
+  _handleNotifCellClick(e) {
+    const checkbox = dom(e.target).querySelector('input');
+    if (checkbox) { checkbox.click(); }
+  }
+}
+
+customElements.define(GrWatchedProjectsEditor.is, GrWatchedProjectsEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.js
index b1ecb2e..bc381e0 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-watched-projects-editor">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -60,11 +53,7 @@
           </tr>
         </thead>
         <tbody>
-          <template
-              is="dom-repeat"
-              items="[[_projects]]"
-              as="project"
-              index-as="projectIndex">
+          <template is="dom-repeat" items="[[_projects]]" as="project" index-as="projectIndex">
             <tr>
               <td>
                 [[project.project]]
@@ -72,24 +61,13 @@
                   <div class="projectFilter">[[project.filter]]</div>
                 </template>
               </td>
-              <template
-                  is="dom-repeat"
-                  items="[[_getTypes()]]"
-                  as="type">
+              <template is="dom-repeat" items="[[_getTypes()]]" as="type">
                 <td class="notifControl" on-click="_handleNotifCellClick">
-                  <input
-                      type="checkbox"
-                      data-index$="[[projectIndex]]"
-                      data-key$="[[type.key]]"
-                      on-change="_handleCheckboxChange"
-                      checked$="[[_computeCheckboxChecked(project, type.key)]]">
+                  <input type="checkbox" data-index\$="[[projectIndex]]" data-key\$="[[type.key]]" on-change="_handleCheckboxChange" checked\$="[[_computeCheckboxChecked(project, type.key)]]">
                 </td>
               </template>
               <td>
-                <gr-button
-                    link
-                    data-index$="[[projectIndex]]"
-                    on-click="_handleRemoveProject">Delete</gr-button>
+                <gr-button link="" data-index\$="[[projectIndex]]" on-click="_handleRemoveProject">Delete</gr-button>
               </td>
             </tr>
           </template>
@@ -97,33 +75,19 @@
         <tfoot>
           <tr>
             <th>
-              <gr-autocomplete
-                  id="newProject"
-                  query="[[_query]]"
-                  threshold="1"
-                  allow-non-suggested-values
-                  tab-complete
-                  placeholder="Repo"></gr-autocomplete>
+              <gr-autocomplete id="newProject" query="[[_query]]" threshold="1" allow-non-suggested-values="" tab-complete="" placeholder="Repo"></gr-autocomplete>
             </th>
-            <th colspan$="[[_getTypeCount()]]">
-              <iron-input
-                  class="newFilterInput"
-                  placeholder="branch:name, or other search expression">
-                <input
-                    id="newFilter"
-                    class="newFilterInput"
-                    is="iron-input"
-                    placeholder="branch:name, or other search expression">
+            <th colspan\$="[[_getTypeCount()]]">
+              <iron-input class="newFilterInput" placeholder="branch:name, or other search expression">
+                <input id="newFilter" class="newFilterInput" is="iron-input" placeholder="branch:name, or other search expression">
               </iron-input>
             </th>
             <th>
-              <gr-button link on-click="_handleAddProject">Add</gr-button>
+              <gr-button link="" on-click="_handleAddProject">Add</gr-button>
             </th>
           </tr>
         </tfoot>
       </table>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-watched-projects-editor.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
index c96d6a0..a02afcb 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-watched-projects-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-watched-projects-editor.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-watched-projects-editor.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,184 +40,186 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-watched-projects-editor tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-watched-projects-editor.js';
+suite('gr-watched-projects-editor tests', () => {
+  let element;
 
-    setup(done => {
-      const projects = [
-        {
-          project: 'project a',
-          notify_submitted_changes: true,
-          notify_abandoned_changes: true,
-        }, {
-          project: 'project b',
-          filter: 'filter 1',
-          notify_new_changes: true,
-        }, {
-          project: 'project b',
-          filter: 'filter 2',
-        }, {
-          project: 'project c',
-          notify_new_changes: true,
-          notify_new_patch_sets: true,
-          notify_all_comments: true,
-        },
-      ];
+  setup(done => {
+    const projects = [
+      {
+        project: 'project a',
+        notify_submitted_changes: true,
+        notify_abandoned_changes: true,
+      }, {
+        project: 'project b',
+        filter: 'filter 1',
+        notify_new_changes: true,
+      }, {
+        project: 'project b',
+        filter: 'filter 2',
+      }, {
+        project: 'project c',
+        notify_new_changes: true,
+        notify_new_patch_sets: true,
+        notify_all_comments: true,
+      },
+    ];
 
-      stub('gr-rest-api-interface', {
-        getSuggestedProjects(input) {
-          if (input.startsWith('th')) {
-            return Promise.resolve({'the project': {
-              id: 'the project',
-              state: 'ACTIVE',
-              web_links: [],
-            }});
-          } else {
-            return Promise.resolve({});
-          }
-        },
-        getWatchedProjects() {
-          return Promise.resolve(projects);
-        },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getSuggestedProjects(input) {
+        if (input.startsWith('th')) {
+          return Promise.resolve({'the project': {
+            id: 'the project',
+            state: 'ACTIVE',
+            web_links: [],
+          }});
+        } else {
+          return Promise.resolve({});
+        }
+      },
+      getWatchedProjects() {
+        return Promise.resolve(projects);
+      },
     });
 
-    test('renders', () => {
-      const rows = element.shadowRoot
-          .querySelector('table').querySelectorAll('tbody tr');
-      assert.equal(rows.length, 4);
+    element = fixture('basic');
 
-      function getKeysOfRow(row) {
-        const boxes = rows[row].querySelectorAll('input[checked]');
-        return Array.prototype.map.call(boxes,
-            e => e.getAttribute('data-key'));
-      }
+    element.loadData().then(() => { flush(done); });
+  });
 
-      let checkedKeys = getKeysOfRow(0);
-      assert.equal(checkedKeys.length, 2);
-      assert.equal(checkedKeys[0], 'notify_submitted_changes');
-      assert.equal(checkedKeys[1], 'notify_abandoned_changes');
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('table').querySelectorAll('tbody tr');
+    assert.equal(rows.length, 4);
 
-      checkedKeys = getKeysOfRow(1);
-      assert.equal(checkedKeys.length, 1);
-      assert.equal(checkedKeys[0], 'notify_new_changes');
+    function getKeysOfRow(row) {
+      const boxes = rows[row].querySelectorAll('input[checked]');
+      return Array.prototype.map.call(boxes,
+          e => e.getAttribute('data-key'));
+    }
 
-      checkedKeys = getKeysOfRow(2);
-      assert.equal(checkedKeys.length, 0);
+    let checkedKeys = getKeysOfRow(0);
+    assert.equal(checkedKeys.length, 2);
+    assert.equal(checkedKeys[0], 'notify_submitted_changes');
+    assert.equal(checkedKeys[1], 'notify_abandoned_changes');
 
-      checkedKeys = getKeysOfRow(3);
-      assert.equal(checkedKeys.length, 3);
-      assert.equal(checkedKeys[0], 'notify_new_changes');
-      assert.equal(checkedKeys[1], 'notify_new_patch_sets');
-      assert.equal(checkedKeys[2], 'notify_all_comments');
-    });
+    checkedKeys = getKeysOfRow(1);
+    assert.equal(checkedKeys.length, 1);
+    assert.equal(checkedKeys[0], 'notify_new_changes');
 
-    test('_getProjectSuggestions empty', done => {
-      element._getProjectSuggestions('nonexistent').then(projects => {
-        assert.equal(projects.length, 0);
-        done();
-      });
-    });
+    checkedKeys = getKeysOfRow(2);
+    assert.equal(checkedKeys.length, 0);
 
-    test('_getProjectSuggestions non-empty', done => {
-      element._getProjectSuggestions('the project').then(projects => {
-        assert.equal(projects.length, 1);
-        assert.equal(projects[0].name, 'the project');
-        done();
-      });
-    });
+    checkedKeys = getKeysOfRow(3);
+    assert.equal(checkedKeys.length, 3);
+    assert.equal(checkedKeys[0], 'notify_new_changes');
+    assert.equal(checkedKeys[1], 'notify_new_patch_sets');
+    assert.equal(checkedKeys[2], 'notify_all_comments');
+  });
 
-    test('_getProjectSuggestions non-empty with two letter project', done => {
-      element._getProjectSuggestions('th').then(projects => {
-        assert.equal(projects.length, 1);
-        assert.equal(projects[0].name, 'the project');
-        done();
-      });
-    });
-
-    test('_canAddProject', () => {
-      assert.isFalse(element._canAddProject(null, null, null));
-      assert.isFalse(element._canAddProject({}, null, null));
-
-      // Can add a project that is not in the list.
-      assert.isTrue(element._canAddProject({id: 'project d'}, null, null));
-      assert.isTrue(element._canAddProject({id: 'project d'}, null, 'filter 3'));
-
-      // Cannot add a project that is in the list with no filter.
-      assert.isFalse(element._canAddProject({id: 'project a'}, null, null));
-
-      // Can add a project that is in the list if the filter differs.
-      assert.isTrue(element._canAddProject({id: 'project a'}, null, 'filter 4'));
-
-      // Cannot add a project that is in the list with the same filter.
-      assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 1'));
-      assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 2'));
-
-      // Can add a project that is in the list using a new filter.
-      assert.isTrue(element._canAddProject({id: 'project b'}, null, 'filter 3'));
-
-      // Can add a project that is not added by the auto complete
-      assert.isTrue(element._canAddProject(null, 'test', null));
-    });
-
-    test('_getNewProjectIndex', () => {
-      // Projects are sorted in ASCII order.
-      assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
-      assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
-
-      // Projects are sorted by filter when the names are equal
-      assert.equal(element._getNewProjectIndex('project b', 'filter 0'), 1);
-      assert.equal(element._getNewProjectIndex('project b', 'filter 1.5'), 2);
-      assert.equal(element._getNewProjectIndex('project b', 'filter 3'), 3);
-
-      // Projects with filters follow those without
-      assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
-    });
-
-    test('_handleAddProject', () => {
-      element.$.newProject.value = {id: 'project d'};
-      element.$.newProject.setText('project d');
-      element.$.newFilter.bindValue = '';
-
-      element._handleAddProject();
-
-      assert.equal(element._projects.length, 5);
-      assert.equal(element._projects[4].project, 'project d');
-      assert.isNotOk(element._projects[4].filter);
-      assert.isTrue(element._projects[4]._is_local);
-    });
-
-    test('_handleAddProject with invalid inputs', () => {
-      element.$.newProject.value = {id: 'project b'};
-      element.$.newProject.setText('project b');
-      element.$.newFilter.bindValue = 'filter 1';
-      element.$.newFilter.value = 'filter 1';
-
-      element._handleAddProject();
-
-      assert.equal(element._projects.length, 4);
-    });
-
-    test('_handleRemoveProject', () => {
-      assert.equal(element._projectsToRemove, 0);
-      const button = element.shadowRoot
-          .querySelector('table tbody tr:nth-child(2) gr-button');
-      MockInteractions.tap(button);
-
-      flushAsynchronousOperations();
-
-      const rows = element.shadowRoot
-          .querySelector('table tbody').querySelectorAll('tr');
-      assert.equal(rows.length, 3);
-
-      assert.equal(element._projectsToRemove.length, 1);
-      assert.equal(element._projectsToRemove[0].project, 'project b');
+  test('_getProjectSuggestions empty', done => {
+    element._getProjectSuggestions('nonexistent').then(projects => {
+      assert.equal(projects.length, 0);
+      done();
     });
   });
+
+  test('_getProjectSuggestions non-empty', done => {
+    element._getProjectSuggestions('the project').then(projects => {
+      assert.equal(projects.length, 1);
+      assert.equal(projects[0].name, 'the project');
+      done();
+    });
+  });
+
+  test('_getProjectSuggestions non-empty with two letter project', done => {
+    element._getProjectSuggestions('th').then(projects => {
+      assert.equal(projects.length, 1);
+      assert.equal(projects[0].name, 'the project');
+      done();
+    });
+  });
+
+  test('_canAddProject', () => {
+    assert.isFalse(element._canAddProject(null, null, null));
+    assert.isFalse(element._canAddProject({}, null, null));
+
+    // Can add a project that is not in the list.
+    assert.isTrue(element._canAddProject({id: 'project d'}, null, null));
+    assert.isTrue(element._canAddProject({id: 'project d'}, null, 'filter 3'));
+
+    // Cannot add a project that is in the list with no filter.
+    assert.isFalse(element._canAddProject({id: 'project a'}, null, null));
+
+    // Can add a project that is in the list if the filter differs.
+    assert.isTrue(element._canAddProject({id: 'project a'}, null, 'filter 4'));
+
+    // Cannot add a project that is in the list with the same filter.
+    assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 1'));
+    assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 2'));
+
+    // Can add a project that is in the list using a new filter.
+    assert.isTrue(element._canAddProject({id: 'project b'}, null, 'filter 3'));
+
+    // Can add a project that is not added by the auto complete
+    assert.isTrue(element._canAddProject(null, 'test', null));
+  });
+
+  test('_getNewProjectIndex', () => {
+    // Projects are sorted in ASCII order.
+    assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
+    assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
+
+    // Projects are sorted by filter when the names are equal
+    assert.equal(element._getNewProjectIndex('project b', 'filter 0'), 1);
+    assert.equal(element._getNewProjectIndex('project b', 'filter 1.5'), 2);
+    assert.equal(element._getNewProjectIndex('project b', 'filter 3'), 3);
+
+    // Projects with filters follow those without
+    assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
+  });
+
+  test('_handleAddProject', () => {
+    element.$.newProject.value = {id: 'project d'};
+    element.$.newProject.setText('project d');
+    element.$.newFilter.bindValue = '';
+
+    element._handleAddProject();
+
+    assert.equal(element._projects.length, 5);
+    assert.equal(element._projects[4].project, 'project d');
+    assert.isNotOk(element._projects[4].filter);
+    assert.isTrue(element._projects[4]._is_local);
+  });
+
+  test('_handleAddProject with invalid inputs', () => {
+    element.$.newProject.value = {id: 'project b'};
+    element.$.newProject.setText('project b');
+    element.$.newFilter.bindValue = 'filter 1';
+    element.$.newFilter.value = 'filter 1';
+
+    element._handleAddProject();
+
+    assert.equal(element._projects.length, 4);
+  });
+
+  test('_handleRemoveProject', () => {
+    assert.equal(element._projectsToRemove, 0);
+    const button = element.shadowRoot
+        .querySelector('table tbody tr:nth-child(2) gr-button');
+    MockInteractions.tap(button);
+
+    flushAsynchronousOperations();
+
+    const rows = element.shadowRoot
+        .querySelector('table tbody').querySelectorAll('tr');
+    assert.equal(rows.length, 3);
+
+    assert.equal(element._projectsToRemove.length, 1);
+    assert.equal(element._projectsToRemove[0].project, 'project b');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index 8cd2021..4ac540d 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -14,80 +14,92 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../gr-account-link/gr-account-link.js';
+import '../gr-button/gr-button.js';
+import '../gr-icons/gr-icons.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-chip_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrAccountChip extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-account-chip'; }
+  /**
+   * Fired to indicate a key was pressed while this chip was focused.
+   *
+   * @event account-chip-keydown
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired to indicate this chip should be removed, i.e. when the x button is
+   * clicked or when the remove function is called.
+   *
+   * @event remove
    */
-  class GrAccountChip extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-account-chip'; }
-    /**
-     * Fired to indicate a key was pressed while this chip was focused.
-     *
-     * @event account-chip-keydown
-     */
 
-    /**
-     * Fired to indicate this chip should be removed, i.e. when the x button is
-     * clicked or when the remove function is called.
-     *
-     * @event remove
-     */
-
-    static get properties() {
-      return {
-        account: Object,
-        additionalText: String,
-        disabled: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-        removable: {
-          type: Boolean,
-          value: false,
-        },
-        showAvatar: {
-          type: Boolean,
-          reflectToAttribute: true,
-        },
-        transparentBackground: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
-
-    /** @override */
-    ready() {
-      super.ready();
-      this._getHasAvatars().then(hasAvatars => {
-        this.showAvatar = hasAvatars;
-      });
-    }
-
-    _getBackgroundClass(transparent) {
-      return transparent ? 'transparentBackground' : '';
-    }
-
-    _handleRemoveTap(e) {
-      e.preventDefault();
-      this.fire('remove', {account: this.account});
-    }
-
-    _getHasAvatars() {
-      return this.$.restAPI.getConfig()
-          .then(cfg => Promise.resolve(!!(
-            cfg && cfg.plugin && cfg.plugin.has_avatars
-          )));
-    }
+  static get properties() {
+    return {
+      account: Object,
+      additionalText: String,
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      removable: {
+        type: Boolean,
+        value: false,
+      },
+      showAvatar: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
+      transparentBackground: {
+        type: Boolean,
+        value: false,
+      },
+    };
   }
 
-  customElements.define(GrAccountChip.is, GrAccountChip);
-})();
+  /** @override */
+  ready() {
+    super.ready();
+    this._getHasAvatars().then(hasAvatars => {
+      this.showAvatar = hasAvatars;
+    });
+  }
+
+  _getBackgroundClass(transparent) {
+    return transparent ? 'transparentBackground' : '';
+  }
+
+  _handleRemoveTap(e) {
+    e.preventDefault();
+    this.fire('remove', {account: this.account});
+  }
+
+  _getHasAvatars() {
+    return this.$.restAPI.getConfig()
+        .then(cfg => Promise.resolve(!!(
+          cfg && cfg.plugin && cfg.plugin.has_avatars
+        )));
+  }
+}
+
+customElements.define(GrAccountChip.is, GrAccountChip);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
index 7e2d872..7f219e5 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../gr-account-link/gr-account-link.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../gr-icons/gr-icons.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-account-chip">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -88,23 +80,12 @@
         width: 1.2rem;
       }
     </style>
-    <div class$="container [[_getBackgroundClass(transparentBackground)]]">
-      <gr-account-link account="[[account]]"
-          additional-text="[[additionalText]]">
+    <div class\$="container [[_getBackgroundClass(transparentBackground)]]">
+      <gr-account-link account="[[account]]" additional-text="[[additionalText]]">
       </gr-account-link>
-      <gr-button
-          id="remove"
-          link
-          hidden$="[[!removable]]"
-          hidden
-          tabindex="-1"
-          aria-label="Remove"
-          class$="remove [[_getBackgroundClass(transparentBackground)]]"
-          on-click="_handleRemoveTap">
+      <gr-button id="remove" link="" hidden\$="[[!removable]]" hidden="" tabindex="-1" aria-label="Remove" class\$="remove [[_getBackgroundClass(transparentBackground)]]" on-click="_handleRemoveTap">
         <iron-icon icon="gr-icons:close"></iron-icon>
       </gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-account-chip.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
index d2a111a..49e984c 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
@@ -14,96 +14,105 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../gr-autocomplete/gr-autocomplete.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-entry_html.js';
+
+/**
+ * gr-account-entry is an element for entering account
+ * and/or group with autocomplete support.
+ *
+ * @extends Polymer.Element
+ */
+class GrAccountEntry extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-account-entry'; }
+  /**
+   * Fired when an account is entered.
+   *
+   * @event add
+   */
 
   /**
-   * gr-account-entry is an element for entering account
-   * and/or group with autocomplete support.
+   * When allowAnyInput is true, account-text-changed is fired when input text
+   * changed. This is needed so that the reply dialog's save button can be
+   * enabled for arbitrary cc's, which don't need a 'commit'.
    *
-   * @extends Polymer.Element
+   * @event account-text-changed
    */
-  class GrAccountEntry extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-account-entry'; }
-    /**
-     * Fired when an account is entered.
-     *
-     * @event add
-     */
 
-    /**
-     * When allowAnyInput is true, account-text-changed is fired when input text
-     * changed. This is needed so that the reply dialog's save button can be
-     * enabled for arbitrary cc's, which don't need a 'commit'.
-     *
-     * @event account-text-changed
-     */
+  static get properties() {
+    return {
+      allowAnyInput: Boolean,
+      borderless: Boolean,
+      placeholder: String,
 
-    static get properties() {
-      return {
-        allowAnyInput: Boolean,
-        borderless: Boolean,
-        placeholder: String,
+      // suggestFrom = 0 to enable default suggestions.
+      suggestFrom: {
+        type: Number,
+        value: 0,
+      },
 
-        // suggestFrom = 0 to enable default suggestions.
-        suggestFrom: {
-          type: Number,
-          value: 0,
+      /** @type {!function(string): !Promise<Array<{name, value}>>} */
+      querySuggestions: {
+        type: Function,
+        notify: true,
+        value() {
+          return input => Promise.resolve([]);
         },
+      },
 
-        /** @type {!function(string): !Promise<Array<{name, value}>>} */
-        querySuggestions: {
-          type: Function,
-          notify: true,
-          value() {
-            return input => Promise.resolve([]);
-          },
-        },
+      _config: Object,
+      /** The value of the autocomplete entry. */
+      _inputText: {
+        type: String,
+        observer: '_inputTextChanged',
+      },
 
-        _config: Object,
-        /** The value of the autocomplete entry. */
-        _inputText: {
-          type: String,
-          observer: '_inputTextChanged',
-        },
-
-      };
-    }
-
-    get focusStart() {
-      return this.$.input.focusStart;
-    }
-
-    focus() {
-      this.$.input.focus();
-    }
-
-    clear() {
-      this.$.input.clear();
-    }
-
-    setText(text) {
-      this.$.input.setText(text);
-    }
-
-    getText() {
-      return this.$.input.text;
-    }
-
-    _handleInputCommit(e) {
-      this.fire('add', {value: e.detail.value});
-      this.$.input.focus();
-    }
-
-    _inputTextChanged(text) {
-      if (text.length && this.allowAnyInput) {
-        this.dispatchEvent(new CustomEvent(
-            'account-text-changed', {bubbles: true, composed: true}));
-      }
-    }
+    };
   }
 
-  customElements.define(GrAccountEntry.is, GrAccountEntry);
-})();
+  get focusStart() {
+    return this.$.input.focusStart;
+  }
+
+  focus() {
+    this.$.input.focus();
+  }
+
+  clear() {
+    this.$.input.clear();
+  }
+
+  setText(text) {
+    this.$.input.setText(text);
+  }
+
+  getText() {
+    return this.$.input.text;
+  }
+
+  _handleInputCommit(e) {
+    this.fire('add', {value: e.detail.value});
+    this.$.input.focus();
+  }
+
+  _inputTextChanged(text) {
+    if (text.length && this.allowAnyInput) {
+      this.dispatchEvent(new CustomEvent(
+          'account-text-changed', {bubbles: true, composed: true}));
+    }
+  }
+}
+
+customElements.define(GrAccountEntry.is, GrAccountEntry);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.js
index 992ea8407..281526d 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-account-entry">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       gr-autocomplete {
         display: inline-block;
@@ -30,19 +24,6 @@
         overflow: hidden;
       }
     </style>
-    <gr-autocomplete
-        id="input"
-        borderless="[[borderless]]"
-        placeholder="[[placeholder]]"
-        threshold="[[suggestFrom]]"
-        query="[[querySuggestions]]"
-        allow-non-suggested-values="[[allowAnyInput]]"
-        on-commit="_handleInputCommit"
-        clear-on-commit
-        warn-uncommitted
-        text="{{_inputText}}"
-        vertical-offset="24">
+    <gr-autocomplete id="input" borderless="[[borderless]]" placeholder="[[placeholder]]" threshold="[[suggestFrom]]" query="[[querySuggestions]]" allow-non-suggested-values="[[allowAnyInput]]" on-commit="_handleInputCommit" clear-on-commit="" warn-uncommitted="" text="{{_inputText}}" vertical-offset="24">
     </gr-autocomplete>
-  </template>
-  <script src="gr-account-entry.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html
index 51310eb..7ef07d5 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-entry</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-account-entry.html">
+<script type="module" src="./gr-account-entry.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-account-entry.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,78 +43,81 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-account-entry tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-account-entry.js';
+suite('gr-account-entry tests', () => {
+  let sandbox;
+  let element;
 
-    const suggestion1 = {
-      email: 'email1@example.com',
-      _account_id: 1,
-      some_property: 'value',
-    };
-    const suggestion2 = {
-      email: 'email2@example.com',
-      _account_id: 2,
-    };
-    const suggestion3 = {
-      email: 'email25@example.com',
-      _account_id: 25,
-      some_other_property: 'other value',
-    };
+  const suggestion1 = {
+    email: 'email1@example.com',
+    _account_id: 1,
+    some_property: 'value',
+  };
+  const suggestion2 = {
+    email: 'email2@example.com',
+    _account_id: 2,
+  };
+  const suggestion3 = {
+    email: 'email25@example.com',
+    _account_id: 25,
+    some_other_property: 'other value',
+  };
 
-    setup(done => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      return flush(done);
-    });
+  setup(done => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    return flush(done);
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    suite('stubbed values for querySuggestions', () => {
-      setup(() => {
-        element.querySuggestions = input => Promise.resolve([
-          suggestion1,
-          suggestion2,
-          suggestion3,
-        ]);
-      });
-    });
-
-    test('account-text-changed fired when input text changed and allowAnyInput',
-        () => {
-          // Spy on query, as that is called when _updateSuggestions proceeds.
-          const changeStub = sandbox.stub();
-          element.allowAnyInput = true;
-          element.querySuggestions = input => Promise.resolve([]);
-          element.addEventListener('account-text-changed', changeStub);
-          element.$.input.text = 'a';
-          assert.isTrue(changeStub.calledOnce);
-          element.$.input.text = 'ab';
-          assert.isTrue(changeStub.calledTwice);
-        });
-
-    test('account-text-changed not fired when input text changed without ' +
-        'allowAnyInput', () => {
-      // Spy on query, as that is called when _updateSuggestions proceeds.
-      const changeStub = sandbox.stub();
-      element.querySuggestions = input => Promise.resolve([]);
-      element.addEventListener('account-text-changed', changeStub);
-      element.$.input.text = 'a';
-      assert.isFalse(changeStub.called);
-    });
-
-    test('setText', () => {
-      // Spy on query, as that is called when _updateSuggestions proceeds.
-      const suggestSpy = sandbox.spy(element.$.input, 'query');
-      element.setText('test text');
-      flushAsynchronousOperations();
-
-      assert.equal(element.$.input.$.input.value, 'test text');
-      assert.isFalse(suggestSpy.called);
+  suite('stubbed values for querySuggestions', () => {
+    setup(() => {
+      element.querySuggestions = input => Promise.resolve([
+        suggestion1,
+        suggestion2,
+        suggestion3,
+      ]);
     });
   });
+
+  test('account-text-changed fired when input text changed and allowAnyInput',
+      () => {
+        // Spy on query, as that is called when _updateSuggestions proceeds.
+        const changeStub = sandbox.stub();
+        element.allowAnyInput = true;
+        element.querySuggestions = input => Promise.resolve([]);
+        element.addEventListener('account-text-changed', changeStub);
+        element.$.input.text = 'a';
+        assert.isTrue(changeStub.calledOnce);
+        element.$.input.text = 'ab';
+        assert.isTrue(changeStub.calledTwice);
+      });
+
+  test('account-text-changed not fired when input text changed without ' +
+      'allowAnyInput', () => {
+    // Spy on query, as that is called when _updateSuggestions proceeds.
+    const changeStub = sandbox.stub();
+    element.querySuggestions = input => Promise.resolve([]);
+    element.addEventListener('account-text-changed', changeStub);
+    element.$.input.text = 'a';
+    assert.isFalse(changeStub.called);
+  });
+
+  test('setText', () => {
+    // Spy on query, as that is called when _updateSuggestions proceeds.
+    const suggestSpy = sandbox.spy(element.$.input, 'query');
+    element.setText('test text');
+    flushAsynchronousOperations();
+
+    assert.equal(element.$.input.$.input.value, 'test text');
+    assert.isFalse(suggestSpy.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index 34c4cb6..ba65e03 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -14,121 +14,134 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.js';
 
-  /**
-   * @appliesMixin Gerrit.DisplayNameMixin
-   * @appliesMixin Gerrit.TooltipMixin
-   * @extends Polymer.Element
-   */
-  class GrAccountLabel extends Polymer.mixinBehaviors( [
-    Gerrit.DisplayNameBehavior,
-    Gerrit.TooltipBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-account-label'; }
+import '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/shared-styles.js';
+import '../gr-avatar/gr-avatar.js';
+import '../gr-limited-text/gr-limited-text.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../scripts/util.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-label_html.js';
 
-    static get properties() {
-      return {
-      /**
-       * @type {{ name: string, status: string }}
-       */
-        account: Object,
-        avatarImageSize: {
-          type: Number,
-          value: 32,
-        },
-        title: {
-          type: String,
-          reflectToAttribute: true,
-          computed: '_computeAccountTitle(account, additionalText)',
-        },
-        additionalText: String,
-        hasTooltip: {
-          type: Boolean,
-          reflectToAttribute: true,
-          computed: '_computeHasTooltip(account)',
-        },
-        hideAvatar: {
-          type: Boolean,
-          value: false,
-        },
-        _serverConfig: {
-          type: Object,
-          value: null,
-        },
-      };
-    }
+/**
+ * @appliesMixin Gerrit.DisplayNameMixin
+ * @appliesMixin Gerrit.TooltipMixin
+ * @extends Polymer.Element
+ */
+class GrAccountLabel extends mixinBehaviors( [
+  Gerrit.DisplayNameBehavior,
+  Gerrit.TooltipBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /** @override */
-    ready() {
-      super.ready();
-      if (!this.additionalText) { this.additionalText = ''; }
-      this.$.restAPI.getConfig()
-          .then(config => { this._serverConfig = config; });
-    }
+  static get is() { return 'gr-account-label'; }
 
-    _computeName(account, config) {
-      return this.getUserName(config, account, false);
-    }
-
-    _computeStatusTextLength(account, config) {
-      // 35 as the max length of the name + status
-      return Math.max(10, 35 - this._computeName(account, config).length);
-    }
-
-    _computeAccountTitle(account, tooltip) {
-      // Polymer 2: check for undefined
-      if ([
-        account,
-        tooltip,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      if (!account) { return; }
-      let result = '';
-      if (this._computeName(account, this._serverConfig)) {
-        result += this._computeName(account, this._serverConfig);
-      }
-      if (account.email) {
-        result += ` <${account.email}>`;
-      }
-      if (this.additionalText) {
-        result += ` ${this.additionalText}`;
-      }
-
-      // Show status in the label tooltip instead of
-      // in a separate tooltip on status
-      if (account.status) {
-        result += ` (${account.status})`;
-      }
-
-      return result;
-    }
-
-    _computeShowEmailClass(account) {
-      if (!account || account.name || !account.email) { return ''; }
-      return 'showEmail';
-    }
-
-    _computeEmailStr(account) {
-      if (!account || !account.email) {
-        return '';
-      }
-      if (account.name) {
-        return '(' + account.email + ')';
-      }
-      return account.email;
-    }
-
-    _computeHasTooltip(account) {
-      // If an account has loaded to fire this method, then set to true.
-      return !!account;
-    }
+  static get properties() {
+    return {
+    /**
+     * @type {{ name: string, status: string }}
+     */
+      account: Object,
+      avatarImageSize: {
+        type: Number,
+        value: 32,
+      },
+      title: {
+        type: String,
+        reflectToAttribute: true,
+        computed: '_computeAccountTitle(account, additionalText)',
+      },
+      additionalText: String,
+      hasTooltip: {
+        type: Boolean,
+        reflectToAttribute: true,
+        computed: '_computeHasTooltip(account)',
+      },
+      hideAvatar: {
+        type: Boolean,
+        value: false,
+      },
+      _serverConfig: {
+        type: Object,
+        value: null,
+      },
+    };
   }
 
-  customElements.define(GrAccountLabel.is, GrAccountLabel);
-})();
+  /** @override */
+  ready() {
+    super.ready();
+    if (!this.additionalText) { this.additionalText = ''; }
+    this.$.restAPI.getConfig()
+        .then(config => { this._serverConfig = config; });
+  }
+
+  _computeName(account, config) {
+    return this.getUserName(config, account, false);
+  }
+
+  _computeStatusTextLength(account, config) {
+    // 35 as the max length of the name + status
+    return Math.max(10, 35 - this._computeName(account, config).length);
+  }
+
+  _computeAccountTitle(account, tooltip) {
+    // Polymer 2: check for undefined
+    if ([
+      account,
+      tooltip,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    if (!account) { return; }
+    let result = '';
+    if (this._computeName(account, this._serverConfig)) {
+      result += this._computeName(account, this._serverConfig);
+    }
+    if (account.email) {
+      result += ` <${account.email}>`;
+    }
+    if (this.additionalText) {
+      result += ` ${this.additionalText}`;
+    }
+
+    // Show status in the label tooltip instead of
+    // in a separate tooltip on status
+    if (account.status) {
+      result += ` (${account.status})`;
+    }
+
+    return result;
+  }
+
+  _computeShowEmailClass(account) {
+    if (!account || account.name || !account.email) { return ''; }
+    return 'showEmail';
+  }
+
+  _computeEmailStr(account) {
+    if (!account || !account.email) {
+      return '';
+    }
+    if (account.name) {
+      return '(' + account.email + ')';
+    }
+    return account.email;
+  }
+
+  _computeHasTooltip(account) {
+    // If an account has loaded to fire this method, then set to true.
+    return !!account;
+  }
+}
+
+customElements.define(GrAccountLabel.is, GrAccountLabel);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js
index fcd9ccd..e9d0e5d 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js
@@ -1,31 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.html">
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-avatar/gr-avatar.html">
-<link rel="import" href="../gr-limited-text/gr-limited-text.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-<script src="../../../scripts/util.js"></script>
-
-<dom-module id="gr-account-label">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: inline;
@@ -54,25 +45,19 @@
     </style>
     <span>
       <template is="dom-if" if="[[!hideAvatar]]">
-        <gr-avatar account="[[account]]"
-            image-size="[[avatarImageSize]]"></gr-avatar>
+        <gr-avatar account="[[account]]" image-size="[[avatarImageSize]]"></gr-avatar>
       </template>
-      <span class$="text [[_computeShowEmailClass(account)]]">
+      <span class\$="text [[_computeShowEmailClass(account)]]">
         <span class="name">
           [[_computeName(account, _serverConfig)]]</span>
         <span class="email">
           [[_computeEmailStr(account)]]
         </span>
         <template is="dom-if" if="[[account.status]]">
-          (<gr-limited-text
-            disable-tooltip="true"
-            limit="[[_computeStatusTextLength(account, _serverConfig)]]"
-            text="[[account.status]]">
+          (<gr-limited-text disable-tooltip="true" limit="[[_computeStatusTextLength(account, _serverConfig)]]" text="[[account.status]]">
           </gr-limited-text>)
         </template>
       </span>
     </span>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-account-label.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
index f5a9b8d..db742e6 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-label</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-account-label.html">
+<script type="module" src="./gr-account-label.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-account-label.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,17 +43,124 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-account-label tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-account-label.js';
+suite('gr-account-label tests', () => {
+  let element;
 
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+    element = fixture('basic');
+    element._config = {
+      user: {
+        anonymous_coward_name: 'Anonymous Coward',
+      },
+    };
+  });
+
+  test('null guard', () => {
+    assert.doesNotThrow(() => {
+      element.account = null;
+    });
+  });
+
+  test('missing email', () => {
+    assert.equal('', element._computeEmailStr({name: 'foo'}));
+  });
+
+  test('computed fields', () => {
+    assert.equal(
+        element._computeAccountTitle({
+          name: 'Andrew Bonventre',
+          email: 'andybons+gerrit@gmail.com',
+        }, /* additionalText= */ ''),
+        'Andrew Bonventre <andybons+gerrit@gmail.com>');
+
+    assert.equal(
+        element._computeAccountTitle({
+          name: 'Andrew Bonventre',
+        }, /* additionalText= */ ''),
+        'Andrew Bonventre');
+
+    assert.equal(
+        element._computeAccountTitle({
+          email: 'andybons+gerrit@gmail.com',
+        }, /* additionalText= */ ''),
+        'Anonymous <andybons+gerrit@gmail.com>');
+
+    assert.equal(element._computeShowEmailClass(
+        {
+          name: 'Andrew Bonventre',
+          email: 'andybons+gerrit@gmail.com',
+        }, /* additionalText= */ ''), '');
+
+    assert.equal(element._computeShowEmailClass(
+        {
+          email: 'andybons+gerrit@gmail.com',
+        }, /* additionalText= */ ''), 'showEmail');
+
+    assert.equal(element._computeShowEmailClass(
+        {name: 'Andrew Bonventre'},
+        /* additionalText= */ ''
+    ),
+    '');
+
+    assert.equal(element._computeShowEmailClass(undefined), '');
+
+    assert.equal(
+        element._computeEmailStr({name: 'test', email: 'test'}), '(test)');
+    assert.equal(element._computeEmailStr({email: 'test'}, ''), 'test');
+  });
+
+  suite('_computeName', () => {
+    test('not showing anonymous', () => {
+      const account = {name: 'Wyatt'};
+      assert.deepEqual(element._computeName(account, null), 'Wyatt');
+    });
+
+    test('showing anonymous but no config', () => {
+      const account = {};
+      assert.deepEqual(element._computeName(account, null),
+          'Anonymous');
+    });
+
+    test('test for Anonymous Coward user and replace with Anonymous', () => {
+      const config = {
+        user: {
+          anonymous_coward_name: 'Anonymous Coward',
+        },
+      };
+      const account = {};
+      assert.deepEqual(element._computeName(account, config),
+          'Anonymous');
+    });
+
+    test('test for anonymous_coward_name', () => {
+      const config = {
+        user: {
+          anonymous_coward_name: 'TestAnon',
+        },
+      };
+      const account = {};
+      assert.deepEqual(element._computeName(account, config),
+          'TestAnon');
+    });
+  });
+
+  suite('status in tooltip', () => {
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-      });
       element = fixture('basic');
+      element.account = {
+        name: 'test',
+        email: 'test@google.com',
+        status: 'OOO until Aug 10th',
+      };
       element._config = {
         user: {
           anonymous_coward_name: 'Anonymous Coward',
@@ -55,133 +168,29 @@
       };
     });
 
-    test('null guard', () => {
-      assert.doesNotThrow(() => {
-        element.account = null;
-      });
+    test('tooltip should contain status text', () => {
+      assert.deepEqual(element.title,
+          'test <test@google.com> (OOO until Aug 10th)');
     });
 
-    test('missing email', () => {
-      assert.equal('', element._computeEmailStr({name: 'foo'}));
+    test('status text should not have tooltip', () => {
+      flushAsynchronousOperations();
+      assert.deepEqual(element.shadowRoot
+          .querySelector('gr-limited-text').title, '');
     });
 
-    test('computed fields', () => {
-      assert.equal(
-          element._computeAccountTitle({
-            name: 'Andrew Bonventre',
-            email: 'andybons+gerrit@gmail.com',
-          }, /* additionalText= */ ''),
-          'Andrew Bonventre <andybons+gerrit@gmail.com>');
-
-      assert.equal(
-          element._computeAccountTitle({
-            name: 'Andrew Bonventre',
-          }, /* additionalText= */ ''),
-          'Andrew Bonventre');
-
-      assert.equal(
-          element._computeAccountTitle({
-            email: 'andybons+gerrit@gmail.com',
-          }, /* additionalText= */ ''),
-          'Anonymous <andybons+gerrit@gmail.com>');
-
-      assert.equal(element._computeShowEmailClass(
-          {
-            name: 'Andrew Bonventre',
-            email: 'andybons+gerrit@gmail.com',
-          }, /* additionalText= */ ''), '');
-
-      assert.equal(element._computeShowEmailClass(
-          {
-            email: 'andybons+gerrit@gmail.com',
-          }, /* additionalText= */ ''), 'showEmail');
-
-      assert.equal(element._computeShowEmailClass(
-          {name: 'Andrew Bonventre'},
-          /* additionalText= */ ''
-      ),
-      '');
-
-      assert.equal(element._computeShowEmailClass(undefined), '');
-
-      assert.equal(
-          element._computeEmailStr({name: 'test', email: 'test'}), '(test)');
-      assert.equal(element._computeEmailStr({email: 'test'}, ''), 'test');
-    });
-
-    suite('_computeName', () => {
-      test('not showing anonymous', () => {
-        const account = {name: 'Wyatt'};
-        assert.deepEqual(element._computeName(account, null), 'Wyatt');
-      });
-
-      test('showing anonymous but no config', () => {
-        const account = {};
-        assert.deepEqual(element._computeName(account, null),
-            'Anonymous');
-      });
-
-      test('test for Anonymous Coward user and replace with Anonymous', () => {
-        const config = {
-          user: {
-            anonymous_coward_name: 'Anonymous Coward',
-          },
-        };
-        const account = {};
-        assert.deepEqual(element._computeName(account, config),
-            'Anonymous');
-      });
-
-      test('test for anonymous_coward_name', () => {
-        const config = {
-          user: {
-            anonymous_coward_name: 'TestAnon',
-          },
-        };
-        const account = {};
-        assert.deepEqual(element._computeName(account, config),
-            'TestAnon');
-      });
-    });
-
-    suite('status in tooltip', () => {
-      setup(() => {
-        element = fixture('basic');
-        element.account = {
-          name: 'test',
-          email: 'test@google.com',
-          status: 'OOO until Aug 10th',
-        };
-        element._config = {
-          user: {
-            anonymous_coward_name: 'Anonymous Coward',
-          },
-        };
-      });
-
-      test('tooltip should contain status text', () => {
-        assert.deepEqual(element.title,
-            'test <test@google.com> (OOO until Aug 10th)');
-      });
-
-      test('status text should not have tooltip', () => {
-        flushAsynchronousOperations();
-        assert.deepEqual(element.shadowRoot
-            .querySelector('gr-limited-text').title, '');
-      });
-
-      test('status text should honor the name length and total length', () => {
-        assert.deepEqual(
-            element._computeStatusTextLength(element.account, element._config),
-            31
-        );
-        assert.deepEqual(
-            element._computeStatusTextLength({
-              name: 'a very long long long long name',
-            }, element._config),
-            10
-        );
-      });
+    test('status text should honor the name length and total length', () => {
+      assert.deepEqual(
+          element._computeStatusTextLength(element.account, element._config),
+          31
+      );
+      assert.deepEqual(
+          element._computeStatusTextLength({
+            name: 'a very long long long long name',
+          }, element._config),
+          10
+      );
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
index b0ce04c..4a38427 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,38 +14,48 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @extends Polymer.Element
-   */
-  class GrAccountLink extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-account-link'; }
+import '../../../scripts/bundled-polymer.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../gr-account-label/gr-account-label.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-link_html.js';
 
-    static get properties() {
-      return {
-        additionalText: String,
-        account: Object,
-        avatarImageSize: {
-          type: Number,
-          value: 32,
-        },
-      };
-    }
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @extends Polymer.Element
+ */
+class GrAccountLink extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    _computeOwnerLink(account) {
-      if (!account) { return; }
-      return Gerrit.Nav.getUrlForOwner(
-          account.email || account.username || account.name ||
-          account._account_id);
-    }
+  static get is() { return 'gr-account-link'; }
+
+  static get properties() {
+    return {
+      additionalText: String,
+      account: Object,
+      avatarImageSize: {
+        type: Number,
+        value: 32,
+      },
+    };
   }
 
-  customElements.define(GrAccountLink.is, GrAccountLink);
-})();
+  _computeOwnerLink(account) {
+    if (!account) { return; }
+    return Gerrit.Nav.getUrlForOwner(
+        account.email || account.username || account.name ||
+        account._account_id);
+  }
+}
+
+customElements.define(GrAccountLink.is, GrAccountLink);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
index d3575b2..4ea343e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../gr-account-label/gr-account-label.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-account-link">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: inline-block;
@@ -38,12 +32,8 @@
       }
     </style>
     <span>
-      <a href$="[[_computeOwnerLink(account)]]" tabindex="-1">
-        <gr-account-label account="[[account]]"
-            additional-text="[[additionalText]]"
-            avatar-image-size="[[avatarImageSize]]"></gr-account-label>
+      <a href\$="[[_computeOwnerLink(account)]]" tabindex="-1">
+        <gr-account-label account="[[account]]" additional-text="[[additionalText]]" avatar-image-size="[[avatarImageSize]]"></gr-account-label>
       </a>
     </span>
-  </template>
-  <script src="gr-account-link.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
index c648661..ff89a78 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-link</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-account-link.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-account-link.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-account-link.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,48 +40,50 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-account-link tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-account-link.js';
+suite('gr-account-link tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
     });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('computed fields', () => {
-      const url = 'test/url';
-      const urlStub = sandbox.stub(Gerrit.Nav, 'getUrlForOwner').returns(url);
-      const account = {
-        email: 'email',
-        username: 'username',
-        name: 'name',
-        _account_id: '_account_id',
-      };
-      assert.isNotOk(element._computeOwnerLink());
-      assert.equal(element._computeOwnerLink(account), url);
-      assert.isTrue(urlStub.lastCall.calledWithExactly('email'));
-
-      delete account.email;
-      assert.equal(element._computeOwnerLink(account), url);
-      assert.isTrue(urlStub.lastCall.calledWithExactly('username'));
-
-      delete account.username;
-      assert.equal(element._computeOwnerLink(account), url);
-      assert.isTrue(urlStub.lastCall.calledWithExactly('name'));
-
-      delete account.name;
-      assert.equal(element._computeOwnerLink(account), url);
-      assert.isTrue(urlStub.lastCall.calledWithExactly('_account_id'));
-    });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('computed fields', () => {
+    const url = 'test/url';
+    const urlStub = sandbox.stub(Gerrit.Nav, 'getUrlForOwner').returns(url);
+    const account = {
+      email: 'email',
+      username: 'username',
+      name: 'name',
+      _account_id: '_account_id',
+    };
+    assert.isNotOk(element._computeOwnerLink());
+    assert.equal(element._computeOwnerLink(account), url);
+    assert.isTrue(urlStub.lastCall.calledWithExactly('email'));
+
+    delete account.email;
+    assert.equal(element._computeOwnerLink(account), url);
+    assert.isTrue(urlStub.lastCall.calledWithExactly('username'));
+
+    delete account.username;
+    assert.equal(element._computeOwnerLink(account), url);
+    assert.isTrue(urlStub.lastCall.calledWithExactly('name'));
+
+    delete account.name;
+    assert.equal(element._computeOwnerLink(account), url);
+    assert.isTrue(urlStub.lastCall.calledWithExactly('_account_id'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
index 7955d50..2e8f768 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
@@ -14,342 +14,353 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const VALID_EMAIL_ALERT = 'Please input a valid email.';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../gr-account-chip/gr-account-chip.js';
+import '../gr-account-entry/gr-account-entry.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-list_html.js';
 
+const VALID_EMAIL_ALERT = 'Please input a valid email.';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrAccountList extends mixinBehaviors( [
+  // Used in the tests for gr-account-list and other elements tests.
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-account-list'; }
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when user inputs an invalid email address.
+   *
+   * @event show-alert
    */
-  class GrAccountList extends Polymer.mixinBehaviors( [
-    // Used in the tests for gr-account-list and other elements tests.
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-account-list'; }
-    /**
-     * Fired when user inputs an invalid email address.
-     *
-     * @event show-alert
-     */
 
-    static get properties() {
-      return {
-        accounts: {
-          type: Array,
-          value() { return []; },
-          notify: true,
-        },
-        change: Object,
-        filter: Function,
-        placeholder: String,
-        disabled: {
-          type: Function,
-          value: false,
-        },
+  static get properties() {
+    return {
+      accounts: {
+        type: Array,
+        value() { return []; },
+        notify: true,
+      },
+      change: Object,
+      filter: Function,
+      placeholder: String,
+      disabled: {
+        type: Function,
+        value: false,
+      },
 
-        /**
-         * Returns suggestions and convert them to list item
-         *
-         * @type {Gerrit.GrSuggestionsProvider}
-         */
-        suggestionsProvider: {
-          type: Object,
-        },
+      /**
+       * Returns suggestions and convert them to list item
+       *
+       * @type {Gerrit.GrSuggestionsProvider}
+       */
+      suggestionsProvider: {
+        type: Object,
+      },
 
-        /**
-         * Needed for template checking since value is initially set to null.
-         *
-         * @type {?Object}
-         */
-        pendingConfirmation: {
-          type: Object,
-          value: null,
-          notify: true,
-        },
-        readonly: {
-          type: Boolean,
-          value: false,
-        },
-        /**
-         * When true, allows for non-suggested inputs to be added.
-         */
-        allowAnyInput: {
-          type: Boolean,
-          value: false,
-        },
+      /**
+       * Needed for template checking since value is initially set to null.
+       *
+       * @type {?Object}
+       */
+      pendingConfirmation: {
+        type: Object,
+        value: null,
+        notify: true,
+      },
+      readonly: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * When true, allows for non-suggested inputs to be added.
+       */
+      allowAnyInput: {
+        type: Boolean,
+        value: false,
+      },
 
-        /**
-         * Array of values (groups/accounts) that are removable. When this prop is
-         * undefined, all values are removable.
-         */
-        removableValues: Array,
-        maxCount: {
-          type: Number,
-          value: 0,
-        },
+      /**
+       * Array of values (groups/accounts) that are removable. When this prop is
+       * undefined, all values are removable.
+       */
+      removableValues: Array,
+      maxCount: {
+        type: Number,
+        value: 0,
+      },
 
-        /**
-         * Returns suggestion items
-         *
-         * @type {!function(string): Promise<Array<Gerrit.GrSuggestionItem>>}
-         */
-        _querySuggestions: {
-          type: Function,
-          value() {
-            return this._getSuggestions.bind(this);
-          },
+      /**
+       * Returns suggestion items
+       *
+       * @type {!function(string): Promise<Array<Gerrit.GrSuggestionItem>>}
+       */
+      _querySuggestions: {
+        type: Function,
+        value() {
+          return this._getSuggestions.bind(this);
         },
+      },
 
-        /**
-         * Set to true to disable suggestions on empty input.
-         */
-        skipSuggestOnEmpty: {
-          type: Boolean,
-          value: false,
-        },
-      };
+      /**
+       * Set to true to disable suggestions on empty input.
+       */
+      skipSuggestOnEmpty: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('remove',
+        e => this._handleRemove(e));
+  }
+
+  get accountChips() {
+    return Array.from(
+        dom(this.root).querySelectorAll('gr-account-chip'));
+  }
+
+  get focusStart() {
+    return this.$.entry.focusStart;
+  }
+
+  _getSuggestions(input) {
+    if (this.skipSuggestOnEmpty && !input) {
+      return Promise.resolve([]);
     }
-
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('remove',
-          e => this._handleRemove(e));
+    const provider = this.suggestionsProvider;
+    if (!provider) {
+      return Promise.resolve([]);
     }
-
-    get accountChips() {
-      return Array.from(
-          Polymer.dom(this.root).querySelectorAll('gr-account-chip'));
-    }
-
-    get focusStart() {
-      return this.$.entry.focusStart;
-    }
-
-    _getSuggestions(input) {
-      if (this.skipSuggestOnEmpty && !input) {
-        return Promise.resolve([]);
+    return provider.getSuggestions(input).then(suggestions => {
+      if (!suggestions) { return []; }
+      if (this.filter) {
+        suggestions = suggestions.filter(this.filter);
       }
-      const provider = this.suggestionsProvider;
-      if (!provider) {
-        return Promise.resolve([]);
+      return suggestions.map(suggestion =>
+        provider.makeSuggestionItem(suggestion));
+    });
+  }
+
+  _handleAdd(e) {
+    this._addAccountItem(e.detail.value);
+  }
+
+  _addAccountItem(item) {
+    // Append new account or group to the accounts property. We add our own
+    // internal properties to the account/group here, so we clone the object
+    // to avoid cluttering up the shared change object.
+    if (item.account) {
+      const account =
+          Object.assign({}, item.account, {_pendingAdd: true});
+      this.push('accounts', account);
+    } else if (item.group) {
+      if (item.confirm) {
+        this.pendingConfirmation = item;
+        return;
       }
-      return provider.getSuggestions(input).then(suggestions => {
-        if (!suggestions) { return []; }
-        if (this.filter) {
-          suggestions = suggestions.filter(this.filter);
-        }
-        return suggestions.map(suggestion =>
-          provider.makeSuggestionItem(suggestion));
-      });
-    }
-
-    _handleAdd(e) {
-      this._addAccountItem(e.detail.value);
-    }
-
-    _addAccountItem(item) {
-      // Append new account or group to the accounts property. We add our own
-      // internal properties to the account/group here, so we clone the object
-      // to avoid cluttering up the shared change object.
-      if (item.account) {
-        const account =
-            Object.assign({}, item.account, {_pendingAdd: true});
-        this.push('accounts', account);
-      } else if (item.group) {
-        if (item.confirm) {
-          this.pendingConfirmation = item;
-          return;
-        }
-        const group = Object.assign({}, item.group,
-            {_pendingAdd: true, _group: true});
-        this.push('accounts', group);
-      } else if (this.allowAnyInput) {
-        if (!item.includes('@')) {
-          // Repopulate the input with what the user tried to enter and have
-          // a toast tell them why they can't enter it.
-          this.$.entry.setText(item);
-          this.dispatchEvent(new CustomEvent('show-alert', {
-            detail: {message: VALID_EMAIL_ALERT},
-            bubbles: true,
-            composed: true,
-          }));
-          return false;
-        } else {
-          const account = {email: item, _pendingAdd: true};
-          this.push('accounts', account);
-        }
-      }
-      this.pendingConfirmation = null;
-      return true;
-    }
-
-    confirmGroup(group) {
-      group = Object.assign(
-          {}, group, {confirmed: true, _pendingAdd: true, _group: true});
+      const group = Object.assign({}, item.group,
+          {_pendingAdd: true, _group: true});
       this.push('accounts', group);
-      this.pendingConfirmation = null;
-    }
-
-    _computeChipClass(account) {
-      const classes = [];
-      if (account._group) {
-        classes.push('group');
+    } else if (this.allowAnyInput) {
+      if (!item.includes('@')) {
+        // Repopulate the input with what the user tried to enter and have
+        // a toast tell them why they can't enter it.
+        this.$.entry.setText(item);
+        this.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {message: VALID_EMAIL_ALERT},
+          bubbles: true,
+          composed: true,
+        }));
+        return false;
+      } else {
+        const account = {email: item, _pendingAdd: true};
+        this.push('accounts', account);
       }
-      if (account._pendingAdd) {
-        classes.push('pendingAdd');
-      }
-      return classes.join(' ');
     }
+    this.pendingConfirmation = null;
+    return true;
+  }
 
-    _accountMatches(a, b) {
-      if (a && b) {
-        if (a._account_id) {
-          return a._account_id === b._account_id;
-        }
-        if (a.email) {
-          return a.email === b.email;
+  confirmGroup(group) {
+    group = Object.assign(
+        {}, group, {confirmed: true, _pendingAdd: true, _group: true});
+    this.push('accounts', group);
+    this.pendingConfirmation = null;
+  }
+
+  _computeChipClass(account) {
+    const classes = [];
+    if (account._group) {
+      classes.push('group');
+    }
+    if (account._pendingAdd) {
+      classes.push('pendingAdd');
+    }
+    return classes.join(' ');
+  }
+
+  _accountMatches(a, b) {
+    if (a && b) {
+      if (a._account_id) {
+        return a._account_id === b._account_id;
+      }
+      if (a.email) {
+        return a.email === b.email;
+      }
+    }
+    return a === b;
+  }
+
+  _computeRemovable(account, readonly) {
+    if (readonly) { return false; }
+    if (this.removableValues) {
+      for (let i = 0; i < this.removableValues.length; i++) {
+        if (this._accountMatches(this.removableValues[i], account)) {
+          return true;
         }
       }
-      return a === b;
+      return !!account._pendingAdd;
     }
+    return true;
+  }
 
-    _computeRemovable(account, readonly) {
-      if (readonly) { return false; }
-      if (this.removableValues) {
-        for (let i = 0; i < this.removableValues.length; i++) {
-          if (this._accountMatches(this.removableValues[i], account)) {
-            return true;
-          }
-        }
-        return !!account._pendingAdd;
+  _handleRemove(e) {
+    const toRemove = e.detail.account;
+    this._removeAccount(toRemove);
+    this.$.entry.focus();
+  }
+
+  _removeAccount(toRemove) {
+    if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
+      return;
+    }
+    for (let i = 0; i < this.accounts.length; i++) {
+      let matches;
+      const account = this.accounts[i];
+      if (toRemove._group) {
+        matches = toRemove.id === account.id;
+      } else {
+        matches = this._accountMatches(toRemove, account);
       }
-      return true;
-    }
-
-    _handleRemove(e) {
-      const toRemove = e.detail.account;
-      this._removeAccount(toRemove);
-      this.$.entry.focus();
-    }
-
-    _removeAccount(toRemove) {
-      if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
+      if (matches) {
+        this.splice('accounts', i, 1);
         return;
       }
-      for (let i = 0; i < this.accounts.length; i++) {
-        let matches;
-        const account = this.accounts[i];
-        if (toRemove._group) {
-          matches = toRemove.id === account.id;
-        } else {
-          matches = this._accountMatches(toRemove, account);
+    }
+    console.warn('received remove event for missing account', toRemove);
+  }
+
+  _getNativeInput(paperInput) {
+    // In Polymer 2 inputElement isn't nativeInput anymore
+    return paperInput.$.nativeInput || paperInput.inputElement;
+  }
+
+  _handleInputKeydown(e) {
+    const input = this._getNativeInput(e.detail.input);
+    if (input.selectionStart !== input.selectionEnd ||
+        input.selectionStart !== 0) {
+      return;
+    }
+    switch (e.detail.keyCode) {
+      case 8: // Backspace
+        this._removeAccount(this.accounts[this.accounts.length - 1]);
+        break;
+      case 37: // Left arrow
+        if (this.accountChips[this.accountChips.length - 1]) {
+          this.accountChips[this.accountChips.length - 1].focus();
         }
-        if (matches) {
-          this.splice('accounts', i, 1);
-          return;
-        }
-      }
-      console.warn('received remove event for missing account', toRemove);
-    }
-
-    _getNativeInput(paperInput) {
-      // In Polymer 2 inputElement isn't nativeInput anymore
-      return paperInput.$.nativeInput || paperInput.inputElement;
-    }
-
-    _handleInputKeydown(e) {
-      const input = this._getNativeInput(e.detail.input);
-      if (input.selectionStart !== input.selectionEnd ||
-          input.selectionStart !== 0) {
-        return;
-      }
-      switch (e.detail.keyCode) {
-        case 8: // Backspace
-          this._removeAccount(this.accounts[this.accounts.length - 1]);
-          break;
-        case 37: // Left arrow
-          if (this.accountChips[this.accountChips.length - 1]) {
-            this.accountChips[this.accountChips.length - 1].focus();
-          }
-          break;
-      }
-    }
-
-    _handleChipKeydown(e) {
-      const chip = e.target;
-      const chips = this.accountChips;
-      const index = chips.indexOf(chip);
-      switch (e.keyCode) {
-        case 8: // Backspace
-        case 13: // Enter
-        case 32: // Spacebar
-        case 46: // Delete
-          this._removeAccount(chip.account);
-          // Splice from this array to avoid inconsistent ordering of
-          // event handling.
-          chips.splice(index, 1);
-          if (index < chips.length) {
-            chips[index].focus();
-          } else if (index > 0) {
-            chips[index - 1].focus();
-          } else {
-            this.$.entry.focus();
-          }
-          break;
-        case 37: // Left arrow
-          if (index > 0) {
-            chip.blur();
-            chips[index - 1].focus();
-          }
-          break;
-        case 39: // Right arrow
-          chip.blur();
-          if (index < chips.length - 1) {
-            chips[index + 1].focus();
-          } else {
-            this.$.entry.focus();
-          }
-          break;
-      }
-    }
-
-    /**
-     * Submit the text of the entry as a reviewer value, if it exists. If it is
-     * a successful submit of the text, clear the entry value.
-     *
-     * @return {boolean} If there is text in the entry, return true if the
-     *     submission was successful and false if not. If there is no text,
-     *     return true.
-     */
-    submitEntryText() {
-      const text = this.$.entry.getText();
-      if (!text.length) { return true; }
-      const wasSubmitted = this._addAccountItem(text);
-      if (wasSubmitted) { this.$.entry.clear(); }
-      return wasSubmitted;
-    }
-
-    additions() {
-      return this.accounts
-          .filter(account => account._pendingAdd)
-          .map(account => {
-            if (account._group) {
-              return {group: account};
-            } else {
-              return {account};
-            }
-          });
-    }
-
-    _computeEntryHidden(maxCount, accountsRecord, readonly) {
-      return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
+        break;
     }
   }
 
-  customElements.define(GrAccountList.is, GrAccountList);
-})();
+  _handleChipKeydown(e) {
+    const chip = e.target;
+    const chips = this.accountChips;
+    const index = chips.indexOf(chip);
+    switch (e.keyCode) {
+      case 8: // Backspace
+      case 13: // Enter
+      case 32: // Spacebar
+      case 46: // Delete
+        this._removeAccount(chip.account);
+        // Splice from this array to avoid inconsistent ordering of
+        // event handling.
+        chips.splice(index, 1);
+        if (index < chips.length) {
+          chips[index].focus();
+        } else if (index > 0) {
+          chips[index - 1].focus();
+        } else {
+          this.$.entry.focus();
+        }
+        break;
+      case 37: // Left arrow
+        if (index > 0) {
+          chip.blur();
+          chips[index - 1].focus();
+        }
+        break;
+      case 39: // Right arrow
+        chip.blur();
+        if (index < chips.length - 1) {
+          chips[index + 1].focus();
+        } else {
+          this.$.entry.focus();
+        }
+        break;
+    }
+  }
+
+  /**
+   * Submit the text of the entry as a reviewer value, if it exists. If it is
+   * a successful submit of the text, clear the entry value.
+   *
+   * @return {boolean} If there is text in the entry, return true if the
+   *     submission was successful and false if not. If there is no text,
+   *     return true.
+   */
+  submitEntryText() {
+    const text = this.$.entry.getText();
+    if (!text.length) { return true; }
+    const wasSubmitted = this._addAccountItem(text);
+    if (wasSubmitted) { this.$.entry.clear(); }
+    return wasSubmitted;
+  }
+
+  additions() {
+    return this.accounts
+        .filter(account => account._pendingAdd)
+        .map(account => {
+          if (account._group) {
+            return {group: account};
+          } else {
+            return {account};
+          }
+        });
+  }
+
+  _computeEntryHidden(maxCount, accountsRecord, readonly) {
+    return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
+  }
+}
+
+customElements.define(GrAccountList.is, GrAccountList);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.js
index 37591d8..2438bc1 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../gr-account-entry/gr-account-entry.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-account-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       gr-account-chip {
         display: inline-block;
@@ -53,27 +47,11 @@
     -->
     <div class="list">
       <template id="chips" is="dom-repeat" items="[[accounts]]" as="account">
-        <gr-account-chip
-            account="[[account]]"
-            class$="[[_computeChipClass(account)]]"
-            data-account-id$="[[account._account_id]]"
-            removable="[[_computeRemovable(account, readonly)]]"
-            on-keydown="_handleChipKeydown"
-            tabindex="-1">
+        <gr-account-chip account="[[account]]" class\$="[[_computeChipClass(account)]]" data-account-id\$="[[account._account_id]]" removable="[[_computeRemovable(account, readonly)]]" on-keydown="_handleChipKeydown" tabindex="-1">
         </gr-account-chip>
       </template>
     </div>
-    <gr-account-entry
-        borderless
-        hidden$="[[_computeEntryHidden(maxCount, accounts.*, readonly)]]"
-        id="entry"
-        placeholder="[[placeholder]]"
-        on-add="_handleAdd"
-        on-input-keydown="_handleInputKeydown"
-        allow-any-input="[[allowAnyInput]]"
-        query-suggestions="[[_querySuggestions]]">
+    <gr-account-entry borderless="" hidden\$="[[_computeEntryHidden(maxCount, accounts.*, readonly)]]" id="entry" placeholder="[[placeholder]]" on-add="_handleAdd" on-input-keydown="_handleInputKeydown" allow-any-input="[[allowAnyInput]]" query-suggestions="[[_querySuggestions]]">
     </gr-account-entry>
     <slot></slot>
-  </template>
-  <script src="gr-account-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
index 39d0a88..1fc78ea 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-account-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-account-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-account-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,505 +40,509 @@
   </template>
 </test-fixture>
 
-<script>
-  class MockSuggestionsProvider {
-    getSuggestions(input) {
-      return Promise.resolve([]);
-    }
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-account-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 
-    makeSuggestionItem(item) {
-      return item;
-    }
+class MockSuggestionsProvider {
+  getSuggestions(input) {
+    return Promise.resolve([]);
   }
 
-  suite('gr-account-list tests', async () => {
-    await readyToTest();
-    let _nextAccountId = 0;
-    const makeAccount = function() {
-      const accountId = ++_nextAccountId;
-      return {
-        _account_id: accountId,
-      };
+  makeSuggestionItem(item) {
+    return item;
+  }
+}
+
+suite('gr-account-list tests', () => {
+  let _nextAccountId = 0;
+  const makeAccount = function() {
+    const accountId = ++_nextAccountId;
+    return {
+      _account_id: accountId,
     };
-    const makeGroup = function() {
-      const groupId = 'group' + (++_nextAccountId);
-      return {
-        id: groupId,
-        _group: true,
-      };
+  };
+  const makeGroup = function() {
+    const groupId = 'group' + (++_nextAccountId);
+    return {
+      id: groupId,
+      _group: true,
     };
+  };
 
-    let existingAccount1;
-    let existingAccount2;
-    let sandbox;
-    let element;
-    let suggestionsProvider;
+  let existingAccount1;
+  let existingAccount2;
+  let sandbox;
+  let element;
+  let suggestionsProvider;
 
-    function getChips() {
-      return Polymer.dom(element.root).querySelectorAll('gr-account-chip');
-    }
+  function getChips() {
+    return dom(element.root).querySelectorAll('gr-account-chip');
+  }
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    existingAccount1 = makeAccount();
+    existingAccount2 = makeAccount();
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = fixture('basic');
+    element.accounts = [existingAccount1, existingAccount2];
+    suggestionsProvider = new MockSuggestionsProvider();
+    element.suggestionsProvider = suggestionsProvider;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('account entry only appears when editable', () => {
+    element.readonly = false;
+    assert.isFalse(element.$.entry.hasAttribute('hidden'));
+    element.readonly = true;
+    assert.isTrue(element.$.entry.hasAttribute('hidden'));
+  });
+
+  test('addition and removal of account/group chips', () => {
+    flushAsynchronousOperations();
+    sandbox.stub(element, '_computeRemovable').returns(true);
+    // Existing accounts are listed.
+    let chips = getChips();
+    assert.equal(chips.length, 2);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isFalse(chips[1].classList.contains('pendingAdd'));
+
+    // New accounts are added to end with pendingAdd class.
+    const newAccount = makeAccount();
+    element._handleAdd({
+      detail: {
+        value: {
+          account: newAccount,
+        },
+      },
+    });
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 3);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isFalse(chips[1].classList.contains('pendingAdd'));
+    assert.isTrue(chips[2].classList.contains('pendingAdd'));
+
+    // Removed accounts are taken out of the list.
+    element.fire('remove', {account: existingAccount1});
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 2);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isTrue(chips[1].classList.contains('pendingAdd'));
+
+    // Invalid remove is ignored.
+    element.fire('remove', {account: existingAccount1});
+    element.fire('remove', {account: newAccount});
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 1);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+
+    // New groups are added to end with pendingAdd and group classes.
+    const newGroup = makeGroup();
+    element._handleAdd({
+      detail: {
+        value: {
+          group: newGroup,
+        },
+      },
+    });
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 2);
+    assert.isTrue(chips[1].classList.contains('group'));
+    assert.isTrue(chips[1].classList.contains('pendingAdd'));
+
+    // Removed groups are taken out of the list.
+    element.fire('remove', {account: newGroup});
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 1);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+  });
+
+  test('_getSuggestions uses filter correctly', done => {
+    const originalSuggestions = [
+      {
+        email: 'abc@example.com',
+        text: 'abcd',
+        _account_id: 3,
+      },
+      {
+        email: 'qwe@example.com',
+        text: 'qwer',
+        _account_id: 1,
+      },
+      {
+        email: 'xyz@example.com',
+        text: 'aaaaa',
+        _account_id: 25,
+      },
+    ];
+    sandbox.stub(suggestionsProvider, 'getSuggestions')
+        .returns(Promise.resolve(originalSuggestions));
+    sandbox.stub(suggestionsProvider, 'makeSuggestionItem', suggestion => {
+      return {
+        name: suggestion.email,
+        value: suggestion._account_id,
+      };
+    });
+
+    element._getSuggestions().then(suggestions => {
+      // Default is no filtering.
+      assert.equal(suggestions.length, 3);
+
+      // Set up filter that only accepts suggestion1.
+      const accountId = originalSuggestions[0]._account_id;
+      element.filter = function(suggestion) {
+        return suggestion._account_id === accountId;
+      };
+
+      element._getSuggestions()
+          .then(suggestions => {
+            assert.deepEqual(suggestions,
+                [{name: originalSuggestions[0].email,
+                  value: originalSuggestions[0]._account_id}]);
+          })
+          .then(done);
+    });
+  });
+
+  test('_computeChipClass', () => {
+    const account = makeAccount();
+    assert.equal(element._computeChipClass(account), '');
+    account._pendingAdd = true;
+    assert.equal(element._computeChipClass(account), 'pendingAdd');
+    account._group = true;
+    assert.equal(element._computeChipClass(account), 'group pendingAdd');
+    account._pendingAdd = false;
+    assert.equal(element._computeChipClass(account), 'group');
+  });
+
+  test('_computeRemovable', () => {
+    const newAccount = makeAccount();
+    newAccount._pendingAdd = true;
+    element.readonly = false;
+    element.removableValues = [];
+    assert.isFalse(element._computeRemovable(existingAccount1, false));
+    assert.isTrue(element._computeRemovable(newAccount, false));
+
+    element.removableValues = [existingAccount1];
+    assert.isTrue(element._computeRemovable(existingAccount1, false));
+    assert.isTrue(element._computeRemovable(newAccount, false));
+    assert.isFalse(element._computeRemovable(existingAccount2, false));
+
+    element.readonly = true;
+    assert.isFalse(element._computeRemovable(existingAccount1, true));
+    assert.isFalse(element._computeRemovable(newAccount, true));
+  });
+
+  test('submitEntryText', () => {
+    element.allowAnyInput = true;
+    flushAsynchronousOperations();
+
+    const getTextStub = sandbox.stub(element.$.entry, 'getText');
+    getTextStub.onFirstCall().returns('');
+    getTextStub.onSecondCall().returns('test');
+    getTextStub.onThirdCall().returns('test@test');
+
+    // When entry is empty, return true.
+    const clearStub = sandbox.stub(element.$.entry, 'clear');
+    assert.isTrue(element.submitEntryText());
+    assert.isFalse(clearStub.called);
+
+    // When entry is invalid, return false.
+    assert.isFalse(element.submitEntryText());
+    assert.isFalse(clearStub.called);
+
+    // When entry is valid, return true and clear text.
+    assert.isTrue(element.submitEntryText());
+    assert.isTrue(clearStub.called);
+    assert.equal(element.additions()[0].account.email, 'test@test');
+  });
+
+  test('additions returns sanitized new accounts and groups', () => {
+    assert.equal(element.additions().length, 0);
+
+    const newAccount = makeAccount();
+    element._handleAdd({
+      detail: {
+        value: {
+          account: newAccount,
+        },
+      },
+    });
+    const newGroup = makeGroup();
+    element._handleAdd({
+      detail: {
+        value: {
+          group: newGroup,
+        },
+      },
+    });
+
+    assert.deepEqual(element.additions(), [
+      {
+        account: {
+          _account_id: newAccount._account_id,
+          _pendingAdd: true,
+        },
+      },
+      {
+        group: {
+          id: newGroup.id,
+          _group: true,
+          _pendingAdd: true,
+        },
+      },
+    ]);
+  });
+
+  test('large group confirmations', () => {
+    assert.isNull(element.pendingConfirmation);
+    assert.deepEqual(element.additions(), []);
+
+    const group = makeGroup();
+    const reviewer = {
+      group,
+      count: 10,
+      confirm: true,
+    };
+    element._handleAdd({
+      detail: {
+        value: reviewer,
+      },
+    });
+
+    assert.deepEqual(element.pendingConfirmation, reviewer);
+    assert.deepEqual(element.additions(), []);
+
+    element.confirmGroup(group);
+    assert.isNull(element.pendingConfirmation);
+    assert.deepEqual(element.additions(), [
+      {
+        group: {
+          id: group.id,
+          _group: true,
+          _pendingAdd: true,
+          confirmed: true,
+        },
+      },
+    ]);
+  });
+
+  test('removeAccount fails if account is not removable', () => {
+    element.readonly = true;
+    const acct = makeAccount();
+    element.accounts = [acct];
+    element._removeAccount(acct);
+    assert.equal(element.accounts.length, 1);
+  });
+
+  test('max-count', () => {
+    element.maxCount = 1;
+    const acct = makeAccount();
+    element._handleAdd({
+      detail: {
+        value: {
+          account: acct,
+        },
+      },
+    });
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.entry.hasAttribute('hidden'));
+  });
+
+  test('enter text calls suggestions provider', done => {
+    const suggestions = [
+      {
+        email: 'abc@example.com',
+        text: 'abcd',
+      },
+      {
+        email: 'qwe@example.com',
+        text: 'qwer',
+      },
+    ];
+    const getSuggestionsStub =
+        sandbox.stub(suggestionsProvider, 'getSuggestions')
+            .returns(Promise.resolve(suggestions));
+
+    const makeSuggestionItemStub =
+        sandbox.stub(suggestionsProvider, 'makeSuggestionItem', item => item);
+
+    const input = element.$.entry.$.input;
+
+    input.text = 'newTest';
+    MockInteractions.focus(input.$.input);
+    input.noDebounce = true;
+    flushAsynchronousOperations();
+    flush(() => {
+      assert.isTrue(getSuggestionsStub.calledOnce);
+      assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
+      assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+      done();
+    });
+  });
+
+  test('suggestion on empty', done => {
+    element.skipSuggestOnEmpty = false;
+    const suggestions = [
+      {
+        email: 'abc@example.com',
+        text: 'abcd',
+      },
+      {
+        email: 'qwe@example.com',
+        text: 'qwer',
+      },
+    ];
+    const getSuggestionsStub =
+        sandbox.stub(suggestionsProvider, 'getSuggestions')
+            .returns(Promise.resolve(suggestions));
+
+    const makeSuggestionItemStub =
+        sandbox.stub(suggestionsProvider, 'makeSuggestionItem', item => item);
+
+    const input = element.$.entry.$.input;
+
+    input.text = '';
+    MockInteractions.focus(input.$.input);
+    input.noDebounce = true;
+    flushAsynchronousOperations();
+    flush(() => {
+      assert.isTrue(getSuggestionsStub.calledOnce);
+      assert.equal(getSuggestionsStub.lastCall.args[0], '');
+      assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+      done();
+    });
+  });
+
+  test('skip suggestion on empty', done => {
+    element.skipSuggestOnEmpty = true;
+    const getSuggestionsStub =
+        sandbox.stub(suggestionsProvider, 'getSuggestions')
+            .returns(Promise.resolve([]));
+
+    const input = element.$.entry.$.input;
+
+    input.text = '';
+    MockInteractions.focus(input.$.input);
+    input.noDebounce = true;
+    flushAsynchronousOperations();
+    flush(() => {
+      assert.isTrue(getSuggestionsStub.notCalled);
+      done();
+    });
+  });
+
+  suite('allowAnyInput', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      existingAccount1 = makeAccount();
-      existingAccount2 = makeAccount();
-
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      element.accounts = [existingAccount1, existingAccount2];
-      suggestionsProvider = new MockSuggestionsProvider();
-      element.suggestionsProvider = suggestionsProvider;
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('account entry only appears when editable', () => {
-      element.readonly = false;
-      assert.isFalse(element.$.entry.hasAttribute('hidden'));
-      element.readonly = true;
-      assert.isTrue(element.$.entry.hasAttribute('hidden'));
-    });
-
-    test('addition and removal of account/group chips', () => {
-      flushAsynchronousOperations();
-      sandbox.stub(element, '_computeRemovable').returns(true);
-      // Existing accounts are listed.
-      let chips = getChips();
-      assert.equal(chips.length, 2);
-      assert.isFalse(chips[0].classList.contains('pendingAdd'));
-      assert.isFalse(chips[1].classList.contains('pendingAdd'));
-
-      // New accounts are added to end with pendingAdd class.
-      const newAccount = makeAccount();
-      element._handleAdd({
-        detail: {
-          value: {
-            account: newAccount,
-          },
-        },
-      });
-      flushAsynchronousOperations();
-      chips = getChips();
-      assert.equal(chips.length, 3);
-      assert.isFalse(chips[0].classList.contains('pendingAdd'));
-      assert.isFalse(chips[1].classList.contains('pendingAdd'));
-      assert.isTrue(chips[2].classList.contains('pendingAdd'));
-
-      // Removed accounts are taken out of the list.
-      element.fire('remove', {account: existingAccount1});
-      flushAsynchronousOperations();
-      chips = getChips();
-      assert.equal(chips.length, 2);
-      assert.isFalse(chips[0].classList.contains('pendingAdd'));
-      assert.isTrue(chips[1].classList.contains('pendingAdd'));
-
-      // Invalid remove is ignored.
-      element.fire('remove', {account: existingAccount1});
-      element.fire('remove', {account: newAccount});
-      flushAsynchronousOperations();
-      chips = getChips();
-      assert.equal(chips.length, 1);
-      assert.isFalse(chips[0].classList.contains('pendingAdd'));
-
-      // New groups are added to end with pendingAdd and group classes.
-      const newGroup = makeGroup();
-      element._handleAdd({
-        detail: {
-          value: {
-            group: newGroup,
-          },
-        },
-      });
-      flushAsynchronousOperations();
-      chips = getChips();
-      assert.equal(chips.length, 2);
-      assert.isTrue(chips[1].classList.contains('group'));
-      assert.isTrue(chips[1].classList.contains('pendingAdd'));
-
-      // Removed groups are taken out of the list.
-      element.fire('remove', {account: newGroup});
-      flushAsynchronousOperations();
-      chips = getChips();
-      assert.equal(chips.length, 1);
-      assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    });
-
-    test('_getSuggestions uses filter correctly', done => {
-      const originalSuggestions = [
-        {
-          email: 'abc@example.com',
-          text: 'abcd',
-          _account_id: 3,
-        },
-        {
-          email: 'qwe@example.com',
-          text: 'qwer',
-          _account_id: 1,
-        },
-        {
-          email: 'xyz@example.com',
-          text: 'aaaaa',
-          _account_id: 25,
-        },
-      ];
-      sandbox.stub(suggestionsProvider, 'getSuggestions')
-          .returns(Promise.resolve(originalSuggestions));
-      sandbox.stub(suggestionsProvider, 'makeSuggestionItem', suggestion => {
-        return {
-          name: suggestion.email,
-          value: suggestion._account_id,
-        };
-      });
-
-      element._getSuggestions().then(suggestions => {
-        // Default is no filtering.
-        assert.equal(suggestions.length, 3);
-
-        // Set up filter that only accepts suggestion1.
-        const accountId = originalSuggestions[0]._account_id;
-        element.filter = function(suggestion) {
-          return suggestion._account_id === accountId;
-        };
-
-        element._getSuggestions()
-            .then(suggestions => {
-              assert.deepEqual(suggestions,
-                  [{name: originalSuggestions[0].email,
-                    value: originalSuggestions[0]._account_id}]);
-            })
-            .then(done);
-      });
-    });
-
-    test('_computeChipClass', () => {
-      const account = makeAccount();
-      assert.equal(element._computeChipClass(account), '');
-      account._pendingAdd = true;
-      assert.equal(element._computeChipClass(account), 'pendingAdd');
-      account._group = true;
-      assert.equal(element._computeChipClass(account), 'group pendingAdd');
-      account._pendingAdd = false;
-      assert.equal(element._computeChipClass(account), 'group');
-    });
-
-    test('_computeRemovable', () => {
-      const newAccount = makeAccount();
-      newAccount._pendingAdd = true;
-      element.readonly = false;
-      element.removableValues = [];
-      assert.isFalse(element._computeRemovable(existingAccount1, false));
-      assert.isTrue(element._computeRemovable(newAccount, false));
-
-      element.removableValues = [existingAccount1];
-      assert.isTrue(element._computeRemovable(existingAccount1, false));
-      assert.isTrue(element._computeRemovable(newAccount, false));
-      assert.isFalse(element._computeRemovable(existingAccount2, false));
-
-      element.readonly = true;
-      assert.isFalse(element._computeRemovable(existingAccount1, true));
-      assert.isFalse(element._computeRemovable(newAccount, true));
-    });
-
-    test('submitEntryText', () => {
       element.allowAnyInput = true;
-      flushAsynchronousOperations();
-
-      const getTextStub = sandbox.stub(element.$.entry, 'getText');
-      getTextStub.onFirstCall().returns('');
-      getTextStub.onSecondCall().returns('test');
-      getTextStub.onThirdCall().returns('test@test');
-
-      // When entry is empty, return true.
-      const clearStub = sandbox.stub(element.$.entry, 'clear');
-      assert.isTrue(element.submitEntryText());
-      assert.isFalse(clearStub.called);
-
-      // When entry is invalid, return false.
-      assert.isFalse(element.submitEntryText());
-      assert.isFalse(clearStub.called);
-
-      // When entry is valid, return true and clear text.
-      assert.isTrue(element.submitEntryText());
-      assert.isTrue(clearStub.called);
-      assert.equal(element.additions()[0].account.email, 'test@test');
     });
 
-    test('additions returns sanitized new accounts and groups', () => {
-      assert.equal(element.additions().length, 0);
-
-      const newAccount = makeAccount();
-      element._handleAdd({
-        detail: {
-          value: {
-            account: newAccount,
-          },
-        },
-      });
-      const newGroup = makeGroup();
-      element._handleAdd({
-        detail: {
-          value: {
-            group: newGroup,
-          },
-        },
-      });
-
-      assert.deepEqual(element.additions(), [
-        {
-          account: {
-            _account_id: newAccount._account_id,
-            _pendingAdd: true,
-          },
-        },
-        {
-          group: {
-            id: newGroup.id,
-            _group: true,
-            _pendingAdd: true,
-          },
-        },
-      ]);
+    test('adds emails', () => {
+      const accountLen = element.accounts.length;
+      element._handleAdd({detail: {value: 'test@test'}});
+      assert.equal(element.accounts.length, accountLen + 1);
+      assert.equal(element.accounts[accountLen].email, 'test@test');
     });
 
-    test('large group confirmations', () => {
-      assert.isNull(element.pendingConfirmation);
-      assert.deepEqual(element.additions(), []);
-
-      const group = makeGroup();
-      const reviewer = {
-        group,
-        count: 10,
-        confirm: true,
-      };
-      element._handleAdd({
-        detail: {
-          value: reviewer,
-        },
-      });
-
-      assert.deepEqual(element.pendingConfirmation, reviewer);
-      assert.deepEqual(element.additions(), []);
-
-      element.confirmGroup(group);
-      assert.isNull(element.pendingConfirmation);
-      assert.deepEqual(element.additions(), [
-        {
-          group: {
-            id: group.id,
-            _group: true,
-            _pendingAdd: true,
-            confirmed: true,
-          },
-        },
-      ]);
+    test('toasts on invalid email', () => {
+      const toastHandler = sandbox.stub();
+      element.addEventListener('show-alert', toastHandler);
+      element._handleAdd({detail: {value: 'test'}});
+      assert.isTrue(toastHandler.called);
     });
+  });
 
-    test('removeAccount fails if account is not removable', () => {
-      element.readonly = true;
-      const acct = makeAccount();
-      element.accounts = [acct];
-      element._removeAccount(acct);
-      assert.equal(element.accounts.length, 1);
-    });
+  test('_accountMatches', () => {
+    const acct = makeAccount();
 
-    test('max-count', () => {
-      element.maxCount = 1;
-      const acct = makeAccount();
-      element._handleAdd({
-        detail: {
-          value: {
-            account: acct,
-          },
-        },
-      });
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.entry.hasAttribute('hidden'));
-    });
+    assert.isTrue(element._accountMatches(acct, acct));
+    acct.email = 'test';
+    assert.isTrue(element._accountMatches(acct, acct));
+    assert.isTrue(element._accountMatches({email: 'test'}, acct));
 
-    test('enter text calls suggestions provider', done => {
-      const suggestions = [
-        {
-          email: 'abc@example.com',
-          text: 'abcd',
-        },
-        {
-          email: 'qwe@example.com',
-          text: 'qwer',
-        },
-      ];
-      const getSuggestionsStub =
-          sandbox.stub(suggestionsProvider, 'getSuggestions')
-              .returns(Promise.resolve(suggestions));
+    assert.isFalse(element._accountMatches({}, acct));
+    assert.isFalse(element._accountMatches({email: 'test2'}, acct));
+    assert.isFalse(element._accountMatches({_account_id: -1}, acct));
+  });
 
-      const makeSuggestionItemStub =
-          sandbox.stub(suggestionsProvider, 'makeSuggestionItem', item => item);
-
+  suite('keyboard interactions', () => {
+    test('backspace at text input start removes last account', done => {
       const input = element.$.entry.$.input;
-
-      input.text = 'newTest';
-      MockInteractions.focus(input.$.input);
-      input.noDebounce = true;
-      flushAsynchronousOperations();
+      sandbox.stub(input, '_updateSuggestions');
+      sandbox.stub(element, '_computeRemovable').returns(true);
       flush(() => {
-        assert.isTrue(getSuggestionsStub.calledOnce);
-        assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
-        assert.equal(makeSuggestionItemStub.getCalls().length, 2);
-        done();
-      });
-    });
-
-    test('suggestion on empty', done => {
-      element.skipSuggestOnEmpty = false;
-      const suggestions = [
-        {
-          email: 'abc@example.com',
-          text: 'abcd',
-        },
-        {
-          email: 'qwe@example.com',
-          text: 'qwer',
-        },
-      ];
-      const getSuggestionsStub =
-          sandbox.stub(suggestionsProvider, 'getSuggestions')
-              .returns(Promise.resolve(suggestions));
-
-      const makeSuggestionItemStub =
-          sandbox.stub(suggestionsProvider, 'makeSuggestionItem', item => item);
-
-      const input = element.$.entry.$.input;
-
-      input.text = '';
-      MockInteractions.focus(input.$.input);
-      input.noDebounce = true;
-      flushAsynchronousOperations();
-      flush(() => {
-        assert.isTrue(getSuggestionsStub.calledOnce);
-        assert.equal(getSuggestionsStub.lastCall.args[0], '');
-        assert.equal(makeSuggestionItemStub.getCalls().length, 2);
-        done();
-      });
-    });
-
-    test('skip suggestion on empty', done => {
-      element.skipSuggestOnEmpty = true;
-      const getSuggestionsStub =
-          sandbox.stub(suggestionsProvider, 'getSuggestions')
-              .returns(Promise.resolve([]));
-
-      const input = element.$.entry.$.input;
-
-      input.text = '';
-      MockInteractions.focus(input.$.input);
-      input.noDebounce = true;
-      flushAsynchronousOperations();
-      flush(() => {
-        assert.isTrue(getSuggestionsStub.notCalled);
-        done();
-      });
-    });
-
-    suite('allowAnyInput', () => {
-      setup(() => {
-        element.allowAnyInput = true;
-      });
-
-      test('adds emails', () => {
-        const accountLen = element.accounts.length;
-        element._handleAdd({detail: {value: 'test@test'}});
-        assert.equal(element.accounts.length, accountLen + 1);
-        assert.equal(element.accounts[accountLen].email, 'test@test');
-      });
-
-      test('toasts on invalid email', () => {
-        const toastHandler = sandbox.stub();
-        element.addEventListener('show-alert', toastHandler);
-        element._handleAdd({detail: {value: 'test'}});
-        assert.isTrue(toastHandler.called);
-      });
-    });
-
-    test('_accountMatches', () => {
-      const acct = makeAccount();
-
-      assert.isTrue(element._accountMatches(acct, acct));
-      acct.email = 'test';
-      assert.isTrue(element._accountMatches(acct, acct));
-      assert.isTrue(element._accountMatches({email: 'test'}, acct));
-
-      assert.isFalse(element._accountMatches({}, acct));
-      assert.isFalse(element._accountMatches({email: 'test2'}, acct));
-      assert.isFalse(element._accountMatches({_account_id: -1}, acct));
-    });
-
-    suite('keyboard interactions', () => {
-      test('backspace at text input start removes last account', done => {
-        const input = element.$.entry.$.input;
-        sandbox.stub(input, '_updateSuggestions');
-        sandbox.stub(element, '_computeRemovable').returns(true);
-        flush(() => {
-          // Next line is a workaround for Firefix not moving cursor
-          // on input field update
-          assert.equal(
-              element._getNativeInput(input.$.input).selectionStart, 0);
-          input.text = 'test';
-          MockInteractions.focus(input.$.input);
-          flushAsynchronousOperations();
-          assert.equal(element.accounts.length, 2);
-          MockInteractions.pressAndReleaseKeyOn(
-              element._getNativeInput(input.$.input), 8); // Backspace
-          assert.equal(element.accounts.length, 2);
-          input.text = '';
-          MockInteractions.pressAndReleaseKeyOn(
-              element._getNativeInput(input.$.input), 8); // Backspace
-          flushAsynchronousOperations();
-          assert.equal(element.accounts.length, 1);
-          done();
-        });
-      });
-
-      test('arrow key navigation', done => {
-        const input = element.$.entry.$.input;
+        // Next line is a workaround for Firefix not moving cursor
+        // on input field update
+        assert.equal(
+            element._getNativeInput(input.$.input).selectionStart, 0);
+        input.text = 'test';
+        MockInteractions.focus(input.$.input);
+        flushAsynchronousOperations();
+        assert.equal(element.accounts.length, 2);
+        MockInteractions.pressAndReleaseKeyOn(
+            element._getNativeInput(input.$.input), 8); // Backspace
+        assert.equal(element.accounts.length, 2);
         input.text = '';
-        element.accounts = [makeAccount(), makeAccount()];
-        flush(() => {
-          MockInteractions.focus(input.$.input);
-          flushAsynchronousOperations();
-          const chips = element.accountChips;
-          const chipsOneSpy = sandbox.spy(chips[1], 'focus');
-          MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
-          assert.isTrue(chipsOneSpy.called);
-          const chipsZeroSpy = sandbox.spy(chips[0], 'focus');
-          MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
-          assert.isTrue(chipsZeroSpy.called);
-          MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
-          assert.isTrue(chipsZeroSpy.calledOnce);
-          MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
-          assert.isTrue(chipsOneSpy.calledTwice);
-          done();
-        });
+        MockInteractions.pressAndReleaseKeyOn(
+            element._getNativeInput(input.$.input), 8); // Backspace
+        flushAsynchronousOperations();
+        assert.equal(element.accounts.length, 1);
+        done();
       });
+    });
 
-      test('delete', done => {
-        element.accounts = [makeAccount(), makeAccount()];
-        flush(() => {
-          const focusSpy = sandbox.spy(element.accountChips[1], 'focus');
-          const removeSpy = sandbox.spy(element, '_removeAccount');
-          MockInteractions.pressAndReleaseKeyOn(
-              element.accountChips[0], 8); // Backspace
-          assert.isTrue(focusSpy.called);
-          assert.isTrue(removeSpy.calledOnce);
+    test('arrow key navigation', done => {
+      const input = element.$.entry.$.input;
+      input.text = '';
+      element.accounts = [makeAccount(), makeAccount()];
+      flush(() => {
+        MockInteractions.focus(input.$.input);
+        flushAsynchronousOperations();
+        const chips = element.accountChips;
+        const chipsOneSpy = sandbox.spy(chips[1], 'focus');
+        MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
+        assert.isTrue(chipsOneSpy.called);
+        const chipsZeroSpy = sandbox.spy(chips[0], 'focus');
+        MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
+        assert.isTrue(chipsZeroSpy.called);
+        MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
+        assert.isTrue(chipsZeroSpy.calledOnce);
+        MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
+        assert.isTrue(chipsOneSpy.calledTwice);
+        done();
+      });
+    });
 
-          MockInteractions.pressAndReleaseKeyOn(
-              element.accountChips[1], 46); // Delete
-          assert.isTrue(removeSpy.calledTwice);
-          done();
-        });
+    test('delete', done => {
+      element.accounts = [makeAccount(), makeAccount()];
+      flush(() => {
+        const focusSpy = sandbox.spy(element.accountChips[1], 'focus');
+        const removeSpy = sandbox.spy(element, '_removeAccount');
+        MockInteractions.pressAndReleaseKeyOn(
+            element.accountChips[0], 8); // Backspace
+        assert.isTrue(focusSpy.called);
+        assert.isTrue(removeSpy.calledOnce);
+
+        MockInteractions.pressAndReleaseKeyOn(
+            element.accountChips[1], 46); // Delete
+        assert.isTrue(removeSpy.calledTwice);
+        done();
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
index 6a0769d..dc8eea3 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
@@ -14,94 +14,102 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrAlert extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-alert'; }
-    /**
-     * Fired when the action button is pressed.
-     *
-     * @event action
-     */
+import '../gr-button/gr-button.js';
+import '../../../styles/shared-styles.js';
+import '../../../scripts/rootElement.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-alert_html.js';
 
-    static get properties() {
-      return {
-        text: String,
-        actionText: String,
-        /** @type {?string} */
-        type: String,
-        shown: {
-          type: Boolean,
-          value: true,
-          readOnly: true,
-          reflectToAttribute: true,
-        },
-        toast: {
-          type: Boolean,
-          value: true,
-          reflectToAttribute: true,
-        },
+/** @extends Polymer.Element */
+class GrAlert extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-        _hideActionButton: Boolean,
-        _boundTransitionEndHandler: {
-          type: Function,
-          value() { return this._handleTransitionEnd.bind(this); },
-        },
-        _actionCallback: Function,
-      };
-    }
+  static get is() { return 'gr-alert'; }
+  /**
+   * Fired when the action button is pressed.
+   *
+   * @event action
+   */
 
-    /** @override */
-    attached() {
-      super.attached();
-      this.addEventListener('transitionend', this._boundTransitionEndHandler);
-    }
+  static get properties() {
+    return {
+      text: String,
+      actionText: String,
+      /** @type {?string} */
+      type: String,
+      shown: {
+        type: Boolean,
+        value: true,
+        readOnly: true,
+        reflectToAttribute: true,
+      },
+      toast: {
+        type: Boolean,
+        value: true,
+        reflectToAttribute: true,
+      },
 
-    /** @override */
-    detached() {
-      super.detached();
-      this.removeEventListener('transitionend',
-          this._boundTransitionEndHandler);
-    }
+      _hideActionButton: Boolean,
+      _boundTransitionEndHandler: {
+        type: Function,
+        value() { return this._handleTransitionEnd.bind(this); },
+      },
+      _actionCallback: Function,
+    };
+  }
 
-    show(text, opt_actionText, opt_actionCallback) {
-      this.text = text;
-      this.actionText = opt_actionText;
-      this._hideActionButton = !opt_actionText;
-      this._actionCallback = opt_actionCallback;
-      Gerrit.getRootElement().appendChild(this);
-      this._setShown(true);
-    }
+  /** @override */
+  attached() {
+    super.attached();
+    this.addEventListener('transitionend', this._boundTransitionEndHandler);
+  }
 
-    hide() {
-      this._setShown(false);
-      if (this._hasZeroTransitionDuration()) {
-        Gerrit.getRootElement().removeChild(this);
-      }
-    }
+  /** @override */
+  detached() {
+    super.detached();
+    this.removeEventListener('transitionend',
+        this._boundTransitionEndHandler);
+  }
 
-    _hasZeroTransitionDuration() {
-      const style = window.getComputedStyle(this);
-      // transitionDuration is always given in seconds.
-      const duration = Math.round(parseFloat(style.transitionDuration) * 100);
-      return duration === 0;
-    }
+  show(text, opt_actionText, opt_actionCallback) {
+    this.text = text;
+    this.actionText = opt_actionText;
+    this._hideActionButton = !opt_actionText;
+    this._actionCallback = opt_actionCallback;
+    Gerrit.getRootElement().appendChild(this);
+    this._setShown(true);
+  }
 
-    _handleTransitionEnd(e) {
-      if (this.shown) { return; }
-
+  hide() {
+    this._setShown(false);
+    if (this._hasZeroTransitionDuration()) {
       Gerrit.getRootElement().removeChild(this);
     }
-
-    _handleActionTap(e) {
-      e.preventDefault();
-      if (this._actionCallback) { this._actionCallback(); }
-    }
   }
 
-  customElements.define(GrAlert.is, GrAlert);
-})();
+  _hasZeroTransitionDuration() {
+    const style = window.getComputedStyle(this);
+    // transitionDuration is always given in seconds.
+    const duration = Math.round(parseFloat(style.transitionDuration) * 100);
+    return duration === 0;
+  }
+
+  _handleTransitionEnd(e) {
+    if (this.shown) { return; }
+
+    Gerrit.getRootElement().removeChild(this);
+  }
+
+  _handleActionTap(e) {
+    e.preventDefault();
+    if (this._actionCallback) { this._actionCallback(); }
+  }
+}
+
+customElements.define(GrAlert.is, GrAlert);
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.js
index 0d44164..1190516 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<script src="../../../scripts/rootElement.js"></script>
-
-<dom-module id="gr-alert">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /**
        * ALERT: DO NOT ADD TRANSITION PROPERTIES WITHOUT PROPERLY UNDERSTANDING
@@ -48,7 +42,7 @@
        * (as outside styles always win), .content-wrapper is introduced as a
        * wrapper around main content to have better encapsulation, styles that
        * may be affected by outside should be defined on it.
-       * In this case, `padding:0px` is defined in main.css for all elements
+       * In this case, \`padding:0px\` is defined in main.css for all elements
        * with the universal selector: *.
        */
       .content-wrapper {
@@ -74,13 +68,6 @@
     </style>
     <div class="content-wrapper">
       <span class="text">[[text]]</span>
-      <gr-button
-          link
-          class="action"
-          hidden$="[[_hideActionButton]]"
-          on-click="_handleActionTap">[[actionText]]</gr-button>
+      <gr-button link="" class="action" hidden\$="[[_hideActionButton]]" on-click="_handleActionTap">[[actionText]]</gr-button>
     </div>
-  </template>
-  <script src="gr-alert.js"></script>
-</dom-module>
-
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
index 68d782b..d291fc7 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
@@ -19,43 +19,45 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-alert</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-alert.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-alert.js"></script>
 
-<script>
-  suite('gr-alert tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-alert.js';
+suite('gr-alert tests', () => {
+  let element;
 
-    setup(() => {
-      element = document.createElement('gr-alert');
-    });
-
-    teardown(() => {
-      if (element.parentNode) {
-        element.parentNode.removeChild(element);
-      }
-    });
-
-    test('show/hide', () => {
-      assert.isNull(element.parentNode);
-      element.show();
-      assert.equal(element.parentNode, document.body);
-      element.updateStyles({'--gr-alert-transition-duration': '0ms'});
-      element.hide();
-      assert.isNull(element.parentNode);
-    });
-
-    test('action event', done => {
-      element.show();
-      element._actionCallback = done;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.action'));
-    });
+  setup(() => {
+    element = document.createElement('gr-alert');
   });
+
+  teardown(() => {
+    if (element.parentNode) {
+      element.parentNode.removeChild(element);
+    }
+  });
+
+  test('show/hide', () => {
+    assert.isNull(element.parentNode);
+    element.show();
+    assert.equal(element.parentNode, document.body);
+    element.updateStyles({'--gr-alert-transition-duration': '0ms'});
+    element.hide();
+    assert.isNull(element.parentNode);
+  });
+
+  test('action event', done => {
+    element.show();
+    element._actionCallback = done;
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.action'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
index 5ca95e1..813d45d 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
@@ -14,179 +14,193 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '@polymer/iron-dropdown/iron-dropdown.js';
+import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior.js';
+import '../gr-cursor-manager/gr-cursor-manager.js';
+import '../../../scripts/rootElement.js';
+import '../../../styles/shared-styles.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-autocomplete-dropdown_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @appliesMixin Polymer.IronFitMixin
+ * @extends Polymer.Element
+ */
+class GrAutocompleteDropdown extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+  IronFitBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-autocomplete-dropdown'; }
+  /**
+   * Fired when the dropdown is closed.
+   *
+   * @event dropdown-closed
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @appliesMixin Polymer.IronFitMixin
-   * @extends Polymer.Element
+   * Fired when item is selected.
+   *
+   * @event item-selected
    */
-  class GrAutocompleteDropdown extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-    Polymer.IronFitBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-autocomplete-dropdown'; }
-    /**
-     * Fired when the dropdown is closed.
-     *
-     * @event dropdown-closed
-     */
 
-    /**
-     * Fired when item is selected.
-     *
-     * @event item-selected
-     */
+  static get properties() {
+    return {
+      index: Number,
+      isHidden: {
+        type: Boolean,
+        value: true,
+        reflectToAttribute: true,
+      },
+      verticalOffset: {
+        type: Number,
+        value: null,
+      },
+      horizontalOffset: {
+        type: Number,
+        value: null,
+      },
+      suggestions: {
+        type: Array,
+        value: () => [],
+        observer: '_resetCursorStops',
+      },
+      _suggestionEls: Array,
+    };
+  }
 
-    static get properties() {
-      return {
-        index: Number,
-        isHidden: {
-          type: Boolean,
-          value: true,
-          reflectToAttribute: true,
-        },
-        verticalOffset: {
-          type: Number,
-          value: null,
-        },
-        horizontalOffset: {
-          type: Number,
-          value: null,
-        },
-        suggestions: {
-          type: Array,
-          value: () => [],
-          observer: '_resetCursorStops',
-        },
-        _suggestionEls: Array,
-      };
-    }
+  get keyBindings() {
+    return {
+      up: '_handleUp',
+      down: '_handleDown',
+      enter: '_handleEnter',
+      esc: '_handleEscape',
+      tab: '_handleTab',
+    };
+  }
 
-    get keyBindings() {
-      return {
-        up: '_handleUp',
-        down: '_handleDown',
-        enter: '_handleEnter',
-        esc: '_handleEscape',
-        tab: '_handleTab',
-      };
-    }
+  close() {
+    this.isHidden = true;
+  }
 
-    close() {
-      this.isHidden = true;
-    }
+  open() {
+    this.isHidden = false;
+    this._resetCursorStops();
+    // Refit should run after we call Polymer.flush inside _resetCursorStops
+    this.refit();
+  }
 
-    open() {
-      this.isHidden = false;
-      this._resetCursorStops();
-      // Refit should run after we call Polymer.flush inside _resetCursorStops
-      this.refit();
-    }
+  getCurrentText() {
+    return this.getCursorTarget().dataset.value;
+  }
 
-    getCurrentText() {
-      return this.getCursorTarget().dataset.value;
-    }
-
-    _handleUp(e) {
-      if (!this.isHidden) {
-        e.preventDefault();
-        e.stopPropagation();
-        this.cursorUp();
-      }
-    }
-
-    _handleDown(e) {
-      if (!this.isHidden) {
-        e.preventDefault();
-        e.stopPropagation();
-        this.cursorDown();
-      }
-    }
-
-    cursorDown() {
-      if (!this.isHidden) {
-        this.$.cursor.next();
-      }
-    }
-
-    cursorUp() {
-      if (!this.isHidden) {
-        this.$.cursor.previous();
-      }
-    }
-
-    _handleTab(e) {
+  _handleUp(e) {
+    if (!this.isHidden) {
       e.preventDefault();
       e.stopPropagation();
-      this.fire('item-selected', {
-        trigger: 'tab',
-        selected: this.$.cursor.target,
-      });
-    }
-
-    _handleEnter(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('item-selected', {
-        trigger: 'enter',
-        selected: this.$.cursor.target,
-      });
-    }
-
-    _handleEscape() {
-      this._fireClose();
-      this.close();
-    }
-
-    _handleClickItem(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      let selected = e.target;
-      while (!selected.classList.contains('autocompleteOption')) {
-        if (!selected || selected === this) { return; }
-        selected = selected.parentElement;
-      }
-      this.fire('item-selected', {
-        trigger: 'click',
-        selected,
-      });
-    }
-
-    _fireClose() {
-      this.fire('dropdown-closed');
-    }
-
-    getCursorTarget() {
-      return this.$.cursor.target;
-    }
-
-    _resetCursorStops() {
-      if (this.suggestions.length > 0) {
-        if (!this.isHidden) {
-          Polymer.dom.flush();
-          this._suggestionEls = Array.from(
-              this.$.suggestions.querySelectorAll('li'));
-          this._resetCursorIndex();
-        }
-      } else {
-        this._suggestionEls = [];
-      }
-    }
-
-    _resetCursorIndex() {
-      this.$.cursor.setCursorAtIndex(0);
-    }
-
-    _computeLabelClass(item) {
-      return item.label ? '' : 'hide';
+      this.cursorUp();
     }
   }
 
-  customElements.define(GrAutocompleteDropdown.is, GrAutocompleteDropdown);
-})();
+  _handleDown(e) {
+    if (!this.isHidden) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.cursorDown();
+    }
+  }
+
+  cursorDown() {
+    if (!this.isHidden) {
+      this.$.cursor.next();
+    }
+  }
+
+  cursorUp() {
+    if (!this.isHidden) {
+      this.$.cursor.previous();
+    }
+  }
+
+  _handleTab(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('item-selected', {
+      trigger: 'tab',
+      selected: this.$.cursor.target,
+    });
+  }
+
+  _handleEnter(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('item-selected', {
+      trigger: 'enter',
+      selected: this.$.cursor.target,
+    });
+  }
+
+  _handleEscape() {
+    this._fireClose();
+    this.close();
+  }
+
+  _handleClickItem(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    let selected = e.target;
+    while (!selected.classList.contains('autocompleteOption')) {
+      if (!selected || selected === this) { return; }
+      selected = selected.parentElement;
+    }
+    this.fire('item-selected', {
+      trigger: 'click',
+      selected,
+    });
+  }
+
+  _fireClose() {
+    this.fire('dropdown-closed');
+  }
+
+  getCursorTarget() {
+    return this.$.cursor.target;
+  }
+
+  _resetCursorStops() {
+    if (this.suggestions.length > 0) {
+      if (!this.isHidden) {
+        flush();
+        this._suggestionEls = Array.from(
+            this.$.suggestions.querySelectorAll('li'));
+        this._resetCursorIndex();
+      }
+    } else {
+      this._suggestionEls = [];
+    }
+  }
+
+  _resetCursorIndex() {
+    this.$.cursor.setCursorAtIndex(0);
+  }
+
+  _computeLabelClass(item) {
+    return item.label ? '' : 'hide';
+  }
+}
+
+customElements.define(GrAutocompleteDropdown.is, GrAutocompleteDropdown);
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.js
index 649cd22..711315d 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.js
@@ -1,32 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="/bower_components/iron-fit-behavior/iron-fit-behavior.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-<script src="../../../scripts/rootElement.js"></script>
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-autocomplete-dropdown">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         z-index: 100;
@@ -76,33 +66,15 @@
         display: none;
       }
     </style>
-    <div
-        class="dropdown-content"
-        slot="dropdown-content"
-        id="suggestions"
-        role="listbox">
+    <div class="dropdown-content" slot="dropdown-content" id="suggestions" role="listbox">
       <ul>
         <template is="dom-repeat" items="[[suggestions]]">
-          <li data-index$="[[index]]"
-              data-value$="[[item.dataValue]]"
-              tabindex="-1"
-              aria-label$="[[item.name]]"
-              class="autocompleteOption"
-              role="option"
-              on-click="_handleClickItem">
+          <li data-index\$="[[index]]" data-value\$="[[item.dataValue]]" tabindex="-1" aria-label\$="[[item.name]]" class="autocompleteOption" role="option" on-click="_handleClickItem">
             <span>[[item.text]]</span>
-            <span class$="label [[_computeLabelClass(item)]]">[[item.label]]</span>
+            <span class\$="label [[_computeLabelClass(item)]]">[[item.label]]</span>
           </li>
         </template>
       </ul>
     </div>
-    <gr-cursor-manager
-        id="cursor"
-        index="{{index}}"
-        cursor-target-class="selected"
-        scroll-behavior="never"
-        focus-on-move
-        stops="[[_suggestionEls]]"></gr-cursor-manager>
-  </template>
-  <script src="gr-autocomplete-dropdown.js"></script>
-</dom-module>
+    <gr-cursor-manager id="cursor" index="{{index}}" cursor-target-class="selected" scroll-behavior="never" focus-on-move="" stops="[[_suggestionEls]]"></gr-cursor-manager>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
index 9ea8259..a18fcac 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-autocomplete-dropdown</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-autocomplete-dropdown.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-autocomplete-dropdown.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-autocomplete-dropdown.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,124 +40,125 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-autocomplete-dropdown', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-autocomplete-dropdown.js';
+suite('gr-autocomplete-dropdown', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.open();
-      element.suggestions = [
-        {dataValue: 'test value 1', name: 'test name 1', text: 1, label: 'hi'},
-        {dataValue: 'test value 2', name: 'test name 2', text: 2}];
-      flushAsynchronousOperations();
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.open();
+    element.suggestions = [
+      {dataValue: 'test value 1', name: 'test name 1', text: 1, label: 'hi'},
+      {dataValue: 'test value 2', name: 'test name 2', text: 2}];
+    flushAsynchronousOperations();
+  });
 
-    teardown(() => {
-      sandbox.restore();
-      if (element.isOpen) element.close();
-    });
+  teardown(() => {
+    sandbox.restore();
+    if (element.isOpen) element.close();
+  });
 
-    test('shows labels', () => {
-      const els = element.$.suggestions.querySelectorAll('li');
-      assert.equal(els[0].innerText.trim(), '1\nhi');
-      assert.equal(els[1].innerText.trim(), '2');
-    });
+  test('shows labels', () => {
+    const els = element.$.suggestions.querySelectorAll('li');
+    assert.equal(els[0].innerText.trim(), '1\nhi');
+    assert.equal(els[1].innerText.trim(), '2');
+  });
 
-    test('escape key', done => {
-      const closeSpy = sandbox.spy(element, 'close');
-      MockInteractions.pressAndReleaseKeyOn(element, 27);
-      flushAsynchronousOperations();
-      assert.isTrue(closeSpy.called);
-      done();
-    });
+  test('escape key', done => {
+    const closeSpy = sandbox.spy(element, 'close');
+    MockInteractions.pressAndReleaseKeyOn(element, 27);
+    flushAsynchronousOperations();
+    assert.isTrue(closeSpy.called);
+    done();
+  });
 
-    test('tab key', () => {
-      const handleTabSpy = sandbox.spy(element, '_handleTab');
-      const itemSelectedStub = sandbox.stub();
-      element.addEventListener('item-selected', itemSelectedStub);
-      MockInteractions.pressAndReleaseKeyOn(element, 9);
-      assert.isTrue(handleTabSpy.called);
-      assert.equal(element.$.cursor.index, 0);
-      assert.isTrue(itemSelectedStub.called);
-      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-        trigger: 'tab',
-        selected: element.getCursorTarget(),
-      });
-    });
-
-    test('enter key', () => {
-      const handleEnterSpy = sandbox.spy(element, '_handleEnter');
-      const itemSelectedStub = sandbox.stub();
-      element.addEventListener('item-selected', itemSelectedStub);
-      MockInteractions.pressAndReleaseKeyOn(element, 13);
-      assert.isTrue(handleEnterSpy.called);
-      assert.equal(element.$.cursor.index, 0);
-      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-        trigger: 'enter',
-        selected: element.getCursorTarget(),
-      });
-    });
-
-    test('down key', () => {
-      element.isHidden = true;
-      const nextSpy = sandbox.spy(element.$.cursor, 'next');
-      MockInteractions.pressAndReleaseKeyOn(element, 40);
-      assert.isFalse(nextSpy.called);
-      assert.equal(element.$.cursor.index, 0);
-      element.isHidden = false;
-      MockInteractions.pressAndReleaseKeyOn(element, 40);
-      assert.isTrue(nextSpy.called);
-      assert.equal(element.$.cursor.index, 1);
-    });
-
-    test('up key', () => {
-      element.isHidden = true;
-      const prevSpy = sandbox.spy(element.$.cursor, 'previous');
-      MockInteractions.pressAndReleaseKeyOn(element, 38);
-      assert.isFalse(prevSpy.called);
-      assert.equal(element.$.cursor.index, 0);
-      element.isHidden = false;
-      element.$.cursor.setCursorAtIndex(1);
-      assert.equal(element.$.cursor.index, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 38);
-      assert.isTrue(prevSpy.called);
-      assert.equal(element.$.cursor.index, 0);
-    });
-
-    test('tapping selects item', () => {
-      const itemSelectedStub = sandbox.stub();
-      element.addEventListener('item-selected', itemSelectedStub);
-
-      MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
-      flushAsynchronousOperations();
-      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-        trigger: 'click',
-        selected: element.$.suggestions.querySelectorAll('li')[1],
-      });
-    });
-
-    test('tapping child still selects item', () => {
-      const itemSelectedStub = sandbox.stub();
-      element.addEventListener('item-selected', itemSelectedStub);
-
-      MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[0]
-          .lastElementChild);
-      flushAsynchronousOperations();
-      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-        trigger: 'click',
-        selected: element.$.suggestions.querySelectorAll('li')[0],
-      });
-    });
-
-    test('updated suggestions resets cursor stops', () => {
-      const resetStopsSpy = sandbox.spy(element, '_resetCursorStops');
-      element.suggestions = [];
-      assert.isTrue(resetStopsSpy.called);
+  test('tab key', () => {
+    const handleTabSpy = sandbox.spy(element, '_handleTab');
+    const itemSelectedStub = sandbox.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+    MockInteractions.pressAndReleaseKeyOn(element, 9);
+    assert.isTrue(handleTabSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+    assert.isTrue(itemSelectedStub.called);
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'tab',
+      selected: element.getCursorTarget(),
     });
   });
 
+  test('enter key', () => {
+    const handleEnterSpy = sandbox.spy(element, '_handleEnter');
+    const itemSelectedStub = sandbox.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+    MockInteractions.pressAndReleaseKeyOn(element, 13);
+    assert.isTrue(handleEnterSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'enter',
+      selected: element.getCursorTarget(),
+    });
+  });
+
+  test('down key', () => {
+    element.isHidden = true;
+    const nextSpy = sandbox.spy(element.$.cursor, 'next');
+    MockInteractions.pressAndReleaseKeyOn(element, 40);
+    assert.isFalse(nextSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+    element.isHidden = false;
+    MockInteractions.pressAndReleaseKeyOn(element, 40);
+    assert.isTrue(nextSpy.called);
+    assert.equal(element.$.cursor.index, 1);
+  });
+
+  test('up key', () => {
+    element.isHidden = true;
+    const prevSpy = sandbox.spy(element.$.cursor, 'previous');
+    MockInteractions.pressAndReleaseKeyOn(element, 38);
+    assert.isFalse(prevSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+    element.isHidden = false;
+    element.$.cursor.setCursorAtIndex(1);
+    assert.equal(element.$.cursor.index, 1);
+    MockInteractions.pressAndReleaseKeyOn(element, 38);
+    assert.isTrue(prevSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+  });
+
+  test('tapping selects item', () => {
+    const itemSelectedStub = sandbox.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+
+    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
+    flushAsynchronousOperations();
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'click',
+      selected: element.$.suggestions.querySelectorAll('li')[1],
+    });
+  });
+
+  test('tapping child still selects item', () => {
+    const itemSelectedStub = sandbox.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+
+    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[0]
+        .lastElementChild);
+    flushAsynchronousOperations();
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'click',
+      selected: element.$.suggestions.querySelectorAll('li')[0],
+    });
+  });
+
+  test('updated suggestions resets cursor stops', () => {
+    const resetStopsSpy = sandbox.spy(element, '_resetCursorStops');
+    element.suggestions = [];
+    assert.isTrue(resetStopsSpy.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index 60985c1..22b62db 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -14,449 +14,463 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
-  const DEBOUNCE_WAIT_MS = 200;
+import '@polymer/paper-input/paper-input.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown.js';
+import '../gr-cursor-manager/gr-cursor-manager.js';
+import '../gr-icons/gr-icons.js';
+import '../../../styles/shared-styles.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-autocomplete_html.js';
+
+const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
+const DEBOUNCE_WAIT_MS = 200;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @extends Polymer.Element
+ */
+class GrAutocomplete extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-autocomplete'; }
+  /**
+   * Fired when a value is chosen.
+   *
+   * @event commit
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @extends Polymer.Element
+   * Fired when the user cancels.
+   *
+   * @event cancel
    */
-  class GrAutocomplete extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-autocomplete'; }
-    /**
-     * Fired when a value is chosen.
-     *
-     * @event commit
-     */
 
-    /**
-     * Fired when the user cancels.
-     *
-     * @event cancel
-     */
+  /**
+   * Fired on keydown to allow for custom hooks into autocomplete textbox
+   * behavior.
+   *
+   * @event input-keydown
+   */
 
-    /**
-     * Fired on keydown to allow for custom hooks into autocomplete textbox
-     * behavior.
-     *
-     * @event input-keydown
-     */
+  static get properties() {
+    return {
 
-    static get properties() {
-      return {
-
-        /**
-         * Query for requesting autocomplete suggestions. The function should
-         * accept the input as a string parameter and return a promise. The
-         * promise yields an array of suggestion objects with "name", "label",
-         * "value" properties. The "name" property will be displayed in the
-         * suggestion entry. The "label" property will, when specified, appear
-         * next to the "name" as label text. The "value" property will be emitted
-         * if that suggestion is selected.
-         *
-         * @type {function(string): Promise<?>}
-         */
-        query: {
-          type: Function,
-          value() {
-            return function() {
-              return Promise.resolve([]);
-            };
-          },
+      /**
+       * Query for requesting autocomplete suggestions. The function should
+       * accept the input as a string parameter and return a promise. The
+       * promise yields an array of suggestion objects with "name", "label",
+       * "value" properties. The "name" property will be displayed in the
+       * suggestion entry. The "label" property will, when specified, appear
+       * next to the "name" as label text. The "value" property will be emitted
+       * if that suggestion is selected.
+       *
+       * @type {function(string): Promise<?>}
+       */
+      query: {
+        type: Function,
+        value() {
+          return function() {
+            return Promise.resolve([]);
+          };
         },
+      },
 
-        /**
-         * The number of characters that must be typed before suggestions are
-         * made. If threshold is zero, default suggestions are enabled.
-         */
-        threshold: {
-          type: Number,
-          value: 1,
-        },
+      /**
+       * The number of characters that must be typed before suggestions are
+       * made. If threshold is zero, default suggestions are enabled.
+       */
+      threshold: {
+        type: Number,
+        value: 1,
+      },
 
-        allowNonSuggestedValues: Boolean,
-        borderless: Boolean,
-        disabled: Boolean,
-        showSearchIcon: {
-          type: Boolean,
-          value: false,
-        },
-        /**
-         * Vertical offset needed for an element with 20px line-height, 4px
-         * padding and 1px border (30px height total). Plus 1px spacing between
-         * input and dropdown. Inputs with different line-height or padding will
-         * need to tweak vertical offset.
-         */
-        verticalOffset: {
-          type: Number,
-          value: 31,
-        },
+      allowNonSuggestedValues: Boolean,
+      borderless: Boolean,
+      disabled: Boolean,
+      showSearchIcon: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * Vertical offset needed for an element with 20px line-height, 4px
+       * padding and 1px border (30px height total). Plus 1px spacing between
+       * input and dropdown. Inputs with different line-height or padding will
+       * need to tweak vertical offset.
+       */
+      verticalOffset: {
+        type: Number,
+        value: 31,
+      },
 
-        text: {
-          type: String,
-          value: '',
-          notify: true,
-        },
+      text: {
+        type: String,
+        value: '',
+        notify: true,
+      },
 
-        placeholder: String,
+      placeholder: String,
 
-        clearOnCommit: {
-          type: Boolean,
-          value: false,
-        },
+      clearOnCommit: {
+        type: Boolean,
+        value: false,
+      },
 
-        /**
-         * When true, tab key autocompletes but does not fire the commit event.
-         * When false, tab key not caught, and focus is removed from the element.
-         * See Issue 4556, Issue 6645.
-         */
-        tabComplete: {
-          type: Boolean,
-          value: false,
-        },
+      /**
+       * When true, tab key autocompletes but does not fire the commit event.
+       * When false, tab key not caught, and focus is removed from the element.
+       * See Issue 4556, Issue 6645.
+       */
+      tabComplete: {
+        type: Boolean,
+        value: false,
+      },
 
-        value: {
-          type: String,
-          notify: true,
-        },
+      value: {
+        type: String,
+        notify: true,
+      },
 
-        /**
-         * Multi mode appends autocompleted entries to the value.
-         * If false, autocompleted entries replace value.
-         */
-        multi: {
-          type: Boolean,
-          value: false,
-        },
+      /**
+       * Multi mode appends autocompleted entries to the value.
+       * If false, autocompleted entries replace value.
+       */
+      multi: {
+        type: Boolean,
+        value: false,
+      },
 
-        /**
-         * When true and uncommitted text is left in the autocomplete input after
-         * blurring, the text will appear red.
-         */
-        warnUncommitted: {
-          type: Boolean,
-          value: false,
-        },
+      /**
+       * When true and uncommitted text is left in the autocomplete input after
+       * blurring, the text will appear red.
+       */
+      warnUncommitted: {
+        type: Boolean,
+        value: false,
+      },
 
-        /**
-         * When true, querying for suggestions is not debounced w/r/t keypresses
-         */
-        noDebounce: {
-          type: Boolean,
-          value: false,
-        },
+      /**
+       * When true, querying for suggestions is not debounced w/r/t keypresses
+       */
+      noDebounce: {
+        type: Boolean,
+        value: false,
+      },
 
-        /** @type {?} */
-        _suggestions: {
-          type: Array,
-          value() { return []; },
-        },
+      /** @type {?} */
+      _suggestions: {
+        type: Array,
+        value() { return []; },
+      },
 
-        _suggestionEls: {
-          type: Array,
-          value() { return []; },
-        },
+      _suggestionEls: {
+        type: Array,
+        value() { return []; },
+      },
 
-        _index: Number,
-        _disableSuggestions: {
-          type: Boolean,
-          value: false,
-        },
-        _focused: {
-          type: Boolean,
-          value: false,
-        },
+      _index: Number,
+      _disableSuggestions: {
+        type: Boolean,
+        value: false,
+      },
+      _focused: {
+        type: Boolean,
+        value: false,
+      },
 
-        /** The DOM element of the selected suggestion. */
-        _selected: Object,
-      };
+      /** The DOM element of the selected suggestion. */
+      _selected: Object,
+    };
+  }
+
+  static get observers() {
+    return [
+      '_maybeOpenDropdown(_suggestions, _focused)',
+      '_updateSuggestions(text, threshold, noDebounce)',
+    ];
+  }
+
+  get _nativeInput() {
+    // In Polymer 2 inputElement isn't nativeInput anymore
+    return this.$.input.$.nativeInput || this.$.input.inputElement;
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.listen(document.body, 'click', '_handleBodyClick');
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(document.body, 'click', '_handleBodyClick');
+    this.cancelDebouncer('update-suggestions');
+  }
+
+  get focusStart() {
+    return this.$.input;
+  }
+
+  focus() {
+    this._nativeInput.focus();
+  }
+
+  selectAll() {
+    const nativeInputElement = this._nativeInput;
+    if (!this.$.input.value) { return; }
+    nativeInputElement.setSelectionRange(0, this.$.input.value.length);
+  }
+
+  clear() {
+    this.text = '';
+  }
+
+  _handleItemSelect(e) {
+    // Let _handleKeydown deal with keyboard interaction.
+    if (e.detail.trigger !== 'click') { return; }
+    this._selected = e.detail.selected;
+    this._commit();
+  }
+
+  get _inputElement() {
+    // Polymer2: this.$ can be undefined when this is first evaluated.
+    return this.$ && this.$.input;
+  }
+
+  /**
+   * Set the text of the input without triggering the suggestion dropdown.
+   *
+   * @param {string} text The new text for the input.
+   */
+  setText(text) {
+    this._disableSuggestions = true;
+    this.text = text;
+    this._disableSuggestions = false;
+  }
+
+  _onInputFocus() {
+    this._focused = true;
+    this._updateSuggestions(this.text, this.threshold, this.noDebounce);
+    this.$.input.classList.remove('warnUncommitted');
+    // Needed so that --paper-input-container-input updated style is applied.
+    this.updateStyles();
+  }
+
+  _onInputBlur() {
+    this.$.input.classList.toggle('warnUncommitted',
+        this.warnUncommitted && this.text.length && !this._focused);
+    // Needed so that --paper-input-container-input updated style is applied.
+    this.updateStyles();
+  }
+
+  _updateSuggestions(text, threshold, noDebounce) {
+    // Polymer 2: check for undefined
+    if ([text, threshold, noDebounce].some(arg => arg === undefined)) {
+      return;
     }
 
-    static get observers() {
-      return [
-        '_maybeOpenDropdown(_suggestions, _focused)',
-        '_updateSuggestions(text, threshold, noDebounce)',
-      ];
+    // Reset _suggestions for every update
+    // This will also prevent from carrying over suggestions:
+    // @see Issue 12039
+    this._suggestions = [];
+
+    // TODO(taoalpha): Also skip if text has not changed
+
+    if (this._disableSuggestions) { return; }
+    if (text.length < threshold) {
+      this.value = '';
+      return;
     }
 
-    get _nativeInput() {
-      // In Polymer 2 inputElement isn't nativeInput anymore
-      return this.$.input.$.nativeInput || this.$.input.inputElement;
+    if (!this._focused) {
+      return;
     }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this.listen(document.body, 'click', '_handleBodyClick');
-    }
-
-    /** @override */
-    detached() {
-      super.detached();
-      this.unlisten(document.body, 'click', '_handleBodyClick');
-      this.cancelDebouncer('update-suggestions');
-    }
-
-    get focusStart() {
-      return this.$.input;
-    }
-
-    focus() {
-      this._nativeInput.focus();
-    }
-
-    selectAll() {
-      const nativeInputElement = this._nativeInput;
-      if (!this.$.input.value) { return; }
-      nativeInputElement.setSelectionRange(0, this.$.input.value.length);
-    }
-
-    clear() {
-      this.text = '';
-    }
-
-    _handleItemSelect(e) {
-      // Let _handleKeydown deal with keyboard interaction.
-      if (e.detail.trigger !== 'click') { return; }
-      this._selected = e.detail.selected;
-      this._commit();
-    }
-
-    get _inputElement() {
-      // Polymer2: this.$ can be undefined when this is first evaluated.
-      return this.$ && this.$.input;
-    }
-
-    /**
-     * Set the text of the input without triggering the suggestion dropdown.
-     *
-     * @param {string} text The new text for the input.
-     */
-    setText(text) {
-      this._disableSuggestions = true;
-      this.text = text;
-      this._disableSuggestions = false;
-    }
-
-    _onInputFocus() {
-      this._focused = true;
-      this._updateSuggestions(this.text, this.threshold, this.noDebounce);
-      this.$.input.classList.remove('warnUncommitted');
-      // Needed so that --paper-input-container-input updated style is applied.
-      this.updateStyles();
-    }
-
-    _onInputBlur() {
-      this.$.input.classList.toggle('warnUncommitted',
-          this.warnUncommitted && this.text.length && !this._focused);
-      // Needed so that --paper-input-container-input updated style is applied.
-      this.updateStyles();
-    }
-
-    _updateSuggestions(text, threshold, noDebounce) {
-      // Polymer 2: check for undefined
-      if ([text, threshold, noDebounce].some(arg => arg === undefined)) {
-        return;
-      }
-
-      // Reset _suggestions for every update
-      // This will also prevent from carrying over suggestions:
-      // @see Issue 12039
-      this._suggestions = [];
-
-      // TODO(taoalpha): Also skip if text has not changed
-
-      if (this._disableSuggestions) { return; }
-      if (text.length < threshold) {
-        this.value = '';
-        return;
-      }
-
-      if (!this._focused) {
-        return;
-      }
-
-      const update = () => {
-        this.query(text).then(suggestions => {
-          if (text !== this.text) {
-            // Late response.
-            return;
-          }
-          for (const suggestion of suggestions) {
-            suggestion.text = suggestion.name;
-          }
-          this._suggestions = suggestions;
-          Polymer.dom.flush();
-          if (this._index === -1) {
-            this.value = '';
-          }
-        });
-      };
-
-      if (noDebounce) {
-        update();
-      } else {
-        this.debounce('update-suggestions', update, DEBOUNCE_WAIT_MS);
-      }
-    }
-
-    _maybeOpenDropdown(suggestions, focused) {
-      if (suggestions.length > 0 && focused) {
-        return this.$.suggestions.open();
-      }
-      return this.$.suggestions.close();
-    }
-
-    _computeClass(borderless) {
-      return borderless ? 'borderless' : '';
-    }
-
-    /**
-     * _handleKeydown used for key handling in the this.$.input AND all child
-     * autocomplete options.
-     */
-    _handleKeydown(e) {
-      this._focused = true;
-      switch (e.keyCode) {
-        case 38: // Up
-          e.preventDefault();
-          this.$.suggestions.cursorUp();
-          break;
-        case 40: // Down
-          e.preventDefault();
-          this.$.suggestions.cursorDown();
-          break;
-        case 27: // Escape
-          e.preventDefault();
-          this._cancel();
-          break;
-        case 9: // Tab
-          if (this._suggestions.length > 0 && this.tabComplete) {
-            e.preventDefault();
-            this._handleInputCommit(true);
-            this.focus();
-          } else {
-            this._focused = false;
-          }
-          break;
-        case 13: // Enter
-          if (this.modifierPressed(e)) { break; }
-          e.preventDefault();
-          this._handleInputCommit();
-          break;
-        default:
-          // For any normal keypress, return focus to the input to allow for
-          // unbroken user input.
-          this.focus();
-
-          // Since this has been a normal keypress, the suggestions will have
-          // been based on a previous input. Clear them. This prevents an
-          // outdated suggestion from being used if the input keystroke is
-          // immediately followed by a commit keystroke. @see Issue 8655
-          this._suggestions = [];
-      }
-      this.fire('input-keydown', {keyCode: e.keyCode, input: this.$.input});
-    }
-
-    _cancel() {
-      if (this._suggestions.length) {
-        this.set('_suggestions', []);
-      } else {
-        this.fire('cancel');
-      }
-    }
-
-    /**
-     * @param {boolean=} opt_tabComplete
-     */
-    _handleInputCommit(opt_tabComplete) {
-      // Nothing to do if the dropdown is not open.
-      if (!this.allowNonSuggestedValues &&
-          this.$.suggestions.isHidden) { return; }
-
-      this._selected = this.$.suggestions.getCursorTarget();
-      this._commit(opt_tabComplete);
-    }
-
-    _updateValue(suggestion, suggestions) {
-      if (!suggestion) { return; }
-      const completed = suggestions[suggestion.dataset.index].value;
-      if (this.multi) {
-        // Append the completed text to the end of the string.
-        // Allow spaces within quoted terms.
-        const tokens = this.text.match(TOKENIZE_REGEX);
-        tokens[tokens.length - 1] = completed;
-        this.value = tokens.join(' ');
-      } else {
-        this.value = completed;
-      }
-    }
-
-    _handleBodyClick(e) {
-      const eventPath = Polymer.dom(e).path;
-      for (let i = 0; i < eventPath.length; i++) {
-        if (eventPath[i] === this) {
+    const update = () => {
+      this.query(text).then(suggestions => {
+        if (text !== this.text) {
+          // Late response.
           return;
         }
-      }
-      this._focused = false;
-    }
-
-    _handleSuggestionTap(e) {
-      e.stopPropagation();
-      this.$.cursor.setCursor(e.target);
-      this._commit();
-    }
-
-    /**
-     * Commits the suggestion, optionally firing the commit event.
-     *
-     * @param {boolean=} opt_silent Allows for silent committing of an
-     *     autocomplete suggestion in order to handle cases like tab-to-complete
-     *     without firing the commit event.
-     */
-    _commit(opt_silent) {
-      // Allow values that are not in suggestion list iff suggestions are empty.
-      if (this._suggestions.length > 0) {
-        this._updateValue(this._selected, this._suggestions);
-      } else {
-        this.value = this.text || '';
-      }
-
-      const value = this.value;
-
-      // Value and text are mirrors of each other in multi mode.
-      if (this.multi) {
-        this.setText(this.value);
-      } else {
-        if (!this.clearOnCommit && this._selected) {
-          this.setText(this._suggestions[this._selected.dataset.index].name);
-        } else {
-          this.clear();
+        for (const suggestion of suggestions) {
+          suggestion.text = suggestion.name;
         }
-      }
+        this._suggestions = suggestions;
+        flush();
+        if (this._index === -1) {
+          this.value = '';
+        }
+      });
+    };
 
-      this._suggestions = [];
-      if (!opt_silent) {
-        this.fire('commit', {value});
-      }
-
-      this._textChangedSinceCommit = false;
-    }
-
-    _computeShowSearchIconClass(showSearchIcon) {
-      return showSearchIcon ? 'showSearchIcon' : '';
+    if (noDebounce) {
+      update();
+    } else {
+      this.debounce('update-suggestions', update, DEBOUNCE_WAIT_MS);
     }
   }
 
-  customElements.define(GrAutocomplete.is, GrAutocomplete);
-})();
+  _maybeOpenDropdown(suggestions, focused) {
+    if (suggestions.length > 0 && focused) {
+      return this.$.suggestions.open();
+    }
+    return this.$.suggestions.close();
+  }
+
+  _computeClass(borderless) {
+    return borderless ? 'borderless' : '';
+  }
+
+  /**
+   * _handleKeydown used for key handling in the this.$.input AND all child
+   * autocomplete options.
+   */
+  _handleKeydown(e) {
+    this._focused = true;
+    switch (e.keyCode) {
+      case 38: // Up
+        e.preventDefault();
+        this.$.suggestions.cursorUp();
+        break;
+      case 40: // Down
+        e.preventDefault();
+        this.$.suggestions.cursorDown();
+        break;
+      case 27: // Escape
+        e.preventDefault();
+        this._cancel();
+        break;
+      case 9: // Tab
+        if (this._suggestions.length > 0 && this.tabComplete) {
+          e.preventDefault();
+          this._handleInputCommit(true);
+          this.focus();
+        } else {
+          this._focused = false;
+        }
+        break;
+      case 13: // Enter
+        if (this.modifierPressed(e)) { break; }
+        e.preventDefault();
+        this._handleInputCommit();
+        break;
+      default:
+        // For any normal keypress, return focus to the input to allow for
+        // unbroken user input.
+        this.focus();
+
+        // Since this has been a normal keypress, the suggestions will have
+        // been based on a previous input. Clear them. This prevents an
+        // outdated suggestion from being used if the input keystroke is
+        // immediately followed by a commit keystroke. @see Issue 8655
+        this._suggestions = [];
+    }
+    this.fire('input-keydown', {keyCode: e.keyCode, input: this.$.input});
+  }
+
+  _cancel() {
+    if (this._suggestions.length) {
+      this.set('_suggestions', []);
+    } else {
+      this.fire('cancel');
+    }
+  }
+
+  /**
+   * @param {boolean=} opt_tabComplete
+   */
+  _handleInputCommit(opt_tabComplete) {
+    // Nothing to do if the dropdown is not open.
+    if (!this.allowNonSuggestedValues &&
+        this.$.suggestions.isHidden) { return; }
+
+    this._selected = this.$.suggestions.getCursorTarget();
+    this._commit(opt_tabComplete);
+  }
+
+  _updateValue(suggestion, suggestions) {
+    if (!suggestion) { return; }
+    const completed = suggestions[suggestion.dataset.index].value;
+    if (this.multi) {
+      // Append the completed text to the end of the string.
+      // Allow spaces within quoted terms.
+      const tokens = this.text.match(TOKENIZE_REGEX);
+      tokens[tokens.length - 1] = completed;
+      this.value = tokens.join(' ');
+    } else {
+      this.value = completed;
+    }
+  }
+
+  _handleBodyClick(e) {
+    const eventPath = dom(e).path;
+    for (let i = 0; i < eventPath.length; i++) {
+      if (eventPath[i] === this) {
+        return;
+      }
+    }
+    this._focused = false;
+  }
+
+  _handleSuggestionTap(e) {
+    e.stopPropagation();
+    this.$.cursor.setCursor(e.target);
+    this._commit();
+  }
+
+  /**
+   * Commits the suggestion, optionally firing the commit event.
+   *
+   * @param {boolean=} opt_silent Allows for silent committing of an
+   *     autocomplete suggestion in order to handle cases like tab-to-complete
+   *     without firing the commit event.
+   */
+  _commit(opt_silent) {
+    // Allow values that are not in suggestion list iff suggestions are empty.
+    if (this._suggestions.length > 0) {
+      this._updateValue(this._selected, this._suggestions);
+    } else {
+      this.value = this.text || '';
+    }
+
+    const value = this.value;
+
+    // Value and text are mirrors of each other in multi mode.
+    if (this.multi) {
+      this.setText(this.value);
+    } else {
+      if (!this.clearOnCommit && this._selected) {
+        this.setText(this._suggestions[this._selected.dataset.index].name);
+      } else {
+        this.clear();
+      }
+    }
+
+    this._suggestions = [];
+    if (!opt_silent) {
+      this.fire('commit', {value});
+    }
+
+    this._textChangedSinceCommit = false;
+  }
+
+  _computeShowSearchIconClass(showSearchIcon) {
+    return showSearchIcon ? 'showSearchIcon' : '';
+  }
+}
+
+customElements.define(GrAutocomplete.is, GrAutocomplete);
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js
index 381ef0b..11726d9 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/paper-input/paper-input.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-autocomplete">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .searchIcon {
         display: none;
@@ -82,38 +74,14 @@
         }
       }
     </style>
-    <paper-input
-        no-label-float
-        id="input"
-        class$="[[_computeClass(borderless)]]"
-        disabled$="[[disabled]]"
-        value="{{text}}"
-        placeholder="[[placeholder]]"
-        on-keydown="_handleKeydown"
-        on-focus="_onInputFocus"
-        on-blur="_onInputBlur"
-        autocomplete="off">
+    <paper-input no-label-float="" id="input" class\$="[[_computeClass(borderless)]]" disabled\$="[[disabled]]" value="{{text}}" placeholder="[[placeholder]]" on-keydown="_handleKeydown" on-focus="_onInputFocus" on-blur="_onInputBlur" autocomplete="off">
 
       <!-- prefix as attribute is required to for polymer 1 -->
-      <div slot="prefix" prefix>
-        <iron-icon
-          icon="gr-icons:search"
-          class$="searchIcon [[_computeShowSearchIconClass(showSearchIcon)]]">
+      <div slot="prefix" prefix="">
+        <iron-icon icon="gr-icons:search" class\$="searchIcon [[_computeShowSearchIconClass(showSearchIcon)]]">
         </iron-icon>
       </div>
     </paper-input>
-    <gr-autocomplete-dropdown
-        vertical-align="top"
-        vertical-offset="[[verticalOffset]]"
-        horizontal-align="left"
-        id="suggestions"
-        on-item-selected="_handleItemSelect"
-        on-keydown="_handleKeydown"
-        suggestions="[[_suggestions]]"
-        role="listbox"
-        index="[[_index]]"
-        position-target="[[_inputElement]]">
+    <gr-autocomplete-dropdown vertical-align="top" vertical-offset="[[verticalOffset]]" horizontal-align="left" id="suggestions" on-item-selected="_handleItemSelect" on-keydown="_handleKeydown" suggestions="[[_suggestions]]" role="listbox" index="[[_index]]" position-target="[[_inputElement]]">
     </gr-autocomplete-dropdown>
-  </template>
-  <script src="gr-autocomplete.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index 295572f..8a8f2f5 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-autocomplete.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-autocomplete.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-autocomplete.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,580 +40,583 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-autocomplete tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    const focusOnInput = element => {
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-autocomplete.js';
+import {dom, flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-autocomplete tests', () => {
+  let element;
+  let sandbox;
+  const focusOnInput = element => {
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+        'enter');
+  };
+
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('renders', () => {
+    let promise;
+    const queryStub = sandbox.spy(input => promise = Promise.resolve([
+      {name: input + ' 0', value: 0},
+      {name: input + ' 1', value: 1},
+      {name: input + ' 2', value: 2},
+      {name: input + ' 3', value: 3},
+      {name: input + ' 4', value: 4},
+    ]));
+    element.query = queryStub;
+    assert.isTrue(element.$.suggestions.isHidden);
+    assert.equal(element.$.suggestions.$.cursor.index, -1);
+
+    focusOnInput(element);
+    element.text = 'blah';
+
+    assert.isTrue(queryStub.called);
+    element._focused = true;
+
+    return promise.then(() => {
+      assert.isFalse(element.$.suggestions.isHidden);
+      const suggestions =
+          dom(element.$.suggestions.root).querySelectorAll('li');
+      assert.equal(suggestions.length, 5);
+
+      for (let i = 0; i < 5; i++) {
+        assert.equal(suggestions[i].innerText.trim(), 'blah ' + i);
+      }
+
+      assert.notEqual(element.$.suggestions.$.cursor.index, -1);
+    });
+  });
+
+  test('selectAll', done => {
+    flush(() => {
+      const nativeInput = element._nativeInput;
+      const selectionStub = sandbox.stub(nativeInput, 'setSelectionRange');
+
+      element.selectAll();
+      assert.isFalse(selectionStub.called);
+
+      element.$.input.value = 'test';
+      element.selectAll();
+      assert.isTrue(selectionStub.called);
+      done();
+    });
+  });
+
+  test('esc key behavior', done => {
+    let promise;
+    const queryStub = sandbox.spy(() => promise = Promise.resolve([
+      {name: 'blah', value: 123},
+    ]));
+    element.query = queryStub;
+
+    assert.isTrue(element.$.suggestions.isHidden);
+
+    element._focused = true;
+    element.text = 'blah';
+
+    promise.then(() => {
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      const cancelHandler = sandbox.spy();
+      element.addEventListener('cancel', cancelHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
+      assert.isFalse(cancelHandler.called);
+      assert.isTrue(element.$.suggestions.isHidden);
+      assert.equal(element._suggestions.length, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
+      assert.isTrue(cancelHandler.called);
+      done();
+    });
+  });
+
+  test('emits commit and handles cursor movement', done => {
+    let promise;
+    const queryStub = sandbox.spy(input => promise = Promise.resolve([
+      {name: input + ' 0', value: 0},
+      {name: input + ' 1', value: 1},
+      {name: input + ' 2', value: 2},
+      {name: input + ' 3', value: 3},
+      {name: input + ' 4', value: 4},
+    ]));
+    element.query = queryStub;
+
+    assert.isTrue(element.$.suggestions.isHidden);
+    assert.equal(element.$.suggestions.$.cursor.index, -1);
+    element._focused = true;
+    element.text = 'blah';
+
+    promise.then(() => {
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      const commitHandler = sandbox.spy();
+      element.addEventListener('commit', commitHandler);
+
+      assert.equal(element.$.suggestions.$.cursor.index, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
+          'down');
+
+      assert.equal(element.$.suggestions.$.cursor.index, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
+          'down');
+
+      assert.equal(element.$.suggestions.$.cursor.index, 2);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
+
+      assert.equal(element.$.suggestions.$.cursor.index, 1);
+
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
           'enter');
-    };
+
+      assert.equal(element.value, 1);
+      assert.isTrue(commitHandler.called);
+      assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
+      assert.isTrue(element.$.suggestions.isHidden);
+      assert.isTrue(element._focused);
+      done();
+    });
+  });
+
+  test('clear-on-commit behavior (off)', done => {
+    let promise;
+    const queryStub = sandbox.spy(() => {
+      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'blah';
+
+    promise.then(() => {
+      const commitHandler = sandbox.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'suggestion');
+      done();
+    });
+  });
+
+  test('clear-on-commit behavior (on)', done => {
+    let promise;
+    const queryStub = sandbox.spy(() => {
+      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'blah';
+    element.clearOnCommit = true;
+
+    promise.then(() => {
+      const commitHandler = sandbox.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, '');
+      done();
+    });
+  });
+
+  test('threshold guards the query', () => {
+    const queryStub = sandbox.spy(() => Promise.resolve([]));
+    element.query = queryStub;
+    element.threshold = 2;
+    focusOnInput(element);
+    element.text = 'a';
+    assert.isFalse(queryStub.called);
+    element.text = 'ab';
+    assert.isTrue(queryStub.called);
+  });
+
+  test('noDebounce=false debounces the query', () => {
+    const queryStub = sandbox.spy(() => Promise.resolve([]));
+    let callback;
+    const debounceStub = sandbox.stub(element, 'debounce',
+        (name, cb) => { callback = cb; });
+    element.query = queryStub;
+    element.noDebounce = false;
+    focusOnInput(element);
+    element.text = 'a';
+    assert.isFalse(queryStub.called);
+    assert.isTrue(debounceStub.called);
+    assert.equal(debounceStub.lastCall.args[2], 200);
+    assert.isFunction(callback);
+    callback();
+    assert.isTrue(queryStub.called);
+  });
+
+  test('_computeClass respects border property', () => {
+    assert.equal(element._computeClass(), '');
+    assert.equal(element._computeClass(false), '');
+    assert.equal(element._computeClass(true), 'borderless');
+  });
+
+  test('undefined or empty text results in no suggestions', () => {
+    element._updateSuggestions(undefined, 0, null);
+    assert.equal(element._suggestions.length, 0);
+  });
+
+  test('when focused', done => {
+    let promise;
+    const queryStub = sandbox.stub()
+        .returns(promise = Promise.resolve([
+          {name: 'suggestion', value: 0},
+        ]));
+    element.query = queryStub;
+    element.suggestOnlyWhenFocus = true;
+    focusOnInput(element);
+    element.text = 'bla';
+    assert.equal(element._focused, true);
+    flushAsynchronousOperations();
+    promise.then(() => {
+      assert.equal(element._suggestions.length, 1);
+      assert.equal(queryStub.notCalled, false);
+      done();
+    });
+  });
+
+  test('when not focused', done => {
+    let promise;
+    const queryStub = sandbox.stub()
+        .returns(promise = Promise.resolve([
+          {name: 'suggestion', value: 0},
+        ]));
+    element.query = queryStub;
+    element.suggestOnlyWhenFocus = true;
+    element.text = 'bla';
+    assert.equal(element._focused, false);
+    flushAsynchronousOperations();
+    promise.then(() => {
+      assert.equal(element._suggestions.length, 0);
+      done();
+    });
+  });
+
+  test('suggestions should not carry over', done => {
+    let promise;
+    const queryStub = sandbox.stub()
+        .returns(promise = Promise.resolve([
+          {name: 'suggestion', value: 0},
+        ]));
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'bla';
+    flushAsynchronousOperations();
+    promise.then(() => {
+      assert.equal(element._suggestions.length, 1);
+      element._updateSuggestions('', 0, false);
+      assert.equal(element._suggestions.length, 0);
+      done();
+    });
+  });
+
+  test('multi completes only the last part of the query', done => {
+    let promise;
+    const queryStub = sandbox.stub()
+        .returns(promise = Promise.resolve([
+          {name: 'suggestion', value: 0},
+        ]));
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'blah blah';
+    element.multi = true;
+
+    promise.then(() => {
+      const commitHandler = sandbox.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'blah 0');
+      done();
+    });
+  });
+
+  test('tabComplete flag functions', () => {
+    // commitHandler checks for the commit event, whereas commitSpy checks for
+    // the _commit function of the element.
+    const commitHandler = sandbox.spy();
+    element.addEventListener('commit', commitHandler);
+    const commitSpy = sandbox.spy(element, '_commit');
+    element._focused = true;
+
+    element._suggestions = ['tunnel snakes rule!'];
+    element.tabComplete = false;
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+    assert.isFalse(commitHandler.called);
+    assert.isFalse(commitSpy.called);
+    assert.isFalse(element._focused);
+
+    element.tabComplete = true;
+    element._focused = true;
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+    assert.isFalse(commitHandler.called);
+    assert.isTrue(commitSpy.called);
+    assert.isTrue(element._focused);
+  });
+
+  test('_focused flag properly triggered', done => {
+    flush(() => {
+      assert.isFalse(element._focused);
+      const input = element.shadowRoot
+          .querySelector('paper-input').inputElement;
+      MockInteractions.focus(input);
+      assert.isTrue(element._focused);
+      done();
+    });
+  });
+
+  test('search icon shows with showSearchIcon property', done => {
+    flush(() => {
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('iron-icon')).display,
+      'none');
+      element.showSearchIcon = true;
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('iron-icon')).display,
+      'none');
+      done();
+    });
+  });
+
+  test('vertical offset overridden by param if it exists', () => {
+    assert.equal(element.$.suggestions.verticalOffset, 31);
+    element.verticalOffset = 30;
+    assert.equal(element.$.suggestions.verticalOffset, 30);
+  });
+
+  test('_focused flag shows/hides the suggestions', () => {
+    const openStub = sandbox.stub(element.$.suggestions, 'open');
+    const closedStub = sandbox.stub(element.$.suggestions, 'close');
+    element._suggestions = ['hello', 'its me'];
+    assert.isFalse(openStub.called);
+    assert.isTrue(closedStub.calledOnce);
+    element._focused = true;
+    assert.isTrue(openStub.calledOnce);
+    element._suggestions = [];
+    assert.isTrue(closedStub.calledTwice);
+    assert.isTrue(openStub.calledOnce);
+  });
+
+  test('_handleInputCommit with autocomplete hidden does nothing without' +
+        'without allowNonSuggestedValues', () => {
+    const commitStub = sandbox.stub(element, '_commit');
+    element.$.suggestions.isHidden = true;
+    element._handleInputCommit();
+    assert.isFalse(commitStub.called);
+  });
+
+  test('_handleInputCommit with autocomplete hidden with' +
+        'allowNonSuggestedValues', () => {
+    const commitStub = sandbox.stub(element, '_commit');
+    element.allowNonSuggestedValues = true;
+    element.$.suggestions.isHidden = true;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.called);
+  });
+
+  test('_handleInputCommit with autocomplete open calls commit', () => {
+    const commitStub = sandbox.stub(element, '_commit');
+    element.$.suggestions.isHidden = false;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.calledOnce);
+  });
+
+  test('_handleInputCommit with autocomplete open calls commit' +
+        'with allowNonSuggestedValues', () => {
+    const commitStub = sandbox.stub(element, '_commit');
+    element.allowNonSuggestedValues = true;
+    element.$.suggestions.isHidden = false;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.calledOnce);
+  });
+
+  test('issue 8655', () => {
+    function makeSuggestion(s) { return {name: s, text: s, value: s}; }
+    const keydownSpy = sandbox.spy(element, '_handleKeydown');
+    element.setText('file:');
+    element._suggestions =
+        [makeSuggestion('file:'), makeSuggestion('-file:')];
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 88, null, 'x');
+    // Must set the value, because the MockInteraction does not.
+    element.$.input.value = 'file:x';
+    assert.isTrue(keydownSpy.calledOnce);
+    MockInteractions.pressAndReleaseKeyOn(
+        element.$.input,
+        13,
+        null,
+        'enter'
+    );
+    assert.isTrue(keydownSpy.calledTwice);
+    assert.equal(element.text, 'file:x');
+  });
+
+  suite('focus', () => {
+    let commitSpy;
+    let focusSpy;
 
     setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
+      commitSpy = sandbox.spy(element, '_commit');
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    test('enter does not call focus', () => {
+      element._suggestions = ['sugar bombs'];
+      focusSpy = sandbox.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+      flushAsynchronousOperations();
 
-    test('renders', () => {
-      let promise;
-      const queryStub = sandbox.spy(input => promise = Promise.resolve([
-        {name: input + ' 0', value: 0},
-        {name: input + ' 1', value: 1},
-        {name: input + ' 2', value: 2},
-        {name: input + ' 3', value: 3},
-        {name: input + ' 4', value: 4},
-      ]));
-      element.query = queryStub;
-      assert.isTrue(element.$.suggestions.isHidden);
-      assert.equal(element.$.suggestions.$.cursor.index, -1);
-
-      focusOnInput(element);
-      element.text = 'blah';
-
-      assert.isTrue(queryStub.called);
-      element._focused = true;
-
-      return promise.then(() => {
-        assert.isFalse(element.$.suggestions.isHidden);
-        const suggestions =
-            Polymer.dom(element.$.suggestions.root).querySelectorAll('li');
-        assert.equal(suggestions.length, 5);
-
-        for (let i = 0; i < 5; i++) {
-          assert.equal(suggestions[i].innerText.trim(), 'blah ' + i);
-        }
-
-        assert.notEqual(element.$.suggestions.$.cursor.index, -1);
-      });
-    });
-
-    test('selectAll', done => {
-      flush(() => {
-        const nativeInput = element._nativeInput;
-        const selectionStub = sandbox.stub(nativeInput, 'setSelectionRange');
-
-        element.selectAll();
-        assert.isFalse(selectionStub.called);
-
-        element.$.input.value = 'test';
-        element.selectAll();
-        assert.isTrue(selectionStub.called);
-        done();
-      });
-    });
-
-    test('esc key behavior', done => {
-      let promise;
-      const queryStub = sandbox.spy(() => promise = Promise.resolve([
-        {name: 'blah', value: 123},
-      ]));
-      element.query = queryStub;
-
-      assert.isTrue(element.$.suggestions.isHidden);
-
-      element._focused = true;
-      element.text = 'blah';
-
-      promise.then(() => {
-        assert.isFalse(element.$.suggestions.isHidden);
-
-        const cancelHandler = sandbox.spy();
-        element.addEventListener('cancel', cancelHandler);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-        assert.isFalse(cancelHandler.called);
-        assert.isTrue(element.$.suggestions.isHidden);
-        assert.equal(element._suggestions.length, 0);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-        assert.isTrue(cancelHandler.called);
-        done();
-      });
-    });
-
-    test('emits commit and handles cursor movement', done => {
-      let promise;
-      const queryStub = sandbox.spy(input => promise = Promise.resolve([
-        {name: input + ' 0', value: 0},
-        {name: input + ' 1', value: 1},
-        {name: input + ' 2', value: 2},
-        {name: input + ' 3', value: 3},
-        {name: input + ' 4', value: 4},
-      ]));
-      element.query = queryStub;
-
-      assert.isTrue(element.$.suggestions.isHidden);
-      assert.equal(element.$.suggestions.$.cursor.index, -1);
-      element._focused = true;
-      element.text = 'blah';
-
-      promise.then(() => {
-        assert.isFalse(element.$.suggestions.isHidden);
-
-        const commitHandler = sandbox.spy();
-        element.addEventListener('commit', commitHandler);
-
-        assert.equal(element.$.suggestions.$.cursor.index, 0);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
-            'down');
-
-        assert.equal(element.$.suggestions.$.cursor.index, 1);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
-            'down');
-
-        assert.equal(element.$.suggestions.$.cursor.index, 2);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
-
-        assert.equal(element.$.suggestions.$.cursor.index, 1);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-            'enter');
-
-        assert.equal(element.value, 1);
-        assert.isTrue(commitHandler.called);
-        assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
-        assert.isTrue(element.$.suggestions.isHidden);
-        assert.isTrue(element._focused);
-        done();
-      });
-    });
-
-    test('clear-on-commit behavior (off)', done => {
-      let promise;
-      const queryStub = sandbox.spy(() => {
-        promise = Promise.resolve([{name: 'suggestion', value: 0}]);
-        return promise;
-      });
-      element.query = queryStub;
-      focusOnInput(element);
-      element.text = 'blah';
-
-      promise.then(() => {
-        const commitHandler = sandbox.spy();
-        element.addEventListener('commit', commitHandler);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-            'enter');
-
-        assert.isTrue(commitHandler.called);
-        assert.equal(element.text, 'suggestion');
-        done();
-      });
-    });
-
-    test('clear-on-commit behavior (on)', done => {
-      let promise;
-      const queryStub = sandbox.spy(() => {
-        promise = Promise.resolve([{name: 'suggestion', value: 0}]);
-        return promise;
-      });
-      element.query = queryStub;
-      focusOnInput(element);
-      element.text = 'blah';
-      element.clearOnCommit = true;
-
-      promise.then(() => {
-        const commitHandler = sandbox.spy();
-        element.addEventListener('commit', commitHandler);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-            'enter');
-
-        assert.isTrue(commitHandler.called);
-        assert.equal(element.text, '');
-        done();
-      });
-    });
-
-    test('threshold guards the query', () => {
-      const queryStub = sandbox.spy(() => Promise.resolve([]));
-      element.query = queryStub;
-      element.threshold = 2;
-      focusOnInput(element);
-      element.text = 'a';
-      assert.isFalse(queryStub.called);
-      element.text = 'ab';
-      assert.isTrue(queryStub.called);
-    });
-
-    test('noDebounce=false debounces the query', () => {
-      const queryStub = sandbox.spy(() => Promise.resolve([]));
-      let callback;
-      const debounceStub = sandbox.stub(element, 'debounce',
-          (name, cb) => { callback = cb; });
-      element.query = queryStub;
-      element.noDebounce = false;
-      focusOnInput(element);
-      element.text = 'a';
-      assert.isFalse(queryStub.called);
-      assert.isTrue(debounceStub.called);
-      assert.equal(debounceStub.lastCall.args[2], 200);
-      assert.isFunction(callback);
-      callback();
-      assert.isTrue(queryStub.called);
-    });
-
-    test('_computeClass respects border property', () => {
-      assert.equal(element._computeClass(), '');
-      assert.equal(element._computeClass(false), '');
-      assert.equal(element._computeClass(true), 'borderless');
-    });
-
-    test('undefined or empty text results in no suggestions', () => {
-      element._updateSuggestions(undefined, 0, null);
+      assert.isTrue(commitSpy.called);
+      assert.isFalse(focusSpy.called);
       assert.equal(element._suggestions.length, 0);
     });
 
-    test('when focused', done => {
-      let promise;
-      const queryStub = sandbox.stub()
-          .returns(promise = Promise.resolve([
-            {name: 'suggestion', value: 0},
-          ]));
-      element.query = queryStub;
-      element.suggestOnlyWhenFocus = true;
-      focusOnInput(element);
-      element.text = 'bla';
-      assert.equal(element._focused, true);
-      flushAsynchronousOperations();
-      promise.then(() => {
-        assert.equal(element._suggestions.length, 1);
-        assert.equal(queryStub.notCalled, false);
-        done();
-      });
-    });
-
-    test('when not focused', done => {
-      let promise;
-      const queryStub = sandbox.stub()
-          .returns(promise = Promise.resolve([
-            {name: 'suggestion', value: 0},
-          ]));
-      element.query = queryStub;
-      element.suggestOnlyWhenFocus = true;
-      element.text = 'bla';
-      assert.equal(element._focused, false);
-      flushAsynchronousOperations();
-      promise.then(() => {
-        assert.equal(element._suggestions.length, 0);
-        done();
-      });
-    });
-
-    test('suggestions should not carry over', done => {
-      let promise;
-      const queryStub = sandbox.stub()
-          .returns(promise = Promise.resolve([
-            {name: 'suggestion', value: 0},
-          ]));
-      element.query = queryStub;
-      focusOnInput(element);
-      element.text = 'bla';
-      flushAsynchronousOperations();
-      promise.then(() => {
-        assert.equal(element._suggestions.length, 1);
-        element._updateSuggestions('', 0, false);
-        assert.equal(element._suggestions.length, 0);
-        done();
-      });
-    });
-
-    test('multi completes only the last part of the query', done => {
-      let promise;
-      const queryStub = sandbox.stub()
-          .returns(promise = Promise.resolve([
-            {name: 'suggestion', value: 0},
-          ]));
-      element.query = queryStub;
-      focusOnInput(element);
-      element.text = 'blah blah';
-      element.multi = true;
-
-      promise.then(() => {
-        const commitHandler = sandbox.spy();
-        element.addEventListener('commit', commitHandler);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-            'enter');
-
-        assert.isTrue(commitHandler.called);
-        assert.equal(element.text, 'blah 0');
-        done();
-      });
-    });
-
-    test('tabComplete flag functions', () => {
-      // commitHandler checks for the commit event, whereas commitSpy checks for
-      // the _commit function of the element.
-      const commitHandler = sandbox.spy();
+    test('tab in input, tabComplete = true', () => {
+      focusSpy = sandbox.spy(element, 'focus');
+      const commitHandler = sandbox.stub();
       element.addEventListener('commit', commitHandler);
-      const commitSpy = sandbox.spy(element, '_commit');
-      element._focused = true;
-
-      element._suggestions = ['tunnel snakes rule!'];
-      element.tabComplete = false;
+      element.tabComplete = true;
+      element._suggestions = ['tunnel snakes drool'];
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      flushAsynchronousOperations();
+
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(focusSpy.called);
       assert.isFalse(commitHandler.called);
+      assert.equal(element._suggestions.length, 0);
+    });
+
+    test('tab in input, tabComplete = false', () => {
+      element._suggestions = ['sugar bombs'];
+      focusSpy = sandbox.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      flushAsynchronousOperations();
+
+      assert.isFalse(commitSpy.called);
+      assert.isFalse(focusSpy.called);
+      assert.equal(element._suggestions.length, 1);
+    });
+
+    test('tab on suggestion, tabComplete = false', () => {
+      element._suggestions = [{name: 'sugar bombs'}];
+      element._focused = true;
+      // When tabComplete is false, do not focus.
+      element.tabComplete = false;
+      focusSpy = sandbox.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+          element.$.suggestions.shadowRoot
+              .querySelector('li:first-child'), 9, null, 'tab');
+      flushAsynchronousOperations();
       assert.isFalse(commitSpy.called);
       assert.isFalse(element._focused);
+    });
 
-      element.tabComplete = true;
+    test('tab on suggestion, tabComplete = true', () => {
+      element._suggestions = [{name: 'sugar bombs'}];
       element._focused = true;
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      assert.isFalse(commitHandler.called);
+      // When tabComplete is true, focus.
+      element.tabComplete = true;
+      focusSpy = sandbox.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+          element.$.suggestions.shadowRoot
+              .querySelector('li:first-child'), 9, null, 'tab');
+      flushAsynchronousOperations();
+
       assert.isTrue(commitSpy.called);
       assert.isTrue(element._focused);
     });
 
-    test('_focused flag properly triggered', done => {
-      flush(() => {
-        assert.isFalse(element._focused);
-        const input = element.shadowRoot
-            .querySelector('paper-input').inputElement;
-        MockInteractions.focus(input);
-        assert.isTrue(element._focused);
-        done();
-      });
-    });
-
-    test('search icon shows with showSearchIcon property', done => {
-      flush(() => {
-        assert.equal(getComputedStyle(element.shadowRoot
-            .querySelector('iron-icon')).display,
-        'none');
-        element.showSearchIcon = true;
-        assert.notEqual(getComputedStyle(element.shadowRoot
-            .querySelector('iron-icon')).display,
-        'none');
-        done();
-      });
-    });
-
-    test('vertical offset overridden by param if it exists', () => {
-      assert.equal(element.$.suggestions.verticalOffset, 31);
-      element.verticalOffset = 30;
-      assert.equal(element.$.suggestions.verticalOffset, 30);
-    });
-
-    test('_focused flag shows/hides the suggestions', () => {
-      const openStub = sandbox.stub(element.$.suggestions, 'open');
-      const closedStub = sandbox.stub(element.$.suggestions, 'close');
-      element._suggestions = ['hello', 'its me'];
-      assert.isFalse(openStub.called);
-      assert.isTrue(closedStub.calledOnce);
+    test('tap on suggestion commits, does not call focus', () => {
+      focusSpy = sandbox.spy(element, 'focus');
       element._focused = true;
-      assert.isTrue(openStub.calledOnce);
-      element._suggestions = [];
-      assert.isTrue(closedStub.calledTwice);
-      assert.isTrue(openStub.calledOnce);
-    });
-
-    test('_handleInputCommit with autocomplete hidden does nothing without' +
-          'without allowNonSuggestedValues', () => {
-      const commitStub = sandbox.stub(element, '_commit');
-      element.$.suggestions.isHidden = true;
-      element._handleInputCommit();
-      assert.isFalse(commitStub.called);
-    });
-
-    test('_handleInputCommit with autocomplete hidden with' +
-          'allowNonSuggestedValues', () => {
-      const commitStub = sandbox.stub(element, '_commit');
-      element.allowNonSuggestedValues = true;
-      element.$.suggestions.isHidden = true;
-      element._handleInputCommit();
-      assert.isTrue(commitStub.called);
-    });
-
-    test('_handleInputCommit with autocomplete open calls commit', () => {
-      const commitStub = sandbox.stub(element, '_commit');
-      element.$.suggestions.isHidden = false;
-      element._handleInputCommit();
-      assert.isTrue(commitStub.calledOnce);
-    });
-
-    test('_handleInputCommit with autocomplete open calls commit' +
-          'with allowNonSuggestedValues', () => {
-      const commitStub = sandbox.stub(element, '_commit');
-      element.allowNonSuggestedValues = true;
-      element.$.suggestions.isHidden = false;
-      element._handleInputCommit();
-      assert.isTrue(commitStub.calledOnce);
-    });
-
-    test('issue 8655', () => {
-      function makeSuggestion(s) { return {name: s, text: s, value: s}; }
-      const keydownSpy = sandbox.spy(element, '_handleKeydown');
-      element.setText('file:');
-      element._suggestions =
-          [makeSuggestion('file:'), makeSuggestion('-file:')];
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 88, null, 'x');
-      // Must set the value, because the MockInteraction does not.
-      element.$.input.value = 'file:x';
-      assert.isTrue(keydownSpy.calledOnce);
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.input,
-          13,
-          null,
-          'enter'
-      );
-      assert.isTrue(keydownSpy.calledTwice);
-      assert.equal(element.text, 'file:x');
-    });
-
-    suite('focus', () => {
-      let commitSpy;
-      let focusSpy;
-
-      setup(() => {
-        commitSpy = sandbox.spy(element, '_commit');
-      });
-
-      test('enter does not call focus', () => {
-        element._suggestions = ['sugar bombs'];
-        focusSpy = sandbox.spy(element, 'focus');
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-            'enter');
-        flushAsynchronousOperations();
-
-        assert.isTrue(commitSpy.called);
-        assert.isFalse(focusSpy.called);
-        assert.equal(element._suggestions.length, 0);
-      });
-
-      test('tab in input, tabComplete = true', () => {
-        focusSpy = sandbox.spy(element, 'focus');
-        const commitHandler = sandbox.stub();
-        element.addEventListener('commit', commitHandler);
-        element.tabComplete = true;
-        element._suggestions = ['tunnel snakes drool'];
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-        flushAsynchronousOperations();
-
-        assert.isTrue(commitSpy.called);
-        assert.isTrue(focusSpy.called);
-        assert.isFalse(commitHandler.called);
-        assert.equal(element._suggestions.length, 0);
-      });
-
-      test('tab in input, tabComplete = false', () => {
-        element._suggestions = ['sugar bombs'];
-        focusSpy = sandbox.spy(element, 'focus');
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-        flushAsynchronousOperations();
-
-        assert.isFalse(commitSpy.called);
-        assert.isFalse(focusSpy.called);
-        assert.equal(element._suggestions.length, 1);
-      });
-
-      test('tab on suggestion, tabComplete = false', () => {
-        element._suggestions = [{name: 'sugar bombs'}];
-        element._focused = true;
-        // When tabComplete is false, do not focus.
-        element.tabComplete = false;
-        focusSpy = sandbox.spy(element, 'focus');
-        Polymer.dom.flush();
-        assert.isFalse(element.$.suggestions.isHidden);
-
-        MockInteractions.pressAndReleaseKeyOn(
-            element.$.suggestions.shadowRoot
-                .querySelector('li:first-child'), 9, null, 'tab');
-        flushAsynchronousOperations();
-        assert.isFalse(commitSpy.called);
-        assert.isFalse(element._focused);
-      });
-
-      test('tab on suggestion, tabComplete = true', () => {
-        element._suggestions = [{name: 'sugar bombs'}];
-        element._focused = true;
-        // When tabComplete is true, focus.
-        element.tabComplete = true;
-        focusSpy = sandbox.spy(element, 'focus');
-        Polymer.dom.flush();
-        assert.isFalse(element.$.suggestions.isHidden);
-
-        MockInteractions.pressAndReleaseKeyOn(
-            element.$.suggestions.shadowRoot
-                .querySelector('li:first-child'), 9, null, 'tab');
-        flushAsynchronousOperations();
-
-        assert.isTrue(commitSpy.called);
-        assert.isTrue(element._focused);
-      });
-
-      test('tap on suggestion commits, does not call focus', () => {
-        focusSpy = sandbox.spy(element, 'focus');
-        element._focused = true;
-        element._suggestions = [{name: 'first suggestion'}];
-        Polymer.dom.flush();
-        assert.isFalse(element.$.suggestions.isHidden);
-        MockInteractions.tap(element.$.suggestions.shadowRoot
-            .querySelector('li:first-child'));
-        flushAsynchronousOperations();
-
-        assert.isFalse(focusSpy.called);
-        assert.isTrue(commitSpy.called);
-        assert.isTrue(element.$.suggestions.isHidden);
-      });
-    });
-
-    test('input-keydown event fired', () => {
-      const listener = sandbox.spy();
-      element.addEventListener('input-keydown', listener);
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      element._suggestions = [{name: 'first suggestion'}];
+      flush$0();
+      assert.isFalse(element.$.suggestions.isHidden);
+      MockInteractions.tap(element.$.suggestions.shadowRoot
+          .querySelector('li:first-child'));
       flushAsynchronousOperations();
-      assert.isTrue(listener.called);
-    });
 
-    test('enter with modifier does not complete', () => {
-      const handleSpy = sandbox.spy(element, '_handleKeydown');
-      const commitStub = sandbox.stub(element, '_handleInputCommit');
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.input, 13, 'ctrl', 'enter');
-      assert.isTrue(handleSpy.called);
-      assert.isFalse(commitStub.called);
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.input, 13, null, 'enter');
-      assert.isTrue(commitStub.called);
-    });
-
-    suite('warnUncommitted', () => {
-      let inputClassList;
-      setup(() => {
-        inputClassList = element.$.input.classList;
-      });
-
-      test('enabled', () => {
-        element.warnUncommitted = true;
-        element.text = 'blah blah blah';
-        MockInteractions.blur(element.$.input);
-        assert.isTrue(inputClassList.contains('warnUncommitted'));
-        MockInteractions.focus(element.$.input);
-        assert.isFalse(inputClassList.contains('warnUncommitted'));
-      });
-
-      test('disabled', () => {
-        element.warnUncommitted = false;
-        element.text = 'blah blah blah';
-        MockInteractions.blur(element.$.input);
-        assert.isFalse(inputClassList.contains('warnUncommitted'));
-      });
-
-      test('no text', () => {
-        element.warnUncommitted = true;
-        element.text = '';
-        MockInteractions.blur(element.$.input);
-        assert.isFalse(inputClassList.contains('warnUncommitted'));
-      });
+      assert.isFalse(focusSpy.called);
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(element.$.suggestions.isHidden);
     });
   });
+
+  test('input-keydown event fired', () => {
+    const listener = sandbox.spy();
+    element.addEventListener('input-keydown', listener);
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+    flushAsynchronousOperations();
+    assert.isTrue(listener.called);
+  });
+
+  test('enter with modifier does not complete', () => {
+    const handleSpy = sandbox.spy(element, '_handleKeydown');
+    const commitStub = sandbox.stub(element, '_handleInputCommit');
+    MockInteractions.pressAndReleaseKeyOn(
+        element.$.input, 13, 'ctrl', 'enter');
+    assert.isTrue(handleSpy.called);
+    assert.isFalse(commitStub.called);
+    MockInteractions.pressAndReleaseKeyOn(
+        element.$.input, 13, null, 'enter');
+    assert.isTrue(commitStub.called);
+  });
+
+  suite('warnUncommitted', () => {
+    let inputClassList;
+    setup(() => {
+      inputClassList = element.$.input.classList;
+    });
+
+    test('enabled', () => {
+      element.warnUncommitted = true;
+      element.text = 'blah blah blah';
+      MockInteractions.blur(element.$.input);
+      assert.isTrue(inputClassList.contains('warnUncommitted'));
+      MockInteractions.focus(element.$.input);
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+
+    test('disabled', () => {
+      element.warnUncommitted = false;
+      element.text = 'blah blah blah';
+      MockInteractions.blur(element.$.input);
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+
+    test('no text', () => {
+      element.warnUncommitted = true;
+      element.text = '';
+      MockInteractions.blur(element.$.input);
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
index efa97cf..857f1b4 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,89 +14,99 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @extends Polymer.Element
-   */
-  class GrAvatar extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-avatar'; }
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../gr-js-api-interface/gr-js-api-interface.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-avatar_html.js';
 
-    static get properties() {
-      return {
-        account: {
-          type: Object,
-          observer: '_accountChanged',
-        },
-        imageSize: {
-          type: Number,
-          value: 16,
-        },
-        _hasAvatars: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @extends Polymer.Element
+ */
+class GrAvatar extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /** @override */
-    attached() {
-      super.attached();
-      Promise.all([
-        this._getConfig(),
-        Gerrit.awaitPluginsLoaded(),
-      ]).then(([cfg]) => {
-        this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+  static get is() { return 'gr-avatar'; }
 
-        this._updateAvatarURL();
-      });
-    }
+  static get properties() {
+    return {
+      account: {
+        type: Object,
+        observer: '_accountChanged',
+      },
+      imageSize: {
+        type: Number,
+        value: 16,
+      },
+      _hasAvatars: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-    _getConfig() {
-      return this.$.restAPI.getConfig();
-    }
+  /** @override */
+  attached() {
+    super.attached();
+    Promise.all([
+      this._getConfig(),
+      Gerrit.awaitPluginsLoaded(),
+    ]).then(([cfg]) => {
+      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
 
-    _accountChanged(account) {
       this._updateAvatarURL();
+    });
+  }
+
+  _getConfig() {
+    return this.$.restAPI.getConfig();
+  }
+
+  _accountChanged(account) {
+    this._updateAvatarURL();
+  }
+
+  _updateAvatarURL() {
+    if (!this._hasAvatars || !this.account) {
+      this.hidden = true;
+      return;
     }
+    this.hidden = false;
 
-    _updateAvatarURL() {
-      if (!this._hasAvatars || !this.account) {
-        this.hidden = true;
-        return;
-      }
-      this.hidden = false;
-
-      const url = this._buildAvatarURL(this.account);
-      if (url) {
-        this.style.backgroundImage = 'url("' + url + '")';
-      }
-    }
-
-    _getAccounts(account) {
-      return account._account_id || account.email || account.username ||
-          account.name;
-    }
-
-    _buildAvatarURL(account) {
-      if (!account) { return ''; }
-      const avatars = account.avatars || [];
-      for (let i = 0; i < avatars.length; i++) {
-        if (avatars[i].height === this.imageSize) {
-          return avatars[i].url;
-        }
-      }
-      return this.getBaseUrl() + '/accounts/' +
-        encodeURIComponent(this._getAccounts(account)) +
-        '/avatar?s=' + this.imageSize;
+    const url = this._buildAvatarURL(this.account);
+    if (url) {
+      this.style.backgroundImage = 'url("' + url + '")';
     }
   }
 
-  customElements.define(GrAvatar.is, GrAvatar);
-})();
+  _getAccounts(account) {
+    return account._account_id || account.email || account.username ||
+        account.name;
+  }
+
+  _buildAvatarURL(account) {
+    if (!account) { return ''; }
+    const avatars = account.avatars || [];
+    for (let i = 0; i < avatars.length; i++) {
+      if (avatars[i].height === this.imageSize) {
+        return avatars[i].url;
+      }
+    }
+    return this.getBaseUrl() + '/accounts/' +
+      encodeURIComponent(this._getAccounts(account)) +
+      '/avatar?s=' + this.imageSize;
+  }
+}
+
+customElements.define(GrAvatar.is, GrAvatar);
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.js
index 1daffa2..be4c350 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-avatar">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: inline-block;
@@ -32,6 +26,4 @@
       }
     </style>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-avatar.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
index bd3f805..2cec20e 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-avatar</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-avatar.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-avatar.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-avatar.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,14 +40,117 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-avatar tests', async () => {
-    await readyToTest();
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-avatar.js';
+suite('gr-avatar tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('methods', () => {
+    assert.equal(
+        element._buildAvatarURL({
+          _account_id: 123,
+        }),
+        '/accounts/123/avatar?s=16');
+    assert.equal(
+        element._buildAvatarURL({
+          email: 'test@example.com',
+        }),
+        '/accounts/test%40example.com/avatar?s=16');
+    assert.equal(
+        element._buildAvatarURL({
+          name: 'John Doe',
+        }),
+        '/accounts/John%20Doe/avatar?s=16');
+    assert.equal(
+        element._buildAvatarURL({
+          username: 'John_Doe',
+        }),
+        '/accounts/John_Doe/avatar?s=16');
+    assert.equal(
+        element._buildAvatarURL({
+          _account_id: 123,
+          avatars: [
+            {
+              url: 'https://cdn.example.com/s12-p/photo.jpg',
+              height: 12,
+            },
+            {
+              url: 'https://cdn.example.com/s16-p/photo.jpg',
+              height: 16,
+            },
+            {
+              url: 'https://cdn.example.com/s100-p/photo.jpg',
+              height: 100,
+            },
+          ],
+        }),
+        'https://cdn.example.com/s16-p/photo.jpg');
+    assert.equal(
+        element._buildAvatarURL({
+          _account_id: 123,
+          avatars: [
+            {
+              url: 'https://cdn.example.com/s95-p/photo.jpg',
+              height: 95,
+            },
+          ],
+        }),
+        '/accounts/123/avatar?s=16');
+    assert.equal(element._buildAvatarURL(undefined), '');
+  });
+
+  test('dom for existing account', () => {
+    assert.isFalse(element.hasAttribute('hidden'));
+
+    sandbox.stub(
+        element,
+        '_getConfig',
+        () => Promise.resolve({plugin: {has_avatars: true}}));
+
+    element.imageSize = 64;
+    element.account = {
+      _account_id: 123,
+    };
+
+    assert.strictEqual(element.style.backgroundImage, '');
+
+    // Emulate plugins loaded.
+    Gerrit._loadPlugins([]);
+
+    Promise.all([
+      element.$.restAPI.getConfig(),
+      Gerrit.awaitPluginsLoaded(),
+    ]).then(() => {
+      assert.isFalse(element.hasAttribute('hidden'));
+
+      assert.isTrue(
+          element.style.backgroundImage.includes('/accounts/123/avatar?s=64'));
+    });
+  });
+
+  suite('plugin has avatars', () => {
     let element;
     let sandbox;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
+
+      stub('gr-avatar', {
+        _getConfig: () => Promise.resolve({plugin: {has_avatars: true}}),
+      });
+
       element = fixture('basic');
     });
 
@@ -50,110 +158,49 @@
       sandbox.restore();
     });
 
-    test('methods', () => {
-      assert.equal(
-          element._buildAvatarURL({
-            _account_id: 123,
-          }),
-          '/accounts/123/avatar?s=16');
-      assert.equal(
-          element._buildAvatarURL({
-            email: 'test@example.com',
-          }),
-          '/accounts/test%40example.com/avatar?s=16');
-      assert.equal(
-          element._buildAvatarURL({
-            name: 'John Doe',
-          }),
-          '/accounts/John%20Doe/avatar?s=16');
-      assert.equal(
-          element._buildAvatarURL({
-            username: 'John_Doe',
-          }),
-          '/accounts/John_Doe/avatar?s=16');
-      assert.equal(
-          element._buildAvatarURL({
-            _account_id: 123,
-            avatars: [
-              {
-                url: 'https://cdn.example.com/s12-p/photo.jpg',
-                height: 12,
-              },
-              {
-                url: 'https://cdn.example.com/s16-p/photo.jpg',
-                height: 16,
-              },
-              {
-                url: 'https://cdn.example.com/s100-p/photo.jpg',
-                height: 100,
-              },
-            ],
-          }),
-          'https://cdn.example.com/s16-p/photo.jpg');
-      assert.equal(
-          element._buildAvatarURL({
-            _account_id: 123,
-            avatars: [
-              {
-                url: 'https://cdn.example.com/s95-p/photo.jpg',
-                height: 95,
-              },
-            ],
-          }),
-          '/accounts/123/avatar?s=16');
-      assert.equal(element._buildAvatarURL(undefined), '');
-    });
-
-    test('dom for existing account', () => {
+    test('dom for non available account', () => {
       assert.isFalse(element.hasAttribute('hidden'));
 
-      sandbox.stub(
-          element,
-          '_getConfig',
-          () => Promise.resolve({plugin: {has_avatars: true}}));
-
-      element.imageSize = 64;
-      element.account = {
-        _account_id: 123,
-      };
-
-      assert.strictEqual(element.style.backgroundImage, '');
-
       // Emulate plugins loaded.
       Gerrit._loadPlugins([]);
 
-      Promise.all([
+      return Promise.all([
         element.$.restAPI.getConfig(),
         Gerrit.awaitPluginsLoaded(),
       ]).then(() => {
-        assert.isFalse(element.hasAttribute('hidden'));
+        assert.isTrue(element.hasAttribute('hidden'));
 
-        assert.isTrue(
-            element.style.backgroundImage.includes('/accounts/123/avatar?s=64'));
+        assert.strictEqual(element.style.backgroundImage, '');
       });
     });
+  });
 
-    suite('plugin has avatars', () => {
-      let element;
-      let sandbox;
+  suite('config not set', () => {
+    let element;
+    let sandbox;
 
-      setup(() => {
-        sandbox = sinon.sandbox.create();
+    setup(() => {
+      sandbox = sinon.sandbox.create();
 
-        stub('gr-avatar', {
-          _getConfig: () => Promise.resolve({plugin: {has_avatars: true}}),
-        });
-
-        element = fixture('basic');
+      stub('gr-avatar', {
+        _getConfig: () => Promise.resolve({}),
       });
 
-      teardown(() => {
-        sandbox.restore();
-      });
+      element = fixture('basic');
+    });
 
-      test('dom for non available account', () => {
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('avatar hidden when account set', () => {
+      flush(() => {
         assert.isFalse(element.hasAttribute('hidden'));
 
+        element.imageSize = 64;
+        element.account = {
+          _account_id: 123,
+        };
         // Emulate plugins loaded.
         Gerrit._loadPlugins([]);
 
@@ -162,49 +209,9 @@
           Gerrit.awaitPluginsLoaded(),
         ]).then(() => {
           assert.isTrue(element.hasAttribute('hidden'));
-
-          assert.strictEqual(element.style.backgroundImage, '');
-        });
-      });
-    });
-
-    suite('config not set', () => {
-      let element;
-      let sandbox;
-
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-
-        stub('gr-avatar', {
-          _getConfig: () => Promise.resolve({}),
-        });
-
-        element = fixture('basic');
-      });
-
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      test('avatar hidden when account set', () => {
-        flush(() => {
-          assert.isFalse(element.hasAttribute('hidden'));
-
-          element.imageSize = 64;
-          element.account = {
-            _account_id: 123,
-          };
-          // Emulate plugins loaded.
-          Gerrit._loadPlugins([]);
-
-          return Promise.all([
-            element.$.restAPI.getConfig(),
-            Gerrit.awaitPluginsLoaded(),
-          ]).then(() => {
-            assert.isTrue(element.hasAttribute('hidden'));
-          });
         });
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index 9d96038..cde56df 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -14,120 +14,131 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @appliesMixin Gerrit.TooltipMixin
-   * @extends Polymer.Element
-   */
-  class GrButton extends Polymer.mixinBehaviors( [
-    Gerrit.KeyboardShortcutBehavior,
-    Gerrit.TooltipBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-button'; }
+import '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '@polymer/paper-button/paper-button.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-button_html.js';
 
-    static get properties() {
-      return {
-        tooltip: String,
-        downArrow: {
-          type: Boolean,
-          reflectToAttribute: true,
-        },
-        link: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-        disabled: {
-          type: Boolean,
-          observer: '_disabledChanged',
-          reflectToAttribute: true,
-        },
-        noUppercase: {
-          type: Boolean,
-          value: false,
-        },
-        loading: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
+/**
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @appliesMixin Gerrit.TooltipMixin
+ * @extends Polymer.Element
+ */
+class GrButton extends mixinBehaviors( [
+  Gerrit.KeyboardShortcutBehavior,
+  Gerrit.TooltipBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-        _disabled: {
-          type: Boolean,
-          computed: '_computeDisabled(disabled, loading)',
-        },
+  static get is() { return 'gr-button'; }
 
-        _initialTabindex: {
-          type: String,
-          value: '0',
-        },
-      };
-    }
+  static get properties() {
+    return {
+      tooltip: String,
+      downArrow: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
+      link: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      disabled: {
+        type: Boolean,
+        observer: '_disabledChanged',
+        reflectToAttribute: true,
+      },
+      noUppercase: {
+        type: Boolean,
+        value: false,
+      },
+      loading: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
 
-    /** @override */
-    created() {
-      super.created();
-      this._initialTabindex = this.getAttribute('tabindex') || '0';
-      this.addEventListener('click', e => this._handleAction(e));
-      this.addEventListener('keydown',
-          e => this._handleKeydown(e));
-    }
+      _disabled: {
+        type: Boolean,
+        computed: '_computeDisabled(disabled, loading)',
+      },
 
-    /** @override */
-    ready() {
-      super.ready();
-      this._ensureAttribute('role', 'button');
-      this._ensureAttribute('tabindex', '0');
-    }
-
-    _handleAction(e) {
-      if (this._disabled) {
-        e.preventDefault();
-        e.stopPropagation();
-        e.stopImmediatePropagation();
-        return;
-      }
-
-      let el = this.root;
-      let path = '';
-      while (el = el.parentNode || el.host) {
-        if (el.tagName && el.tagName.startsWith('GR-APP')) {
-          break;
-        }
-        if (el.tagName) {
-          const idString = el.id ? '#' + el.id : '';
-          path = el.tagName + idString + ' ' + path;
-        }
-      }
-      this.$.reporting.reportInteraction('button-click',
-          {path: path.trim().toLowerCase()});
-    }
-
-    _disabledChanged(disabled) {
-      this.setAttribute('tabindex', disabled ? '-1' : this._initialTabindex);
-      this.updateStyles();
-    }
-
-    _computeDisabled(disabled, loading) {
-      return disabled || loading;
-    }
-
-    _handleKeydown(e) {
-      if (this.modifierPressed(e)) { return; }
-      e = this.getKeyboardEvent(e);
-      // Handle `enter`, `space`.
-      if (e.keyCode === 13 || e.keyCode === 32) {
-        e.preventDefault();
-        e.stopPropagation();
-        this.click();
-      }
-    }
+      _initialTabindex: {
+        type: String,
+        value: '0',
+      },
+    };
   }
 
-  customElements.define(GrButton.is, GrButton);
-})();
+  /** @override */
+  created() {
+    super.created();
+    this._initialTabindex = this.getAttribute('tabindex') || '0';
+    this.addEventListener('click', e => this._handleAction(e));
+    this.addEventListener('keydown',
+        e => this._handleKeydown(e));
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'button');
+    this._ensureAttribute('tabindex', '0');
+  }
+
+  _handleAction(e) {
+    if (this._disabled) {
+      e.preventDefault();
+      e.stopPropagation();
+      e.stopImmediatePropagation();
+      return;
+    }
+
+    let el = this.root;
+    let path = '';
+    while (el = el.parentNode || el.host) {
+      if (el.tagName && el.tagName.startsWith('GR-APP')) {
+        break;
+      }
+      if (el.tagName) {
+        const idString = el.id ? '#' + el.id : '';
+        path = el.tagName + idString + ' ' + path;
+      }
+    }
+    this.$.reporting.reportInteraction('button-click',
+        {path: path.trim().toLowerCase()});
+  }
+
+  _disabledChanged(disabled) {
+    this.setAttribute('tabindex', disabled ? '-1' : this._initialTabindex);
+    this.updateStyles();
+  }
+
+  _computeDisabled(disabled, loading) {
+    return disabled || loading;
+  }
+
+  _handleKeydown(e) {
+    if (this.modifierPressed(e)) { return; }
+    e = this.getKeyboardEvent(e);
+    // Handle `enter`, `space`.
+    if (e.keyCode === 13 || e.keyCode === 32) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.click();
+    }
+  }
+}
+
+customElements.define(GrButton.is, GrButton);
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.js
index b94359f..ad5c00d 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="/bower_components/paper-button/paper-button.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-
-<dom-module id="gr-button">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* general styles for all buttons */
       :host {
@@ -172,10 +164,7 @@
         border-top-color: var(--deemphasized-text-color);
       }
     </style>
-    <paper-button
-        raised="[[!link]]"
-        disabled="[[_disabled]]"
-        tabindex="-1">
+    <paper-button raised="[[!link]]" disabled="[[_disabled]]" tabindex="-1">
       <template is="dom-if" if="[[loading]]">
         <span class="loadingSpin"></span>
       </template>
@@ -183,6 +172,4 @@
       <i class="downArrow"></i>
     </paper-button>
     <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-button.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
index cfac37f..42f9a5a 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-button</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-button.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-button.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-button.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -41,146 +46,149 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-button tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-button.js';
+import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
+suite('gr-button tests', () => {
+  let element;
+  let sandbox;
 
-    const addSpyOn = function(eventName) {
-      const spy = sandbox.spy();
-      if (eventName == 'tap') {
-        Polymer.Gestures.addListener(element, eventName, spy);
-      } else {
-        element.addEventListener(eventName, spy);
-      }
-      return spy;
-    };
+  const addSpyOn = function(eventName) {
+    const spy = sandbox.spy();
+    if (eventName == 'tap') {
+      addListener(element, eventName, spy);
+    } else {
+      element.addEventListener(eventName, spy);
+    }
+    return spy;
+  };
 
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('disabled is set by disabled', () => {
+    const paperBtn = element.shadowRoot.querySelector('paper-button');
+    assert.isFalse(paperBtn.disabled);
+    element.disabled = true;
+    assert.isTrue(paperBtn.disabled);
+    element.disabled = false;
+    assert.isFalse(paperBtn.disabled);
+  });
+
+  test('loading set from listener', done => {
+    let resolve;
+    element.addEventListener('click', e => {
+      e.target.loading = true;
+      resolve = () => e.target.loading = false;
+    });
+    const paperBtn = element.shadowRoot.querySelector('paper-button');
+    assert.isFalse(paperBtn.disabled);
+    MockInteractions.tap(element);
+    assert.isTrue(paperBtn.disabled);
+    assert.isTrue(element.hasAttribute('loading'));
+    resolve();
+    flush(() => {
+      assert.isFalse(paperBtn.disabled);
+      assert.isFalse(element.hasAttribute('loading'));
+      done();
+    });
+  });
+
+  test('tabindex should be -1 if disabled', () => {
+    element.disabled = true;
+    assert.isTrue(element.getAttribute('tabindex') === '-1');
+  });
+
+  // Regression tests for Issue: 11969
+  test('tabindex should be reset to 0 if enabled', () => {
+    element.disabled = false;
+    assert.equal(element.getAttribute('tabindex'), '0');
+    element.disabled = true;
+    assert.equal(element.getAttribute('tabindex'), '-1');
+    element.disabled = false;
+    assert.equal(element.getAttribute('tabindex'), '0');
+  });
+
+  test('tabindex should be preserved', () => {
+    element = fixture('tabindex');
+    element.disabled = false;
+    assert.equal(element.getAttribute('tabindex'), '3');
+    element.disabled = true;
+    assert.equal(element.getAttribute('tabindex'), '-1');
+    element.disabled = false;
+    assert.equal(element.getAttribute('tabindex'), '3');
+  });
+
+  // 'tap' event is tested so we don't loose backward compatibility with older
+  // plugins who didn't move to on-click which is faster and well supported.
+  test('dispatches click event', () => {
+    const spy = addSpyOn('click');
+    MockInteractions.click(element);
+    assert.isTrue(spy.calledOnce);
+  });
+
+  test('dispatches tap event', () => {
+    const spy = addSpyOn('tap');
+    MockInteractions.tap(element);
+    assert.isTrue(spy.calledOnce);
+  });
+
+  test('dispatches click from tap event', () => {
+    const spy = addSpyOn('click');
+    MockInteractions.tap(element);
+    assert.isTrue(spy.calledOnce);
+  });
+
+  // Keycodes: 32 for Space, 13 for Enter.
+  for (const key of [32, 13]) {
+    test('dispatches click event on keycode ' + key, () => {
+      const tapSpy = sandbox.spy();
+      element.addEventListener('click', tapSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, key);
+      assert.isTrue(tapSpy.calledOnce);
+    });
+
+    test('dispatches no click event with modifier on keycode ' + key, () => {
+      const tapSpy = sandbox.spy();
+      element.addEventListener('click', tapSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, key, 'shift');
+      MockInteractions.pressAndReleaseKeyOn(element, key, 'ctrl');
+      MockInteractions.pressAndReleaseKeyOn(element, key, 'meta');
+      MockInteractions.pressAndReleaseKeyOn(element, key, 'alt');
+      assert.isFalse(tapSpy.calledOnce);
+    });
+  }
+
+  suite('disabled', () => {
     setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('disabled is set by disabled', () => {
-      const paperBtn = element.shadowRoot.querySelector('paper-button');
-      assert.isFalse(paperBtn.disabled);
       element.disabled = true;
-      assert.isTrue(paperBtn.disabled);
-      element.disabled = false;
-      assert.isFalse(paperBtn.disabled);
     });
 
-    test('loading set from listener', done => {
-      let resolve;
-      element.addEventListener('click', e => {
-        e.target.loading = true;
-        resolve = () => e.target.loading = false;
-      });
-      const paperBtn = element.shadowRoot.querySelector('paper-button');
-      assert.isFalse(paperBtn.disabled);
-      MockInteractions.tap(element);
-      assert.isTrue(paperBtn.disabled);
-      assert.isTrue(element.hasAttribute('loading'));
-      resolve();
-      flush(() => {
-        assert.isFalse(paperBtn.disabled);
-        assert.isFalse(element.hasAttribute('loading'));
-        done();
-      });
-    });
-
-    test('tabindex should be -1 if disabled', () => {
-      element.disabled = true;
-      assert.isTrue(element.getAttribute('tabindex') === '-1');
-    });
-
-    // Regression tests for Issue: 11969
-    test('tabindex should be reset to 0 if enabled', () => {
-      element.disabled = false;
-      assert.equal(element.getAttribute('tabindex'), '0');
-      element.disabled = true;
-      assert.equal(element.getAttribute('tabindex'), '-1');
-      element.disabled = false;
-      assert.equal(element.getAttribute('tabindex'), '0');
-    });
-
-    test('tabindex should be preserved', () => {
-      element = fixture('tabindex');
-      element.disabled = false;
-      assert.equal(element.getAttribute('tabindex'), '3');
-      element.disabled = true;
-      assert.equal(element.getAttribute('tabindex'), '-1');
-      element.disabled = false;
-      assert.equal(element.getAttribute('tabindex'), '3');
-    });
-
-    // 'tap' event is tested so we don't loose backward compatibility with older
-    // plugins who didn't move to on-click which is faster and well supported.
-    test('dispatches click event', () => {
-      const spy = addSpyOn('click');
-      MockInteractions.click(element);
-      assert.isTrue(spy.calledOnce);
-    });
-
-    test('dispatches tap event', () => {
-      const spy = addSpyOn('tap');
-      MockInteractions.tap(element);
-      assert.isTrue(spy.calledOnce);
-    });
-
-    test('dispatches click from tap event', () => {
-      const spy = addSpyOn('click');
-      MockInteractions.tap(element);
-      assert.isTrue(spy.calledOnce);
-    });
-
-    // Keycodes: 32 for Space, 13 for Enter.
-    for (const key of [32, 13]) {
-      test('dispatches click event on keycode ' + key, () => {
-        const tapSpy = sandbox.spy();
-        element.addEventListener('click', tapSpy);
-        MockInteractions.pressAndReleaseKeyOn(element, key);
-        assert.isTrue(tapSpy.calledOnce);
-      });
-
-      test('dispatches no click event with modifier on keycode ' + key, () => {
-        const tapSpy = sandbox.spy();
-        element.addEventListener('click', tapSpy);
-        MockInteractions.pressAndReleaseKeyOn(element, key, 'shift');
-        MockInteractions.pressAndReleaseKeyOn(element, key, 'ctrl');
-        MockInteractions.pressAndReleaseKeyOn(element, key, 'meta');
-        MockInteractions.pressAndReleaseKeyOn(element, key, 'alt');
-        assert.isFalse(tapSpy.calledOnce);
+    for (const eventName of ['tap', 'click']) {
+      test('stops ' + eventName + ' event', () => {
+        const spy = addSpyOn(eventName);
+        MockInteractions.tap(element);
+        assert.isFalse(spy.called);
       });
     }
 
-    suite('disabled', () => {
-      setup(() => {
-        element.disabled = true;
+    // Keycodes: 32 for Space, 13 for Enter.
+    for (const key of [32, 13]) {
+      test('stops click event on keycode ' + key, () => {
+        const tapSpy = sandbox.spy();
+        element.addEventListener('click', tapSpy);
+        MockInteractions.pressAndReleaseKeyOn(element, key);
+        assert.isFalse(tapSpy.called);
       });
-
-      for (const eventName of ['tap', 'click']) {
-        test('stops ' + eventName + ' event', () => {
-          const spy = addSpyOn(eventName);
-          MockInteractions.tap(element);
-          assert.isFalse(spy.called);
-        });
-      }
-
-      // Keycodes: 32 for Space, 13 for Enter.
-      for (const key of [32, 13]) {
-        test('stops click event on keycode ' + key, () => {
-          const tapSpy = sandbox.spy();
-          element.addEventListener('click', tapSpy);
-          MockInteractions.pressAndReleaseKeyOn(element, key);
-          assert.isFalse(tapSpy.called);
-        });
-      }
-    });
+    }
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
index 001632f..10e06dd 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,49 +14,56 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrChangeStar extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-change-star'; }
-    /**
-     * Fired when star state is toggled.
-     *
-     * @event toggle-star
-     */
+import '../gr-icons/gr-icons.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-star_html.js';
 
-    static get properties() {
-      return {
-      /** @type {?} */
-        change: {
-          type: Object,
-          notify: true,
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrChangeStar extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _computeStarClass(starred) {
-      return starred ? 'active' : '';
-    }
+  static get is() { return 'gr-change-star'; }
+  /**
+   * Fired when star state is toggled.
+   *
+   * @event toggle-star
+   */
 
-    _computeStarIcon(starred) {
-      // Hollow star is used to indicate inactive state.
-      return `gr-icons:star${starred ? '' : '-border'}`;
-    }
-
-    toggleStar() {
-      const newVal = !this.change.starred;
-      this.set('change.starred', newVal);
-      this.dispatchEvent(new CustomEvent('toggle-star', {
-        bubbles: true,
-        composed: true,
-        detail: {change: this.change, starred: newVal},
-      }));
-    }
+  static get properties() {
+    return {
+    /** @type {?} */
+      change: {
+        type: Object,
+        notify: true,
+      },
+    };
   }
 
-  customElements.define(GrChangeStar.is, GrChangeStar);
-})();
+  _computeStarClass(starred) {
+    return starred ? 'active' : '';
+  }
+
+  _computeStarIcon(starred) {
+    // Hollow star is used to indicate inactive state.
+    return `gr-icons:star${starred ? '' : '-border'}`;
+  }
+
+  toggleStar() {
+    const newVal = !this.change.starred;
+    this.set('change.starred', newVal);
+    this.dispatchEvent(new CustomEvent('toggle-star', {
+      bubbles: true,
+      composed: true,
+      detail: {change: this.change, starred: newVal},
+    }));
+  }
+}
+
+customElements.define(GrChangeStar.is, GrChangeStar);
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js
index dc8ba34..a4925aa 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js
@@ -1,26 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-change-star">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       button {
         background-color: transparent;
@@ -36,10 +32,6 @@
       }
     </style>
     <button aria-label="Change star" on-click="toggleStar">
-      <iron-icon
-          class$="[[_computeStarClass(change.starred)]]"
-          icon$="[[_computeStarIcon(change.starred)]]"></iron-icon>
+      <iron-icon class\$="[[_computeStarClass(change.starred)]]" icon\$="[[_computeStarIcon(change.starred)]]"></iron-icon>
     </button>
-  </template>
-  <script src="gr-change-star.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
index b76ce4d..aa138d3 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-star</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-change-star.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-change-star.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-change-star.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,51 +40,53 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-star tests', async () => {
-    await readyToTest();
-    let element;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-change-star.js';
+suite('gr-change-star tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-      element.change = {
-        _number: 2,
-        starred: true,
-      };
-    });
-
-    test('star visibility states', () => {
-      element.set('change.starred', true);
-      let icon = element.shadowRoot
-          .querySelector('iron-icon');
-      assert.isTrue(icon.classList.contains('active'));
-      assert.equal(icon.icon, 'gr-icons:star');
-
-      element.set('change.starred', false);
-      icon = element.shadowRoot
-          .querySelector('iron-icon');
-      assert.isFalse(icon.classList.contains('active'));
-      assert.equal(icon.icon, 'gr-icons:star-border');
-    });
-
-    test('starring', done => {
-      element.addEventListener('toggle-star', () => {
-        assert.equal(element.change.starred, true);
-        done();
-      });
-      element.set('change.starred', false);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('button'));
-    });
-
-    test('unstarring', done => {
-      element.addEventListener('toggle-star', () => {
-        assert.equal(element.change.starred, false);
-        done();
-      });
-      element.set('change.starred', true);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('button'));
-    });
+  setup(() => {
+    element = fixture('basic');
+    element.change = {
+      _number: 2,
+      starred: true,
+    };
   });
+
+  test('star visibility states', () => {
+    element.set('change.starred', true);
+    let icon = element.shadowRoot
+        .querySelector('iron-icon');
+    assert.isTrue(icon.classList.contains('active'));
+    assert.equal(icon.icon, 'gr-icons:star');
+
+    element.set('change.starred', false);
+    icon = element.shadowRoot
+        .querySelector('iron-icon');
+    assert.isFalse(icon.classList.contains('active'));
+    assert.equal(icon.icon, 'gr-icons:star-border');
+  });
+
+  test('starring', done => {
+    element.addEventListener('toggle-star', () => {
+      assert.equal(element.change.starred, true);
+      done();
+    });
+    element.set('change.starred', false);
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('button'));
+  });
+
+  test('unstarring', done => {
+    element.addEventListener('toggle-star', () => {
+      assert.equal(element.change.starred, false);
+      done();
+    });
+    element.set('change.starred', true);
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('button'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
index 7052a6a..b99612e 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
@@ -14,85 +14,93 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const ChangeStates = {
-    MERGED: 'Merged',
-    ABANDONED: 'Abandoned',
-    MERGE_CONFLICT: 'Merge Conflict',
-    WIP: 'WIP',
-    PRIVATE: 'Private',
-  };
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-tooltip-content/gr-tooltip-content.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-status_html.js';
 
-  const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
-      'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
-      'and email notifications will be silenced until the review is started.';
+const ChangeStates = {
+  MERGED: 'Merged',
+  ABANDONED: 'Abandoned',
+  MERGE_CONFLICT: 'Merge Conflict',
+  WIP: 'WIP',
+  PRIVATE: 'Private',
+};
 
-  const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
-      'Download the patch and run "git rebase master". ' +
-      'Upload a new patchset after resolving all merge conflicts.';
+const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
+    'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
+    'and email notifications will be silenced until the review is started.';
 
-  const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
-      'current reviewers (or anyone with "View Private Changes" permission).';
+const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
+    'Download the patch and run "git rebase master". ' +
+    'Upload a new patchset after resolving all merge conflicts.';
 
-  /** @extends Polymer.Element */
-  class GrChangeStatus extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-change-status'; }
+const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
+    'current reviewers (or anyone with "View Private Changes" permission).';
 
-    static get properties() {
-      return {
-        flat: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-        status: {
-          type: String,
-          observer: '_updateChipDetails',
-        },
-        tooltipText: {
-          type: String,
-          value: '',
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrChangeStatus extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _computeStatusString(status) {
-      if (status === ChangeStates.WIP && !this.flat) {
-        return 'Work in Progress';
-      }
-      return status;
-    }
+  static get is() { return 'gr-change-status'; }
 
-    _toClassName(str) {
-      return str.toLowerCase().replace(/\s/g, '-');
-    }
-
-    _updateChipDetails(status, previousStatus) {
-      if (previousStatus) {
-        this.classList.remove(this._toClassName(previousStatus));
-      }
-      this.classList.add(this._toClassName(status));
-
-      switch (status) {
-        case ChangeStates.WIP:
-          this.tooltipText = WIP_TOOLTIP;
-          break;
-        case ChangeStates.PRIVATE:
-          this.tooltipText = PRIVATE_TOOLTIP;
-          break;
-        case ChangeStates.MERGE_CONFLICT:
-          this.tooltipText = MERGE_CONFLICT_TOOLTIP;
-          break;
-        default:
-          this.tooltipText = '';
-          break;
-      }
-    }
+  static get properties() {
+    return {
+      flat: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      status: {
+        type: String,
+        observer: '_updateChipDetails',
+      },
+      tooltipText: {
+        type: String,
+        value: '',
+      },
+    };
   }
 
-  customElements.define(GrChangeStatus.is, GrChangeStatus);
-})();
+  _computeStatusString(status) {
+    if (status === ChangeStates.WIP && !this.flat) {
+      return 'Work in Progress';
+    }
+    return status;
+  }
+
+  _toClassName(str) {
+    return str.toLowerCase().replace(/\s/g, '-');
+  }
+
+  _updateChipDetails(status, previousStatus) {
+    if (previousStatus) {
+      this.classList.remove(this._toClassName(previousStatus));
+    }
+    this.classList.add(this._toClassName(status));
+
+    switch (status) {
+      case ChangeStates.WIP:
+        this.tooltipText = WIP_TOOLTIP;
+        break;
+      case ChangeStates.PRIVATE:
+        this.tooltipText = PRIVATE_TOOLTIP;
+        break;
+      case ChangeStates.MERGE_CONFLICT:
+        this.tooltipText = MERGE_CONFLICT_TOOLTIP;
+        break;
+      default:
+        this.tooltipText = '';
+        break;
+    }
+  }
+}
+
+customElements.define(GrChangeStatus.is, GrChangeStatus);
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js
index eaca593..1a1bc1b8 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-change-status">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .chip {
         border-radius: var(--border-radius);
@@ -70,17 +64,9 @@
         color: white;
       }
     </style>
-    <gr-tooltip-content
-        has-tooltip
-        position-below
-        title="[[tooltipText]]"
-        max-width="40em">
-      <div
-          class="chip"
-          aria-label$="Label: [[status]]">
+    <gr-tooltip-content has-tooltip="" position-below="" title="[[tooltipText]]" max-width="40em">
+      <div class="chip" aria-label\$="Label: [[status]]">
         [[_computeStatusString(status)]]
       </div>
     </gr-tooltip-content>
-  </template>
-  <script src="gr-change-status.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
index d78cc3a..819411f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-status</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-change-status.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-change-status.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-change-status.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,106 +40,108 @@
   </template>
 </test-fixture>
 
-<script>
-  const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
-      'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
-      'and email notifications will be silenced until the review is started.';
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-change-status.js';
+const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
+    'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
+    'and email notifications will be silenced until the review is started.';
 
-  const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
-    'Download the patch and run "git rebase master". ' +
-    'Upload a new patchset after resolving all merge conflicts.';
+const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
+  'Download the patch and run "git rebase master". ' +
+  'Upload a new patchset after resolving all merge conflicts.';
 
-  const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
-      'current reviewers (or anyone with "View Private Changes" permission).';
+const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
+    'current reviewers (or anyone with "View Private Changes" permission).';
 
-  suite('gr-change-status tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+suite('gr-change-status tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('WIP', () => {
-      element.status = 'WIP';
-      assert.equal(element.shadowRoot
-          .querySelector('.chip').innerText, 'Work in Progress');
-      assert.equal(element.tooltipText, WIP_TOOLTIP);
-      assert.isTrue(element.classList.contains('wip'));
-    });
-
-    test('WIP flat', () => {
-      element.flat = true;
-      element.status = 'WIP';
-      assert.equal(element.shadowRoot
-          .querySelector('.chip').innerText, 'WIP');
-      assert.isDefined(element.tooltipText);
-      assert.isTrue(element.classList.contains('wip'));
-      assert.isTrue(element.hasAttribute('flat'));
-    });
-
-    test('merged', () => {
-      element.status = 'Merged';
-      assert.equal(element.shadowRoot
-          .querySelector('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, '');
-      assert.isTrue(element.classList.contains('merged'));
-    });
-
-    test('abandoned', () => {
-      element.status = 'Abandoned';
-      assert.equal(element.shadowRoot
-          .querySelector('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, '');
-      assert.isTrue(element.classList.contains('abandoned'));
-    });
-
-    test('merge conflict', () => {
-      element.status = 'Merge Conflict';
-      assert.equal(element.shadowRoot
-          .querySelector('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
-      assert.isTrue(element.classList.contains('merge-conflict'));
-    });
-
-    test('private', () => {
-      element.status = 'Private';
-      assert.equal(element.shadowRoot
-          .querySelector('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
-      assert.isTrue(element.classList.contains('private'));
-    });
-
-    test('active', () => {
-      element.status = 'Active';
-      assert.equal(element.shadowRoot
-          .querySelector('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, '');
-      assert.isTrue(element.classList.contains('active'));
-    });
-
-    test('ready to submit', () => {
-      element.status = 'Ready to submit';
-      assert.equal(element.shadowRoot
-          .querySelector('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, '');
-      assert.isTrue(element.classList.contains('ready-to-submit'));
-    });
-
-    test('updating status removes the previous class', () => {
-      element.status = 'Private';
-      assert.isTrue(element.classList.contains('private'));
-      assert.isFalse(element.classList.contains('wip'));
-
-      element.status = 'WIP';
-      assert.isFalse(element.classList.contains('private'));
-      assert.isTrue(element.classList.contains('wip'));
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('WIP', () => {
+    element.status = 'WIP';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, 'Work in Progress');
+    assert.equal(element.tooltipText, WIP_TOOLTIP);
+    assert.isTrue(element.classList.contains('wip'));
+  });
+
+  test('WIP flat', () => {
+    element.flat = true;
+    element.status = 'WIP';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, 'WIP');
+    assert.isDefined(element.tooltipText);
+    assert.isTrue(element.classList.contains('wip'));
+    assert.isTrue(element.hasAttribute('flat'));
+  });
+
+  test('merged', () => {
+    element.status = 'Merged';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('merged'));
+  });
+
+  test('abandoned', () => {
+    element.status = 'Abandoned';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('abandoned'));
+  });
+
+  test('merge conflict', () => {
+    element.status = 'Merge Conflict';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
+    assert.isTrue(element.classList.contains('merge-conflict'));
+  });
+
+  test('private', () => {
+    element.status = 'Private';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
+    assert.isTrue(element.classList.contains('private'));
+  });
+
+  test('active', () => {
+    element.status = 'Active';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('active'));
+  });
+
+  test('ready to submit', () => {
+    element.status = 'Ready to submit';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('ready-to-submit'));
+  });
+
+  test('updating status removes the previous class', () => {
+    element.status = 'Private';
+    assert.isTrue(element.classList.contains('private'));
+    assert.isFalse(element.classList.contains('wip'));
+
+    element.status = 'WIP';
+    assert.isFalse(element.classList.contains('private'));
+    assert.isTrue(element.classList.contains('wip'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
index d8a56f8..765c5cc 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,517 +14,532 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const UNRESOLVED_EXPAND_COUNT = 5;
-  const NEWLINE_PATTERN = /\n/g;
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-storage/gr-storage.js';
+import '../gr-comment/gr-comment.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-comment-thread_html.js';
+
+const UNRESOLVED_EXPAND_COUNT = 5;
+const NEWLINE_PATTERN = /\n/g;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @appliesMixin Gerrit.PathListMixin
+ * @extends Polymer.Element
+ */
+class GrCommentThread extends mixinBehaviors( [
+  /**
+   * Not used in this element rather other elements tests
+   */
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+  Gerrit.PathListBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-comment-thread'; }
+  /**
+   * Fired when the thread should be discarded.
+   *
+   * @event thread-discard
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @appliesMixin Gerrit.PathListMixin
-   * @extends Polymer.Element
+   * Fired when a comment in the thread is permanently modified.
+   *
+   * @event thread-changed
    */
-  class GrCommentThread extends Polymer.mixinBehaviors( [
-    /**
-     * Not used in this element rather other elements tests
-     */
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-    Gerrit.PathListBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-comment-thread'; }
-    /**
-     * Fired when the thread should be discarded.
-     *
-     * @event thread-discard
-     */
 
-    /**
-     * Fired when a comment in the thread is permanently modified.
-     *
-     * @event thread-changed
-     */
+  /**
+   * gr-comment-thread exposes the following attributes that allow a
+   * diff widget like gr-diff to show the thread in the right location:
+   *
+   * line-num:
+   *     1-based line number or undefined if it refers to the entire file.
+   *
+   * comment-side:
+   *     "left" or "right". These indicate which of the two diffed versions
+   *     the comment relates to. In the case of unified diff, the left
+   *     version is the one whose line number column is further to the left.
+   *
+   * range:
+   *     The range of text that the comment refers to (start_line,
+   *     start_character, end_line, end_character), serialized as JSON. If
+   *     set, range's end_line will have the same value as line-num. Line
+   *     numbers are 1-based, char numbers are 0-based. The start position
+   *     (start_line, start_character) is inclusive, and the end position
+   *     (end_line, end_character) is exclusive.
+   */
+  static get properties() {
+    return {
+      changeNum: String,
+      comments: {
+        type: Array,
+        value() { return []; },
+      },
+      /**
+       * @type {?{start_line: number, start_character: number, end_line: number,
+       *          end_character: number}}
+       */
+      range: {
+        type: Object,
+        reflectToAttribute: true,
+      },
+      keyEventTarget: {
+        type: Object,
+        value() { return document.body; },
+      },
+      commentSide: {
+        type: String,
+        reflectToAttribute: true,
+      },
+      patchNum: String,
+      path: String,
+      projectName: {
+        type: String,
+        observer: '_projectNameChanged',
+      },
+      hasDraft: {
+        type: Boolean,
+        notify: true,
+        reflectToAttribute: true,
+      },
+      isOnParent: {
+        type: Boolean,
+        value: false,
+      },
+      parentIndex: {
+        type: Number,
+        value: null,
+      },
+      rootId: {
+        type: String,
+        notify: true,
+        computed: '_computeRootId(comments.*)',
+      },
+      /**
+       * If this is true, the comment thread also needs to have the change and
+       * line properties property set
+       */
+      showFilePath: {
+        type: Boolean,
+        value: false,
+      },
+      /** Necessary only if showFilePath is true or when used with gr-diff */
+      lineNum: {
+        type: Number,
+        reflectToAttribute: true,
+      },
+      unresolved: {
+        type: Boolean,
+        notify: true,
+        reflectToAttribute: true,
+      },
+      _showActions: Boolean,
+      _lastComment: Object,
+      _orderedComments: Array,
+      _projectConfig: Object,
+      isRobotComment: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+    };
+  }
 
-    /**
-     * gr-comment-thread exposes the following attributes that allow a
-     * diff widget like gr-diff to show the thread in the right location:
-     *
-     * line-num:
-     *     1-based line number or undefined if it refers to the entire file.
-     *
-     * comment-side:
-     *     "left" or "right". These indicate which of the two diffed versions
-     *     the comment relates to. In the case of unified diff, the left
-     *     version is the one whose line number column is further to the left.
-     *
-     * range:
-     *     The range of text that the comment refers to (start_line,
-     *     start_character, end_line, end_character), serialized as JSON. If
-     *     set, range's end_line will have the same value as line-num. Line
-     *     numbers are 1-based, char numbers are 0-based. The start position
-     *     (start_line, start_character) is inclusive, and the end position
-     *     (end_line, end_character) is exclusive.
-     */
-    static get properties() {
-      return {
-        changeNum: String,
-        comments: {
-          type: Array,
-          value() { return []; },
-        },
-        /**
-         * @type {?{start_line: number, start_character: number, end_line: number,
-         *          end_character: number}}
-         */
-        range: {
-          type: Object,
-          reflectToAttribute: true,
-        },
-        keyEventTarget: {
-          type: Object,
-          value() { return document.body; },
-        },
-        commentSide: {
-          type: String,
-          reflectToAttribute: true,
-        },
-        patchNum: String,
-        path: String,
-        projectName: {
-          type: String,
-          observer: '_projectNameChanged',
-        },
-        hasDraft: {
-          type: Boolean,
-          notify: true,
-          reflectToAttribute: true,
-        },
-        isOnParent: {
-          type: Boolean,
-          value: false,
-        },
-        parentIndex: {
-          type: Number,
-          value: null,
-        },
-        rootId: {
-          type: String,
-          notify: true,
-          computed: '_computeRootId(comments.*)',
-        },
-        /**
-         * If this is true, the comment thread also needs to have the change and
-         * line properties property set
-         */
-        showFilePath: {
-          type: Boolean,
-          value: false,
-        },
-        /** Necessary only if showFilePath is true or when used with gr-diff */
-        lineNum: {
-          type: Number,
-          reflectToAttribute: true,
-        },
-        unresolved: {
-          type: Boolean,
-          notify: true,
-          reflectToAttribute: true,
-        },
-        _showActions: Boolean,
-        _lastComment: Object,
-        _orderedComments: Array,
-        _projectConfig: Object,
-        isRobotComment: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-      };
-    }
+  static get observers() {
+    return [
+      '_commentsChanged(comments.*)',
+    ];
+  }
 
-    static get observers() {
-      return [
-        '_commentsChanged(comments.*)',
-      ];
-    }
+  get keyBindings() {
+    return {
+      'e shift+e': '_handleEKey',
+    };
+  }
 
-    get keyBindings() {
-      return {
-        'e shift+e': '_handleEKey',
-      };
-    }
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('comment-update',
+        e => this._handleCommentUpdate(e));
+  }
 
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('comment-update',
-          e => this._handleCommentUpdate(e));
-    }
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._showActions = loggedIn;
+    });
+    this._setInitialExpandedState();
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this._getLoggedIn().then(loggedIn => {
-        this._showActions = loggedIn;
-      });
-      this._setInitialExpandedState();
-    }
+  addOrEditDraft(opt_lineNum, opt_range) {
+    const lastComment = this.comments[this.comments.length - 1] || {};
+    if (lastComment.__draft) {
+      const commentEl = this._commentElWithDraftID(
+          lastComment.id || lastComment.__draftID);
+      commentEl.editing = true;
 
-    addOrEditDraft(opt_lineNum, opt_range) {
-      const lastComment = this.comments[this.comments.length - 1] || {};
-      if (lastComment.__draft) {
-        const commentEl = this._commentElWithDraftID(
-            lastComment.id || lastComment.__draftID);
-        commentEl.editing = true;
-
-        // If the comment was collapsed, re-open it to make it clear which
-        // actions are available.
-        commentEl.collapsed = false;
-      } else {
-        const range = opt_range ? opt_range :
-          lastComment ? lastComment.range : undefined;
-        const unresolved = lastComment ? lastComment.unresolved : undefined;
-        this.addDraft(opt_lineNum, range, unresolved);
-      }
-    }
-
-    addDraft(opt_lineNum, opt_range, opt_unresolved) {
-      const draft = this._newDraft(opt_lineNum, opt_range);
-      draft.__editing = true;
-      draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
-      this.push('comments', draft);
-    }
-
-    fireRemoveSelf() {
-      this.dispatchEvent(new CustomEvent('thread-discard',
-          {detail: {rootId: this.rootId}, bubbles: false}));
-    }
-
-    _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
-      return Gerrit.Nav.getUrlForDiffById(changeNum,
-          projectName, path, patchNum,
-          null, this.lineNum);
-    }
-
-    _computeDisplayPath(path) {
-      const lineString = this.lineNum ? `#${this.lineNum}` : '';
-      return this.computeDisplayPath(path) + lineString;
-    }
-
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    }
-
-    _commentsChanged() {
-      this._orderedComments = this._sortedComments(this.comments);
-      this.updateThreadProperties();
-    }
-
-    updateThreadProperties() {
-      if (this._orderedComments.length) {
-        this._lastComment = this._getLastComment();
-        this.unresolved = this._lastComment.unresolved;
-        this.hasDraft = this._lastComment.__draft;
-        this.isRobotComment = !!(this._lastComment.robot_id);
-      }
-    }
-
-    _shouldDisableAction(_showActions, _lastComment) {
-      return !_showActions || !_lastComment || !!_lastComment.__draft;
-    }
-
-    _hideActions(_showActions, _lastComment) {
-      return this._shouldDisableAction(_showActions, _lastComment) ||
-        !!_lastComment.robot_id;
-    }
-
-    _getLastComment() {
-      return this._orderedComments[this._orderedComments.length - 1] || {};
-    }
-
-    _handleEKey(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      // Don’t preventDefault in this case because it will render the event
-      // useless for other handlers (other gr-comment-thread elements).
-      if (e.detail.keyboardEvent.shiftKey) {
-        this._expandCollapseComments(true);
-      } else {
-        if (this.modifierPressed(e)) { return; }
-        this._expandCollapseComments(false);
-      }
-    }
-
-    _expandCollapseComments(actionIsCollapse) {
-      const comments =
-          Polymer.dom(this.root).querySelectorAll('gr-comment');
-      for (const comment of comments) {
-        comment.collapsed = actionIsCollapse;
-      }
-    }
-
-    /**
-     * Sets the initial state of the comment thread.
-     * Expands the thread if one of the following is true:
-     * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
-     * thread is unresolved,
-     * - it's a robot comment.
-     */
-    _setInitialExpandedState() {
-      if (this._orderedComments) {
-        for (let i = 0; i < this._orderedComments.length; i++) {
-          const comment = this._orderedComments[i];
-          const isRobotComment = !!comment.robot_id;
-          // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
-          const resolvedThread = !this.unresolved ||
-                this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
-          comment.collapsed = !isRobotComment && resolvedThread;
-        }
-      }
-    }
-
-    _sortedComments(comments) {
-      return comments.slice().sort((c1, c2) => {
-        const c1Date = c1.__date || util.parseDate(c1.updated);
-        const c2Date = c2.__date || util.parseDate(c2.updated);
-        const dateCompare = c1Date - c2Date;
-        // Ensure drafts are at the end. There should only be one but in edge
-        // cases could be more. In the unlikely event two drafts are being
-        // compared, use the typical date compare.
-        if (c2.__draft && !c1.__draft ) { return -1; }
-        if (c1.__draft && !c2.__draft ) { return 1; }
-        if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
-        // If same date, fall back to sorting by id.
-        return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
-      });
-    }
-
-    _createReplyComment(parent, content, opt_isEditing,
-        opt_unresolved) {
-      this.$.reporting.recordDraftInteraction();
-      const reply = this._newReply(
-          this._orderedComments[this._orderedComments.length - 1].id,
-          parent.line,
-          content,
-          opt_unresolved,
-          parent.range);
-
-      // If there is currently a comment in an editing state, add an attribute
-      // so that the gr-comment knows not to populate the draft text.
-      for (let i = 0; i < this.comments.length; i++) {
-        if (this.comments[i].__editing) {
-          reply.__otherEditing = true;
-          break;
-        }
-      }
-
-      if (opt_isEditing) {
-        reply.__editing = true;
-      }
-
-      this.push('comments', reply);
-
-      if (!opt_isEditing) {
-        // Allow the reply to render in the dom-repeat.
-        this.async(() => {
-          const commentEl = this._commentElWithDraftID(reply.__draftID);
-          commentEl.save();
-        }, 1);
-      }
-    }
-
-    _isDraft(comment) {
-      return !!comment.__draft;
-    }
-
-    /**
-     * @param {boolean=} opt_quote
-     */
-    _processCommentReply(opt_quote) {
-      const comment = this._lastComment;
-      let quoteStr;
-      if (opt_quote) {
-        const msg = comment.message;
-        quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
-      }
-      this._createReplyComment(comment, quoteStr, true, comment.unresolved);
-    }
-
-    _handleCommentReply(e) {
-      this._processCommentReply();
-    }
-
-    _handleCommentQuote(e) {
-      this._processCommentReply(true);
-    }
-
-    _handleCommentAck(e) {
-      const comment = this._lastComment;
-      this._createReplyComment(comment, 'Ack', false, false);
-    }
-
-    _handleCommentDone(e) {
-      const comment = this._lastComment;
-      this._createReplyComment(comment, 'Done', false, false);
-    }
-
-    _handleCommentFix(e) {
-      const comment = e.detail.comment;
-      const msg = comment.message;
-      const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
-      const response = quoteStr + 'Please fix.';
-      this._createReplyComment(comment, response, false, true);
-    }
-
-    _commentElWithDraftID(id) {
-      const els = Polymer.dom(this.root).querySelectorAll('gr-comment');
-      for (const el of els) {
-        if (el.comment.id === id || el.comment.__draftID === id) {
-          return el;
-        }
-      }
-      return null;
-    }
-
-    _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
-        opt_range) {
-      const d = this._newDraft(opt_lineNum);
-      d.in_reply_to = inReplyTo;
-      d.range = opt_range;
-      if (opt_message != null) {
-        d.message = opt_message;
-      }
-      if (opt_unresolved !== undefined) {
-        d.unresolved = opt_unresolved;
-      }
-      return d;
-    }
-
-    /**
-     * @param {number=} opt_lineNum
-     * @param {!Object=} opt_range
-     */
-    _newDraft(opt_lineNum, opt_range) {
-      const d = {
-        __draft: true,
-        __draftID: Math.random().toString(36),
-        __date: new Date(),
-        path: this.path,
-        patchNum: this.patchNum,
-        side: this._getSide(this.isOnParent),
-        __commentSide: this.commentSide,
-      };
-      if (opt_lineNum) {
-        d.line = opt_lineNum;
-      }
-      if (opt_range) {
-        d.range = opt_range;
-      }
-      if (this.parentIndex) {
-        d.parent = this.parentIndex;
-      }
-      return d;
-    }
-
-    _getSide(isOnParent) {
-      if (isOnParent) { return 'PARENT'; }
-      return 'REVISION';
-    }
-
-    _computeRootId(comments) {
-      // Keep the root ID even if the comment was removed, so that notification
-      // to sync will know which thread to remove.
-      if (!comments.base.length) { return this.rootId; }
-      const rootComment = comments.base[0];
-      return rootComment.id || rootComment.__draftID;
-    }
-
-    _handleCommentDiscard(e) {
-      const diffCommentEl = Polymer.dom(e).rootTarget;
-      const comment = diffCommentEl.comment;
-      const idx = this._indexOf(comment, this.comments);
-      if (idx == -1) {
-        throw Error('Cannot find comment ' +
-            JSON.stringify(diffCommentEl.comment));
-      }
-      this.splice('comments', idx, 1);
-      if (this.comments.length === 0) {
-        this.fireRemoveSelf();
-      }
-      this._handleCommentSavedOrDiscarded(e);
-
-      // Check to see if there are any other open comments getting edited and
-      // set the local storage value to its message value.
-      for (const changeComment of this.comments) {
-        if (changeComment.__editing) {
-          const commentLocation = {
-            changeNum: this.changeNum,
-            patchNum: this.patchNum,
-            path: changeComment.path,
-            line: changeComment.line,
-          };
-          return this.$.storage.setDraftComment(commentLocation,
-              changeComment.message);
-        }
-      }
-    }
-
-    _handleCommentSavedOrDiscarded(e) {
-      this.dispatchEvent(new CustomEvent('thread-changed',
-          {detail: {rootId: this.rootId, path: this.path},
-            bubbles: false}));
-    }
-
-    _handleCommentUpdate(e) {
-      const comment = e.detail.comment;
-      const index = this._indexOf(comment, this.comments);
-      if (index === -1) {
-        // This should never happen: comment belongs to another thread.
-        console.warn('Comment update for another comment thread.');
-        return;
-      }
-      this.set(['comments', index], comment);
-      // Because of the way we pass these comment objects around by-ref, in
-      // combination with the fact that Polymer does dirty checking in
-      // observers, the this.set() call above will not cause a thread update in
-      // some situations.
-      this.updateThreadProperties();
-    }
-
-    _indexOf(comment, arr) {
-      for (let i = 0; i < arr.length; i++) {
-        const c = arr[i];
-        if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
-            (c.id != null && c.id == comment.id)) {
-          return i;
-        }
-      }
-      return -1;
-    }
-
-    _computeHostClass(unresolved) {
-      if (this.isRobotComment) {
-        return 'robotComment';
-      }
-      return unresolved ? 'unresolved' : '';
-    }
-
-    /**
-     * Load the project config when a project name has been provided.
-     *
-     * @param {string} name The project name.
-     */
-    _projectNameChanged(name) {
-      if (!name) { return; }
-      this.$.restAPI.getProjectConfig(name).then(config => {
-        this._projectConfig = config;
-      });
+      // If the comment was collapsed, re-open it to make it clear which
+      // actions are available.
+      commentEl.collapsed = false;
+    } else {
+      const range = opt_range ? opt_range :
+        lastComment ? lastComment.range : undefined;
+      const unresolved = lastComment ? lastComment.unresolved : undefined;
+      this.addDraft(opt_lineNum, range, unresolved);
     }
   }
 
-  customElements.define(GrCommentThread.is, GrCommentThread);
-})();
+  addDraft(opt_lineNum, opt_range, opt_unresolved) {
+    const draft = this._newDraft(opt_lineNum, opt_range);
+    draft.__editing = true;
+    draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
+    this.push('comments', draft);
+  }
+
+  fireRemoveSelf() {
+    this.dispatchEvent(new CustomEvent('thread-discard',
+        {detail: {rootId: this.rootId}, bubbles: false}));
+  }
+
+  _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
+    return Gerrit.Nav.getUrlForDiffById(changeNum,
+        projectName, path, patchNum,
+        null, this.lineNum);
+  }
+
+  _computeDisplayPath(path) {
+    const lineString = this.lineNum ? `#${this.lineNum}` : '';
+    return this.computeDisplayPath(path) + lineString;
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _commentsChanged() {
+    this._orderedComments = this._sortedComments(this.comments);
+    this.updateThreadProperties();
+  }
+
+  updateThreadProperties() {
+    if (this._orderedComments.length) {
+      this._lastComment = this._getLastComment();
+      this.unresolved = this._lastComment.unresolved;
+      this.hasDraft = this._lastComment.__draft;
+      this.isRobotComment = !!(this._lastComment.robot_id);
+    }
+  }
+
+  _shouldDisableAction(_showActions, _lastComment) {
+    return !_showActions || !_lastComment || !!_lastComment.__draft;
+  }
+
+  _hideActions(_showActions, _lastComment) {
+    return this._shouldDisableAction(_showActions, _lastComment) ||
+      !!_lastComment.robot_id;
+  }
+
+  _getLastComment() {
+    return this._orderedComments[this._orderedComments.length - 1] || {};
+  }
+
+  _handleEKey(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    // Don’t preventDefault in this case because it will render the event
+    // useless for other handlers (other gr-comment-thread elements).
+    if (e.detail.keyboardEvent.shiftKey) {
+      this._expandCollapseComments(true);
+    } else {
+      if (this.modifierPressed(e)) { return; }
+      this._expandCollapseComments(false);
+    }
+  }
+
+  _expandCollapseComments(actionIsCollapse) {
+    const comments =
+        dom(this.root).querySelectorAll('gr-comment');
+    for (const comment of comments) {
+      comment.collapsed = actionIsCollapse;
+    }
+  }
+
+  /**
+   * Sets the initial state of the comment thread.
+   * Expands the thread if one of the following is true:
+   * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
+   * thread is unresolved,
+   * - it's a robot comment.
+   */
+  _setInitialExpandedState() {
+    if (this._orderedComments) {
+      for (let i = 0; i < this._orderedComments.length; i++) {
+        const comment = this._orderedComments[i];
+        const isRobotComment = !!comment.robot_id;
+        // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
+        const resolvedThread = !this.unresolved ||
+              this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
+        comment.collapsed = !isRobotComment && resolvedThread;
+      }
+    }
+  }
+
+  _sortedComments(comments) {
+    return comments.slice().sort((c1, c2) => {
+      const c1Date = c1.__date || util.parseDate(c1.updated);
+      const c2Date = c2.__date || util.parseDate(c2.updated);
+      const dateCompare = c1Date - c2Date;
+      // Ensure drafts are at the end. There should only be one but in edge
+      // cases could be more. In the unlikely event two drafts are being
+      // compared, use the typical date compare.
+      if (c2.__draft && !c1.__draft ) { return -1; }
+      if (c1.__draft && !c2.__draft ) { return 1; }
+      if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
+      // If same date, fall back to sorting by id.
+      return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
+    });
+  }
+
+  _createReplyComment(parent, content, opt_isEditing,
+      opt_unresolved) {
+    this.$.reporting.recordDraftInteraction();
+    const reply = this._newReply(
+        this._orderedComments[this._orderedComments.length - 1].id,
+        parent.line,
+        content,
+        opt_unresolved,
+        parent.range);
+
+    // If there is currently a comment in an editing state, add an attribute
+    // so that the gr-comment knows not to populate the draft text.
+    for (let i = 0; i < this.comments.length; i++) {
+      if (this.comments[i].__editing) {
+        reply.__otherEditing = true;
+        break;
+      }
+    }
+
+    if (opt_isEditing) {
+      reply.__editing = true;
+    }
+
+    this.push('comments', reply);
+
+    if (!opt_isEditing) {
+      // Allow the reply to render in the dom-repeat.
+      this.async(() => {
+        const commentEl = this._commentElWithDraftID(reply.__draftID);
+        commentEl.save();
+      }, 1);
+    }
+  }
+
+  _isDraft(comment) {
+    return !!comment.__draft;
+  }
+
+  /**
+   * @param {boolean=} opt_quote
+   */
+  _processCommentReply(opt_quote) {
+    const comment = this._lastComment;
+    let quoteStr;
+    if (opt_quote) {
+      const msg = comment.message;
+      quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
+    }
+    this._createReplyComment(comment, quoteStr, true, comment.unresolved);
+  }
+
+  _handleCommentReply(e) {
+    this._processCommentReply();
+  }
+
+  _handleCommentQuote(e) {
+    this._processCommentReply(true);
+  }
+
+  _handleCommentAck(e) {
+    const comment = this._lastComment;
+    this._createReplyComment(comment, 'Ack', false, false);
+  }
+
+  _handleCommentDone(e) {
+    const comment = this._lastComment;
+    this._createReplyComment(comment, 'Done', false, false);
+  }
+
+  _handleCommentFix(e) {
+    const comment = e.detail.comment;
+    const msg = comment.message;
+    const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
+    const response = quoteStr + 'Please fix.';
+    this._createReplyComment(comment, response, false, true);
+  }
+
+  _commentElWithDraftID(id) {
+    const els = dom(this.root).querySelectorAll('gr-comment');
+    for (const el of els) {
+      if (el.comment.id === id || el.comment.__draftID === id) {
+        return el;
+      }
+    }
+    return null;
+  }
+
+  _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
+      opt_range) {
+    const d = this._newDraft(opt_lineNum);
+    d.in_reply_to = inReplyTo;
+    d.range = opt_range;
+    if (opt_message != null) {
+      d.message = opt_message;
+    }
+    if (opt_unresolved !== undefined) {
+      d.unresolved = opt_unresolved;
+    }
+    return d;
+  }
+
+  /**
+   * @param {number=} opt_lineNum
+   * @param {!Object=} opt_range
+   */
+  _newDraft(opt_lineNum, opt_range) {
+    const d = {
+      __draft: true,
+      __draftID: Math.random().toString(36),
+      __date: new Date(),
+      path: this.path,
+      patchNum: this.patchNum,
+      side: this._getSide(this.isOnParent),
+      __commentSide: this.commentSide,
+    };
+    if (opt_lineNum) {
+      d.line = opt_lineNum;
+    }
+    if (opt_range) {
+      d.range = opt_range;
+    }
+    if (this.parentIndex) {
+      d.parent = this.parentIndex;
+    }
+    return d;
+  }
+
+  _getSide(isOnParent) {
+    if (isOnParent) { return 'PARENT'; }
+    return 'REVISION';
+  }
+
+  _computeRootId(comments) {
+    // Keep the root ID even if the comment was removed, so that notification
+    // to sync will know which thread to remove.
+    if (!comments.base.length) { return this.rootId; }
+    const rootComment = comments.base[0];
+    return rootComment.id || rootComment.__draftID;
+  }
+
+  _handleCommentDiscard(e) {
+    const diffCommentEl = dom(e).rootTarget;
+    const comment = diffCommentEl.comment;
+    const idx = this._indexOf(comment, this.comments);
+    if (idx == -1) {
+      throw Error('Cannot find comment ' +
+          JSON.stringify(diffCommentEl.comment));
+    }
+    this.splice('comments', idx, 1);
+    if (this.comments.length === 0) {
+      this.fireRemoveSelf();
+    }
+    this._handleCommentSavedOrDiscarded(e);
+
+    // Check to see if there are any other open comments getting edited and
+    // set the local storage value to its message value.
+    for (const changeComment of this.comments) {
+      if (changeComment.__editing) {
+        const commentLocation = {
+          changeNum: this.changeNum,
+          patchNum: this.patchNum,
+          path: changeComment.path,
+          line: changeComment.line,
+        };
+        return this.$.storage.setDraftComment(commentLocation,
+            changeComment.message);
+      }
+    }
+  }
+
+  _handleCommentSavedOrDiscarded(e) {
+    this.dispatchEvent(new CustomEvent('thread-changed',
+        {detail: {rootId: this.rootId, path: this.path},
+          bubbles: false}));
+  }
+
+  _handleCommentUpdate(e) {
+    const comment = e.detail.comment;
+    const index = this._indexOf(comment, this.comments);
+    if (index === -1) {
+      // This should never happen: comment belongs to another thread.
+      console.warn('Comment update for another comment thread.');
+      return;
+    }
+    this.set(['comments', index], comment);
+    // Because of the way we pass these comment objects around by-ref, in
+    // combination with the fact that Polymer does dirty checking in
+    // observers, the this.set() call above will not cause a thread update in
+    // some situations.
+    this.updateThreadProperties();
+  }
+
+  _indexOf(comment, arr) {
+    for (let i = 0; i < arr.length; i++) {
+      const c = arr[i];
+      if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
+          (c.id != null && c.id == comment.id)) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  _computeHostClass(unresolved) {
+    if (this.isRobotComment) {
+      return 'robotComment';
+    }
+    return unresolved ? 'unresolved' : '';
+  }
+
+  /**
+   * Load the project config when a project name has been provided.
+   *
+   * @param {string} name The project name.
+   */
+  _projectNameChanged(name) {
+    if (!name) { return; }
+    this.$.restAPI.getProjectConfig(name).then(config => {
+      this._projectConfig = config;
+    });
+  }
+}
+
+customElements.define(GrCommentThread.is, GrCommentThread);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
index d615a7f..1d991cb 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
@@ -1,32 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../gr-comment/gr-comment.html">
-
-<dom-module id="gr-comment-thread">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         font-family: var(--font-family);
@@ -83,58 +73,25 @@
     </style>
     <template is="dom-if" if="[[showFilePath]]">
       <div class="pathInfo">
-        <a href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]">[[_computeDisplayPath(path)]]</a>
+        <a href\$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]">[[_computeDisplayPath(path)]]</a>
         <span class="descriptionText">Patchset [[patchNum]]</span>
       </div>
     </template>
-    <div id="container" class$="[[_computeHostClass(unresolved, isRobotComment)]]">
-      <template id="commentList" is="dom-repeat" items="[[_orderedComments]]"
-          as="comment">
-        <gr-comment
-            comment="{{comment}}"
-            comments="{{comments}}"
-            robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
-            change-num="[[changeNum]]"
-            patch-num="[[patchNum]]"
-            draft="[[_isDraft(comment)]]"
-            show-actions="[[_showActions]]"
-            comment-side="[[comment.__commentSide]]"
-            side="[[comment.side]]"
-            project-config="[[_projectConfig]]"
-            on-create-fix-comment="_handleCommentFix"
-            on-comment-discard="_handleCommentDiscard"
-            on-comment-save="_handleCommentSavedOrDiscarded"></gr-comment>
+    <div id="container" class\$="[[_computeHostClass(unresolved, isRobotComment)]]">
+      <template id="commentList" is="dom-repeat" items="[[_orderedComments]]" as="comment">
+        <gr-comment comment="{{comment}}" comments="{{comments}}" robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]" change-num="[[changeNum]]" patch-num="[[patchNum]]" draft="[[_isDraft(comment)]]" show-actions="[[_showActions]]" comment-side="[[comment.__commentSide]]" side="[[comment.side]]" project-config="[[_projectConfig]]" on-create-fix-comment="_handleCommentFix" on-comment-discard="_handleCommentDiscard" on-comment-save="_handleCommentSavedOrDiscarded"></gr-comment>
       </template>
-      <div id="commentInfoContainer"
-          hidden$="[[_hideActions(_showActions, _lastComment)]]">
-        <span id="unresolvedLabel" hidden$="[[!unresolved]]">Unresolved</span>
+      <div id="commentInfoContainer" hidden\$="[[_hideActions(_showActions, _lastComment)]]">
+        <span id="unresolvedLabel" hidden\$="[[!unresolved]]">Unresolved</span>
         <div id="actions">
-          <gr-button
-              id="replyBtn"
-              link
-              class="action reply"
-              on-click="_handleCommentReply">Reply</gr-button>
-          <gr-button
-              id="quoteBtn"
-              link
-              class="action quote"
-              on-click="_handleCommentQuote">Quote</gr-button>
-          <gr-button
-              id="ackBtn"
-              link
-              class="action ack"
-              on-click="_handleCommentAck">Ack</gr-button>
-          <gr-button
-              id="doneBtn"
-              link
-              class="action done"
-              on-click="_handleCommentDone">Done</gr-button>
+          <gr-button id="replyBtn" link="" class="action reply" on-click="_handleCommentReply">Reply</gr-button>
+          <gr-button id="quoteBtn" link="" class="action quote" on-click="_handleCommentQuote">Quote</gr-button>
+          <gr-button id="ackBtn" link="" class="action ack" on-click="_handleCommentAck">Ack</gr-button>
+          <gr-button id="doneBtn" link="" class="action done" on-click="_handleCommentDone">Done</gr-button>
         </div>
       </div>
     </div>
     <gr-reporting id="reporting"></gr-reporting>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
-  </template>
-  <script src="gr-comment-thread.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
index a17a174..bb71869 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment-thread</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-comment-thread.html">
+<script type="module" src="./gr-comment-thread.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-comment-thread.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -43,197 +49,14 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-comment-thread tests', async () => {
-    await readyToTest();
-
-    suite('basic test', () => {
-      let element;
-      let sandbox;
-
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-        stub('gr-rest-api-interface', {
-          getLoggedIn() { return Promise.resolve(false); },
-        });
-        sandbox = sinon.sandbox.create();
-        element = fixture('basic');
-      });
-
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      test('comments are sorted correctly', () => {
-        const comments = [
-          {
-            message: 'i like you, too',
-            in_reply_to: 'sallys_confession',
-            __date: new Date('2015-12-25'),
-          }, {
-            id: 'sallys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-24 15:00:20.396000000',
-          }, {
-            id: 'sally_to_dr_finklestein',
-            message: 'i’m running away',
-            updated: '2015-10-31 09:00:20.396000000',
-          }, {
-            id: 'sallys_defiance',
-            in_reply_to: 'sally_to_dr_finklestein',
-            message: 'i will poison you so i can get away',
-            updated: '2015-10-31 15:00:20.396000000',
-          }, {
-            id: 'dr_finklesteins_response',
-            in_reply_to: 'sally_to_dr_finklestein',
-            message: 'no i will pull a thread and your arm will fall off',
-            updated: '2015-10-31 11:00:20.396000000',
-          }, {
-            id: 'sallys_mission',
-            message: 'i have to find santa',
-            updated: '2015-12-24 15:00:20.396000000',
-          },
-        ];
-        const results = element._sortedComments(comments);
-        assert.deepEqual(results, [
-          {
-            id: 'sally_to_dr_finklestein',
-            message: 'i’m running away',
-            updated: '2015-10-31 09:00:20.396000000',
-          }, {
-            id: 'dr_finklesteins_response',
-            in_reply_to: 'sally_to_dr_finklestein',
-            message: 'no i will pull a thread and your arm will fall off',
-            updated: '2015-10-31 11:00:20.396000000',
-          }, {
-            id: 'sallys_defiance',
-            in_reply_to: 'sally_to_dr_finklestein',
-            message: 'i will poison you so i can get away',
-            updated: '2015-10-31 15:00:20.396000000',
-          }, {
-            id: 'sallys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-24 15:00:20.396000000',
-          }, {
-            id: 'sallys_mission',
-            message: 'i have to find santa',
-            updated: '2015-12-24 15:00:20.396000000',
-          }, {
-            message: 'i like you, too',
-            in_reply_to: 'sallys_confession',
-            __date: new Date('2015-12-25'),
-          },
-        ]);
-      });
-
-      test('addOrEditDraft w/ edit draft', () => {
-        element.comments = [{
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          updated: '2015-12-25 15:00:20.396000000',
-          __draft: true,
-        }];
-        const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
-            () => { return {}; });
-        const addDraftStub = sandbox.stub(element, 'addDraft');
-
-        element.addOrEditDraft(123);
-
-        assert.isTrue(commentElStub.called);
-        assert.isFalse(addDraftStub.called);
-      });
-
-      test('addOrEditDraft w/o edit draft', () => {
-        element.comments = [];
-        const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
-            () => { return {}; });
-        const addDraftStub = sandbox.stub(element, 'addDraft');
-
-        element.addOrEditDraft(123);
-
-        assert.isFalse(commentElStub.called);
-        assert.isTrue(addDraftStub.called);
-      });
-
-      test('_shouldDisableAction', () => {
-        let showActions = true;
-        const lastComment = {};
-        assert.equal(
-            element._shouldDisableAction(showActions, lastComment), false);
-        showActions = false;
-        assert.equal(
-            element._shouldDisableAction(showActions, lastComment), true);
-        showActions = true;
-        lastComment.__draft = true;
-        assert.equal(
-            element._shouldDisableAction(showActions, lastComment), true);
-        const robotComment = {};
-        robotComment.robot_id = true;
-        assert.equal(
-            element._shouldDisableAction(showActions, robotComment), false);
-      });
-
-      test('_hideActions', () => {
-        let showActions = true;
-        const lastComment = {};
-        assert.equal(element._hideActions(showActions, lastComment), false);
-        showActions = false;
-        assert.equal(element._hideActions(showActions, lastComment), true);
-        showActions = true;
-        lastComment.__draft = true;
-        assert.equal(element._hideActions(showActions, lastComment), true);
-        const robotComment = {};
-        robotComment.robot_id = true;
-        assert.equal(element._hideActions(showActions, robotComment), true);
-      });
-
-      test('setting project name loads the project config', done => {
-        const projectName = 'foo/bar/baz';
-        const getProjectStub = sandbox.stub(element.$.restAPI, 'getProjectConfig')
-            .returns(Promise.resolve({}));
-        element.projectName = projectName;
-        flush(() => {
-          assert.isTrue(getProjectStub.calledWithExactly(projectName));
-          done();
-        });
-      });
-
-      test('optionally show file path', () => {
-        // Path info doesn't exist when showFilePath is false. Because it's in a
-        // dom-if it is not yet in the dom.
-        assert.isNotOk(element.shadowRoot
-            .querySelector('.pathInfo'));
-
-        sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
-        element.changeNum = 123;
-        element.projectName = 'test project';
-        element.path = 'path/to/file';
-        element.patchNum = 3;
-        element.lineNum = 5;
-        element.showFilePath = true;
-        flushAsynchronousOperations();
-        assert.isOk(element.shadowRoot
-            .querySelector('.pathInfo'));
-        assert.notEqual(getComputedStyle(element.shadowRoot
-            .querySelector('.pathInfo')).display,
-        'none');
-        assert.isTrue(Gerrit.Nav.getUrlForDiffById.lastCall.calledWithExactly(
-            element.changeNum, element.projectName, element.path,
-            element.patchNum, null, element.lineNum));
-      });
-
-      test('_computeDisplayPath', () => {
-        const path = 'path/to/file';
-        assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-        element.lineNum = 5;
-        assert.equal(element._computeDisplayPath(path), 'path/to/file#5');
-      });
-    });
-  });
-
-  suite('comment action tests', () => {
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-comment-thread.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-comment-thread tests', () => {
+  suite('basic test', () => {
     let element;
     let sandbox;
 
@@ -241,535 +64,721 @@
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getLoggedIn() { return Promise.resolve(false); },
-        saveDiffDraft() {
-          return Promise.resolve({
-            ok: true,
-            text() {
-              return Promise.resolve(')]}\'\n' +
-                  JSON.stringify({
-                    id: '7afa4931_de3d65bd',
-                    path: '/path/to/file.txt',
-                    line: 5,
-                    in_reply_to: 'baf0414d_60047215',
-                    updated: '2015-12-21 02:01:10.850000000',
-                    message: 'Done',
-                  }));
-            },
-          });
-        },
-        deleteDiffDraft() { return Promise.resolve({ok: true}); },
       });
-      element = fixture('withComment');
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000',
-        path: '/path/to/file.txt',
-      }];
-      flushAsynchronousOperations();
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
-    test('reply', () => {
-      const commentEl = element.shadowRoot
-          .querySelector('gr-comment');
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      assert.ok(commentEl);
-
-      const replyBtn = element.$.replyBtn;
-      MockInteractions.tap(replyBtn);
-      flushAsynchronousOperations();
-
-      const drafts = element._orderedComments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.notOk(drafts[0].message, 'message should be empty');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('quote reply', () => {
-      const commentEl = element.shadowRoot
-          .querySelector('gr-comment');
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      assert.ok(commentEl);
-
-      const quoteBtn = element.$.quoteBtn;
-      MockInteractions.tap(quoteBtn);
-      flushAsynchronousOperations();
-
-      const drafts = element._orderedComments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('quote reply multiline', () => {
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
+    test('comments are sorted correctly', () => {
+      const comments = [
+        {
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          __date: new Date('2015-12-25'),
+        }, {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sally_to_dr_finklestein',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }, {
+          id: 'dr_finklesteins_response',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000',
+        }, {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000',
         },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?\nIt might be!',
-        updated: '2015-12-08 19:48:33.843000000',
-      }];
-      flushAsynchronousOperations();
-
-      const commentEl = element.shadowRoot
-          .querySelector('gr-comment');
-      assert.ok(commentEl);
-
-      const quoteBtn = element.$.quoteBtn;
-      MockInteractions.tap(quoteBtn);
-      flushAsynchronousOperations();
-
-      const drafts = element._orderedComments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message,
-          '> is this a crossover episode!?\n> It might be!\n\n');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('ack', done => {
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      element.changeNum = '42';
-      element.patchNum = '1';
-
-      const commentEl = element.shadowRoot
-          .querySelector('gr-comment');
-      assert.ok(commentEl);
-
-      const ackBtn = element.$.ackBtn;
-      MockInteractions.tap(ackBtn);
-      flush(() => {
-        const drafts = element.comments.filter(c => c.__draft == true);
-        assert.equal(drafts.length, 1);
-        assert.equal(drafts[0].message, 'Ack');
-        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-        assert.equal(drafts[0].unresolved, false);
-        assert.isTrue(reportStub.calledOnce);
-        done();
-      });
-    });
-
-    test('done', done => {
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      element.changeNum = '42';
-      element.patchNum = '1';
-      const commentEl = element.shadowRoot
-          .querySelector('gr-comment');
-      assert.ok(commentEl);
-
-      const doneBtn = element.$.doneBtn;
-      MockInteractions.tap(doneBtn);
-      flush(() => {
-        const drafts = element.comments.filter(c => c.__draft == true);
-        assert.equal(drafts.length, 1);
-        assert.equal(drafts[0].message, 'Done');
-        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-        assert.isFalse(drafts[0].unresolved);
-        assert.isTrue(reportStub.calledOnce);
-        done();
-      });
-    });
-
-    test('save', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      element.path = '/path/to/file.txt';
-      const commentEl = element.shadowRoot
-          .querySelector('gr-comment');
-      assert.ok(commentEl);
-
-      const saveOrDiscardStub = sandbox.stub();
-      element.addEventListener('thread-changed', saveOrDiscardStub);
-      element.shadowRoot
-          .querySelector('gr-comment')._fireSave();
-
-      flush(() => {
-        assert.isTrue(saveOrDiscardStub.called);
-        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-            'baf0414d_60047215');
-        assert.equal(element.rootId, 'baf0414d_60047215');
-        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-            '/path/to/file.txt');
-        done();
-      });
-    });
-
-    test('please fix', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      const commentEl = element.shadowRoot
-          .querySelector('gr-comment');
-      assert.ok(commentEl);
-      commentEl.addEventListener('create-fix-comment', () => {
-        const drafts = element._orderedComments.filter(c => c.__draft == true);
-        assert.equal(drafts.length, 1);
-        assert.equal(
-            drafts[0].message, '> is this a crossover episode!?\n\nPlease fix.');
-        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-        assert.isTrue(drafts[0].unresolved);
-        done();
-      });
-      commentEl.fire('create-fix-comment', {comment: commentEl.comment},
-          {bubbles: false});
-    });
-
-    test('discard', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      element.path = '/path/to/file.txt';
-      element.push('comments', element._newReply(
-          element.comments[0].id,
-          element.comments[0].line,
-          element.comments[0].path,
-          'it’s pronouced jiff, not giff'));
-      flushAsynchronousOperations();
-
-      const saveOrDiscardStub = sandbox.stub();
-      element.addEventListener('thread-changed', saveOrDiscardStub);
-      const draftEl =
-          Polymer.dom(element.root).querySelectorAll('gr-comment')[1];
-      assert.ok(draftEl);
-      draftEl.addEventListener('comment-discard', () => {
-        const drafts = element.comments.filter(c => c.__draft == true);
-        assert.equal(drafts.length, 0);
-        assert.isTrue(saveOrDiscardStub.called);
-        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-            element.rootId);
-        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-            element.path);
-        done();
-      });
-      draftEl.fire('comment-discard', {comment: draftEl.comment},
-          {bubbles: false});
-    });
-
-    test('discard with a single comment still fires event with previous rootId',
-        done => {
-          element.changeNum = '42';
-          element.patchNum = '1';
-          element.path = '/path/to/file.txt';
-          element.comments = [];
-          element.addOrEditDraft('1');
-          flushAsynchronousOperations();
-          const rootId = element.rootId;
-          assert.isOk(rootId);
-
-          const saveOrDiscardStub = sandbox.stub();
-          element.addEventListener('thread-changed', saveOrDiscardStub);
-          const draftEl =
-          Polymer.dom(element.root).querySelectorAll('gr-comment')[0];
-          assert.ok(draftEl);
-          draftEl.addEventListener('comment-discard', () => {
-            assert.equal(element.comments.length, 0);
-            assert.isTrue(saveOrDiscardStub.called);
-            assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-                rootId);
-            assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-                element.path);
-            done();
-          });
-          draftEl.fire('comment-discard', {comment: draftEl.comment},
-              {bubbles: false});
-        });
-
-    test('first editing comment does not add __otherEditing attribute', () => {
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
+      ];
+      const results = element._sortedComments(comments);
+      assert.deepEqual(results, [
+        {
+          id: 'sally_to_dr_finklestein',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'dr_finklesteins_response',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }, {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          __date: new Date('2015-12-25'),
         },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000',
+      ]);
+    });
+
+    test('addOrEditDraft w/ edit draft', () => {
+      element.comments = [{
+        id: 'jacks_reply',
+        message: 'i like you, too',
+        in_reply_to: 'sallys_confession',
+        updated: '2015-12-25 15:00:20.396000000',
         __draft: true,
       }];
+      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
+          () => { return {}; });
+      const addDraftStub = sandbox.stub(element, 'addDraft');
 
-      const replyBtn = element.$.replyBtn;
-      MockInteractions.tap(replyBtn);
-      flushAsynchronousOperations();
+      element.addOrEditDraft(123);
 
-      const editing = element._orderedComments.filter(c => c.__editing == true);
-      assert.equal(editing.length, 1);
-      assert.equal(!!editing[0].__otherEditing, false);
+      assert.isTrue(commentElStub.called);
+      assert.isFalse(addDraftStub.called);
     });
 
-    test('When not editing other comments, local storage not set' +
-        ' after discard', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:31.843000000',
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        __draftID: '1',
-        in_reply_to: 'baf0414d_60047215',
-        line: 5,
-        message: 'yes',
-        updated: '2015-12-08 19:48:32.843000000',
-        __draft: true,
-        __editing: true,
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        __draftID: '2',
-        in_reply_to: 'baf0414d_60047215',
-        line: 5,
-        message: 'no',
-        updated: '2015-12-08 19:48:33.843000000',
-        __draft: true,
-      }];
-      const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
-      flushAsynchronousOperations();
+    test('addOrEditDraft w/o edit draft', () => {
+      element.comments = [];
+      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
+          () => { return {}; });
+      const addDraftStub = sandbox.stub(element, 'addDraft');
 
-      const draftEl =
-      Polymer.dom(element.root).querySelectorAll('gr-comment')[1];
-      assert.ok(draftEl);
-      draftEl.addEventListener('comment-discard', () => {
-        assert.isFalse(storageStub.called);
-        storageStub.restore();
+      element.addOrEditDraft(123);
+
+      assert.isFalse(commentElStub.called);
+      assert.isTrue(addDraftStub.called);
+    });
+
+    test('_shouldDisableAction', () => {
+      let showActions = true;
+      const lastComment = {};
+      assert.equal(
+          element._shouldDisableAction(showActions, lastComment), false);
+      showActions = false;
+      assert.equal(
+          element._shouldDisableAction(showActions, lastComment), true);
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(
+          element._shouldDisableAction(showActions, lastComment), true);
+      const robotComment = {};
+      robotComment.robot_id = true;
+      assert.equal(
+          element._shouldDisableAction(showActions, robotComment), false);
+    });
+
+    test('_hideActions', () => {
+      let showActions = true;
+      const lastComment = {};
+      assert.equal(element._hideActions(showActions, lastComment), false);
+      showActions = false;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      const robotComment = {};
+      robotComment.robot_id = true;
+      assert.equal(element._hideActions(showActions, robotComment), true);
+    });
+
+    test('setting project name loads the project config', done => {
+      const projectName = 'foo/bar/baz';
+      const getProjectStub = sandbox.stub(element.$.restAPI, 'getProjectConfig')
+          .returns(Promise.resolve({}));
+      element.projectName = projectName;
+      flush(() => {
+        assert.isTrue(getProjectStub.calledWithExactly(projectName));
         done();
       });
-      draftEl.fire('comment-discard', {comment: draftEl.comment},
-          {bubbles: false});
     });
 
-    test('comment-update', () => {
-      const commentEl = element.shadowRoot
-          .querySelector('gr-comment');
-      const updatedComment = {
-        id: element.comments[0].id,
-        foo: 'bar',
-      };
-      commentEl.fire('comment-update', {comment: updatedComment});
-      assert.strictEqual(element.comments[0], updatedComment);
-    });
+    test('optionally show file path', () => {
+      // Path info doesn't exist when showFilePath is false. Because it's in a
+      // dom-if it is not yet in the dom.
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.pathInfo'));
 
-    suite('jack and sally comment data test consolidation', () => {
-      setup(() => {
-        element.comments = [
-          {
-            id: 'jacks_reply',
-            message: 'i like you, too',
-            in_reply_to: 'sallys_confession',
-            updated: '2015-12-25 15:00:20.396000000',
-            unresolved: false,
-          }, {
-            id: 'sallys_confession',
-            in_reply_to: 'nonexistent_comment',
-            message: 'i like you, jack',
-            updated: '2015-12-24 15:00:20.396000000',
-          }, {
-            id: 'sally_to_dr_finklestein',
-            in_reply_to: 'nonexistent_comment',
-            message: 'i’m running away',
-            updated: '2015-10-31 09:00:20.396000000',
-          }, {
-            id: 'sallys_defiance',
-            message: 'i will poison you so i can get away',
-            updated: '2015-10-31 15:00:20.396000000',
-          }];
-      });
-
-      test('orphan replies', () => {
-        assert.equal(4, element._orderedComments.length);
-      });
-
-      test('keyboard shortcuts', () => {
-        const expandCollapseStub =
-            sinon.stub(element, '_expandCollapseComments');
-        MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
-        assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
-
-        MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
-        assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
-      });
-
-      test('comment in_reply_to is either null or most recent comment', () => {
-        element._createReplyComment(element.comments[3], 'dummy', true);
-        flushAsynchronousOperations();
-        assert.equal(element._orderedComments.length, 5);
-        assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
-      });
-
-      test('resolvable comments', () => {
-        assert.isFalse(element.unresolved);
-        element._createReplyComment(element.comments[3], 'dummy', true, true);
-        flushAsynchronousOperations();
-        assert.isTrue(element.unresolved);
-      });
-
-      test('_setInitialExpandedState', () => {
-        element.unresolved = true;
-        element._setInitialExpandedState();
-        for (let i = 0; i < element.comments.length; i++) {
-          assert.isFalse(element.comments[i].collapsed);
-        }
-        element.unresolved = false;
-        element._setInitialExpandedState();
-        for (let i = 0; i < element.comments.length; i++) {
-          assert.isTrue(element.comments[i].collapsed);
-        }
-        for (let i = 0; i < element.comments.length; i++) {
-          element.comments[i].robot_id = 123;
-        }
-        element._setInitialExpandedState();
-        for (let i = 0; i < element.comments.length; i++) {
-          assert.isFalse(element.comments[i].collapsed);
-        }
-      });
-    });
-
-    test('_computeHostClass', () => {
-      assert.equal(element._computeHostClass(true), 'unresolved');
-      assert.equal(element._computeHostClass(false), '');
-    });
-
-    test('addDraft sets unresolved state correctly', () => {
-      let unresolved = true;
-      element.comments = [];
-      element.addDraft(null, null, unresolved);
-      assert.equal(element.comments[0].unresolved, true);
-
-      unresolved = false; // comment should get added as actually resolved.
-      element.comments = [];
-      element.addDraft(null, null, unresolved);
-      assert.equal(element.comments[0].unresolved, false);
-
-      element.comments = [];
-      element.addDraft();
-      assert.equal(element.comments[0].unresolved, true);
-    });
-
-    test('_newDraft', () => {
-      element.commentSide = 'left';
+      sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
+      element.changeNum = 123;
+      element.projectName = 'test project';
+      element.path = 'path/to/file';
       element.patchNum = 3;
-      const draft = element._newDraft();
-      assert.equal(draft.__commentSide, 'left');
-      assert.equal(draft.patchNum, 3);
+      element.lineNum = 5;
+      element.showFilePath = true;
+      flushAsynchronousOperations();
+      assert.isOk(element.shadowRoot
+          .querySelector('.pathInfo'));
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('.pathInfo')).display,
+      'none');
+      assert.isTrue(Gerrit.Nav.getUrlForDiffById.lastCall.calledWithExactly(
+          element.changeNum, element.projectName, element.path,
+          element.patchNum, null, element.lineNum));
     });
 
-    test('new comment gets created', () => {
-      element.comments = [];
-      element.addOrEditDraft(1);
-      assert.equal(element.comments.length, 1);
-      // Mock a submitted comment.
-      element.comments[0].id = element.comments[0].__draftID;
-      element.comments[0].__draft = false;
-      element.addOrEditDraft(1);
-      assert.equal(element.comments.length, 2);
-    });
+    test('_computeDisplayPath', () => {
+      const path = 'path/to/file';
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
 
-    test('unresolved label', () => {
-      element.unresolved = false;
-      assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
-      element.unresolved = true;
-      assert.isFalse(element.$.unresolvedLabel.hasAttribute('hidden'));
-    });
-
-    test('draft comments are at the end of orderedComments', () => {
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 2,
-        line: 5,
-        message: 'Earlier draft',
-        updated: '2015-12-08 19:48:33.843000000',
-        __draft: true,
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter2',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 1,
-        line: 5,
-        message: 'This comment was left last but is not a draft',
-        updated: '2015-12-10 19:48:33.843000000',
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter2',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 3,
-        line: 5,
-        message: 'Later draft',
-        updated: '2015-12-09 19:48:33.843000000',
-        __draft: true,
-      }];
-      assert.equal(element._orderedComments[0].id, '1');
-      assert.equal(element._orderedComments[1].id, '2');
-      assert.equal(element._orderedComments[2].id, '3');
-    });
-
-    test('reflects lineNum and commentSide to attributes', () => {
-      element.lineNum = 7;
-      element.commentSide = 'left';
-
-      assert.equal(element.getAttribute('line-num'), '7');
-      assert.equal(element.getAttribute('comment-side'), 'left');
-    });
-
-    test('reflects range to JSON serialized attribute if set', () => {
-      element.range = {
-        start_line: 4,
-        end_line: 5,
-        start_character: 6,
-        end_character: 7,
-      };
-
-      assert.deepEqual(
-          JSON.parse(element.getAttribute('range')),
-          {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
-    });
-
-    test('removes range attribute if range is unset', () => {
-      element.range = {
-        start_line: 4,
-        end_line: 5,
-        start_character: 6,
-        end_character: 7,
-      };
-      element.range = undefined;
-
-      assert.notOk(element.hasAttribute('range'));
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayPath(path), 'path/to/file#5');
     });
   });
+});
+
+suite('comment action tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      saveDiffDraft() {
+        return Promise.resolve({
+          ok: true,
+          text() {
+            return Promise.resolve(')]}\'\n' +
+                JSON.stringify({
+                  id: '7afa4931_de3d65bd',
+                  path: '/path/to/file.txt',
+                  line: 5,
+                  in_reply_to: 'baf0414d_60047215',
+                  updated: '2015-12-21 02:01:10.850000000',
+                  message: 'Done',
+                }));
+          },
+        });
+      },
+      deleteDiffDraft() { return Promise.resolve({ok: true}); },
+    });
+    element = fixture('withComment');
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?',
+      updated: '2015-12-08 19:48:33.843000000',
+      path: '/path/to/file.txt',
+    }];
+    flushAsynchronousOperations();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('reply', () => {
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    const reportStub = sandbox.stub(element.$.reporting,
+        'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const replyBtn = element.$.replyBtn;
+    MockInteractions.tap(replyBtn);
+    flushAsynchronousOperations();
+
+    const drafts = element._orderedComments.filter(c => c.__draft == true);
+    assert.equal(drafts.length, 1);
+    assert.notOk(drafts[0].message, 'message should be empty');
+    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('quote reply', () => {
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    const reportStub = sandbox.stub(element.$.reporting,
+        'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    MockInteractions.tap(quoteBtn);
+    flushAsynchronousOperations();
+
+    const drafts = element._orderedComments.filter(c => c.__draft == true);
+    assert.equal(drafts.length, 1);
+    assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
+    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('quote reply multiline', () => {
+    const reportStub = sandbox.stub(element.$.reporting,
+        'recordDraftInteraction');
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?\nIt might be!',
+      updated: '2015-12-08 19:48:33.843000000',
+    }];
+    flushAsynchronousOperations();
+
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    MockInteractions.tap(quoteBtn);
+    flushAsynchronousOperations();
+
+    const drafts = element._orderedComments.filter(c => c.__draft == true);
+    assert.equal(drafts.length, 1);
+    assert.equal(drafts[0].message,
+        '> is this a crossover episode!?\n> It might be!\n\n');
+    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('ack', done => {
+    const reportStub = sandbox.stub(element.$.reporting,
+        'recordDraftInteraction');
+    element.changeNum = '42';
+    element.patchNum = '1';
+
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const ackBtn = element.$.ackBtn;
+    MockInteractions.tap(ackBtn);
+    flush(() => {
+      const drafts = element.comments.filter(c => c.__draft == true);
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message, 'Ack');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      assert.equal(drafts[0].unresolved, false);
+      assert.isTrue(reportStub.calledOnce);
+      done();
+    });
+  });
+
+  test('done', done => {
+    const reportStub = sandbox.stub(element.$.reporting,
+        'recordDraftInteraction');
+    element.changeNum = '42';
+    element.patchNum = '1';
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const doneBtn = element.$.doneBtn;
+    MockInteractions.tap(doneBtn);
+    flush(() => {
+      const drafts = element.comments.filter(c => c.__draft == true);
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message, 'Done');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      assert.isFalse(drafts[0].unresolved);
+      assert.isTrue(reportStub.calledOnce);
+      done();
+    });
+  });
+
+  test('save', done => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+    element.path = '/path/to/file.txt';
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const saveOrDiscardStub = sandbox.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    element.shadowRoot
+        .querySelector('gr-comment')._fireSave();
+
+    flush(() => {
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+          'baf0414d_60047215');
+      assert.equal(element.rootId, 'baf0414d_60047215');
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+          '/path/to/file.txt');
+      done();
+    });
+  });
+
+  test('please fix', done => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+    commentEl.addEventListener('create-fix-comment', () => {
+      const drafts = element._orderedComments.filter(c => c.__draft == true);
+      assert.equal(drafts.length, 1);
+      assert.equal(
+          drafts[0].message, '> is this a crossover episode!?\n\nPlease fix.');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      assert.isTrue(drafts[0].unresolved);
+      done();
+    });
+    commentEl.fire('create-fix-comment', {comment: commentEl.comment},
+        {bubbles: false});
+  });
+
+  test('discard', done => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+    element.path = '/path/to/file.txt';
+    element.push('comments', element._newReply(
+        element.comments[0].id,
+        element.comments[0].line,
+        element.comments[0].path,
+        'it’s pronouced jiff, not giff'));
+    flushAsynchronousOperations();
+
+    const saveOrDiscardStub = sandbox.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    const draftEl =
+        dom(element.root).querySelectorAll('gr-comment')[1];
+    assert.ok(draftEl);
+    draftEl.addEventListener('comment-discard', () => {
+      const drafts = element.comments.filter(c => c.__draft == true);
+      assert.equal(drafts.length, 0);
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+          element.rootId);
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+          element.path);
+      done();
+    });
+    draftEl.fire('comment-discard', {comment: draftEl.comment},
+        {bubbles: false});
+  });
+
+  test('discard with a single comment still fires event with previous rootId',
+      done => {
+        element.changeNum = '42';
+        element.patchNum = '1';
+        element.path = '/path/to/file.txt';
+        element.comments = [];
+        element.addOrEditDraft('1');
+        flushAsynchronousOperations();
+        const rootId = element.rootId;
+        assert.isOk(rootId);
+
+        const saveOrDiscardStub = sandbox.stub();
+        element.addEventListener('thread-changed', saveOrDiscardStub);
+        const draftEl =
+        dom(element.root).querySelectorAll('gr-comment')[0];
+        assert.ok(draftEl);
+        draftEl.addEventListener('comment-discard', () => {
+          assert.equal(element.comments.length, 0);
+          assert.isTrue(saveOrDiscardStub.called);
+          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+              rootId);
+          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+              element.path);
+          done();
+        });
+        draftEl.fire('comment-discard', {comment: draftEl.comment},
+            {bubbles: false});
+      });
+
+  test('first editing comment does not add __otherEditing attribute', () => {
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?',
+      updated: '2015-12-08 19:48:33.843000000',
+      __draft: true,
+    }];
+
+    const replyBtn = element.$.replyBtn;
+    MockInteractions.tap(replyBtn);
+    flushAsynchronousOperations();
+
+    const editing = element._orderedComments.filter(c => c.__editing == true);
+    assert.equal(editing.length, 1);
+    assert.equal(!!editing[0].__otherEditing, false);
+  });
+
+  test('When not editing other comments, local storage not set' +
+      ' after discard', done => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?',
+      updated: '2015-12-08 19:48:31.843000000',
+    },
+    {
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      __draftID: '1',
+      in_reply_to: 'baf0414d_60047215',
+      line: 5,
+      message: 'yes',
+      updated: '2015-12-08 19:48:32.843000000',
+      __draft: true,
+      __editing: true,
+    },
+    {
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      __draftID: '2',
+      in_reply_to: 'baf0414d_60047215',
+      line: 5,
+      message: 'no',
+      updated: '2015-12-08 19:48:33.843000000',
+      __draft: true,
+    }];
+    const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
+    flushAsynchronousOperations();
+
+    const draftEl =
+    dom(element.root).querySelectorAll('gr-comment')[1];
+    assert.ok(draftEl);
+    draftEl.addEventListener('comment-discard', () => {
+      assert.isFalse(storageStub.called);
+      storageStub.restore();
+      done();
+    });
+    draftEl.fire('comment-discard', {comment: draftEl.comment},
+        {bubbles: false});
+  });
+
+  test('comment-update', () => {
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    const updatedComment = {
+      id: element.comments[0].id,
+      foo: 'bar',
+    };
+    commentEl.fire('comment-update', {comment: updatedComment});
+    assert.strictEqual(element.comments[0], updatedComment);
+  });
+
+  suite('jack and sally comment data test consolidation', () => {
+    setup(() => {
+      element.comments = [
+        {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          updated: '2015-12-25 15:00:20.396000000',
+          unresolved: false,
+        }, {
+          id: 'sallys_confession',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sally_to_dr_finklestein',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }];
+    });
+
+    test('orphan replies', () => {
+      assert.equal(4, element._orderedComments.length);
+    });
+
+    test('keyboard shortcuts', () => {
+      const expandCollapseStub =
+          sinon.stub(element, '_expandCollapseComments');
+      MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
+
+      MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
+    });
+
+    test('comment in_reply_to is either null or most recent comment', () => {
+      element._createReplyComment(element.comments[3], 'dummy', true);
+      flushAsynchronousOperations();
+      assert.equal(element._orderedComments.length, 5);
+      assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
+    });
+
+    test('resolvable comments', () => {
+      assert.isFalse(element.unresolved);
+      element._createReplyComment(element.comments[3], 'dummy', true, true);
+      flushAsynchronousOperations();
+      assert.isTrue(element.unresolved);
+    });
+
+    test('_setInitialExpandedState', () => {
+      element.unresolved = true;
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isFalse(element.comments[i].collapsed);
+      }
+      element.unresolved = false;
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isTrue(element.comments[i].collapsed);
+      }
+      for (let i = 0; i < element.comments.length; i++) {
+        element.comments[i].robot_id = 123;
+      }
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isFalse(element.comments[i].collapsed);
+      }
+    });
+  });
+
+  test('_computeHostClass', () => {
+    assert.equal(element._computeHostClass(true), 'unresolved');
+    assert.equal(element._computeHostClass(false), '');
+  });
+
+  test('addDraft sets unresolved state correctly', () => {
+    let unresolved = true;
+    element.comments = [];
+    element.addDraft(null, null, unresolved);
+    assert.equal(element.comments[0].unresolved, true);
+
+    unresolved = false; // comment should get added as actually resolved.
+    element.comments = [];
+    element.addDraft(null, null, unresolved);
+    assert.equal(element.comments[0].unresolved, false);
+
+    element.comments = [];
+    element.addDraft();
+    assert.equal(element.comments[0].unresolved, true);
+  });
+
+  test('_newDraft', () => {
+    element.commentSide = 'left';
+    element.patchNum = 3;
+    const draft = element._newDraft();
+    assert.equal(draft.__commentSide, 'left');
+    assert.equal(draft.patchNum, 3);
+  });
+
+  test('new comment gets created', () => {
+    element.comments = [];
+    element.addOrEditDraft(1);
+    assert.equal(element.comments.length, 1);
+    // Mock a submitted comment.
+    element.comments[0].id = element.comments[0].__draftID;
+    element.comments[0].__draft = false;
+    element.addOrEditDraft(1);
+    assert.equal(element.comments.length, 2);
+  });
+
+  test('unresolved label', () => {
+    element.unresolved = false;
+    assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
+    element.unresolved = true;
+    assert.isFalse(element.$.unresolvedLabel.hasAttribute('hidden'));
+  });
+
+  test('draft comments are at the end of orderedComments', () => {
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 2,
+      line: 5,
+      message: 'Earlier draft',
+      updated: '2015-12-08 19:48:33.843000000',
+      __draft: true,
+    },
+    {
+      author: {
+        name: 'Mr. Peanutbutter2',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 1,
+      line: 5,
+      message: 'This comment was left last but is not a draft',
+      updated: '2015-12-10 19:48:33.843000000',
+    },
+    {
+      author: {
+        name: 'Mr. Peanutbutter2',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 3,
+      line: 5,
+      message: 'Later draft',
+      updated: '2015-12-09 19:48:33.843000000',
+      __draft: true,
+    }];
+    assert.equal(element._orderedComments[0].id, '1');
+    assert.equal(element._orderedComments[1].id, '2');
+    assert.equal(element._orderedComments[2].id, '3');
+  });
+
+  test('reflects lineNum and commentSide to attributes', () => {
+    element.lineNum = 7;
+    element.commentSide = 'left';
+
+    assert.equal(element.getAttribute('line-num'), '7');
+    assert.equal(element.getAttribute('comment-side'), 'left');
+  });
+
+  test('reflects range to JSON serialized attribute if set', () => {
+    element.range = {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    };
+
+    assert.deepEqual(
+        JSON.parse(element.getAttribute('range')),
+        {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
+  });
+
+  test('removes range attribute if range is unset', () => {
+    element.range = {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    };
+    element.range = undefined;
+
+    assert.notOk(element.hasAttribute('range'));
+  });
+});
 </script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
index 9880e88..6f1eaa8 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,797 +14,823 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const STORAGE_DEBOUNCE_INTERVAL = 400;
-  const TOAST_DEBOUNCE_INTERVAL = 200;
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../gr-button/gr-button.js';
+import '../gr-dialog/gr-dialog.js';
+import '../gr-date-formatter/gr-date-formatter.js';
+import '../gr-formatted-text/gr-formatted-text.js';
+import '../gr-icons/gr-icons.js';
+import '../gr-overlay/gr-overlay.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-storage/gr-storage.js';
+import '../gr-textarea/gr-textarea.js';
+import '../gr-tooltip-content/gr-tooltip-content.js';
+import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js';
+import '../../../scripts/rootElement.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-comment_html.js';
 
-  const SAVING_MESSAGE = 'Saving';
-  const DRAFT_SINGULAR = 'draft...';
-  const DRAFT_PLURAL = 'drafts...';
-  const SAVED_MESSAGE = 'All changes saved';
+const STORAGE_DEBOUNCE_INTERVAL = 400;
+const TOAST_DEBOUNCE_INTERVAL = 200;
 
-  const REPORT_CREATE_DRAFT = 'CreateDraftComment';
-  const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
-  const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
+const SAVING_MESSAGE = 'Saving';
+const DRAFT_SINGULAR = 'draft...';
+const DRAFT_PLURAL = 'drafts...';
+const SAVED_MESSAGE = 'All changes saved';
 
-  const FILE = 'FILE';
+const REPORT_CREATE_DRAFT = 'CreateDraftComment';
+const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
+const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
+
+const FILE = 'FILE';
+
+/**
+ * All candidates tips to show, will pick randomly.
+ */
+const RESPECTFUL_REVIEW_TIPS= [
+  'DO: Assume competence.',
+  'DO: Provide rationale or context.',
+  'DO: Consider how comments may be interpreted.',
+  'DON’T: Criticize the person.',
+  'DON’T: Use harsh language.',
+];
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @extends Polymer.Element
+ */
+class GrComment extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-comment'; }
+  /**
+   * Fired when the create fix comment action is triggered.
+   *
+   * @event create-fix-comment
+   */
 
   /**
-   * All candidates tips to show, will pick randomly.
+   * Fired when the show fix preview action is triggered.
+   *
+   * @event open-fix-preview
    */
-  const RESPECTFUL_REVIEW_TIPS= [
-    'DO: Assume competence.',
-    'DO: Provide rationale or context.',
-    'DO: Consider how comments may be interpreted.',
-    'DON’T: Criticize the person.',
-    'DON’T: Use harsh language.',
-  ];
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @extends Polymer.Element
+   * Fired when this comment is discarded.
+   *
+   * @event comment-discard
    */
-  class GrComment extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-comment'; }
-    /**
-     * Fired when the create fix comment action is triggered.
-     *
-     * @event create-fix-comment
-     */
 
-    /**
-     * Fired when the show fix preview action is triggered.
-     *
-     * @event open-fix-preview
-     */
+  /**
+   * Fired when this comment is saved.
+   *
+   * @event comment-save
+   */
 
-    /**
-     * Fired when this comment is discarded.
-     *
-     * @event comment-discard
-     */
+  /**
+   * Fired when this comment is updated.
+   *
+   * @event comment-update
+   */
 
-    /**
-     * Fired when this comment is saved.
-     *
-     * @event comment-save
-     */
+  /**
+   * Fired when the comment's timestamp is tapped.
+   *
+   * @event comment-anchor-tap
+   */
 
-    /**
-     * Fired when this comment is updated.
-     *
-     * @event comment-update
-     */
+  static get properties() {
+    return {
+      changeNum: String,
+      /** @type {!Gerrit.Comment} */
+      comment: {
+        type: Object,
+        notify: true,
+        observer: '_commentChanged',
+      },
+      comments: {
+        type: Array,
+      },
+      isRobotComment: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      draft: {
+        type: Boolean,
+        value: false,
+        observer: '_draftChanged',
+      },
+      editing: {
+        type: Boolean,
+        value: false,
+        observer: '_editingChanged',
+      },
+      discarding: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      hasChildren: Boolean,
+      patchNum: String,
+      showActions: Boolean,
+      _showHumanActions: Boolean,
+      _showRobotActions: Boolean,
+      collapsed: {
+        type: Boolean,
+        value: true,
+        observer: '_toggleCollapseClass',
+      },
+      /** @type {?} */
+      projectConfig: Object,
+      robotButtonDisabled: Boolean,
+      _hasHumanReply: Boolean,
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
 
-    /**
-     * Fired when the comment's timestamp is tapped.
-     *
-     * @event comment-anchor-tap
-     */
+      _xhrPromise: Object, // Used for testing.
+      _messageText: {
+        type: String,
+        value: '',
+        observer: '_messageTextChanged',
+      },
+      commentSide: String,
+      side: String,
 
-    static get properties() {
-      return {
-        changeNum: String,
-        /** @type {!Gerrit.Comment} */
-        comment: {
-          type: Object,
-          notify: true,
-          observer: '_commentChanged',
-        },
-        comments: {
-          type: Array,
-        },
-        isRobotComment: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-        disabled: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-        draft: {
-          type: Boolean,
-          value: false,
-          observer: '_draftChanged',
-        },
-        editing: {
-          type: Boolean,
-          value: false,
-          observer: '_editingChanged',
-        },
-        discarding: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-        hasChildren: Boolean,
-        patchNum: String,
-        showActions: Boolean,
-        _showHumanActions: Boolean,
-        _showRobotActions: Boolean,
-        collapsed: {
-          type: Boolean,
-          value: true,
-          observer: '_toggleCollapseClass',
-        },
-        /** @type {?} */
-        projectConfig: Object,
-        robotButtonDisabled: Boolean,
-        _hasHumanReply: Boolean,
-        _isAdmin: {
-          type: Boolean,
-          value: false,
-        },
+      resolved: Boolean,
 
-        _xhrPromise: Object, // Used for testing.
-        _messageText: {
-          type: String,
-          value: '',
-          observer: '_messageTextChanged',
-        },
-        commentSide: String,
-        side: String,
+      _numPendingDraftRequests: {
+        type: Object,
+        value:
+          {number: 0}, // Intentional to share the object across instances.
+      },
 
-        resolved: Boolean,
+      _enableOverlay: {
+        type: Boolean,
+        value: false,
+      },
 
-        _numPendingDraftRequests: {
-          type: Object,
-          value:
-            {number: 0}, // Intentional to share the object across instances.
-        },
+      /**
+       * Property for storing references to overlay elements. When the overlays
+       * are moved to Gerrit.getRootElement() to be shown they are no-longer
+       * children, so they can't be queried along the tree, so they are stored
+       * here.
+       */
+      _overlays: {
+        type: Object,
+        value: () => { return {}; },
+      },
 
-        _enableOverlay: {
-          type: Boolean,
-          value: false,
-        },
+      _showRespectfulTip: {
+        type: Boolean,
+        value: false,
+      },
+      _respectfulReviewTip: String,
+      _respectfulTipDismissed: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-        /**
-         * Property for storing references to overlay elements. When the overlays
-         * are moved to Gerrit.getRootElement() to be shown they are no-longer
-         * children, so they can't be queried along the tree, so they are stored
-         * here.
-         */
-        _overlays: {
-          type: Object,
-          value: () => { return {}; },
-        },
+  static get observers() {
+    return [
+      '_commentMessageChanged(comment.message)',
+      '_loadLocalDraft(changeNum, patchNum, comment)',
+      '_isRobotComment(comment)',
+      '_calculateActionstoShow(showActions, isRobotComment)',
+      '_computeHasHumanReply(comment, comments.*)',
+      '_onEditingChange(editing)',
+    ];
+  }
 
-        _showRespectfulTip: {
-          type: Boolean,
-          value: false,
-        },
-        _respectfulReviewTip: String,
-        _respectfulTipDismissed: {
-          type: Boolean,
-          value: false,
-        },
-      };
+  get keyBindings() {
+    return {
+      'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
+      'esc': '_handleEsc',
+    };
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    if (this.editing) {
+      this.collapsed = false;
+    } else if (this.comment) {
+      this.collapsed = this.comment.collapsed;
     }
+    this._getIsAdmin().then(isAdmin => {
+      this._isAdmin = isAdmin;
+    });
+  }
 
-    static get observers() {
-      return [
-        '_commentMessageChanged(comment.message)',
-        '_loadLocalDraft(changeNum, patchNum, comment)',
-        '_isRobotComment(comment)',
-        '_calculateActionstoShow(showActions, isRobotComment)',
-        '_computeHasHumanReply(comment, comments.*)',
-        '_onEditingChange(editing)',
-      ];
+  /** @override */
+  detached() {
+    super.detached();
+    this.cancelDebouncer('fire-update');
+    if (this.textarea) {
+      this.textarea.closeDropdown();
     }
+  }
 
-    get keyBindings() {
-      return {
-        'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
-        'esc': '_handleEsc',
-      };
-    }
-
-    /** @override */
-    attached() {
-      super.attached();
-      if (this.editing) {
-        this.collapsed = false;
-      } else if (this.comment) {
-        this.collapsed = this.comment.collapsed;
-      }
-      this._getIsAdmin().then(isAdmin => {
-        this._isAdmin = isAdmin;
-      });
-    }
-
-    /** @override */
-    detached() {
-      super.detached();
-      this.cancelDebouncer('fire-update');
-      if (this.textarea) {
-        this.textarea.closeDropdown();
-      }
-    }
-
-    _onEditingChange(editing) {
-      if (!editing) return;
-      // visibility based on cache this will make sure we only and always show
-      // a tip once every Math.max(a day, period between creating comments)
-      const cachedVisibilityOfRespectfulTip =
-        this.$.storage.getRespectfulTipVisibility();
-      if (!cachedVisibilityOfRespectfulTip) {
-        // we still want to show the tip with a probability of 30%
-        if (this.getRandomNum(0, 3) >= 1) return;
-        this._showRespectfulTip = true;
-        const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
-        this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
-        this.$.reporting.reportInteraction(
-            'respectful-tip-appeared',
-            {tip: this._respectfulReviewTip}
-        );
-        // update cache
-        this.$.storage.setRespectfulTipVisibility();
-      }
-    }
-
-    /** Set as a separate method so easy to stub. */
-    getRandomNum(min, max) {
-      return Math.floor(Math.random() * (max - min) + min);
-    }
-
-    _computeVisibilityOfTip(showTip, tipDismissed) {
-      return showTip && !tipDismissed;
-    }
-
-    _dismissRespectfulTip() {
-      this._respectfulTipDismissed = true;
+  _onEditingChange(editing) {
+    if (!editing) return;
+    // visibility based on cache this will make sure we only and always show
+    // a tip once every Math.max(a day, period between creating comments)
+    const cachedVisibilityOfRespectfulTip =
+      this.$.storage.getRespectfulTipVisibility();
+    if (!cachedVisibilityOfRespectfulTip) {
+      // we still want to show the tip with a probability of 30%
+      if (this.getRandomNum(0, 3) >= 1) return;
+      this._showRespectfulTip = true;
+      const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
+      this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
       this.$.reporting.reportInteraction(
-          'respectful-tip-dismissed',
+          'respectful-tip-appeared',
           {tip: this._respectfulReviewTip}
       );
-      // add a 3 day delay to the tip cache
-      this.$.storage.setRespectfulTipVisibility(/* delayDays= */ 3);
+      // update cache
+      this.$.storage.setRespectfulTipVisibility();
+    }
+  }
+
+  /** Set as a separate method so easy to stub. */
+  getRandomNum(min, max) {
+    return Math.floor(Math.random() * (max - min) + min);
+  }
+
+  _computeVisibilityOfTip(showTip, tipDismissed) {
+    return showTip && !tipDismissed;
+  }
+
+  _dismissRespectfulTip() {
+    this._respectfulTipDismissed = true;
+    this.$.reporting.reportInteraction(
+        'respectful-tip-dismissed',
+        {tip: this._respectfulReviewTip}
+    );
+    // add a 3 day delay to the tip cache
+    this.$.storage.setRespectfulTipVisibility(/* delayDays= */ 3);
+  }
+
+  _onRespectfulReadMoreClick() {
+    this.$.reporting.reportInteraction('respectful-read-more-clicked');
+  }
+
+  get textarea() {
+    return this.shadowRoot.querySelector('#editTextarea');
+  }
+
+  get confirmDeleteOverlay() {
+    if (!this._overlays.confirmDelete) {
+      this._enableOverlay = true;
+      flush();
+      this._overlays.confirmDelete = this.shadowRoot
+          .querySelector('#confirmDeleteOverlay');
+    }
+    return this._overlays.confirmDelete;
+  }
+
+  get confirmDiscardOverlay() {
+    if (!this._overlays.confirmDiscard) {
+      this._enableOverlay = true;
+      flush();
+      this._overlays.confirmDiscard = this.shadowRoot
+          .querySelector('#confirmDiscardOverlay');
+    }
+    return this._overlays.confirmDiscard;
+  }
+
+  _computeShowHideIcon(collapsed) {
+    return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+  }
+
+  _calculateActionstoShow(showActions, isRobotComment) {
+    // Polymer 2: check for undefined
+    if ([showActions, isRobotComment].some(arg => arg === undefined)) {
+      return;
     }
 
-    _onRespectfulReadMoreClick() {
-      this.$.reporting.reportInteraction('respectful-read-more-clicked');
+    this._showHumanActions = showActions && !isRobotComment;
+    this._showRobotActions = showActions && isRobotComment;
+  }
+
+  _isRobotComment(comment) {
+    this.isRobotComment = !!comment.robot_id;
+  }
+
+  isOnParent() {
+    return this.side === 'PARENT';
+  }
+
+  _getIsAdmin() {
+    return this.$.restAPI.getIsAdmin();
+  }
+
+  /**
+   * @param {*=} opt_comment
+   */
+  save(opt_comment) {
+    let comment = opt_comment;
+    if (!comment) {
+      comment = this.comment;
     }
 
-    get textarea() {
-      return this.shadowRoot.querySelector('#editTextarea');
+    this.set('comment.message', this._messageText);
+    this.editing = false;
+    this.disabled = true;
+
+    if (!this._messageText) {
+      return this._discardDraft();
     }
 
-    get confirmDeleteOverlay() {
-      if (!this._overlays.confirmDelete) {
-        this._enableOverlay = true;
-        Polymer.dom.flush();
-        this._overlays.confirmDelete = this.shadowRoot
-            .querySelector('#confirmDeleteOverlay');
-      }
-      return this._overlays.confirmDelete;
-    }
+    this._xhrPromise = this._saveDraft(comment).then(response => {
+      this.disabled = false;
+      if (!response.ok) { return response; }
 
-    get confirmDiscardOverlay() {
-      if (!this._overlays.confirmDiscard) {
-        this._enableOverlay = true;
-        Polymer.dom.flush();
-        this._overlays.confirmDiscard = this.shadowRoot
-            .querySelector('#confirmDiscardOverlay');
-      }
-      return this._overlays.confirmDiscard;
-    }
-
-    _computeShowHideIcon(collapsed) {
-      return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
-    }
-
-    _calculateActionstoShow(showActions, isRobotComment) {
-      // Polymer 2: check for undefined
-      if ([showActions, isRobotComment].some(arg => arg === undefined)) {
-        return;
-      }
-
-      this._showHumanActions = showActions && !isRobotComment;
-      this._showRobotActions = showActions && isRobotComment;
-    }
-
-    _isRobotComment(comment) {
-      this.isRobotComment = !!comment.robot_id;
-    }
-
-    isOnParent() {
-      return this.side === 'PARENT';
-    }
-
-    _getIsAdmin() {
-      return this.$.restAPI.getIsAdmin();
-    }
-
-    /**
-     * @param {*=} opt_comment
-     */
-    save(opt_comment) {
-      let comment = opt_comment;
-      if (!comment) {
-        comment = this.comment;
-      }
-
-      this.set('comment.message', this._messageText);
-      this.editing = false;
-      this.disabled = true;
-
-      if (!this._messageText) {
-        return this._discardDraft();
-      }
-
-      this._xhrPromise = this._saveDraft(comment).then(response => {
-        this.disabled = false;
-        if (!response.ok) { return response; }
-
-        this._eraseDraftComment();
-        return this.$.restAPI.getResponseObject(response).then(obj => {
-          const resComment = obj;
-          resComment.__draft = true;
-          // Maintain the ephemeral draft ID for identification by other
-          // elements.
-          if (this.comment.__draftID) {
-            resComment.__draftID = this.comment.__draftID;
-          }
-          resComment.__commentSide = this.commentSide;
-          this.comment = resComment;
-          this._fireSave();
-          return obj;
+      this._eraseDraftComment();
+      return this.$.restAPI.getResponseObject(response).then(obj => {
+        const resComment = obj;
+        resComment.__draft = true;
+        // Maintain the ephemeral draft ID for identification by other
+        // elements.
+        if (this.comment.__draftID) {
+          resComment.__draftID = this.comment.__draftID;
+        }
+        resComment.__commentSide = this.commentSide;
+        this.comment = resComment;
+        this._fireSave();
+        return obj;
+      });
+    })
+        .catch(err => {
+          this.disabled = false;
+          throw err;
         });
-      })
-          .catch(err => {
-            this.disabled = false;
-            throw err;
-          });
 
-      return this._xhrPromise;
+    return this._xhrPromise;
+  }
+
+  _eraseDraftComment() {
+    // Prevents a race condition in which removing the draft comment occurs
+    // prior to it being saved.
+    this.cancelDebouncer('store');
+
+    this.$.storage.eraseDraftComment({
+      changeNum: this.changeNum,
+      patchNum: this._getPatchNum(),
+      path: this.comment.path,
+      line: this.comment.line,
+      range: this.comment.range,
+    });
+  }
+
+  _commentChanged(comment) {
+    this.editing = !!comment.__editing;
+    this.resolved = !comment.unresolved;
+    if (this.editing) { // It's a new draft/reply, notify.
+      this._fireUpdate();
+    }
+  }
+
+  _computeHasHumanReply() {
+    if (!this.comment || !this.comments) return;
+    // hide please fix button for robot comment that has human reply
+    this._hasHumanReply = this.comments
+        .some(c => c.in_reply_to && c.in_reply_to === this.comment.id &&
+          !c.robot_id);
+  }
+
+  /**
+   * @param {!Object=} opt_mixin
+   *
+   * @return {!Object}
+   */
+  _getEventPayload(opt_mixin) {
+    return Object.assign({}, opt_mixin, {
+      comment: this.comment,
+      patchNum: this.patchNum,
+    });
+  }
+
+  _fireSave() {
+    this.fire('comment-save', this._getEventPayload());
+  }
+
+  _fireUpdate() {
+    this.debounce('fire-update', () => {
+      this.fire('comment-update', this._getEventPayload());
+    });
+  }
+
+  _draftChanged(draft) {
+    this.$.container.classList.toggle('draft', draft);
+  }
+
+  _editingChanged(editing, previousValue) {
+    // Polymer 2: observer fires when at least one property is defined.
+    // Do nothing to prevent comment.__editing being overwritten
+    // if previousValue is undefined
+    if (previousValue === undefined) return;
+
+    this.$.container.classList.toggle('editing', editing);
+    if (this.comment && this.comment.id) {
+      this.shadowRoot.querySelector('.cancel').hidden = !editing;
+    }
+    if (this.comment) {
+      this.comment.__editing = this.editing;
+    }
+    if (editing != !!previousValue) {
+      // To prevent event firing on comment creation.
+      this._fireUpdate();
+    }
+    if (editing) {
+      this.async(() => {
+        flush();
+        this.textarea && this.textarea.putCursorAtEnd();
+      }, 1);
+    }
+  }
+
+  _computeDeleteButtonClass(isAdmin, draft) {
+    return isAdmin && !draft ? 'showDeleteButtons' : '';
+  }
+
+  _computeSaveDisabled(draft, comment, resolved) {
+    // If resolved state has changed and a msg exists, save should be enabled.
+    if (!comment || comment.unresolved === resolved && draft) {
+      return false;
+    }
+    return !draft || draft.trim() === '';
+  }
+
+  _handleSaveKey(e) {
+    if (!this._computeSaveDisabled(this._messageText, this.comment,
+        this.resolved)) {
+      e.preventDefault();
+      this._handleSave(e);
+    }
+  }
+
+  _handleEsc(e) {
+    if (!this._messageText.length) {
+      e.preventDefault();
+      this._handleCancel(e);
+    }
+  }
+
+  _handleToggleCollapsed() {
+    this.collapsed = !this.collapsed;
+  }
+
+  _toggleCollapseClass(collapsed) {
+    if (collapsed) {
+      this.$.container.classList.add('collapsed');
+    } else {
+      this.$.container.classList.remove('collapsed');
+    }
+  }
+
+  _commentMessageChanged(message) {
+    this._messageText = message || '';
+  }
+
+  _messageTextChanged(newValue, oldValue) {
+    if (!this.comment || (this.comment && this.comment.id)) {
+      return;
     }
 
-    _eraseDraftComment() {
-      // Prevents a race condition in which removing the draft comment occurs
-      // prior to it being saved.
-      this.cancelDebouncer('store');
-
-      this.$.storage.eraseDraftComment({
+    this.debounce('store', () => {
+      const message = this._messageText;
+      const commentLocation = {
         changeNum: this.changeNum,
         patchNum: this._getPatchNum(),
         path: this.comment.path,
         line: this.comment.line,
         range: this.comment.range,
-      });
-    }
+      };
 
-    _commentChanged(comment) {
-      this.editing = !!comment.__editing;
-      this.resolved = !comment.unresolved;
-      if (this.editing) { // It's a new draft/reply, notify.
-        this._fireUpdate();
-      }
-    }
-
-    _computeHasHumanReply() {
-      if (!this.comment || !this.comments) return;
-      // hide please fix button for robot comment that has human reply
-      this._hasHumanReply = this.comments
-          .some(c => c.in_reply_to && c.in_reply_to === this.comment.id &&
-            !c.robot_id);
-    }
-
-    /**
-     * @param {!Object=} opt_mixin
-     *
-     * @return {!Object}
-     */
-    _getEventPayload(opt_mixin) {
-      return Object.assign({}, opt_mixin, {
-        comment: this.comment,
-        patchNum: this.patchNum,
-      });
-    }
-
-    _fireSave() {
-      this.fire('comment-save', this._getEventPayload());
-    }
-
-    _fireUpdate() {
-      this.debounce('fire-update', () => {
-        this.fire('comment-update', this._getEventPayload());
-      });
-    }
-
-    _draftChanged(draft) {
-      this.$.container.classList.toggle('draft', draft);
-    }
-
-    _editingChanged(editing, previousValue) {
-      // Polymer 2: observer fires when at least one property is defined.
-      // Do nothing to prevent comment.__editing being overwritten
-      // if previousValue is undefined
-      if (previousValue === undefined) return;
-
-      this.$.container.classList.toggle('editing', editing);
-      if (this.comment && this.comment.id) {
-        this.shadowRoot.querySelector('.cancel').hidden = !editing;
-      }
-      if (this.comment) {
-        this.comment.__editing = this.editing;
-      }
-      if (editing != !!previousValue) {
-        // To prevent event firing on comment creation.
-        this._fireUpdate();
-      }
-      if (editing) {
-        this.async(() => {
-          Polymer.dom.flush();
-          this.textarea && this.textarea.putCursorAtEnd();
-        }, 1);
-      }
-    }
-
-    _computeDeleteButtonClass(isAdmin, draft) {
-      return isAdmin && !draft ? 'showDeleteButtons' : '';
-    }
-
-    _computeSaveDisabled(draft, comment, resolved) {
-      // If resolved state has changed and a msg exists, save should be enabled.
-      if (!comment || comment.unresolved === resolved && draft) {
-        return false;
-      }
-      return !draft || draft.trim() === '';
-    }
-
-    _handleSaveKey(e) {
-      if (!this._computeSaveDisabled(this._messageText, this.comment,
-          this.resolved)) {
-        e.preventDefault();
-        this._handleSave(e);
-      }
-    }
-
-    _handleEsc(e) {
-      if (!this._messageText.length) {
-        e.preventDefault();
-        this._handleCancel(e);
-      }
-    }
-
-    _handleToggleCollapsed() {
-      this.collapsed = !this.collapsed;
-    }
-
-    _toggleCollapseClass(collapsed) {
-      if (collapsed) {
-        this.$.container.classList.add('collapsed');
+      if ((!this._messageText || !this._messageText.length) && oldValue) {
+        // If the draft has been modified to be empty, then erase the storage
+        // entry.
+        this.$.storage.eraseDraftComment(commentLocation);
       } else {
-        this.$.container.classList.remove('collapsed');
+        this.$.storage.setDraftComment(commentLocation, message);
       }
+    }, STORAGE_DEBOUNCE_INTERVAL);
+  }
+
+  _handleAnchorClick(e) {
+    e.preventDefault();
+    if (!this.comment.line) {
+      return;
+    }
+    this.dispatchEvent(new CustomEvent('comment-anchor-tap', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        number: this.comment.line || FILE,
+        side: this.side,
+      },
+    }));
+  }
+
+  _handleEdit(e) {
+    e.preventDefault();
+    this._messageText = this.comment.message;
+    this.editing = true;
+    this.$.reporting.recordDraftInteraction();
+  }
+
+  _handleSave(e) {
+    e.preventDefault();
+
+    // Ignore saves started while already saving.
+    if (this.disabled) {
+      return;
+    }
+    const timingLabel = this.comment.id ?
+      REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
+    const timer = this.$.reporting.getTimer(timingLabel);
+    this.set('comment.__editing', false);
+    return this.save().then(() => { timer.end(); });
+  }
+
+  _handleCancel(e) {
+    e.preventDefault();
+
+    if (!this.comment.message ||
+        this.comment.message.trim().length === 0 ||
+        !this.comment.id) {
+      this._fireDiscard();
+      return;
+    }
+    this._messageText = this.comment.message;
+    this.editing = false;
+  }
+
+  _fireDiscard() {
+    this.cancelDebouncer('fire-update');
+    this.fire('comment-discard', this._getEventPayload());
+  }
+
+  _handleFix() {
+    this.dispatchEvent(new CustomEvent('create-fix-comment', {
+      bubbles: true,
+      composed: true,
+      detail: this._getEventPayload(),
+    }));
+  }
+
+  _handleShowFix() {
+    this.dispatchEvent(new CustomEvent('open-fix-preview', {
+      bubbles: true,
+      composed: true,
+      detail: this._getEventPayload(),
+    }));
+  }
+
+  _hasNoFix(comment) {
+    return !comment || !comment.fix_suggestions;
+  }
+
+  _handleDiscard(e) {
+    e.preventDefault();
+    this.$.reporting.recordDraftInteraction();
+
+    if (!this._messageText) {
+      this._discardDraft();
+      return;
     }
 
-    _commentMessageChanged(message) {
-      this._messageText = message || '';
+    this._openOverlay(this.confirmDiscardOverlay).then(() => {
+      this.confirmDiscardOverlay.querySelector('#confirmDiscardDialog')
+          .resetFocus();
+    });
+  }
+
+  _handleConfirmDiscard(e) {
+    e.preventDefault();
+    const timer = this.$.reporting.getTimer(REPORT_DISCARD_DRAFT);
+    this._closeConfirmDiscardOverlay();
+    return this._discardDraft().then(() => { timer.end(); });
+  }
+
+  _discardDraft() {
+    if (!this.comment.__draft) {
+      throw Error('Cannot discard a non-draft comment.');
+    }
+    this.discarding = true;
+    this.editing = false;
+    this.disabled = true;
+    this._eraseDraftComment();
+
+    if (!this.comment.id) {
+      this.disabled = false;
+      this._fireDiscard();
+      return;
     }
 
-    _messageTextChanged(newValue, oldValue) {
-      if (!this.comment || (this.comment && this.comment.id)) {
-        return;
+    this._xhrPromise = this._deleteDraft(this.comment).then(response => {
+      this.disabled = false;
+      if (!response.ok) {
+        this.discarding = false;
+        return response;
       }
 
-      this.debounce('store', () => {
-        const message = this._messageText;
-        const commentLocation = {
-          changeNum: this.changeNum,
-          patchNum: this._getPatchNum(),
-          path: this.comment.path,
-          line: this.comment.line,
-          range: this.comment.range,
-        };
+      this._fireDiscard();
+    })
+        .catch(err => {
+          this.disabled = false;
+          throw err;
+        });
 
-        if ((!this._messageText || !this._messageText.length) && oldValue) {
-          // If the draft has been modified to be empty, then erase the storage
-          // entry.
-          this.$.storage.eraseDraftComment(commentLocation);
-        } else {
-          this.$.storage.setDraftComment(commentLocation, message);
-        }
-      }, STORAGE_DEBOUNCE_INTERVAL);
+    return this._xhrPromise;
+  }
+
+  _closeConfirmDiscardOverlay() {
+    this._closeOverlay(this.confirmDiscardOverlay);
+  }
+
+  _getSavingMessage(numPending) {
+    if (numPending === 0) {
+      return SAVED_MESSAGE;
     }
+    return [
+      SAVING_MESSAGE,
+      numPending,
+      numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
+    ].join(' ');
+  }
 
-    _handleAnchorClick(e) {
-      e.preventDefault();
-      if (!this.comment.line) {
-        return;
+  _showStartRequest() {
+    const numPending = ++this._numPendingDraftRequests.number;
+    this._updateRequestToast(numPending);
+  }
+
+  _showEndRequest() {
+    const numPending = --this._numPendingDraftRequests.number;
+    this._updateRequestToast(numPending);
+  }
+
+  _handleFailedDraftRequest() {
+    this._numPendingDraftRequests.number--;
+
+    // Cancel the debouncer so that error toasts from the error-manager will
+    // not be overridden.
+    this.cancelDebouncer('draft-toast');
+  }
+
+  _updateRequestToast(numPending) {
+    const message = this._getSavingMessage(numPending);
+    this.debounce('draft-toast', () => {
+      // Note: the event is fired on the body rather than this element because
+      // this element may not be attached by the time this executes, in which
+      // case the event would not bubble.
+      document.body.dispatchEvent(new CustomEvent(
+          'show-alert', {detail: {message}, bubbles: true, composed: true}));
+    }, TOAST_DEBOUNCE_INTERVAL);
+  }
+
+  _saveDraft(draft) {
+    this._showStartRequest();
+    return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
+        .then(result => {
+          if (result.ok) {
+            this._showEndRequest();
+          } else {
+            this._handleFailedDraftRequest();
+          }
+          return result;
+        });
+  }
+
+  _deleteDraft(draft) {
+    this._showStartRequest();
+    return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
+        draft).then(result => {
+      if (result.ok) {
+        this._showEndRequest();
+      } else {
+        this._handleFailedDraftRequest();
       }
-      this.dispatchEvent(new CustomEvent('comment-anchor-tap', {
-        bubbles: true,
-        composed: true,
-        detail: {
-          number: this.comment.line || FILE,
-          side: this.side,
-        },
-      }));
+      return result;
+    });
+  }
+
+  _getPatchNum() {
+    return this.isOnParent() ? 'PARENT' : this.patchNum;
+  }
+
+  _loadLocalDraft(changeNum, patchNum, comment) {
+    // Polymer 2: check for undefined
+    if ([changeNum, patchNum, comment].some(arg => arg === undefined)) {
+      return;
     }
 
-    _handleEdit(e) {
-      e.preventDefault();
-      this._messageText = this.comment.message;
-      this.editing = true;
-      this.$.reporting.recordDraftInteraction();
+    // Only apply local drafts to comments that haven't been saved
+    // remotely, and haven't been given a default message already.
+    //
+    // Don't get local draft if there is another comment that is currently
+    // in an editing state.
+    if (!comment || comment.id || comment.message || comment.__otherEditing) {
+      delete comment.__otherEditing;
+      return;
     }
 
-    _handleSave(e) {
-      e.preventDefault();
+    const draft = this.$.storage.getDraftComment({
+      changeNum,
+      patchNum: this._getPatchNum(),
+      path: comment.path,
+      line: comment.line,
+      range: comment.range,
+    });
 
-      // Ignore saves started while already saving.
-      if (this.disabled) {
-        return;
-      }
-      const timingLabel = this.comment.id ?
-        REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
-      const timer = this.$.reporting.getTimer(timingLabel);
-      this.set('comment.__editing', false);
-      return this.save().then(() => { timer.end(); });
-    }
-
-    _handleCancel(e) {
-      e.preventDefault();
-
-      if (!this.comment.message ||
-          this.comment.message.trim().length === 0 ||
-          !this.comment.id) {
-        this._fireDiscard();
-        return;
-      }
-      this._messageText = this.comment.message;
-      this.editing = false;
-    }
-
-    _fireDiscard() {
-      this.cancelDebouncer('fire-update');
-      this.fire('comment-discard', this._getEventPayload());
-    }
-
-    _handleFix() {
-      this.dispatchEvent(new CustomEvent('create-fix-comment', {
-        bubbles: true,
-        composed: true,
-        detail: this._getEventPayload(),
-      }));
-    }
-
-    _handleShowFix() {
-      this.dispatchEvent(new CustomEvent('open-fix-preview', {
-        bubbles: true,
-        composed: true,
-        detail: this._getEventPayload(),
-      }));
-    }
-
-    _hasNoFix(comment) {
-      return !comment || !comment.fix_suggestions;
-    }
-
-    _handleDiscard(e) {
-      e.preventDefault();
-      this.$.reporting.recordDraftInteraction();
-
-      if (!this._messageText) {
-        this._discardDraft();
-        return;
-      }
-
-      this._openOverlay(this.confirmDiscardOverlay).then(() => {
-        this.confirmDiscardOverlay.querySelector('#confirmDiscardDialog')
-            .resetFocus();
-      });
-    }
-
-    _handleConfirmDiscard(e) {
-      e.preventDefault();
-      const timer = this.$.reporting.getTimer(REPORT_DISCARD_DRAFT);
-      this._closeConfirmDiscardOverlay();
-      return this._discardDraft().then(() => { timer.end(); });
-    }
-
-    _discardDraft() {
-      if (!this.comment.__draft) {
-        throw Error('Cannot discard a non-draft comment.');
-      }
-      this.discarding = true;
-      this.editing = false;
-      this.disabled = true;
-      this._eraseDraftComment();
-
-      if (!this.comment.id) {
-        this.disabled = false;
-        this._fireDiscard();
-        return;
-      }
-
-      this._xhrPromise = this._deleteDraft(this.comment).then(response => {
-        this.disabled = false;
-        if (!response.ok) {
-          this.discarding = false;
-          return response;
-        }
-
-        this._fireDiscard();
-      })
-          .catch(err => {
-            this.disabled = false;
-            throw err;
-          });
-
-      return this._xhrPromise;
-    }
-
-    _closeConfirmDiscardOverlay() {
-      this._closeOverlay(this.confirmDiscardOverlay);
-    }
-
-    _getSavingMessage(numPending) {
-      if (numPending === 0) {
-        return SAVED_MESSAGE;
-      }
-      return [
-        SAVING_MESSAGE,
-        numPending,
-        numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
-      ].join(' ');
-    }
-
-    _showStartRequest() {
-      const numPending = ++this._numPendingDraftRequests.number;
-      this._updateRequestToast(numPending);
-    }
-
-    _showEndRequest() {
-      const numPending = --this._numPendingDraftRequests.number;
-      this._updateRequestToast(numPending);
-    }
-
-    _handleFailedDraftRequest() {
-      this._numPendingDraftRequests.number--;
-
-      // Cancel the debouncer so that error toasts from the error-manager will
-      // not be overridden.
-      this.cancelDebouncer('draft-toast');
-    }
-
-    _updateRequestToast(numPending) {
-      const message = this._getSavingMessage(numPending);
-      this.debounce('draft-toast', () => {
-        // Note: the event is fired on the body rather than this element because
-        // this element may not be attached by the time this executes, in which
-        // case the event would not bubble.
-        document.body.dispatchEvent(new CustomEvent(
-            'show-alert', {detail: {message}, bubbles: true, composed: true}));
-      }, TOAST_DEBOUNCE_INTERVAL);
-    }
-
-    _saveDraft(draft) {
-      this._showStartRequest();
-      return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
-          .then(result => {
-            if (result.ok) {
-              this._showEndRequest();
-            } else {
-              this._handleFailedDraftRequest();
-            }
-            return result;
-          });
-    }
-
-    _deleteDraft(draft) {
-      this._showStartRequest();
-      return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
-          draft).then(result => {
-        if (result.ok) {
-          this._showEndRequest();
-        } else {
-          this._handleFailedDraftRequest();
-        }
-        return result;
-      });
-    }
-
-    _getPatchNum() {
-      return this.isOnParent() ? 'PARENT' : this.patchNum;
-    }
-
-    _loadLocalDraft(changeNum, patchNum, comment) {
-      // Polymer 2: check for undefined
-      if ([changeNum, patchNum, comment].some(arg => arg === undefined)) {
-        return;
-      }
-
-      // Only apply local drafts to comments that haven't been saved
-      // remotely, and haven't been given a default message already.
-      //
-      // Don't get local draft if there is another comment that is currently
-      // in an editing state.
-      if (!comment || comment.id || comment.message || comment.__otherEditing) {
-        delete comment.__otherEditing;
-        return;
-      }
-
-      const draft = this.$.storage.getDraftComment({
-        changeNum,
-        patchNum: this._getPatchNum(),
-        path: comment.path,
-        line: comment.line,
-        range: comment.range,
-      });
-
-      if (draft) {
-        this.set('comment.message', draft.message);
-      }
-    }
-
-    _handleToggleResolved() {
-      this.$.reporting.recordDraftInteraction();
-      this.resolved = !this.resolved;
-      // Modify payload instead of this.comment, as this.comment is passed from
-      // the parent by ref.
-      const payload = this._getEventPayload();
-      payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
-      this.fire('comment-update', payload);
-      if (!this.editing) {
-        // Save the resolved state immediately.
-        this.save(payload.comment);
-      }
-    }
-
-    _handleCommentDelete() {
-      this._openOverlay(this.confirmDeleteOverlay);
-    }
-
-    _handleCancelDeleteComment() {
-      this._closeOverlay(this.confirmDeleteOverlay);
-    }
-
-    _openOverlay(overlay) {
-      Polymer.dom(Gerrit.getRootElement()).appendChild(overlay);
-      return overlay.open();
-    }
-
-    _computeAuthorName(comment) {
-      if (!comment) return '';
-      if (comment.robot_id) {
-        return comment.robot_id;
-      }
-      return comment.author && comment.author.name;
-    }
-
-    _computeHideRunDetails(comment, collapsed) {
-      if (!comment) return true;
-      return !(comment.robot_id && comment.url && !collapsed);
-    }
-
-    _closeOverlay(overlay) {
-      Polymer.dom(Gerrit.getRootElement()).removeChild(overlay);
-      overlay.close();
-    }
-
-    _handleConfirmDeleteComment() {
-      const dialog =
-          this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
-      this.$.restAPI.deleteComment(
-          this.changeNum, this.patchNum, this.comment.id, dialog.message)
-          .then(newComment => {
-            this._handleCancelDeleteComment();
-            this.comment = newComment;
-          });
+    if (draft) {
+      this.set('comment.message', draft.message);
     }
   }
 
-  customElements.define(GrComment.is, GrComment);
-})();
+  _handleToggleResolved() {
+    this.$.reporting.recordDraftInteraction();
+    this.resolved = !this.resolved;
+    // Modify payload instead of this.comment, as this.comment is passed from
+    // the parent by ref.
+    const payload = this._getEventPayload();
+    payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
+    this.fire('comment-update', payload);
+    if (!this.editing) {
+      // Save the resolved state immediately.
+      this.save(payload.comment);
+    }
+  }
+
+  _handleCommentDelete() {
+    this._openOverlay(this.confirmDeleteOverlay);
+  }
+
+  _handleCancelDeleteComment() {
+    this._closeOverlay(this.confirmDeleteOverlay);
+  }
+
+  _openOverlay(overlay) {
+    dom(Gerrit.getRootElement()).appendChild(overlay);
+    return overlay.open();
+  }
+
+  _computeAuthorName(comment) {
+    if (!comment) return '';
+    if (comment.robot_id) {
+      return comment.robot_id;
+    }
+    return comment.author && comment.author.name;
+  }
+
+  _computeHideRunDetails(comment, collapsed) {
+    if (!comment) return true;
+    return !(comment.robot_id && comment.url && !collapsed);
+  }
+
+  _closeOverlay(overlay) {
+    dom(Gerrit.getRootElement()).removeChild(overlay);
+    overlay.close();
+  }
+
+  _handleConfirmDeleteComment() {
+    const dialog =
+        this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
+    this.$.restAPI.deleteComment(
+        this.changeNum, this.patchNum, this.comment.id, dialog.message)
+        .then(newComment => {
+          this._handleCancelDeleteComment();
+          this.comment = newComment;
+        });
+  }
+}
+
+customElements.define(GrComment.is, GrComment);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
index 18ffc0e..4a0f388 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
@@ -1,43 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../../shared/gr-textarea/gr-textarea.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html">
-<script src="../../../scripts/rootElement.js"></script>
-
-<dom-module id="gr-comment">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -257,144 +236,85 @@
         <div class="headerLeft">
           <span class="authorName">[[_computeAuthorName(comment)]]</span>
           <span class="draftLabel">DRAFT</span>
-          <gr-tooltip-content class="draftTooltip"
-              has-tooltip
-              title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'A' key."
-              max-width="20em"
-              show-icon></gr-tooltip-content>
+          <gr-tooltip-content class="draftTooltip" has-tooltip="" title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'A' key." max-width="20em" show-icon=""></gr-tooltip-content>
         </div>
         <div class="headerMiddle">
           <span class="collapsedContent">[[comment.message]]</span>
         </div>
-        <div hidden$="[[_computeHideRunDetails(comment, collapsed)]]" class="runIdMessage message">
+        <div hidden\$="[[_computeHideRunDetails(comment, collapsed)]]" class="runIdMessage message">
           <div class="runIdInformation">
-            <a class="robotRunLink" href$="[[comment.url]]">
+            <a class="robotRunLink" href\$="[[comment.url]]">
               <span class="robotRun link">Run Details</span>
             </a>
           </div>
         </div>
-        <gr-button
-            id="deleteBtn"
-            link
-            class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
-            hidden$="[[isRobotComment]]"
-            on-click="_handleCommentDelete">
+        <gr-button id="deleteBtn" link="" class\$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]" hidden\$="[[isRobotComment]]" on-click="_handleCommentDelete">
           <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
         </gr-button>
         <span class="date" on-click="_handleAnchorClick">
-          <gr-date-formatter
-              has-tooltip
-              date-str="[[comment.updated]]"></gr-date-formatter>
+          <gr-date-formatter has-tooltip="" date-str="[[comment.updated]]"></gr-date-formatter>
         </span>
         <div class="show-hide">
           <label class="show-hide">
-            <input type="checkbox" class="show-hide"
-               checked$="[[collapsed]]"
-               on-change="_handleToggleCollapsed">
-            <iron-icon
-                id="icon"
-                icon="[[_computeShowHideIcon(collapsed)]]">
+            <input type="checkbox" class="show-hide" checked\$="[[collapsed]]" on-change="_handleToggleCollapsed">
+            <iron-icon id="icon" icon="[[_computeShowHideIcon(collapsed)]]">
             </iron-icon>
           </label>
         </div>
       </div>
       <div class="body">
         <template is="dom-if" if="[[isRobotComment]]">
-          <div class="robotId" hidden$="[[collapsed]]">
+          <div class="robotId" hidden\$="[[collapsed]]">
             [[comment.author.name]]
           </div>
         </template>
         <template is="dom-if" if="[[editing]]">
-          <gr-textarea
-              id="editTextarea"
-              class="editMessage"
-              autocomplete="on"
-              code
-              disabled="{{disabled}}"
-              rows="4"
-              text="{{_messageText}}"></gr-textarea>
+          <gr-textarea id="editTextarea" class="editMessage" autocomplete="on" code="" disabled="{{disabled}}" rows="4" text="{{_messageText}}"></gr-textarea>
           <template is="dom-if" if="[[_computeVisibilityOfTip(_showRespectfulTip, _respectfulTipDismissed)]]">
             <div class="respectfulReviewTip">
               <div>
-                <gr-tooltip-content
-                  has-tooltip
-                  title="Tips for respectful code reviews.">
+                <gr-tooltip-content has-tooltip="" title="Tips for respectful code reviews.">
                   <iron-icon class="pointer" icon="gr-icons:lightbulb-outline"></iron-icon>
                 </gr-tooltip-content>
                 [[_respectfulReviewTip]]
               </div>
               <div>
-                <a
-                  tabIndex="-1"
-                  on-click="_onRespectfulReadMoreClick"
-                  href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
-                  target="_blank">
+                <a tabindex="-1" on-click="_onRespectfulReadMoreClick" href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html" target="_blank">
                   Read more
                 </a>
-                <iron-icon
-                  class="close pointer"
-                  on-click="_dismissRespectfulTip"
-                  icon="gr-icons:close"></iron-icon>
+                <iron-icon class="close pointer" on-click="_dismissRespectfulTip" icon="gr-icons:close"></iron-icon>
               </div>
             </div>
           </template>
         </template>
         <!--The message class is needed to ensure selectability from
         gr-diff-selection.-->
-        <gr-formatted-text class="message"
-            content="[[comment.message]]"
-            no-trailing-margin="[[!comment.__draft]]"
-            config="[[projectConfig.commentlinks]]"></gr-formatted-text>
-        <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
+        <gr-formatted-text class="message" content="[[comment.message]]" no-trailing-margin="[[!comment.__draft]]" config="[[projectConfig.commentlinks]]"></gr-formatted-text>
+        <div class="actions humanActions" hidden\$="[[!_showHumanActions]]">
           <div class="action resolve hideOnPublished">
             <label>
-              <input type="checkbox"
-                  id="resolvedCheckbox"
-                  checked="[[resolved]]"
-                  on-change="_handleToggleResolved">
+              <input type="checkbox" id="resolvedCheckbox" checked="[[resolved]]" on-change="_handleToggleResolved">
               Resolved
             </label>
           </div>
           <div class="rightActions">
-            <gr-button
-                link
-                class="action cancel hideOnPublished"
-                on-click="_handleCancel">Cancel</gr-button>
-            <gr-button
-                link
-                class="action discard hideOnPublished"
-                on-click="_handleDiscard">Discard</gr-button>
-            <gr-button
-                link
-                class="action edit hideOnPublished"
-                on-click="_handleEdit">Edit</gr-button>
-            <gr-button
-                link
-                disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
-                class="action save hideOnPublished"
-                on-click="_handleSave">Save</gr-button>
+            <gr-button link="" class="action cancel hideOnPublished" on-click="_handleCancel">Cancel</gr-button>
+            <gr-button link="" class="action discard hideOnPublished" on-click="_handleDiscard">Discard</gr-button>
+            <gr-button link="" class="action edit hideOnPublished" on-click="_handleEdit">Edit</gr-button>
+            <gr-button link="" disabled\$="[[_computeSaveDisabled(_messageText, comment, resolved)]]" class="action save hideOnPublished" on-click="_handleSave">Save</gr-button>
           </div>
         </div>
-        <div class="robotActions" hidden$="[[!_showRobotActions]]">
+        <div class="robotActions" hidden\$="[[!_showRobotActions]]">
           <template is="dom-if" if="[[isRobotComment]]">
             <gr-endpoint-decorator name="robot-comment-controls">
               <gr-endpoint-param name="comment" value="[[comment]]">
               </gr-endpoint-param>
             </gr-endpoint-decorator>
-            <gr-button
-                link
-                secondary
-                class="action show-fix"
-                hidden$="[[_hasNoFix(comment)]]"
-                on-click="_handleShowFix">
+            <gr-button link="" secondary="" class="action show-fix" hidden\$="[[_hasNoFix(comment)]]" on-click="_handleShowFix">
               Show Fix
             </gr-button>
             <template is="dom-if" if="[[!_hasHumanReply]]">
-              <gr-button
-                  link
-                  class="action fix"
-                  on-click="_handleFix"
-                  disabled="[[robotButtonDisabled]]">
+              <gr-button link="" class="action fix" on-click="_handleFix" disabled="[[robotButtonDisabled]]">
                 Please Fix
               </gr-button>
             </template>
@@ -403,19 +323,12 @@
       </div>
     </div>
     <template is="dom-if" if="[[_enableOverlay]]">
-      <gr-overlay id="confirmDeleteOverlay" with-backdrop>
-        <gr-confirm-delete-comment-dialog id="confirmDeleteComment"
-            on-confirm="_handleConfirmDeleteComment"
-            on-cancel="_handleCancelDeleteComment">
+      <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
+        <gr-confirm-delete-comment-dialog id="confirmDeleteComment" on-confirm="_handleConfirmDeleteComment" on-cancel="_handleCancelDeleteComment">
         </gr-confirm-delete-comment-dialog>
       </gr-overlay>
-      <gr-overlay id="confirmDiscardOverlay" with-backdrop>
-        <gr-dialog
-            id="confirmDiscardDialog"
-            confirm-label="Discard"
-            confirm-on-enter
-            on-confirm="_handleConfirmDiscard"
-            on-cancel="_closeConfirmDiscardOverlay">
+      <gr-overlay id="confirmDiscardOverlay" with-backdrop="">
+        <gr-dialog id="confirmDiscardDialog" confirm-label="Discard" confirm-on-enter="" on-confirm="_handleConfirmDiscard" on-cancel="_closeConfirmDiscardOverlay">
           <div class="header" slot="header">
             Discard comment
           </div>
@@ -428,6 +341,4 @@
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
     <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-comment.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
index 5e9d37a..96d497e 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
@@ -19,18 +19,24 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script src="/node_modules/page/page.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-comment.html">
+<script type="module" src="./gr-comment.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-comment.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -44,1119 +50,1204 @@
   </template>
 </test-fixture>
 
-<script>
-  function isVisible(el) {
-    assert.ok(el);
-    return getComputedStyle(el).getPropertyValue('display') !== 'none';
-  }
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-comment.js';
+function isVisible(el) {
+  assert.ok(el);
+  return getComputedStyle(el).getPropertyValue('display') !== 'none';
+}
 
-  suite('gr-comment tests', async () => {
-    await readyToTest();
+suite('gr-comment tests', () => {
+  suite('basic tests', () => {
+    let element;
+    let sandbox;
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getAccount() { return Promise.resolve(null); },
+      });
+      element = fixture('basic');
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+      };
+      sandbox = sinon.sandbox.create();
+    });
 
-    suite('basic tests', () => {
-      let element;
-      let sandbox;
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('collapsible comments', () => {
+      // When a comment (not draft) is loaded, it should be collapsed
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+
+      // The header middle content is only visible when comments are collapsed.
+      // It shows the message in a condensed way, and limits to a single line.
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
+
+      // When the header row is clicked, the comment should expand
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is not visible');
+    });
+
+    test('clicking on date link fires event', () => {
+      element.side = 'PARENT';
+      const stub = sinon.stub();
+      element.addEventListener('comment-anchor-tap', stub);
+      const dateEl = element.shadowRoot
+          .querySelector('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail,
+          {side: element.side, number: element.comment.line});
+    });
+
+    test('message is not retrieved from storage when other edits', done => {
+      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1;
+      element.patchNum = 1;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        line: 5,
+        __otherEditing: true,
+      };
+      flush(() => {
+        assert.isTrue(loadSpy.called);
+        assert.isFalse(storageStub.called);
+        done();
+      });
+    });
+
+    test('message is retrieved from storage when no other edits', done => {
+      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1;
+      element.patchNum = 1;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        line: 5,
+      };
+      flush(() => {
+        assert.isTrue(loadSpy.called);
+        assert.isTrue(storageStub.called);
+        done();
+      });
+    });
+
+    test('_getPatchNum', () => {
+      element.side = 'PARENT';
+      element.patchNum = 1;
+      assert.equal(element._getPatchNum(), 'PARENT');
+      element.side = 'REVISION';
+      assert.equal(element._getPatchNum(), 1);
+    });
+
+    test('comment expand and collapse', () => {
+      element.collapsed = true;
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
+
+      element.collapsed = false;
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is is not visible');
+    });
+
+    suite('while editing', () => {
       setup(() => {
-        stub('gr-rest-api-interface', {
-          getAccount() { return Promise.resolve(null); },
-        });
-        element = fixture('basic');
-        element.comment = {
-          author: {
-            name: 'Mr. Peanutbutter',
-            email: 'tenn1sballchaser@aol.com',
-          },
-          id: 'baf0414d_60047215',
-          line: 5,
-          message: 'is this a crossover episode!?',
-          updated: '2015-12-08 19:48:33.843000000',
-        };
-        sandbox = sinon.sandbox.create();
+        element.editing = true;
+        element._messageText = 'test';
+        sandbox.stub(element, '_handleCancel');
+        sandbox.stub(element, '_handleSave');
+        flushAsynchronousOperations();
       });
 
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      test('collapsible comments', () => {
-        // When a comment (not draft) is loaded, it should be collapsed
-        assert.isTrue(element.collapsed);
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('gr-formatted-text')),
-        'gr-formatted-text is not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.actions')),
-        'actions are not visible');
-        assert.isNotOk(element.textarea, 'textarea is not visible');
-
-        // The header middle content is only visible when comments are collapsed.
-        // It shows the message in a condensed way, and limits to a single line.
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.collapsedContent')),
-        'header middle content is visible');
-
-        // When the header row is clicked, the comment should expand
-        MockInteractions.tap(element.$.header);
-        assert.isFalse(element.collapsed);
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('gr-formatted-text')),
-        'gr-formatted-text is visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.actions')),
-        'actions are visible');
-        assert.isNotOk(element.textarea, 'textarea is not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.collapsedContent')),
-        'header middle content is not visible');
-      });
-
-      test('clicking on date link fires event', () => {
-        element.side = 'PARENT';
-        const stub = sinon.stub();
-        element.addEventListener('comment-anchor-tap', stub);
-        const dateEl = element.shadowRoot
-            .querySelector('.date');
-        assert.ok(dateEl);
-        MockInteractions.tap(dateEl);
-
-        assert.isTrue(stub.called);
-        assert.deepEqual(stub.lastCall.args[0].detail,
-            {side: element.side, number: element.comment.line});
-      });
-
-      test('message is not retrieved from storage when other edits', done => {
-        const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
-        const loadSpy = sandbox.spy(element, '_loadLocalDraft');
-
-        element.changeNum = 1;
-        element.patchNum = 1;
-        element.comment = {
-          author: {
-            name: 'Mr. Peanutbutter',
-            email: 'tenn1sballchaser@aol.com',
-          },
-          line: 5,
-          __otherEditing: true,
-        };
-        flush(() => {
-          assert.isTrue(loadSpy.called);
-          assert.isFalse(storageStub.called);
-          done();
-        });
-      });
-
-      test('message is retrieved from storage when no other edits', done => {
-        const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
-        const loadSpy = sandbox.spy(element, '_loadLocalDraft');
-
-        element.changeNum = 1;
-        element.patchNum = 1;
-        element.comment = {
-          author: {
-            name: 'Mr. Peanutbutter',
-            email: 'tenn1sballchaser@aol.com',
-          },
-          line: 5,
-        };
-        flush(() => {
-          assert.isTrue(loadSpy.called);
-          assert.isTrue(storageStub.called);
-          done();
-        });
-      });
-
-      test('_getPatchNum', () => {
-        element.side = 'PARENT';
-        element.patchNum = 1;
-        assert.equal(element._getPatchNum(), 'PARENT');
-        element.side = 'REVISION';
-        assert.equal(element._getPatchNum(), 1);
-      });
-
-      test('comment expand and collapse', () => {
-        element.collapsed = true;
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('gr-formatted-text')),
-        'gr-formatted-text is not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.actions')),
-        'actions are not visible');
-        assert.isNotOk(element.textarea, 'textarea is not visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.collapsedContent')),
-        'header middle content is visible');
-
-        element.collapsed = false;
-        assert.isFalse(element.collapsed);
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('gr-formatted-text')),
-        'gr-formatted-text is visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.actions')),
-        'actions are visible');
-        assert.isNotOk(element.textarea, 'textarea is not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.collapsedContent')),
-        'header middle content is is not visible');
-      });
-
-      suite('while editing', () => {
+      suite('when text is empty', () => {
         setup(() => {
-          element.editing = true;
-          element._messageText = 'test';
-          sandbox.stub(element, '_handleCancel');
-          sandbox.stub(element, '_handleSave');
-          flushAsynchronousOperations();
+          element._messageText = '';
+          element.comment = {};
         });
 
-        suite('when text is empty', () => {
-          setup(() => {
-            element._messageText = '';
-            element.comment = {};
-          });
-
-          test('esc closes comment when text is empty', () => {
-            MockInteractions.pressAndReleaseKeyOn(
-                element.textarea, 27); // esc
-            assert.isTrue(element._handleCancel.called);
-          });
-
-          test('ctrl+enter does not save', () => {
-            MockInteractions.pressAndReleaseKeyOn(
-                element.textarea, 13, 'ctrl'); // ctrl + enter
-            assert.isFalse(element._handleSave.called);
-          });
-
-          test('meta+enter does not save', () => {
-            MockInteractions.pressAndReleaseKeyOn(
-                element.textarea, 13, 'meta'); // meta + enter
-            assert.isFalse(element._handleSave.called);
-          });
-
-          test('ctrl+s does not save', () => {
-            MockInteractions.pressAndReleaseKeyOn(
-                element.textarea, 83, 'ctrl'); // ctrl + s
-            assert.isFalse(element._handleSave.called);
-          });
-        });
-
-        test('esc does not close comment that has content', () => {
+        test('esc closes comment when text is empty', () => {
           MockInteractions.pressAndReleaseKeyOn(
               element.textarea, 27); // esc
-          assert.isFalse(element._handleCancel.called);
+          assert.isTrue(element._handleCancel.called);
         });
 
-        test('ctrl+enter saves', () => {
+        test('ctrl+enter does not save', () => {
           MockInteractions.pressAndReleaseKeyOn(
               element.textarea, 13, 'ctrl'); // ctrl + enter
-          assert.isTrue(element._handleSave.called);
+          assert.isFalse(element._handleSave.called);
         });
 
-        test('meta+enter saves', () => {
+        test('meta+enter does not save', () => {
           MockInteractions.pressAndReleaseKeyOn(
               element.textarea, 13, 'meta'); // meta + enter
-          assert.isTrue(element._handleSave.called);
+          assert.isFalse(element._handleSave.called);
         });
 
-        test('ctrl+s saves', () => {
+        test('ctrl+s does not save', () => {
           MockInteractions.pressAndReleaseKeyOn(
               element.textarea, 83, 'ctrl'); // ctrl + s
-          assert.isTrue(element._handleSave.called);
-        });
-      });
-      test('delete comment button for non-admins is hidden', () => {
-        element._isAdmin = false;
-        assert.isFalse(element.shadowRoot
-            .querySelector('.action.delete')
-            .classList.contains('showDeleteButtons'));
-      });
-
-      test('delete comment button for admins with draft is hidden', () => {
-        element._isAdmin = false;
-        element.draft = true;
-        assert.isFalse(element.shadowRoot
-            .querySelector('.action.delete')
-            .classList.contains('showDeleteButtons'));
-      });
-
-      test('delete comment', done => {
-        sandbox.stub(
-            element.$.restAPI, 'deleteComment').returns(Promise.resolve({}));
-        sandbox.spy(element.confirmDeleteOverlay, 'open');
-        element.changeNum = 42;
-        element.patchNum = 0xDEADBEEF;
-        element._isAdmin = true;
-        assert.isTrue(element.shadowRoot
-            .querySelector('.action.delete')
-            .classList.contains('showDeleteButtons'));
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.action.delete'));
-        flush(() => {
-          element.confirmDeleteOverlay.open.lastCall.returnValue.then(() => {
-            const dialog =
-                window.confirmDeleteOverlay
-                    .querySelector('#confirmDeleteComment');
-            dialog.message = 'removal reason';
-            element._handleConfirmDeleteComment();
-            assert.isTrue(element.$.restAPI.deleteComment.calledWith(
-                42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
-            done();
-          });
+          assert.isFalse(element._handleSave.called);
         });
       });
 
-      suite('draft update reporting', () => {
-        let endStub;
-        let getTimerStub;
-        let mockEvent;
-
-        setup(() => {
-          mockEvent = {preventDefault() {}};
-          sandbox.stub(element, 'save')
-              .returns(Promise.resolve({}));
-          sandbox.stub(element, '_discardDraft')
-              .returns(Promise.resolve({}));
-          endStub = sinon.stub();
-          getTimerStub = sandbox.stub(element.$.reporting, 'getTimer')
-              .returns({end: endStub});
-        });
-
-        test('create', () => {
-          element.comment = {};
-          return element._handleSave(mockEvent).then(() => {
-            assert.isTrue(endStub.calledOnce);
-            assert.isTrue(getTimerStub.calledOnce);
-            assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-          });
-        });
-
-        test('update', () => {
-          element.comment = {id: 'abc_123'};
-          return element._handleSave(mockEvent).then(() => {
-            assert.isTrue(endStub.calledOnce);
-            assert.isTrue(getTimerStub.calledOnce);
-            assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
-          });
-        });
-
-        test('discard', () => {
-          element.comment = {id: 'abc_123'};
-          sandbox.stub(element, '_closeConfirmDiscardOverlay');
-          return element._handleConfirmDiscard(mockEvent).then(() => {
-            assert.isTrue(endStub.calledOnce);
-            assert.isTrue(getTimerStub.calledOnce);
-            assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
-          });
-        });
+      test('esc does not close comment that has content', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 27); // esc
+        assert.isFalse(element._handleCancel.called);
       });
 
-      test('edit reports interaction', () => {
-        const reportStub = sandbox.stub(element.$.reporting,
-            'recordDraftInteraction');
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.edit'));
-        assert.isTrue(reportStub.calledOnce);
+      test('ctrl+enter saves', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 13, 'ctrl'); // ctrl + enter
+        assert.isTrue(element._handleSave.called);
       });
 
-      test('discard reports interaction', () => {
-        const reportStub = sandbox.stub(element.$.reporting,
-            'recordDraftInteraction');
-        element.draft = true;
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.discard'));
-        assert.isTrue(reportStub.calledOnce);
+      test('meta+enter saves', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 13, 'meta'); // meta + enter
+        assert.isTrue(element._handleSave.called);
+      });
+
+      test('ctrl+s saves', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 83, 'ctrl'); // ctrl + s
+        assert.isTrue(element._handleSave.called);
+      });
+    });
+    test('delete comment button for non-admins is hidden', () => {
+      element._isAdmin = false;
+      assert.isFalse(element.shadowRoot
+          .querySelector('.action.delete')
+          .classList.contains('showDeleteButtons'));
+    });
+
+    test('delete comment button for admins with draft is hidden', () => {
+      element._isAdmin = false;
+      element.draft = true;
+      assert.isFalse(element.shadowRoot
+          .querySelector('.action.delete')
+          .classList.contains('showDeleteButtons'));
+    });
+
+    test('delete comment', done => {
+      sandbox.stub(
+          element.$.restAPI, 'deleteComment').returns(Promise.resolve({}));
+      sandbox.spy(element.confirmDeleteOverlay, 'open');
+      element.changeNum = 42;
+      element.patchNum = 0xDEADBEEF;
+      element._isAdmin = true;
+      assert.isTrue(element.shadowRoot
+          .querySelector('.action.delete')
+          .classList.contains('showDeleteButtons'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.action.delete'));
+      flush(() => {
+        element.confirmDeleteOverlay.open.lastCall.returnValue.then(() => {
+          const dialog =
+              window.confirmDeleteOverlay
+                  .querySelector('#confirmDeleteComment');
+          dialog.message = 'removal reason';
+          element._handleConfirmDeleteComment();
+          assert.isTrue(element.$.restAPI.deleteComment.calledWith(
+              42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
+          done();
+        });
       });
     });
 
-    suite('gr-comment draft tests', () => {
-      let element;
-      let sandbox;
+    suite('draft update reporting', () => {
+      let endStub;
+      let getTimerStub;
+      let mockEvent;
 
       setup(() => {
-        stub('gr-rest-api-interface', {
-          getAccount() { return Promise.resolve(null); },
-          saveDiffDraft() {
-            return Promise.resolve({
-              ok: true,
-              text() {
-                return Promise.resolve(
-                    ')]}\'\n{' +
-                    '"id": "baf0414d_40572e03",' +
-                    '"path": "/path/to/file",' +
-                    '"line": 5,' +
-                    '"updated": "2015-12-08 21:52:36.177000000",' +
-                    '"message": "saved!"' +
-                  '}'
-                );
-              },
-            });
-          },
-          removeChangeReviewer() {
-            return Promise.resolve({ok: true});
-          },
-        });
-        stub('gr-storage', {
-          getDraftComment() { return null; },
-        });
-        element = fixture('draft');
-        element.changeNum = 42;
-        element.patchNum = 1;
-        element.editing = false;
-        element.comment = {
-          __commentSide: 'right',
-          __draft: true,
-          __draftID: 'temp_draft_id',
-          path: '/path/to/file',
-          line: 5,
-        };
-        element.commentSide = 'right';
-        sandbox = sinon.sandbox.create();
-      });
-
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      test('button visibility states', () => {
-        element.showActions = false;
-        assert.isTrue(element.shadowRoot
-            .querySelector('.humanActions').hasAttribute('hidden'));
-        assert.isTrue(element.shadowRoot
-            .querySelector('.robotActions').hasAttribute('hidden'));
-
-        element.showActions = true;
-        assert.isFalse(element.shadowRoot
-            .querySelector('.humanActions').hasAttribute('hidden'));
-        assert.isTrue(element.shadowRoot
-            .querySelector('.robotActions').hasAttribute('hidden'));
-
-        element.draft = true;
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.edit')), 'edit is visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.discard')), 'discard is visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.save')), 'save is not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.cancel')), 'cancel is not visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.resolve')), 'resolve is visible');
-        assert.isFalse(element.shadowRoot
-            .querySelector('.humanActions').hasAttribute('hidden'));
-        assert.isTrue(element.shadowRoot
-            .querySelector('.robotActions').hasAttribute('hidden'));
-
-        element.editing = true;
-        flushAsynchronousOperations();
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.edit')), 'edit is not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.discard')), 'discard not visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.save')), 'save is visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.cancel')), 'cancel is visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.resolve')), 'resolve is visible');
-        assert.isFalse(element.shadowRoot
-            .querySelector('.humanActions').hasAttribute('hidden'));
-        assert.isTrue(element.shadowRoot
-            .querySelector('.robotActions').hasAttribute('hidden'));
-
-        element.draft = false;
-        element.editing = false;
-        flushAsynchronousOperations();
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.edit')), 'edit is not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.discard')),
-        'discard is not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.save')), 'save is not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.cancel')), 'cancel is not visible');
-        assert.isFalse(element.shadowRoot
-            .querySelector('.humanActions').hasAttribute('hidden'));
-        assert.isTrue(element.shadowRoot
-            .querySelector('.robotActions').hasAttribute('hidden'));
-
-        element.comment.id = 'foo';
-        element.draft = true;
-        element.editing = true;
-        flushAsynchronousOperations();
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.cancel')), 'cancel is visible');
-        assert.isFalse(element.shadowRoot
-            .querySelector('.humanActions').hasAttribute('hidden'));
-        assert.isTrue(element.shadowRoot
-            .querySelector('.robotActions').hasAttribute('hidden'));
-
-        // Delete button is not hidden by default
-        assert.isFalse(element.shadowRoot.querySelector('#deleteBtn').hidden);
-
-        element.isRobotComment = true;
-        element.draft = true;
-        assert.isTrue(element.shadowRoot
-            .querySelector('.humanActions').hasAttribute('hidden'));
-        assert.isFalse(element.shadowRoot
-            .querySelector('.robotActions').hasAttribute('hidden'));
-
-        // It is not expected to see Robot comment drafts, but if they appear,
-        // they will behave the same as non-drafts.
-        element.draft = false;
-        assert.isTrue(element.shadowRoot
-            .querySelector('.humanActions').hasAttribute('hidden'));
-        assert.isFalse(element.shadowRoot
-            .querySelector('.robotActions').hasAttribute('hidden'));
-
-        // A robot comment with run ID should display plain text.
-        element.set(['comment', 'robot_run_id'], 'text');
-        element.editing = false;
-        element.collapsed = false;
-        flushAsynchronousOperations();
-        assert.isTrue(element.shadowRoot
-            .querySelector('.robotRun.link').textContent === 'Run Details');
-
-        // A robot comment with run ID and url should display a link.
-        element.set(['comment', 'url'], '/path/to/run');
-        flushAsynchronousOperations();
-        assert.notEqual(getComputedStyle(element.shadowRoot
-            .querySelector('.robotRun.link')).display,
-        'none');
-
-        // Delete button is hidden for robot comments
-        assert.isTrue(element.shadowRoot.querySelector('#deleteBtn').hidden);
-      });
-
-      test('collapsible drafts', () => {
-        assert.isTrue(element.collapsed);
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('gr-formatted-text')),
-        'gr-formatted-text is not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.actions')),
-        'actions are not visible');
-        assert.isNotOk(element.textarea, 'textarea is not visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.collapsedContent')),
-        'header middle content is visible');
-
-        MockInteractions.tap(element.$.header);
-        assert.isFalse(element.collapsed);
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('gr-formatted-text')),
-        'gr-formatted-text is visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.actions')),
-        'actions are visible');
-        assert.isNotOk(element.textarea, 'textarea is not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.collapsedContent')),
-        'header middle content is is not visible');
-
-        // When the edit button is pressed, should still see the actions
-        // and also textarea
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.edit'));
-        flushAsynchronousOperations();
-        assert.isFalse(element.collapsed);
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('gr-formatted-text')),
-        'gr-formatted-text is not visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.actions')),
-        'actions are visible');
-        assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.collapsedContent')),
-        'header middle content is not visible');
-
-        // When toggle again, everything should be hidden except for textarea
-        // and header middle content should be visible
-        MockInteractions.tap(element.$.header);
-        assert.isTrue(element.collapsed);
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('gr-formatted-text')),
-        'gr-formatted-text is not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.actions')),
-        'actions are not visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('gr-textarea')),
-        'textarea is not visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.collapsedContent')),
-        'header middle content is visible');
-
-        // When toggle again, textarea should remain open in the state it was
-        // before
-        MockInteractions.tap(element.$.header);
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('gr-formatted-text')),
-        'gr-formatted-text is not visible');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.actions')),
-        'actions are visible');
-        assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-        assert.isFalse(isVisible(element.shadowRoot
-            .querySelector('.collapsedContent')),
-        'header middle content is not visible');
-      });
-
-      test('robot comment layout', () => {
-        const comment = Object.assign({
-          robot_id: 'happy_robot_id',
-          url: '/robot/comment',
-          author: {
-            name: 'Happy Robot',
-          },
-        }, element.comment);
-        element.comment = comment;
-        element.collapsed = false;
-        flushAsynchronousOperations();
-
-        let runIdMessage;
-        runIdMessage = element.shadowRoot
-            .querySelector('.runIdMessage');
-        assert.isFalse(runIdMessage.hidden);
-
-        const runDetailsLink = element.shadowRoot
-            .querySelector('.robotRunLink');
-        assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
-
-        const robotServiceName = element.shadowRoot
-            .querySelector('.authorName');
-        assert.isTrue(robotServiceName.textContent === 'happy_robot_id');
-
-        const authorName = element.shadowRoot
-            .querySelector('.robotId');
-        assert.isTrue(authorName.innerText === 'Happy Robot');
-
-        element.collapsed = true;
-        flushAsynchronousOperations();
-        runIdMessage = element.shadowRoot
-            .querySelector('.runIdMessage');
-        assert.isTrue(runIdMessage.hidden);
-      });
-
-      test('draft creation/cancellation', done => {
-        assert.isFalse(element.editing);
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.edit'));
-        assert.isTrue(element.editing);
-
-        element._messageText = '';
-        const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
-
-        // Save should be disabled on an empty message.
-        let disabled = element.shadowRoot
-            .querySelector('.save').hasAttribute('disabled');
-        assert.isTrue(disabled, 'save button should be disabled.');
-        element._messageText = '     ';
-        disabled = element.shadowRoot
-            .querySelector('.save').hasAttribute('disabled');
-        assert.isTrue(disabled, 'save button should be disabled.');
-
-        const updateStub = sinon.stub();
-        element.addEventListener('comment-update', updateStub);
-
-        let numDiscardEvents = 0;
-        element.addEventListener('comment-discard', e => {
-          numDiscardEvents++;
-          assert.isFalse(eraseMessageDraftSpy.called);
-          if (numDiscardEvents === 2) {
-            assert.isFalse(updateStub.called);
-            done();
-          }
-        });
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.cancel'));
-        element.flushDebouncer('fire-update');
-        element._messageText = '';
-        flushAsynchronousOperations();
-        MockInteractions.pressAndReleaseKeyOn(element.textarea, 27); // esc
-      });
-
-      test('draft discard removes message from storage', done => {
-        element._messageText = '';
-        const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
-        sandbox.stub(element, '_closeConfirmDiscardOverlay');
-
-        element.addEventListener('comment-discard', e => {
-          assert.isTrue(eraseMessageDraftSpy.called);
-          done();
-        });
-        element._handleConfirmDiscard({preventDefault: sinon.stub()});
-      });
-
-      test('storage is cleared only after save success', () => {
-        element._messageText = 'test';
-        const eraseStub = sandbox.stub(element, '_eraseDraftComment');
-        sandbox.stub(element.$.restAPI, 'getResponseObject')
+        mockEvent = {preventDefault() {}};
+        sandbox.stub(element, 'save')
             .returns(Promise.resolve({}));
+        sandbox.stub(element, '_discardDraft')
+            .returns(Promise.resolve({}));
+        endStub = sinon.stub();
+        getTimerStub = sandbox.stub(element.$.reporting, 'getTimer')
+            .returns({end: endStub});
+      });
 
-        sandbox.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
+      test('create', () => {
+        element.comment = {};
+        return element._handleSave(mockEvent).then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
+        });
+      });
 
-        const savePromise = element.save();
-        assert.isFalse(eraseStub.called);
-        return savePromise.then(() => {
-          assert.isFalse(eraseStub.called);
+      test('update', () => {
+        element.comment = {id: 'abc_123'};
+        return element._handleSave(mockEvent).then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
+        });
+      });
 
-          element._saveDraft.restore();
-          sandbox.stub(element, '_saveDraft')
-              .returns(Promise.resolve({ok: true}));
-          return element.save().then(() => {
-            assert.isTrue(eraseStub.called);
+      test('discard', () => {
+        element.comment = {id: 'abc_123'};
+        sandbox.stub(element, '_closeConfirmDiscardOverlay');
+        return element._handleConfirmDiscard(mockEvent).then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
+        });
+      });
+    });
+
+    test('edit reports interaction', () => {
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('discard reports interaction', () => {
+      const reportStub = sandbox.stub(element.$.reporting,
+          'recordDraftInteraction');
+      element.draft = true;
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.discard'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+  });
+
+  suite('gr-comment draft tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getAccount() { return Promise.resolve(null); },
+        saveDiffDraft() {
+          return Promise.resolve({
+            ok: true,
+            text() {
+              return Promise.resolve(
+                  ')]}\'\n{' +
+                  '"id": "baf0414d_40572e03",' +
+                  '"path": "/path/to/file",' +
+                  '"line": 5,' +
+                  '"updated": "2015-12-08 21:52:36.177000000",' +
+                  '"message": "saved!"' +
+                '}'
+              );
+            },
           });
-        });
+        },
+        removeChangeReviewer() {
+          return Promise.resolve({ok: true});
+        },
       });
-
-      test('_computeSaveDisabled', () => {
-        const comment = {unresolved: true};
-        const msgComment = {message: 'test', unresolved: true};
-        assert.equal(element._computeSaveDisabled('', comment, false), true);
-        assert.equal(element._computeSaveDisabled('test', comment, false), false);
-        assert.equal(element._computeSaveDisabled('', msgComment, false), true);
-        assert.equal(
-            element._computeSaveDisabled('test', msgComment, false), false);
-        assert.equal(
-            element._computeSaveDisabled('test2', msgComment, false), false);
-        assert.equal(element._computeSaveDisabled('test', comment, true), false);
-        assert.equal(element._computeSaveDisabled('', comment, true), true);
-        assert.equal(element._computeSaveDisabled('', comment, false), true);
+      stub('gr-storage', {
+        getDraftComment() { return null; },
       });
+      element = fixture('draft');
+      element.changeNum = 42;
+      element.patchNum = 1;
+      element.editing = false;
+      element.comment = {
+        __commentSide: 'right',
+        __draft: true,
+        __draftID: 'temp_draft_id',
+        path: '/path/to/file',
+        line: 5,
+      };
+      element.commentSide = 'right';
+      sandbox = sinon.sandbox.create();
+    });
 
-      suite('confirm discard', () => {
-        let discardStub;
-        let overlayStub;
-        let mockEvent;
+    teardown(() => {
+      sandbox.restore();
+    });
 
-        setup(() => {
-          discardStub = sandbox.stub(element, '_discardDraft');
-          overlayStub = sandbox.stub(element, '_openOverlay')
-              .returns(Promise.resolve());
-          mockEvent = {preventDefault: sinon.stub()};
-        });
+    test('button visibility states', () => {
+      element.showActions = false;
+      assert.isTrue(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
 
-        test('confirms discard of comments with message text', () => {
-          element._messageText = 'test';
-          element._handleDiscard(mockEvent);
-          assert.isTrue(overlayStub.calledWith(element.confirmDiscardOverlay));
-          assert.isFalse(discardStub.called);
-        });
+      element.showActions = true;
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
 
-        test('no confirmation for comments without message text', () => {
-          element._messageText = '';
-          element._handleDiscard(mockEvent);
-          assert.isFalse(overlayStub.called);
-          assert.isTrue(discardStub.calledOnce);
-        });
-      });
+      element.draft = true;
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.edit')), 'edit is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.discard')), 'discard is visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.resolve')), 'resolve is visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
 
-      test('ctrl+s saves comment', done => {
-        const stub = sinon.stub(element, 'save', () => {
-          assert.isTrue(stub.called);
-          stub.restore();
+      element.editing = true;
+      flushAsynchronousOperations();
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.edit')), 'edit is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.discard')), 'discard not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.save')), 'save is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.resolve')), 'resolve is visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      element.draft = false;
+      element.editing = false;
+      flushAsynchronousOperations();
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.edit')), 'edit is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.discard')),
+      'discard is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is not visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      element.comment.id = 'foo';
+      element.draft = true;
+      element.editing = true;
+      flushAsynchronousOperations();
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      // Delete button is not hidden by default
+      assert.isFalse(element.shadowRoot.querySelector('#deleteBtn').hidden);
+
+      element.isRobotComment = true;
+      element.draft = true;
+      assert.isTrue(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isFalse(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      // It is not expected to see Robot comment drafts, but if they appear,
+      // they will behave the same as non-drafts.
+      element.draft = false;
+      assert.isTrue(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isFalse(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      // A robot comment with run ID should display plain text.
+      element.set(['comment', 'robot_run_id'], 'text');
+      element.editing = false;
+      element.collapsed = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotRun.link').textContent === 'Run Details');
+
+      // A robot comment with run ID and url should display a link.
+      element.set(['comment', 'url'], '/path/to/run');
+      flushAsynchronousOperations();
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('.robotRun.link')).display,
+      'none');
+
+      // Delete button is hidden for robot comments
+      assert.isTrue(element.shadowRoot.querySelector('#deleteBtn').hidden);
+    });
+
+    test('collapsible drafts', () => {
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
+
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is is not visible');
+
+      // When the edit button is pressed, should still see the actions
+      // and also textarea
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      flushAsynchronousOperations();
+      assert.isFalse(element.collapsed);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is not visible');
+
+      // When toggle again, everything should be hidden except for textarea
+      // and header middle content should be visible
+      MockInteractions.tap(element.$.header);
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-textarea')),
+      'textarea is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
+
+      // When toggle again, textarea should remain open in the state it was
+      // before
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is not visible');
+    });
+
+    test('robot comment layout', () => {
+      const comment = Object.assign({
+        robot_id: 'happy_robot_id',
+        url: '/robot/comment',
+        author: {
+          name: 'Happy Robot',
+        },
+      }, element.comment);
+      element.comment = comment;
+      element.collapsed = false;
+      flushAsynchronousOperations();
+
+      let runIdMessage;
+      runIdMessage = element.shadowRoot
+          .querySelector('.runIdMessage');
+      assert.isFalse(runIdMessage.hidden);
+
+      const runDetailsLink = element.shadowRoot
+          .querySelector('.robotRunLink');
+      assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
+
+      const robotServiceName = element.shadowRoot
+          .querySelector('.authorName');
+      assert.isTrue(robotServiceName.textContent === 'happy_robot_id');
+
+      const authorName = element.shadowRoot
+          .querySelector('.robotId');
+      assert.isTrue(authorName.innerText === 'Happy Robot');
+
+      element.collapsed = true;
+      flushAsynchronousOperations();
+      runIdMessage = element.shadowRoot
+          .querySelector('.runIdMessage');
+      assert.isTrue(runIdMessage.hidden);
+    });
+
+    test('draft creation/cancellation', done => {
+      assert.isFalse(element.editing);
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      assert.isTrue(element.editing);
+
+      element._messageText = '';
+      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+
+      // Save should be disabled on an empty message.
+      let disabled = element.shadowRoot
+          .querySelector('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+      element._messageText = '     ';
+      disabled = element.shadowRoot
+          .querySelector('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+
+      const updateStub = sinon.stub();
+      element.addEventListener('comment-update', updateStub);
+
+      let numDiscardEvents = 0;
+      element.addEventListener('comment-discard', e => {
+        numDiscardEvents++;
+        assert.isFalse(eraseMessageDraftSpy.called);
+        if (numDiscardEvents === 2) {
+          assert.isFalse(updateStub.called);
           done();
-          return Promise.resolve();
+        }
+      });
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.cancel'));
+      element.flushDebouncer('fire-update');
+      element._messageText = '';
+      flushAsynchronousOperations();
+      MockInteractions.pressAndReleaseKeyOn(element.textarea, 27); // esc
+    });
+
+    test('draft discard removes message from storage', done => {
+      element._messageText = '';
+      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+      sandbox.stub(element, '_closeConfirmDiscardOverlay');
+
+      element.addEventListener('comment-discard', e => {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        done();
+      });
+      element._handleConfirmDiscard({preventDefault: sinon.stub()});
+    });
+
+    test('storage is cleared only after save success', () => {
+      element._messageText = 'test';
+      const eraseStub = sandbox.stub(element, '_eraseDraftComment');
+      sandbox.stub(element.$.restAPI, 'getResponseObject')
+          .returns(Promise.resolve({}));
+
+      sandbox.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
+
+      const savePromise = element.save();
+      assert.isFalse(eraseStub.called);
+      return savePromise.then(() => {
+        assert.isFalse(eraseStub.called);
+
+        element._saveDraft.restore();
+        sandbox.stub(element, '_saveDraft')
+            .returns(Promise.resolve({ok: true}));
+        return element.save().then(() => {
+          assert.isTrue(eraseStub.called);
         });
-        element._messageText = 'is that the horse from horsing around??';
-        element.editing = true;
-        flushAsynchronousOperations();
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea.$.textarea.textarea,
-            83, 'ctrl'); // 'ctrl + s'
+      });
+    });
+
+    test('_computeSaveDisabled', () => {
+      const comment = {unresolved: true};
+      const msgComment = {message: 'test', unresolved: true};
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+      assert.equal(element._computeSaveDisabled('test', comment, false), false);
+      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
+      assert.equal(
+          element._computeSaveDisabled('test', msgComment, false), false);
+      assert.equal(
+          element._computeSaveDisabled('test2', msgComment, false), false);
+      assert.equal(element._computeSaveDisabled('test', comment, true), false);
+      assert.equal(element._computeSaveDisabled('', comment, true), true);
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+    });
+
+    suite('confirm discard', () => {
+      let discardStub;
+      let overlayStub;
+      let mockEvent;
+
+      setup(() => {
+        discardStub = sandbox.stub(element, '_discardDraft');
+        overlayStub = sandbox.stub(element, '_openOverlay')
+            .returns(Promise.resolve());
+        mockEvent = {preventDefault: sinon.stub()};
       });
 
-      test('draft saving/editing', done => {
-        const fireStub = sinon.stub(element, 'fire');
-        const cancelDebounce = sandbox.stub(element, 'cancelDebouncer');
+      test('confirms discard of comments with message text', () => {
+        element._messageText = 'test';
+        element._handleDiscard(mockEvent);
+        assert.isTrue(overlayStub.calledWith(element.confirmDiscardOverlay));
+        assert.isFalse(discardStub.called);
+      });
 
-        element.draft = true;
+      test('no confirmation for comments without message text', () => {
+        element._messageText = '';
+        element._handleDiscard(mockEvent);
+        assert.isFalse(overlayStub.called);
+        assert.isTrue(discardStub.calledOnce);
+      });
+    });
+
+    test('ctrl+s saves comment', done => {
+      const stub = sinon.stub(element, 'save', () => {
+        assert.isTrue(stub.called);
+        stub.restore();
+        done();
+        return Promise.resolve();
+      });
+      element._messageText = 'is that the horse from horsing around??';
+      element.editing = true;
+      flushAsynchronousOperations();
+      MockInteractions.pressAndReleaseKeyOn(
+          element.textarea.$.textarea.textarea,
+          83, 'ctrl'); // 'ctrl + s'
+    });
+
+    test('draft saving/editing', done => {
+      const fireStub = sinon.stub(element, 'fire');
+      const cancelDebounce = sandbox.stub(element, 'cancelDebouncer');
+
+      element.draft = true;
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+      assert(fireStub.calledWith('comment-update'),
+          'comment-update should be sent');
+      assert.isTrue(fireStub.calledOnce);
+
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+      assert.isTrue(fireStub.calledOnce,
+          'No events should fire for text editing');
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+
+      assert.isTrue(element.disabled,
+          'Element should be disabled when creating draft.');
+
+      element._xhrPromise.then(draft => {
+        assert(fireStub.calledWith('comment-save'),
+            'comment-save should be sent');
+        assert(cancelDebounce.calledWith('store'));
+
+        assert.deepEqual(fireStub.lastCall.args[1], {
+          comment: {
+            __commentSide: 'right',
+            __draft: true,
+            __draftID: 'temp_draft_id',
+            id: 'baf0414d_40572e03',
+            line: 5,
+            message: 'saved!',
+            path: '/path/to/file',
+            updated: '2015-12-08 21:52:36.177000000',
+          },
+          patchNum: 1,
+        });
+        assert.isFalse(element.disabled,
+            'Element should be enabled when done creating draft.');
+        assert.equal(draft.message, 'saved!');
+        assert.isFalse(element.editing);
+      }).then(() => {
         MockInteractions.tap(element.shadowRoot
             .querySelector('.edit'));
-        element._messageText = 'good news, everyone!';
-        element.flushDebouncer('fire-update');
-        element.flushDebouncer('store');
-        assert(fireStub.calledWith('comment-update'),
-            'comment-update should be sent');
-        assert.isTrue(fireStub.calledOnce);
-
-        element._messageText = 'good news, everyone!';
-        element.flushDebouncer('fire-update');
-        element.flushDebouncer('store');
-        assert.isTrue(fireStub.calledOnce,
-            'No events should fire for text editing');
-
+        element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
+            'a world where humans are killed on sight.';
         MockInteractions.tap(element.shadowRoot
             .querySelector('.save'));
-
         assert.isTrue(element.disabled,
-            'Element should be disabled when creating draft.');
+            'Element should be disabled when updating draft.');
 
         element._xhrPromise.then(draft => {
-          assert(fireStub.calledWith('comment-save'),
-              'comment-save should be sent');
-          assert(cancelDebounce.calledWith('store'));
-
-          assert.deepEqual(fireStub.lastCall.args[1], {
-            comment: {
-              __commentSide: 'right',
-              __draft: true,
-              __draftID: 'temp_draft_id',
-              id: 'baf0414d_40572e03',
-              line: 5,
-              message: 'saved!',
-              path: '/path/to/file',
-              updated: '2015-12-08 21:52:36.177000000',
-            },
-            patchNum: 1,
-          });
           assert.isFalse(element.disabled,
-              'Element should be enabled when done creating draft.');
+              'Element should be enabled when done updating draft.');
           assert.equal(draft.message, 'saved!');
           assert.isFalse(element.editing);
-        }).then(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.edit'));
-          element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
-              'a world where humans are killed on sight.';
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.save'));
-          assert.isTrue(element.disabled,
-              'Element should be disabled when updating draft.');
-
-          element._xhrPromise.then(draft => {
-            assert.isFalse(element.disabled,
-                'Element should be enabled when done updating draft.');
-            assert.equal(draft.message, 'saved!');
-            assert.isFalse(element.editing);
-            fireStub.restore();
-            done();
-          });
-        });
-      });
-
-      test('draft prevent save when disabled', () => {
-        const saveStub = sandbox.stub(element, 'save').returns(Promise.resolve());
-        element.showActions = true;
-        element.draft = true;
-        MockInteractions.tap(element.$.header);
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.edit'));
-        element._messageText = 'good news, everyone!';
-        element.flushDebouncer('fire-update');
-        element.flushDebouncer('store');
-
-        element.disabled = true;
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.save'));
-        assert.isFalse(saveStub.called);
-
-        element.disabled = false;
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.save'));
-        assert.isTrue(saveStub.calledOnce);
-      });
-
-      test('proper event fires on resolve, comment is not saved', done => {
-        const save = sandbox.stub(element, 'save');
-        element.addEventListener('comment-update', e => {
-          assert.isTrue(e.detail.comment.unresolved);
-          assert.isFalse(save.called);
+          fireStub.restore();
           done();
         });
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.resolve input'));
-      });
-
-      test('resolved comment state indicated by checkbox', () => {
-        sandbox.stub(element, 'save');
-        element.comment = {unresolved: false};
-        assert.isTrue(element.shadowRoot
-            .querySelector('.resolve input').checked);
-        element.comment = {unresolved: true};
-        assert.isFalse(element.shadowRoot
-            .querySelector('.resolve input').checked);
-      });
-
-      test('resolved checkbox saves with tap when !editing', () => {
-        element.editing = false;
-        const save = sandbox.stub(element, 'save');
-
-        element.comment = {unresolved: false};
-        assert.isTrue(element.shadowRoot
-            .querySelector('.resolve input').checked);
-        element.comment = {unresolved: true};
-        assert.isFalse(element.shadowRoot
-            .querySelector('.resolve input').checked);
-        assert.isFalse(save.called);
-        MockInteractions.tap(element.$.resolvedCheckbox);
-        assert.isTrue(element.shadowRoot
-            .querySelector('.resolve input').checked);
-        assert.isTrue(save.called);
-      });
-
-      suite('draft saving messages', () => {
-        test('_getSavingMessage', () => {
-          assert.equal(element._getSavingMessage(0), 'All changes saved');
-          assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
-          assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
-          assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
-        });
-
-        test('_show{Start,End}Request', () => {
-          const updateStub = sandbox.stub(element, '_updateRequestToast');
-          element._numPendingDraftRequests.number = 1;
-
-          element._showStartRequest();
-          assert.isTrue(updateStub.calledOnce);
-          assert.equal(updateStub.lastCall.args[0], 2);
-          assert.equal(element._numPendingDraftRequests.number, 2);
-
-          element._showEndRequest();
-          assert.isTrue(updateStub.calledTwice);
-          assert.equal(updateStub.lastCall.args[0], 1);
-          assert.equal(element._numPendingDraftRequests.number, 1);
-
-          element._showEndRequest();
-          assert.isTrue(updateStub.calledThrice);
-          assert.equal(updateStub.lastCall.args[0], 0);
-          assert.equal(element._numPendingDraftRequests.number, 0);
-        });
-      });
-
-      test('cancelling an unsaved draft discards, persists in storage', () => {
-        const discardSpy = sandbox.spy(element, '_fireDiscard');
-        const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
-        const eraseStub = sandbox.stub(element.$.storage, 'eraseDraftComment');
-        element._messageText = 'test text';
-        flushAsynchronousOperations();
-        element.flushDebouncer('store');
-
-        assert.isTrue(storeStub.called);
-        assert.equal(storeStub.lastCall.args[1], 'test text');
-        element._handleCancel({preventDefault: () => {}});
-        assert.isTrue(discardSpy.called);
-        assert.isFalse(eraseStub.called);
-      });
-
-      test('cancelling edit on a saved draft does not store', () => {
-        element.comment.id = 'foo';
-        const discardSpy = sandbox.spy(element, '_fireDiscard');
-        const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
-        element._messageText = 'test text';
-        flushAsynchronousOperations();
-        element.flushDebouncer('store');
-
-        assert.isFalse(storeStub.called);
-        element._handleCancel({preventDefault: () => {}});
-        assert.isTrue(discardSpy.called);
-      });
-
-      test('deleting text from saved draft and saving deletes the draft', () => {
-        element.comment = {id: 'foo', message: 'test'};
-        element._messageText = '';
-        const discardStub = sandbox.stub(element, '_discardDraft');
-
-        element.save();
-        assert.isTrue(discardStub.called);
-      });
-
-      test('_handleFix fires create-fix event', done => {
-        element.addEventListener('create-fix-comment', e => {
-          assert.deepEqual(e.detail, element._getEventPayload());
-          done();
-        });
-        element.isRobotComment = true;
-        element.comments = [element.comment];
-        flushAsynchronousOperations();
-
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.fix'));
-      });
-
-      test('do not show Please Fix button if human reply exists', () => {
-        element.comments = [
-          {
-            robot_id: 'happy_robot_id',
-            robot_run_id: '5838406743490560',
-            fix_suggestions: [
-              {
-                fix_id: '478ff847_3bf47aaf',
-                description: 'Make the smiley happier by giving it a nose.',
-                replacements: [
-                  {
-                    path: 'Documentation/config-gerrit.txt',
-                    range: {
-                      start_line: 10,
-                      start_character: 7,
-                      end_line: 10,
-                      end_character: 9,
-                    },
-                    replacement: ':-)',
-                  },
-                ],
-              },
-            ],
-            author: {
-              _account_id: 1030912,
-              name: 'Alice Kober-Sotzek',
-              email: 'aliceks@google.com',
-              avatars: [
-                {
-                  url: '/s32-p/photo.jpg',
-                  height: 32,
-                },
-                {
-                  url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                  height: 56,
-                },
-                {
-                  url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                  height: 100,
-                },
-                {
-                  url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                  height: 120,
-                },
-              ],
-            },
-            patch_set: 1,
-            id: 'eb0d03fd_5e95904f',
-            line: 10,
-            updated: '2017-04-04 15:36:17.000000000',
-            message: 'This is a robot comment with a fix.',
-            unresolved: false,
-            __commentSide: 'right',
-            collapsed: false,
-          },
-          {
-            __draft: true,
-            __draftID: '0.wbrfbwj89sa',
-            __date: '2019-12-04T13:41:03.689Z',
-            path: 'Documentation/config-gerrit.txt',
-            patchNum: 1,
-            side: 'REVISION',
-            __commentSide: 'right',
-            line: 10,
-            in_reply_to: 'eb0d03fd_5e95904f',
-            message: '> This is a robot comment with a fix.\n\nPlease fix.',
-            unresolved: true,
-          },
-        ];
-        element.comment = element.comments[0];
-        flushAsynchronousOperations();
-        assert.isNull(element.shadowRoot
-            .querySelector('robotActions gr-button'));
-      });
-
-      test('show Please Fix if no human reply', () => {
-        element.comments = [
-          {
-            robot_id: 'happy_robot_id',
-            robot_run_id: '5838406743490560',
-            fix_suggestions: [
-              {
-                fix_id: '478ff847_3bf47aaf',
-                description: 'Make the smiley happier by giving it a nose.',
-                replacements: [
-                  {
-                    path: 'Documentation/config-gerrit.txt',
-                    range: {
-                      start_line: 10,
-                      start_character: 7,
-                      end_line: 10,
-                      end_character: 9,
-                    },
-                    replacement: ':-)',
-                  },
-                ],
-              },
-            ],
-            author: {
-              _account_id: 1030912,
-              name: 'Alice Kober-Sotzek',
-              email: 'aliceks@google.com',
-              avatars: [
-                {
-                  url: '/s32-p/photo.jpg',
-                  height: 32,
-                },
-                {
-                  url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                  height: 56,
-                },
-                {
-                  url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                  height: 100,
-                },
-                {
-                  url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                  height: 120,
-                },
-              ],
-            },
-            patch_set: 1,
-            id: 'eb0d03fd_5e95904f',
-            line: 10,
-            updated: '2017-04-04 15:36:17.000000000',
-            message: 'This is a robot comment with a fix.',
-            unresolved: false,
-            __commentSide: 'right',
-            collapsed: false,
-          },
-        ];
-        element.comment = element.comments[0];
-        flushAsynchronousOperations();
-        assert.isNotNull(element.shadowRoot
-            .querySelector('.robotActions gr-button'));
-      });
-
-      test('_handleShowFix fires open-fix-preview event', done => {
-        element.addEventListener('open-fix-preview', e => {
-          assert.deepEqual(e.detail, element._getEventPayload());
-          done();
-        });
-        element.comment = {fix_suggestions: [{}]};
-        element.isRobotComment = true;
-        flushAsynchronousOperations();
-
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.show-fix'));
       });
     });
 
-    suite('respectful tips', () => {
-      let element;
-      let sandbox;
-      let clock;
-      setup(() => {
-        stub('gr-rest-api-interface', {
-          getAccount() { return Promise.resolve(null); },
-        });
-        clock = sinon.useFakeTimers();
-        sandbox = sinon.sandbox.create();
+    test('draft prevent save when disabled', () => {
+      const saveStub = sandbox.stub(element, 'save').returns(Promise.resolve());
+      element.showActions = true;
+      element.draft = true;
+      MockInteractions.tap(element.$.header);
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+
+      element.disabled = true;
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+      assert.isFalse(saveStub.called);
+
+      element.disabled = false;
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+      assert.isTrue(saveStub.calledOnce);
+    });
+
+    test('proper event fires on resolve, comment is not saved', done => {
+      const save = sandbox.stub(element, 'save');
+      element.addEventListener('comment-update', e => {
+        assert.isTrue(e.detail.comment.unresolved);
+        assert.isFalse(save.called);
+        done();
+      });
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.resolve input'));
+    });
+
+    test('resolved comment state indicated by checkbox', () => {
+      sandbox.stub(element, 'save');
+      element.comment = {unresolved: false};
+      assert.isTrue(element.shadowRoot
+          .querySelector('.resolve input').checked);
+      element.comment = {unresolved: true};
+      assert.isFalse(element.shadowRoot
+          .querySelector('.resolve input').checked);
+    });
+
+    test('resolved checkbox saves with tap when !editing', () => {
+      element.editing = false;
+      const save = sandbox.stub(element, 'save');
+
+      element.comment = {unresolved: false};
+      assert.isTrue(element.shadowRoot
+          .querySelector('.resolve input').checked);
+      element.comment = {unresolved: true};
+      assert.isFalse(element.shadowRoot
+          .querySelector('.resolve input').checked);
+      assert.isFalse(save.called);
+      MockInteractions.tap(element.$.resolvedCheckbox);
+      assert.isTrue(element.shadowRoot
+          .querySelector('.resolve input').checked);
+      assert.isTrue(save.called);
+    });
+
+    suite('draft saving messages', () => {
+      test('_getSavingMessage', () => {
+        assert.equal(element._getSavingMessage(0), 'All changes saved');
+        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
+        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
+        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
       });
 
-      teardown(() => {
-        clock.restore();
-        sandbox.restore();
-      });
+      test('_show{Start,End}Request', () => {
+        const updateStub = sandbox.stub(element, '_updateRequestToast');
+        element._numPendingDraftRequests.number = 1;
 
-      test('show tip when no cached record', done => {
-        // fake stub for storage
-        const respectfulGetStub = sinon.stub();
-        const respectfulSetStub = sinon.stub();
-        stub('gr-storage', {
-          getRespectfulTipVisibility() { return respectfulGetStub(); },
-          setRespectfulTipVisibility() { return respectfulSetStub(); },
-        });
-        respectfulGetStub.returns(null);
-        element = fixture('draft');
-        // fake random
-        element.getRandomNum = () => 0;
-        element.comment = {__editing: true};
+        element._showStartRequest();
+        assert.isTrue(updateStub.calledOnce);
+        assert.equal(updateStub.lastCall.args[0], 2);
+        assert.equal(element._numPendingDraftRequests.number, 2);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledTwice);
+        assert.equal(updateStub.lastCall.args[0], 1);
+        assert.equal(element._numPendingDraftRequests.number, 1);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledThrice);
+        assert.equal(updateStub.lastCall.args[0], 0);
+        assert.equal(element._numPendingDraftRequests.number, 0);
+      });
+    });
+
+    test('cancelling an unsaved draft discards, persists in storage', () => {
+      const discardSpy = sandbox.spy(element, '_fireDiscard');
+      const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
+      const eraseStub = sandbox.stub(element.$.storage, 'eraseDraftComment');
+      element._messageText = 'test text';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isTrue(storeStub.called);
+      assert.equal(storeStub.lastCall.args[1], 'test text');
+      element._handleCancel({preventDefault: () => {}});
+      assert.isTrue(discardSpy.called);
+      assert.isFalse(eraseStub.called);
+    });
+
+    test('cancelling edit on a saved draft does not store', () => {
+      element.comment.id = 'foo';
+      const discardSpy = sandbox.spy(element, '_fireDiscard');
+      const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
+      element._messageText = 'test text';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isFalse(storeStub.called);
+      element._handleCancel({preventDefault: () => {}});
+      assert.isTrue(discardSpy.called);
+    });
+
+    test('deleting text from saved draft and saving deletes the draft', () => {
+      element.comment = {id: 'foo', message: 'test'};
+      element._messageText = '';
+      const discardStub = sandbox.stub(element, '_discardDraft');
+
+      element.save();
+      assert.isTrue(discardStub.called);
+    });
+
+    test('_handleFix fires create-fix event', done => {
+      element.addEventListener('create-fix-comment', e => {
+        assert.deepEqual(e.detail, element._getEventPayload());
+        done();
+      });
+      element.isRobotComment = true;
+      element.comments = [element.comment];
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.fix'));
+    });
+
+    test('do not show Please Fix button if human reply exists', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id',
+          robot_run_id: '5838406743490560',
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf',
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com',
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+              },
+            ],
+          },
+          patch_set: 1,
+          id: 'eb0d03fd_5e95904f',
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000',
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          __commentSide: 'right',
+          collapsed: false,
+        },
+        {
+          __draft: true,
+          __draftID: '0.wbrfbwj89sa',
+          __date: '2019-12-04T13:41:03.689Z',
+          path: 'Documentation/config-gerrit.txt',
+          patchNum: 1,
+          side: 'REVISION',
+          __commentSide: 'right',
+          line: 10,
+          in_reply_to: 'eb0d03fd_5e95904f',
+          message: '> This is a robot comment with a fix.\n\nPlease fix.',
+          unresolved: true,
+        },
+      ];
+      element.comment = element.comments[0];
+      flushAsynchronousOperations();
+      assert.isNull(element.shadowRoot
+          .querySelector('robotActions gr-button'));
+    });
+
+    test('show Please Fix if no human reply', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id',
+          robot_run_id: '5838406743490560',
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf',
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com',
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+              },
+            ],
+          },
+          patch_set: 1,
+          id: 'eb0d03fd_5e95904f',
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000',
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          __commentSide: 'right',
+          collapsed: false,
+        },
+      ];
+      element.comment = element.comments[0];
+      flushAsynchronousOperations();
+      assert.isNotNull(element.shadowRoot
+          .querySelector('.robotActions gr-button'));
+    });
+
+    test('_handleShowFix fires open-fix-preview event', done => {
+      element.addEventListener('open-fix-preview', e => {
+        assert.deepEqual(e.detail, element._getEventPayload());
+        done();
+      });
+      element.comment = {fix_suggestions: [{}]};
+      element.isRobotComment = true;
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.show-fix'));
+    });
+  });
+
+  suite('respectful tips', () => {
+    let element;
+    let sandbox;
+    let clock;
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getAccount() { return Promise.resolve(null); },
+      });
+      clock = sinon.useFakeTimers();
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(() => {
+      clock.restore();
+      sandbox.restore();
+    });
+
+    test('show tip when no cached record', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility() { return respectfulSetStub(); },
+      });
+      respectfulGetStub.returns(null);
+      element = fixture('draft');
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isTrue(respectfulSetStub.called);
+        assert.isTrue(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+        done();
+      });
+    });
+
+    test('add 3 day delays once dismissed', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility(days) { return respectfulSetStub(days); },
+      });
+      respectfulGetStub.returns(null);
+      element = fixture('draft');
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isTrue(respectfulSetStub.called);
+        assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
+        assert.isTrue(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('.respectfulReviewTip .close'));
+        flushAsynchronousOperations();
+        assert.isTrue(respectfulSetStub.lastCall.args[0] === 3);
+        done();
+      });
+    });
+
+    test('do not show tip when fall out of probability', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility() { return respectfulSetStub(); },
+      });
+      respectfulGetStub.returns(null);
+      element = fixture('draft');
+      // fake random
+      element.getRandomNum = () => 3;
+      element.comment = {__editing: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isFalse(respectfulSetStub.called);
+        assert.isFalse(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+        done();
+      });
+    });
+
+    test('show tip when editing changed to true', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility() { return respectfulSetStub(); },
+      });
+      respectfulGetStub.returns(null);
+      element = fixture('draft');
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: false};
+      flush(() => {
+        assert.isFalse(respectfulGetStub.called);
+        assert.isFalse(respectfulSetStub.called);
+        assert.isFalse(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+
+        element.editing = true;
         flush(() => {
           assert.isTrue(respectfulGetStub.called);
           assert.isTrue(respectfulSetStub.called);
@@ -1166,113 +1257,30 @@
           done();
         });
       });
+    });
 
-      test('add 3 day delays once dismissed', done => {
-        // fake stub for storage
-        const respectfulGetStub = sinon.stub();
-        const respectfulSetStub = sinon.stub();
-        stub('gr-storage', {
-          getRespectfulTipVisibility() { return respectfulGetStub(); },
-          setRespectfulTipVisibility(days) { return respectfulSetStub(days); },
-        });
-        respectfulGetStub.returns(null);
-        element = fixture('draft');
-        // fake random
-        element.getRandomNum = () => 0;
-        element.comment = {__editing: true};
-        flush(() => {
-          assert.isTrue(respectfulGetStub.called);
-          assert.isTrue(respectfulSetStub.called);
-          assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
-          assert.isTrue(
-              !!element.shadowRoot.querySelector('.respectfulReviewTip')
-          );
-
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.respectfulReviewTip .close'));
-          flushAsynchronousOperations();
-          assert.isTrue(respectfulSetStub.lastCall.args[0] === 3);
-          done();
-        });
+    test('no tip when cached record', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility() { return respectfulSetStub(); },
       });
-
-      test('do not show tip when fall out of probability', done => {
-        // fake stub for storage
-        const respectfulGetStub = sinon.stub();
-        const respectfulSetStub = sinon.stub();
-        stub('gr-storage', {
-          getRespectfulTipVisibility() { return respectfulGetStub(); },
-          setRespectfulTipVisibility() { return respectfulSetStub(); },
-        });
-        respectfulGetStub.returns(null);
-        element = fixture('draft');
-        // fake random
-        element.getRandomNum = () => 3;
-        element.comment = {__editing: true};
-        flush(() => {
-          assert.isTrue(respectfulGetStub.called);
-          assert.isFalse(respectfulSetStub.called);
-          assert.isFalse(
-              !!element.shadowRoot.querySelector('.respectfulReviewTip')
-          );
-          done();
-        });
-      });
-
-      test('show tip when editing changed to true', done => {
-        // fake stub for storage
-        const respectfulGetStub = sinon.stub();
-        const respectfulSetStub = sinon.stub();
-        stub('gr-storage', {
-          getRespectfulTipVisibility() { return respectfulGetStub(); },
-          setRespectfulTipVisibility() { return respectfulSetStub(); },
-        });
-        respectfulGetStub.returns(null);
-        element = fixture('draft');
-        // fake random
-        element.getRandomNum = () => 0;
-        element.comment = {__editing: false};
-        flush(() => {
-          assert.isFalse(respectfulGetStub.called);
-          assert.isFalse(respectfulSetStub.called);
-          assert.isFalse(
-              !!element.shadowRoot.querySelector('.respectfulReviewTip')
-          );
-
-          element.editing = true;
-          flush(() => {
-            assert.isTrue(respectfulGetStub.called);
-            assert.isTrue(respectfulSetStub.called);
-            assert.isTrue(
-                !!element.shadowRoot.querySelector('.respectfulReviewTip')
-            );
-            done();
-          });
-        });
-      });
-
-      test('no tip when cached record', done => {
-        // fake stub for storage
-        const respectfulGetStub = sinon.stub();
-        const respectfulSetStub = sinon.stub();
-        stub('gr-storage', {
-          getRespectfulTipVisibility() { return respectfulGetStub(); },
-          setRespectfulTipVisibility() { return respectfulSetStub(); },
-        });
-        respectfulGetStub.returns({});
-        element = fixture('draft');
-        // fake random
-        element.getRandomNum = () => 0;
-        element.comment = {__editing: true};
-        flush(() => {
-          assert.isTrue(respectfulGetStub.called);
-          assert.isFalse(respectfulSetStub.called);
-          assert.isFalse(
-              !!element.shadowRoot.querySelector('.respectfulReviewTip')
-          );
-          done();
-        });
+      respectfulGetStub.returns({});
+      element = fixture('draft');
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isFalse(respectfulSetStub.called);
+        assert.isFalse(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+        done();
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
index 8d50fe0..7db24c2 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
@@ -14,54 +14,64 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+
+import '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../gr-dialog/gr-dialog.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-delete-comment-dialog_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrConfirmDeleteCommentDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-confirm-delete-comment-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
    */
-  class GrConfirmDeleteCommentDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-confirm-delete-comment-dialog'; }
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
-
-    static get properties() {
-      return {
-        message: String,
-      };
-    }
-
-    resetFocus() {
-      this.$.messageInput.textarea.focus();
-    }
-
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', {reason: this.message}, {bubbles: false});
-    }
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    }
+  static get properties() {
+    return {
+      message: String,
+    };
   }
 
-  customElements.define(GrConfirmDeleteCommentDialog.is,
-      GrConfirmDeleteCommentDialog);
-})();
+  resetFocus() {
+    this.$.messageInput.textarea.focus();
+  }
+
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('confirm', {reason: this.message}, {bubbles: false});
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('cancel', null, {bubbles: false});
+  }
+}
+
+customElements.define(GrConfirmDeleteCommentDialog.is,
+    GrConfirmDeleteCommentDialog);
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.js b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.js
index e92bddb..f6caaa1 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-confirm-delete-comment-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -51,22 +45,12 @@
         width: 73ch; /* Add a char to account for the border. */
       }
     </style>
-    <gr-dialog
-        confirm-label="Delete"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
+    <gr-dialog confirm-label="Delete" on-confirm="_handleConfirmTap" on-cancel="_handleCancelTap">
       <div class="header" slot="header">Delete Comment</div>
       <div class="main" slot="main">
         <p>This is an admin function. Please only use in exceptional circumstances.</p>
         <label for="messageInput">Enter comment delete reason</label>
-        <iron-autogrow-textarea
-            id="messageInput"
-            class="message"
-            autocomplete="on"
-            placeholder="<Insert reasoning here>"
-            bind-value="{{message}}"></iron-autogrow-textarea>
+        <iron-autogrow-textarea id="messageInput" class="message" autocomplete="on" placeholder="<Insert reasoning here>" bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
     </gr-dialog>
-  </template>
-  <script src="gr-confirm-delete-comment-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
index 9be6852..0f6168e 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
@@ -14,64 +14,74 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const COPY_TIMEOUT_MS = 1000;
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/shared-styles.js';
+import '../gr-button/gr-button.js';
+import '../gr-icons/gr-icons.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-copy-clipboard_html.js';
 
-  /** @extends Polymer.Element */
-  class GrCopyClipboard extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-copy-clipboard'; }
+const COPY_TIMEOUT_MS = 1000;
 
-    static get properties() {
-      return {
-        text: String,
-        buttonTitle: String,
-        hasTooltip: {
-          type: Boolean,
-          value: false,
-        },
-        hideInput: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrCopyClipboard extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    focusOnCopy() {
-      this.$.button.focus();
-    }
+  static get is() { return 'gr-copy-clipboard'; }
 
-    _computeInputClass(hideInput) {
-      return hideInput ? 'hideInput' : '';
-    }
-
-    _handleInputClick(e) {
-      e.preventDefault();
-      Polymer.dom(e).rootTarget.select();
-    }
-
-    _copyToClipboard(e) {
-      e.preventDefault();
-      e.stopPropagation();
-
-      if (this.hideInput) {
-        this.$.input.style.display = 'block';
-      }
-      this.$.input.focus();
-      this.$.input.select();
-      document.execCommand('copy');
-      if (this.hideInput) {
-        this.$.input.style.display = 'none';
-      }
-      this.$.icon.icon = 'gr-icons:check';
-      this.async(
-          () => this.$.icon.icon = 'gr-icons:content-copy',
-          COPY_TIMEOUT_MS);
-    }
+  static get properties() {
+    return {
+      text: String,
+      buttonTitle: String,
+      hasTooltip: {
+        type: Boolean,
+        value: false,
+      },
+      hideInput: {
+        type: Boolean,
+        value: false,
+      },
+    };
   }
 
-  customElements.define(GrCopyClipboard.is, GrCopyClipboard);
-})();
+  focusOnCopy() {
+    this.$.button.focus();
+  }
+
+  _computeInputClass(hideInput) {
+    return hideInput ? 'hideInput' : '';
+  }
+
+  _handleInputClick(e) {
+    e.preventDefault();
+    dom(e).rootTarget.select();
+  }
+
+  _copyToClipboard(e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    if (this.hideInput) {
+      this.$.input.style.display = 'block';
+    }
+    this.$.input.focus();
+    this.$.input.select();
+    document.execCommand('copy');
+    if (this.hideInput) {
+      this.$.input.style.display = 'none';
+    }
+    this.$.icon.icon = 'gr-icons:check';
+    this.async(
+        () => this.$.icon.icon = 'gr-icons:content-copy',
+        COPY_TIMEOUT_MS);
+  }
+}
+
+customElements.define(GrCopyClipboard.is, GrCopyClipboard);
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.js
index c344bf64..29becbb 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.js
@@ -1,28 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-
-<dom-module id="gr-copy-clipboard">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .text {
         align-items: center;
@@ -61,30 +55,11 @@
       }
     </style>
     <div class="text">
-      <iron-input
-          class="copyText"
-          type="text"
-          bind-value="[[text]]"
-          on-tap="_handleInputClick"
-          readonly>
-        <input
-            id="input"
-            is="iron-input"
-            class$="[[_computeInputClass(hideInput)]]"
-            type="text"
-            bind-value="[[text]]"
-            on-click="_handleInputClick"
-            readonly>
+      <iron-input class="copyText" type="text" bind-value="[[text]]" on-tap="_handleInputClick" readonly="">
+        <input id="input" is="iron-input" class\$="[[_computeInputClass(hideInput)]]" type="text" bind-value="[[text]]" on-click="_handleInputClick" readonly="">
       </iron-input>
-      <gr-button id="button"
-          link
-          has-tooltip="[[hasTooltip]]"
-          class="copyToClipboard"
-          title="[[buttonTitle]]"
-          on-click="_copyToClipboard">
+      <gr-button id="button" link="" has-tooltip="[[hasTooltip]]" class="copyToClipboard" title="[[buttonTitle]]" on-click="_copyToClipboard">
         <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
       </gr-button>
     </div>
-  </template>
-  <script src="gr-copy-clipboard.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
index 45ade85..a39e4c7 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-copy-clipboard</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-copy-clipboard.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-copy-clipboard.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-copy-clipboard.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,73 +40,76 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-copy-clipboard tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-copy-clipboard.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-copy-clipboard tests', () => {
+  let element;
+  let sandbox;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.text = `git fetch http://gerrit@localhost:8080/a/test-project
-          refs/changes/05/5/1 && git checkout FETCH_HEAD`;
-      flushAsynchronousOperations();
-      flush(done);
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('copy to clipboard', () => {
-      const clipboardSpy = sandbox.spy(element, '_copyToClipboard');
-      const copyBtn = element.shadowRoot
-          .querySelector('.copyToClipboard');
-      MockInteractions.tap(copyBtn);
-      assert.isTrue(clipboardSpy.called);
-    });
-
-    test('focusOnCopy', () => {
-      element.focusOnCopy();
-      assert.deepEqual(Polymer.dom(element.root).activeElement,
-          element.shadowRoot
-              .querySelector('.copyToClipboard'));
-    });
-
-    test('_handleInputClick', () => {
-      // iron-input as parent should never be hidden as copy won't work
-      // on nested hidden elements
-      const ironInputElement = element.shadowRoot.querySelector('iron-input');
-      assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
-
-      const inputElement = element.shadowRoot.querySelector('input');
-      MockInteractions.tap(inputElement);
-      assert.equal(inputElement.selectionStart, 0);
-      assert.equal(inputElement.selectionEnd, element.text.length - 1);
-    });
-
-    test('hideInput', () => {
-      // iron-input as parent should never be hidden as copy won't work
-      // on nested hidden elements
-      const ironInputElement = element.shadowRoot.querySelector('iron-input');
-      assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
-
-      assert.notEqual(getComputedStyle(element.$.input).display, 'none');
-      element.hideInput = true;
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(element.$.input).display, 'none');
-    });
-
-    test('stop events propagation', () => {
-      const divParent = document.createElement('div');
-      divParent.appendChild(element);
-      const clickStub = sinon.stub();
-      divParent.addEventListener('click', clickStub);
-      element.stopPropagation = true;
-      const copyBtn = element.shadowRoot.querySelector('.copyToClipboard');
-      MockInteractions.tap(copyBtn);
-      assert.isFalse(clickStub.called);
-    });
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git checkout FETCH_HEAD`;
+    flushAsynchronousOperations();
+    flush(done);
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('copy to clipboard', () => {
+    const clipboardSpy = sandbox.spy(element, '_copyToClipboard');
+    const copyBtn = element.shadowRoot
+        .querySelector('.copyToClipboard');
+    MockInteractions.tap(copyBtn);
+    assert.isTrue(clipboardSpy.called);
+  });
+
+  test('focusOnCopy', () => {
+    element.focusOnCopy();
+    assert.deepEqual(dom(element.root).activeElement,
+        element.shadowRoot
+            .querySelector('.copyToClipboard'));
+  });
+
+  test('_handleInputClick', () => {
+    // iron-input as parent should never be hidden as copy won't work
+    // on nested hidden elements
+    const ironInputElement = element.shadowRoot.querySelector('iron-input');
+    assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
+
+    const inputElement = element.shadowRoot.querySelector('input');
+    MockInteractions.tap(inputElement);
+    assert.equal(inputElement.selectionStart, 0);
+    assert.equal(inputElement.selectionEnd, element.text.length - 1);
+  });
+
+  test('hideInput', () => {
+    // iron-input as parent should never be hidden as copy won't work
+    // on nested hidden elements
+    const ironInputElement = element.shadowRoot.querySelector('iron-input');
+    assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
+
+    assert.notEqual(getComputedStyle(element.$.input).display, 'none');
+    element.hideInput = true;
+    flushAsynchronousOperations();
+    assert.equal(getComputedStyle(element.$.input).display, 'none');
+  });
+
+  test('stop events propagation', () => {
+    const divParent = document.createElement('div');
+    divParent.appendChild(element);
+    const clickStub = sinon.stub();
+    divParent.addEventListener('click', clickStub);
+    element.stopPropagation = true;
+    const copyBtn = element.shadowRoot.querySelector('.copyToClipboard');
+    MockInteractions.tap(copyBtn);
+    assert.isFalse(clickStub.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js
index b69c61aa..02c57e0 100644
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js
@@ -1,58 +1,56 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.
+ */
+(function(window) {
+  'use strict';
+  const GrCountStringFormatter = window.GrCountStringFormatter || {};
 
-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
+  /**
+   * Returns a count plus string that is pluralized when necessary.
+   *
+   * @param {number} count
+   * @param {string} noun
+   * @return {string}
+   */
+  GrCountStringFormatter.computePluralString = function(count, noun) {
+    return this.computeString(count, noun) + (count > 1 ? 's' : '');
+  };
 
-http://www.apache.org/licenses/LICENSE-2.0
+  /**
+   * Returns a count plus string that is not pluralized.
+   *
+   * @param {number} count
+   * @param {string} noun
+   * @return {string}
+   */
+  GrCountStringFormatter.computeString = function(count, noun) {
+    if (count === 0) { return ''; }
+    return count + ' ' + noun;
+  };
 
-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.
--->
-<script>
-  (function(window) {
-    'use strict';
-    const GrCountStringFormatter = window.GrCountStringFormatter || {};
-
-    /**
-     * Returns a count plus string that is pluralized when necessary.
-     *
-     * @param {number} count
-     * @param {string} noun
-     * @return {string}
-     */
-    GrCountStringFormatter.computePluralString = function(count, noun) {
-      return this.computeString(count, noun) + (count > 1 ? 's' : '');
-    };
-
-    /**
-     * Returns a count plus string that is not pluralized.
-     *
-     * @param {number} count
-     * @param {string} noun
-     * @return {string}
-     */
-    GrCountStringFormatter.computeString = function(count, noun) {
-      if (count === 0) { return ''; }
-      return count + ' ' + noun;
-    };
-
-    /**
-     * Returns a count plus arbitrary text.
-     *
-     * @param {number} count
-     * @param {string} text
-     * @return {string}
-     */
-    GrCountStringFormatter.computeShortString = function(count, text) {
-      if (count === 0) { return ''; }
-      return count + text;
-    };
-    window.GrCountStringFormatter = GrCountStringFormatter;
-  })(window);
-</script>
+  /**
+   * Returns a count plus arbitrary text.
+   *
+   * @param {number} count
+   * @param {string} text
+   * @return {string}
+   */
+  GrCountStringFormatter.computeShortString = function(count, text) {
+    if (count === 0) { return ''; }
+    return count + text;
+  };
+  window.GrCountStringFormatter = GrCountStringFormatter;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
index 64dff6a..9981d45 100644
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
@@ -19,41 +19,43 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-count-string-formatter</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-count-string-formatter.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-count-string-formatter.js"></script>
 
-<script>
-  suite('gr-count-string-formatter tests', async () => {
-    await readyToTest();
-    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');
-    });
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './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');
+  });
+});
 </script>
 
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index 4f98e88..f184b6c 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -14,422 +14,426 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-cursor-manager_html.js';
 
-  const ScrollBehavior = {
-    NEVER: 'never',
-    KEEP_VISIBLE: 'keep-visible',
-  };
+const ScrollBehavior = {
+  NEVER: 'never',
+  KEEP_VISIBLE: 'keep-visible',
+};
 
-  /** @extends Polymer.Element */
-  class GrCursorManager extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-cursor-manager'; }
+/** @extends Polymer.Element */
+class GrCursorManager extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    static get properties() {
-      return {
-        stops: {
-          type: Array,
-          value() {
-            return [];
-          },
-          observer: '_updateIndex',
+  static get is() { return 'gr-cursor-manager'; }
+
+  static get properties() {
+    return {
+      stops: {
+        type: Array,
+        value() {
+          return [];
         },
-        /**
-         * @type {?Object}
-         */
-        target: {
-          type: Object,
-          notify: true,
-          observer: '_scrollToTarget',
-        },
-        /**
-         * The height of content intended to be included with the target.
-         *
-         * @type {?number}
-         */
-        _targetHeight: Number,
+        observer: '_updateIndex',
+      },
+      /**
+       * @type {?Object}
+       */
+      target: {
+        type: Object,
+        notify: true,
+        observer: '_scrollToTarget',
+      },
+      /**
+       * The height of content intended to be included with the target.
+       *
+       * @type {?number}
+       */
+      _targetHeight: Number,
 
-        /**
-         * The index of the current target (if any). -1 otherwise.
-         */
-        index: {
-          type: Number,
-          value: -1,
-          notify: true,
-        },
+      /**
+       * The index of the current target (if any). -1 otherwise.
+       */
+      index: {
+        type: Number,
+        value: -1,
+        notify: true,
+      },
 
-        /**
-         * The class to apply to the current target. Use null for no class.
-         */
-        cursorTargetClass: {
-          type: String,
-          value: null,
-        },
+      /**
+       * The class to apply to the current target. Use null for no class.
+       */
+      cursorTargetClass: {
+        type: String,
+        value: null,
+      },
 
-        /**
-         * The scroll behavior for the cursor. Values are 'never' and
-         * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
-         * the viewport.
-         * TODO (beckysiegel) figure out why it can be undefined
-         *
-         * @type {string|undefined}
-         */
-        scrollBehavior: {
-          type: String,
-          value: ScrollBehavior.NEVER,
-        },
+      /**
+       * The scroll behavior for the cursor. Values are 'never' and
+       * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+       * the viewport.
+       * TODO (beckysiegel) figure out why it can be undefined
+       *
+       * @type {string|undefined}
+       */
+      scrollBehavior: {
+        type: String,
+        value: ScrollBehavior.NEVER,
+      },
 
-        /**
-         * When true, will call element.focus() during scrolling.
-         */
-        focusOnMove: {
-          type: Boolean,
-          value: false,
-        },
+      /**
+       * When true, will call element.focus() during scrolling.
+       */
+      focusOnMove: {
+        type: Boolean,
+        value: false,
+      },
 
-        /**
-         * The scrollTopMargin defines height of invisible area at the top
-         * of the page. If cursor locates inside this margin - it is
-         * not visible, because it is covered by some other element.
-         */
-        scrollTopMargin: {
-          type: Number,
-          value: 0,
-        },
-      };
+      /**
+       * The scrollTopMargin defines height of invisible area at the top
+       * of the page. If cursor locates inside this margin - it is
+       * not visible, because it is covered by some other element.
+       */
+      scrollTopMargin: {
+        type: Number,
+        value: 0,
+      },
+    };
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unsetCursor();
+  }
+
+  /**
+   * Move the cursor forward. Clipped to the ends of the stop list.
+   *
+   * @param {!Function=} opt_condition Optional stop condition. If a condition
+   *    is passed the cursor will continue to move in the specified direction
+   *    until the condition is met.
+   * @param {!Function=} opt_getTargetHeight Optional function to calculate the
+   *    height of the target's 'section'. The height of the target itself is
+   *    sometimes different, used by the diff cursor.
+   * @param {boolean=} opt_clipToTop When none of the next indices match, move
+   *     back to first instead of to last.
+   * @private
+   */
+
+  next(opt_condition, opt_getTargetHeight, opt_clipToTop) {
+    this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop);
+  }
+
+  previous(opt_condition) {
+    this._moveCursor(-1, opt_condition);
+  }
+
+  /**
+   * Move the cursor to the row which is the closest to the viewport center
+   * in vertical direction.
+   * The method uses IntersectionObservers API. If browser
+   * doesn't support this API the method does nothing
+   *
+   * @param {!Function=} opt_condition Optional condition. If a condition
+   *    is passed only stops which meet conditions are taken into account.
+   */
+  moveToVisibleArea(opt_condition) {
+    if (!this.stops || !this._isIntersectionObserverSupported()) {
+      return;
     }
+    const filteredStops = opt_condition ? this.stops.filter(opt_condition)
+      : this.stops;
+    const dims = this._getWindowDims();
+    const windowCenter =
+        Math.round((dims.innerHeight + this.scrollTopMargin) / 2);
 
-    /** @override */
-    detached() {
-      super.detached();
-      this.unsetCursor();
-    }
+    let closestToTheCenter = null;
+    let minDistanceToCenter = null;
+    let unobservedCount = filteredStops.length;
 
-    /**
-     * Move the cursor forward. Clipped to the ends of the stop list.
-     *
-     * @param {!Function=} opt_condition Optional stop condition. If a condition
-     *    is passed the cursor will continue to move in the specified direction
-     *    until the condition is met.
-     * @param {!Function=} opt_getTargetHeight Optional function to calculate the
-     *    height of the target's 'section'. The height of the target itself is
-     *    sometimes different, used by the diff cursor.
-     * @param {boolean=} opt_clipToTop When none of the next indices match, move
-     *     back to first instead of to last.
-     * @private
-     */
+    const observer = new IntersectionObserver(entries => {
+      // This callback is called for the first time immediately.
+      // Typically it gets all observed stops at once, but
+      // sometimes can get them in several chunks.
+      entries.forEach(entry => {
+        observer.unobserve(entry.target);
 
-    next(opt_condition, opt_getTargetHeight, opt_clipToTop) {
-      this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop);
-    }
-
-    previous(opt_condition) {
-      this._moveCursor(-1, opt_condition);
-    }
-
-    /**
-     * Move the cursor to the row which is the closest to the viewport center
-     * in vertical direction.
-     * The method uses IntersectionObservers API. If browser
-     * doesn't support this API the method does nothing
-     *
-     * @param {!Function=} opt_condition Optional condition. If a condition
-     *    is passed only stops which meet conditions are taken into account.
-     */
-    moveToVisibleArea(opt_condition) {
-      if (!this.stops || !this._isIntersectionObserverSupported()) {
-        return;
-      }
-      const filteredStops = opt_condition ? this.stops.filter(opt_condition)
-        : this.stops;
-      const dims = this._getWindowDims();
-      const windowCenter =
-          Math.round((dims.innerHeight + this.scrollTopMargin) / 2);
-
-      let closestToTheCenter = null;
-      let minDistanceToCenter = null;
-      let unobservedCount = filteredStops.length;
-
-      const observer = new IntersectionObserver(entries => {
-        // This callback is called for the first time immediately.
-        // Typically it gets all observed stops at once, but
-        // sometimes can get them in several chunks.
-        entries.forEach(entry => {
-          observer.unobserve(entry.target);
-
-          // In Edge it is recommended to use intersectionRatio instead of
-          // isIntersecting.
-          const isInsideViewport =
-              entry.isIntersecting || entry.intersectionRatio > 0;
-          if (!isInsideViewport) {
-            return;
-          }
-          const center = entry.boundingClientRect.top + Math.round(
-              entry.boundingClientRect.height / 2);
-          const distanceToWindowCenter = Math.abs(center - windowCenter);
-          if (minDistanceToCenter === null ||
-              distanceToWindowCenter < minDistanceToCenter) {
-            closestToTheCenter = entry.target;
-            minDistanceToCenter = distanceToWindowCenter;
-          }
-        });
-        unobservedCount -= entries.length;
-        if (unobservedCount == 0 && closestToTheCenter) {
-          // set cursor when all stops were observed.
-          // In most cases the target is visible, so scroll is not
-          // needed. But in rare cases the target can become invisible
-          // at this point (due to some scrolling in window).
-          // To avoid jumps set noScroll options.
-          this.setCursor(closestToTheCenter, true);
-        }
-      });
-      filteredStops.forEach(stop => {
-        observer.observe(stop);
-      });
-    }
-
-    _isIntersectionObserverSupported() {
-      // The copy of this method exists in gr-app-element.js under the
-      // name _isCursorManagerSupportMoveToVisibleLine
-      // If you update this method, you must update gr-app-element.js
-      // as well.
-      return 'IntersectionObserver' in window;
-    }
-
-    /**
-     * Set the cursor to an arbitrary element.
-     *
-     * @param {!HTMLElement} element
-     * @param {boolean=} opt_noScroll prevent any potential scrolling in response
-     *   setting the cursor.
-     */
-    setCursor(element, opt_noScroll) {
-      let behavior;
-      if (opt_noScroll) {
-        behavior = this.scrollBehavior;
-        this.scrollBehavior = ScrollBehavior.NEVER;
-      }
-
-      this.unsetCursor();
-      this.target = element;
-      this._updateIndex();
-      this._decorateTarget();
-
-      if (opt_noScroll) { this.scrollBehavior = behavior; }
-    }
-
-    unsetCursor() {
-      this._unDecorateTarget();
-      this.index = -1;
-      this.target = null;
-      this._targetHeight = null;
-    }
-
-    isAtStart() {
-      return this.index === 0;
-    }
-
-    isAtEnd() {
-      return this.index === this.stops.length - 1;
-    }
-
-    moveToStart() {
-      if (this.stops.length) {
-        this.setCursor(this.stops[0]);
-      }
-    }
-
-    moveToEnd() {
-      if (this.stops.length) {
-        this.setCursor(this.stops[this.stops.length - 1]);
-      }
-    }
-
-    setCursorAtIndex(index, opt_noScroll) {
-      this.setCursor(this.stops[index], opt_noScroll);
-    }
-
-    /**
-     * Move the cursor forward or backward by delta. Clipped to the beginning or
-     * end of stop list.
-     *
-     * @param {number} delta either -1 or 1.
-     * @param {!Function=} opt_condition Optional stop condition. If a condition
-     *    is passed the cursor will continue to move in the specified direction
-     *    until the condition is met.
-     * @param {!Function=} opt_getTargetHeight Optional function to calculate the
-     *    height of the target's 'section'. The height of the target itself is
-     *    sometimes different, used by the diff cursor.
-     * @param {boolean=} opt_clipToTop When none of the next indices match, move
-     *     back to first instead of to last.
-     * @private
-     */
-    _moveCursor(delta, opt_condition, opt_getTargetHeight, opt_clipToTop) {
-      if (!this.stops.length) {
-        this.unsetCursor();
-        return;
-      }
-
-      this._unDecorateTarget();
-
-      const newIndex = this._getNextindex(delta, opt_condition, opt_clipToTop);
-
-      let newTarget = null;
-      if (newIndex !== -1) {
-        newTarget = this.stops[newIndex];
-      }
-
-      this.index = newIndex;
-      this.target = newTarget;
-
-      if (!this.target) { return; }
-
-      if (opt_getTargetHeight) {
-        this._targetHeight = opt_getTargetHeight(newTarget);
-      } else {
-        this._targetHeight = newTarget.scrollHeight;
-      }
-
-      if (this.focusOnMove) { this.target.focus(); }
-
-      this._decorateTarget();
-    }
-
-    _decorateTarget() {
-      if (this.target && this.cursorTargetClass) {
-        this.target.classList.add(this.cursorTargetClass);
-      }
-    }
-
-    _unDecorateTarget() {
-      if (this.target && this.cursorTargetClass) {
-        this.target.classList.remove(this.cursorTargetClass);
-      }
-    }
-
-    /**
-     * Get the next stop index indicated by the delta direction.
-     *
-     * @param {number} delta either -1 or 1.
-     * @param {!Function=} opt_condition Optional stop condition.
-     * @param {boolean=} opt_clipToTop When none of the next indices match, move
-     *     back to first instead of to last.
-     * @return {number} the new index.
-     * @private
-     */
-    _getNextindex(delta, opt_condition, opt_clipToTop) {
-      if (!this.stops.length || this.index === -1) {
-        return -1;
-      }
-
-      let newIndex = this.index;
-      do {
-        newIndex = newIndex + delta;
-      } while (newIndex > 0 &&
-               newIndex < this.stops.length - 1 &&
-               opt_condition && !opt_condition(this.stops[newIndex]));
-
-      newIndex = Math.max(0, Math.min(this.stops.length - 1, newIndex));
-
-      // If we failed to satisfy the condition:
-      if (opt_condition && !opt_condition(this.stops[newIndex])) {
-        if (delta < 0 || opt_clipToTop) {
-          return 0;
-        } else if (delta > 0) {
-          return this.stops.length - 1;
-        }
-        return this.index;
-      }
-
-      return newIndex;
-    }
-
-    _updateIndex() {
-      if (!this.target) {
-        this.index = -1;
-        return;
-      }
-
-      const newIndex = Array.prototype.indexOf.call(this.stops, this.target);
-      if (newIndex === -1) {
-        this.unsetCursor();
-      } else {
-        this.index = newIndex;
-      }
-    }
-
-    /**
-     * Calculate where the element is relative to the window.
-     *
-     * @param {!Object} target Target to scroll to.
-     * @return {number} Distance to top of the target.
-     */
-    _getTop(target) {
-      let top = target.offsetTop;
-      for (let offsetParent = target.offsetParent;
-        offsetParent;
-        offsetParent = offsetParent.offsetParent) {
-        top += offsetParent.offsetTop;
-      }
-      return top;
-    }
-
-    /**
-     * @return {boolean}
-     */
-    _targetIsVisible(top) {
-      const dims = this._getWindowDims();
-      return this.scrollBehavior === ScrollBehavior.KEEP_VISIBLE &&
-          top > (dims.pageYOffset + this.scrollTopMargin) &&
-          top < dims.pageYOffset + dims.innerHeight;
-    }
-
-    _calculateScrollToValue(top, target) {
-      const dims = this._getWindowDims();
-      return top + this.scrollTopMargin - (dims.innerHeight / 3) +
-          (target.offsetHeight / 2);
-    }
-
-    _scrollToTarget() {
-      if (!this.target || this.scrollBehavior === ScrollBehavior.NEVER) {
-        return;
-      }
-
-      const dims = this._getWindowDims();
-      const top = this._getTop(this.target);
-      const bottomIsVisible = this._targetHeight ?
-        this._targetIsVisible(top + this._targetHeight) : true;
-      const scrollToValue = this._calculateScrollToValue(top, this.target);
-
-      if (this._targetIsVisible(top)) {
-        // Don't scroll if either the bottom is visible or if the position that
-        // would get scrolled to is higher up than the current position. this
-        // woulld cause less of the target content to be displayed than is
-        // already.
-        if (bottomIsVisible || scrollToValue < dims.scrollY) {
+        // In Edge it is recommended to use intersectionRatio instead of
+        // isIntersecting.
+        const isInsideViewport =
+            entry.isIntersecting || entry.intersectionRatio > 0;
+        if (!isInsideViewport) {
           return;
         }
+        const center = entry.boundingClientRect.top + Math.round(
+            entry.boundingClientRect.height / 2);
+        const distanceToWindowCenter = Math.abs(center - windowCenter);
+        if (minDistanceToCenter === null ||
+            distanceToWindowCenter < minDistanceToCenter) {
+          closestToTheCenter = entry.target;
+          minDistanceToCenter = distanceToWindowCenter;
+        }
+      });
+      unobservedCount -= entries.length;
+      if (unobservedCount == 0 && closestToTheCenter) {
+        // set cursor when all stops were observed.
+        // In most cases the target is visible, so scroll is not
+        // needed. But in rare cases the target can become invisible
+        // at this point (due to some scrolling in window).
+        // To avoid jumps set noScroll options.
+        this.setCursor(closestToTheCenter, true);
       }
+    });
+    filteredStops.forEach(stop => {
+      observer.observe(stop);
+    });
+  }
 
-      // Scroll the element to the middle of the window. Dividing by a third
-      // instead of half the inner height feels a bit better otherwise the
-      // element appears to be below the center of the window even when it
-      // isn't.
-      window.scrollTo(dims.scrollX, scrollToValue);
+  _isIntersectionObserverSupported() {
+    // The copy of this method exists in gr-app-element.js under the
+    // name _isCursorManagerSupportMoveToVisibleLine
+    // If you update this method, you must update gr-app-element.js
+    // as well.
+    return 'IntersectionObserver' in window;
+  }
+
+  /**
+   * Set the cursor to an arbitrary element.
+   *
+   * @param {!HTMLElement} element
+   * @param {boolean=} opt_noScroll prevent any potential scrolling in response
+   *   setting the cursor.
+   */
+  setCursor(element, opt_noScroll) {
+    let behavior;
+    if (opt_noScroll) {
+      behavior = this.scrollBehavior;
+      this.scrollBehavior = ScrollBehavior.NEVER;
     }
 
-    _getWindowDims() {
-      return {
-        scrollX: window.scrollX,
-        scrollY: window.scrollY,
-        innerHeight: window.innerHeight,
-        pageYOffset: window.pageYOffset,
-      };
+    this.unsetCursor();
+    this.target = element;
+    this._updateIndex();
+    this._decorateTarget();
+
+    if (opt_noScroll) { this.scrollBehavior = behavior; }
+  }
+
+  unsetCursor() {
+    this._unDecorateTarget();
+    this.index = -1;
+    this.target = null;
+    this._targetHeight = null;
+  }
+
+  isAtStart() {
+    return this.index === 0;
+  }
+
+  isAtEnd() {
+    return this.index === this.stops.length - 1;
+  }
+
+  moveToStart() {
+    if (this.stops.length) {
+      this.setCursor(this.stops[0]);
     }
   }
 
-  customElements.define(GrCursorManager.is, GrCursorManager);
-})();
+  moveToEnd() {
+    if (this.stops.length) {
+      this.setCursor(this.stops[this.stops.length - 1]);
+    }
+  }
+
+  setCursorAtIndex(index, opt_noScroll) {
+    this.setCursor(this.stops[index], opt_noScroll);
+  }
+
+  /**
+   * Move the cursor forward or backward by delta. Clipped to the beginning or
+   * end of stop list.
+   *
+   * @param {number} delta either -1 or 1.
+   * @param {!Function=} opt_condition Optional stop condition. If a condition
+   *    is passed the cursor will continue to move in the specified direction
+   *    until the condition is met.
+   * @param {!Function=} opt_getTargetHeight Optional function to calculate the
+   *    height of the target's 'section'. The height of the target itself is
+   *    sometimes different, used by the diff cursor.
+   * @param {boolean=} opt_clipToTop When none of the next indices match, move
+   *     back to first instead of to last.
+   * @private
+   */
+  _moveCursor(delta, opt_condition, opt_getTargetHeight, opt_clipToTop) {
+    if (!this.stops.length) {
+      this.unsetCursor();
+      return;
+    }
+
+    this._unDecorateTarget();
+
+    const newIndex = this._getNextindex(delta, opt_condition, opt_clipToTop);
+
+    let newTarget = null;
+    if (newIndex !== -1) {
+      newTarget = this.stops[newIndex];
+    }
+
+    this.index = newIndex;
+    this.target = newTarget;
+
+    if (!this.target) { return; }
+
+    if (opt_getTargetHeight) {
+      this._targetHeight = opt_getTargetHeight(newTarget);
+    } else {
+      this._targetHeight = newTarget.scrollHeight;
+    }
+
+    if (this.focusOnMove) { this.target.focus(); }
+
+    this._decorateTarget();
+  }
+
+  _decorateTarget() {
+    if (this.target && this.cursorTargetClass) {
+      this.target.classList.add(this.cursorTargetClass);
+    }
+  }
+
+  _unDecorateTarget() {
+    if (this.target && this.cursorTargetClass) {
+      this.target.classList.remove(this.cursorTargetClass);
+    }
+  }
+
+  /**
+   * Get the next stop index indicated by the delta direction.
+   *
+   * @param {number} delta either -1 or 1.
+   * @param {!Function=} opt_condition Optional stop condition.
+   * @param {boolean=} opt_clipToTop When none of the next indices match, move
+   *     back to first instead of to last.
+   * @return {number} the new index.
+   * @private
+   */
+  _getNextindex(delta, opt_condition, opt_clipToTop) {
+    if (!this.stops.length || this.index === -1) {
+      return -1;
+    }
+
+    let newIndex = this.index;
+    do {
+      newIndex = newIndex + delta;
+    } while (newIndex > 0 &&
+             newIndex < this.stops.length - 1 &&
+             opt_condition && !opt_condition(this.stops[newIndex]));
+
+    newIndex = Math.max(0, Math.min(this.stops.length - 1, newIndex));
+
+    // If we failed to satisfy the condition:
+    if (opt_condition && !opt_condition(this.stops[newIndex])) {
+      if (delta < 0 || opt_clipToTop) {
+        return 0;
+      } else if (delta > 0) {
+        return this.stops.length - 1;
+      }
+      return this.index;
+    }
+
+    return newIndex;
+  }
+
+  _updateIndex() {
+    if (!this.target) {
+      this.index = -1;
+      return;
+    }
+
+    const newIndex = Array.prototype.indexOf.call(this.stops, this.target);
+    if (newIndex === -1) {
+      this.unsetCursor();
+    } else {
+      this.index = newIndex;
+    }
+  }
+
+  /**
+   * Calculate where the element is relative to the window.
+   *
+   * @param {!Object} target Target to scroll to.
+   * @return {number} Distance to top of the target.
+   */
+  _getTop(target) {
+    let top = target.offsetTop;
+    for (let offsetParent = target.offsetParent;
+      offsetParent;
+      offsetParent = offsetParent.offsetParent) {
+      top += offsetParent.offsetTop;
+    }
+    return top;
+  }
+
+  /**
+   * @return {boolean}
+   */
+  _targetIsVisible(top) {
+    const dims = this._getWindowDims();
+    return this.scrollBehavior === ScrollBehavior.KEEP_VISIBLE &&
+        top > (dims.pageYOffset + this.scrollTopMargin) &&
+        top < dims.pageYOffset + dims.innerHeight;
+  }
+
+  _calculateScrollToValue(top, target) {
+    const dims = this._getWindowDims();
+    return top + this.scrollTopMargin - (dims.innerHeight / 3) +
+        (target.offsetHeight / 2);
+  }
+
+  _scrollToTarget() {
+    if (!this.target || this.scrollBehavior === ScrollBehavior.NEVER) {
+      return;
+    }
+
+    const dims = this._getWindowDims();
+    const top = this._getTop(this.target);
+    const bottomIsVisible = this._targetHeight ?
+      this._targetIsVisible(top + this._targetHeight) : true;
+    const scrollToValue = this._calculateScrollToValue(top, this.target);
+
+    if (this._targetIsVisible(top)) {
+      // Don't scroll if either the bottom is visible or if the position that
+      // would get scrolled to is higher up than the current position. this
+      // woulld cause less of the target content to be displayed than is
+      // already.
+      if (bottomIsVisible || scrollToValue < dims.scrollY) {
+        return;
+      }
+    }
+
+    // Scroll the element to the middle of the window. Dividing by a third
+    // instead of half the inner height feels a bit better otherwise the
+    // element appears to be below the center of the window even when it
+    // isn't.
+    window.scrollTo(dims.scrollX, scrollToValue);
+  }
+
+  _getWindowDims() {
+    return {
+      scrollX: window.scrollX,
+      scrollY: window.scrollY,
+      innerHeight: window.innerHeight,
+      pageYOffset: window.pageYOffset,
+    };
+  }
+}
+
+customElements.define(GrCursorManager.is, GrCursorManager);
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.js
index 94d7aaa..29757e5 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.js
@@ -1,23 +1,21 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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
+export const htmlTemplate = html`
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-cursor-manager">
-  <template></template>
-  <script src="gr-cursor-manager.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
index e7d5d74..5264464 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-cursor-manager</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-cursor-manager.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-cursor-manager.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-cursor-manager.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -41,243 +46,245 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-cursor-manager tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let element;
-    let list;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-cursor-manager.js';
+suite('gr-cursor-manager tests', () => {
+  let sandbox;
+  let element;
+  let list;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    const fixtureElements = fixture('basic');
+    element = fixtureElements[0];
+    list = fixtureElements[1];
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('core cursor functionality', () => {
+    // The element is initialized into the proper state.
+    assert.isArray(element.stops);
+    assert.equal(element.stops.length, 0);
+    assert.equal(element.index, -1);
+    assert.isNotOk(element.target);
+
+    // Initialize the cursor with its stops.
+    element.stops = list.querySelectorAll('li');
+
+    // It should have the stops but it should not be targeting any of them.
+    assert.isNotNull(element.stops);
+    assert.equal(element.stops.length, 4);
+    assert.equal(element.index, -1);
+    assert.isNotOk(element.target);
+
+    // Select the third stop.
+    element.setCursor(list.children[2]);
+
+    // It should update its internal state and update the element's class.
+    assert.equal(element.index, 2);
+    assert.equal(element.target, list.children[2]);
+    assert.isTrue(list.children[2].classList.contains('targeted'));
+    assert.isFalse(element.isAtStart());
+    assert.isFalse(element.isAtEnd());
+
+    // Progress the cursor.
+    element.next();
+
+    // Confirm that the next stop is selected and that the previous stop is
+    // unselected.
+    assert.equal(element.index, 3);
+    assert.equal(element.target, list.children[3]);
+    assert.isTrue(element.isAtEnd());
+    assert.isFalse(list.children[2].classList.contains('targeted'));
+    assert.isTrue(list.children[3].classList.contains('targeted'));
+
+    // Progress the cursor.
+    element.next();
+
+    // We should still be at the end.
+    assert.equal(element.index, 3);
+    assert.equal(element.target, list.children[3]);
+    assert.isTrue(element.isAtEnd());
+
+    // Wind the cursor all the way back to the first stop.
+    element.previous();
+    element.previous();
+    element.previous();
+
+    // The element state should reflect the end of the list.
+    assert.equal(element.index, 0);
+    assert.equal(element.target, list.children[0]);
+    assert.isTrue(element.isAtStart());
+    assert.isTrue(list.children[0].classList.contains('targeted'));
+
+    const newLi = document.createElement('li');
+    newLi.textContent = 'Z';
+    list.insertBefore(newLi, list.children[0]);
+    element.stops = list.querySelectorAll('li');
+
+    assert.equal(element.index, 1);
+
+    // De-select all targets.
+    element.unsetCursor();
+
+    // There should now be no cursor target.
+    assert.isFalse(list.children[1].classList.contains('targeted'));
+    assert.isNotOk(element.target);
+    assert.equal(element.index, -1);
+  });
+
+  test('_moveCursor', () => {
+    // Initialize the cursor with its stops.
+    element.stops = list.querySelectorAll('li');
+    // Select the first stop.
+    element.setCursor(list.children[0]);
+    const getTargetHeight = sinon.stub();
+
+    // Move the cursor without an optional get target height function.
+    element._moveCursor(1);
+    assert.isFalse(getTargetHeight.called);
+
+    // Move the cursor with an optional get target height function.
+    element._moveCursor(1, null, getTargetHeight);
+    assert.isTrue(getTargetHeight.called);
+  });
+
+  test('_moveCursor from -1 does not check height', () => {
+    element.stops = list.querySelectorAll('li');
+    const getTargetHeight = sinon.stub();
+    element._moveCursor(1, () => false, getTargetHeight);
+    assert.isFalse(getTargetHeight.called);
+  });
+
+  test('opt_noScroll', () => {
+    sandbox.stub(element, '_targetIsVisible', () => false);
+    const scrollStub = sandbox.stub(window, 'scrollTo');
+    element.stops = list.querySelectorAll('li');
+    element.scrollBehavior = 'keep-visible';
+
+    element.setCursorAtIndex(1, true);
+    assert.isFalse(scrollStub.called);
+
+    element.setCursorAtIndex(2);
+    assert.isTrue(scrollStub.called);
+  });
+
+  test('_getNextindex', () => {
+    const isLetterB = function(row) {
+      return row.textContent === 'B';
+    };
+    element.stops = list.querySelectorAll('li');
+    // Start cursor at the first stop.
+    element.setCursor(list.children[0]);
+
+    // Move forward to meet the next condition.
+    assert.equal(element._getNextindex(1, isLetterB), 1);
+    element.index = 1;
+
+    // Nothing else meets the condition, should be at last stop.
+    assert.equal(element._getNextindex(1, isLetterB), 3);
+    element.index = 3;
+
+    // Should stay at last stop if try to proceed.
+    assert.equal(element._getNextindex(1, isLetterB), 3);
+
+    // Go back to the previous condition met. Should be back at.
+    // stop 1.
+    assert.equal(element._getNextindex(-1, isLetterB), 1);
+    element.index = 1;
+
+    // Go back. No more meet the condition. Should be at stop 0.
+    assert.equal(element._getNextindex(-1, isLetterB), 0);
+  });
+
+  test('focusOnMove prop', () => {
+    const listEls = list.querySelectorAll('li');
+    for (let i = 0; i < listEls.length; i++) {
+      sandbox.spy(listEls[i], 'focus');
+    }
+    element.stops = listEls;
+    element.setCursor(list.children[0]);
+
+    element.focusOnMove = false;
+    element.next();
+    assert.isFalse(element.target.focus.called);
+
+    element.focusOnMove = true;
+    element.next();
+    assert.isTrue(element.target.focus.called);
+  });
+
+  suite('_scrollToTarget', () => {
+    let scrollStub;
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      const fixtureElements = fixture('basic');
-      element = fixtureElements[0];
-      list = fixtureElements[1];
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('core cursor functionality', () => {
-      // The element is initialized into the proper state.
-      assert.isArray(element.stops);
-      assert.equal(element.stops.length, 0);
-      assert.equal(element.index, -1);
-      assert.isNotOk(element.target);
-
-      // Initialize the cursor with its stops.
-      element.stops = list.querySelectorAll('li');
-
-      // It should have the stops but it should not be targeting any of them.
-      assert.isNotNull(element.stops);
-      assert.equal(element.stops.length, 4);
-      assert.equal(element.index, -1);
-      assert.isNotOk(element.target);
-
-      // Select the third stop.
-      element.setCursor(list.children[2]);
-
-      // It should update its internal state and update the element's class.
-      assert.equal(element.index, 2);
-      assert.equal(element.target, list.children[2]);
-      assert.isTrue(list.children[2].classList.contains('targeted'));
-      assert.isFalse(element.isAtStart());
-      assert.isFalse(element.isAtEnd());
-
-      // Progress the cursor.
-      element.next();
-
-      // Confirm that the next stop is selected and that the previous stop is
-      // unselected.
-      assert.equal(element.index, 3);
-      assert.equal(element.target, list.children[3]);
-      assert.isTrue(element.isAtEnd());
-      assert.isFalse(list.children[2].classList.contains('targeted'));
-      assert.isTrue(list.children[3].classList.contains('targeted'));
-
-      // Progress the cursor.
-      element.next();
-
-      // We should still be at the end.
-      assert.equal(element.index, 3);
-      assert.equal(element.target, list.children[3]);
-      assert.isTrue(element.isAtEnd());
-
-      // Wind the cursor all the way back to the first stop.
-      element.previous();
-      element.previous();
-      element.previous();
-
-      // The element state should reflect the end of the list.
-      assert.equal(element.index, 0);
-      assert.equal(element.target, list.children[0]);
-      assert.isTrue(element.isAtStart());
-      assert.isTrue(list.children[0].classList.contains('targeted'));
-
-      const newLi = document.createElement('li');
-      newLi.textContent = 'Z';
-      list.insertBefore(newLi, list.children[0]);
-      element.stops = list.querySelectorAll('li');
-
-      assert.equal(element.index, 1);
-
-      // De-select all targets.
-      element.unsetCursor();
-
-      // There should now be no cursor target.
-      assert.isFalse(list.children[1].classList.contains('targeted'));
-      assert.isNotOk(element.target);
-      assert.equal(element.index, -1);
-    });
-
-    test('_moveCursor', () => {
-      // Initialize the cursor with its stops.
-      element.stops = list.querySelectorAll('li');
-      // Select the first stop.
-      element.setCursor(list.children[0]);
-      const getTargetHeight = sinon.stub();
-
-      // Move the cursor without an optional get target height function.
-      element._moveCursor(1);
-      assert.isFalse(getTargetHeight.called);
-
-      // Move the cursor with an optional get target height function.
-      element._moveCursor(1, null, getTargetHeight);
-      assert.isTrue(getTargetHeight.called);
-    });
-
-    test('_moveCursor from -1 does not check height', () => {
-      element.stops = list.querySelectorAll('li');
-      const getTargetHeight = sinon.stub();
-      element._moveCursor(1, () => false, getTargetHeight);
-      assert.isFalse(getTargetHeight.called);
-    });
-
-    test('opt_noScroll', () => {
-      sandbox.stub(element, '_targetIsVisible', () => false);
-      const scrollStub = sandbox.stub(window, 'scrollTo');
       element.stops = list.querySelectorAll('li');
       element.scrollBehavior = 'keep-visible';
 
-      element.setCursorAtIndex(1, true);
-      assert.isFalse(scrollStub.called);
+      // There is a target which has a targetNext
+      element.setCursor(list.children[0]);
+      element._moveCursor(1);
+      scrollStub = sandbox.stub(window, 'scrollTo');
+      window.innerHeight = 60;
+    });
 
-      element.setCursorAtIndex(2);
+    test('Called when top and bottom not visible', () => {
+      sandbox.stub(element, '_targetIsVisible').returns(false);
+      element._scrollToTarget();
       assert.isTrue(scrollStub.called);
     });
 
-    test('_getNextindex', () => {
-      const isLetterB = function(row) {
-        return row.textContent === 'B';
-      };
-      element.stops = list.querySelectorAll('li');
-      // Start cursor at the first stop.
-      element.setCursor(list.children[0]);
-
-      // Move forward to meet the next condition.
-      assert.equal(element._getNextindex(1, isLetterB), 1);
-      element.index = 1;
-
-      // Nothing else meets the condition, should be at last stop.
-      assert.equal(element._getNextindex(1, isLetterB), 3);
-      element.index = 3;
-
-      // Should stay at last stop if try to proceed.
-      assert.equal(element._getNextindex(1, isLetterB), 3);
-
-      // Go back to the previous condition met. Should be back at.
-      // stop 1.
-      assert.equal(element._getNextindex(-1, isLetterB), 1);
-      element.index = 1;
-
-      // Go back. No more meet the condition. Should be at stop 0.
-      assert.equal(element._getNextindex(-1, isLetterB), 0);
+    test('Not called when top and bottom visible', () => {
+      sandbox.stub(element, '_targetIsVisible').returns(true);
+      element._scrollToTarget();
+      assert.isFalse(scrollStub.called);
     });
 
-    test('focusOnMove prop', () => {
-      const listEls = list.querySelectorAll('li');
-      for (let i = 0; i < listEls.length; i++) {
-        sandbox.spy(listEls[i], 'focus');
-      }
-      element.stops = listEls;
-      element.setCursor(list.children[0]);
-
-      element.focusOnMove = false;
-      element.next();
-      assert.isFalse(element.target.focus.called);
-
-      element.focusOnMove = true;
-      element.next();
-      assert.isTrue(element.target.focus.called);
+    test('Called when top is visible, bottom is not, scroll is lower', () => {
+      const visibleStub = sandbox.stub(element, '_targetIsVisible',
+          () => visibleStub.callCount === 2);
+      sandbox.stub(element, '_getWindowDims').returns({
+        scrollX: 123,
+        scrollY: 15,
+        innerHeight: 1000,
+        pageYOffset: 0,
+      });
+      sandbox.stub(element, '_calculateScrollToValue').returns(20);
+      element._scrollToTarget();
+      assert.isTrue(scrollStub.called);
+      assert.isTrue(scrollStub.calledWithExactly(123, 20));
+      assert.equal(visibleStub.callCount, 2);
     });
 
-    suite('_scrollToTarget', () => {
-      let scrollStub;
-      setup(() => {
-        element.stops = list.querySelectorAll('li');
-        element.scrollBehavior = 'keep-visible';
-
-        // There is a target which has a targetNext
-        element.setCursor(list.children[0]);
-        element._moveCursor(1);
-        scrollStub = sandbox.stub(window, 'scrollTo');
-        window.innerHeight = 60;
+    test('Called when top is visible, bottom not, scroll is higher', () => {
+      const visibleStub = sandbox.stub(element, '_targetIsVisible',
+          () => visibleStub.callCount === 2);
+      sandbox.stub(element, '_getWindowDims').returns({
+        scrollX: 123,
+        scrollY: 25,
+        innerHeight: 1000,
+        pageYOffset: 0,
       });
+      sandbox.stub(element, '_calculateScrollToValue').returns(20);
+      element._scrollToTarget();
+      assert.isFalse(scrollStub.called);
+      assert.equal(visibleStub.callCount, 2);
+    });
 
-      test('Called when top and bottom not visible', () => {
-        sandbox.stub(element, '_targetIsVisible').returns(false);
-        element._scrollToTarget();
-        assert.isTrue(scrollStub.called);
+    test('_calculateScrollToValue', () => {
+      sandbox.stub(element, '_getWindowDims').returns({
+        scrollX: 123,
+        scrollY: 25,
+        innerHeight: 300,
+        pageYOffset: 0,
       });
-
-      test('Not called when top and bottom visible', () => {
-        sandbox.stub(element, '_targetIsVisible').returns(true);
-        element._scrollToTarget();
-        assert.isFalse(scrollStub.called);
-      });
-
-      test('Called when top is visible, bottom is not, scroll is lower', () => {
-        const visibleStub = sandbox.stub(element, '_targetIsVisible',
-            () => visibleStub.callCount === 2);
-        sandbox.stub(element, '_getWindowDims').returns({
-          scrollX: 123,
-          scrollY: 15,
-          innerHeight: 1000,
-          pageYOffset: 0,
-        });
-        sandbox.stub(element, '_calculateScrollToValue').returns(20);
-        element._scrollToTarget();
-        assert.isTrue(scrollStub.called);
-        assert.isTrue(scrollStub.calledWithExactly(123, 20));
-        assert.equal(visibleStub.callCount, 2);
-      });
-
-      test('Called when top is visible, bottom not, scroll is higher', () => {
-        const visibleStub = sandbox.stub(element, '_targetIsVisible',
-            () => visibleStub.callCount === 2);
-        sandbox.stub(element, '_getWindowDims').returns({
-          scrollX: 123,
-          scrollY: 25,
-          innerHeight: 1000,
-          pageYOffset: 0,
-        });
-        sandbox.stub(element, '_calculateScrollToValue').returns(20);
-        element._scrollToTarget();
-        assert.isFalse(scrollStub.called);
-        assert.equal(visibleStub.callCount, 2);
-      });
-
-      test('_calculateScrollToValue', () => {
-        sandbox.stub(element, '_getWindowDims').returns({
-          scrollX: 123,
-          scrollY: 25,
-          innerHeight: 300,
-          pageYOffset: 0,
-        });
-        assert.equal(element._calculateScrollToValue(1000, {offsetHeight: 10}),
-            905);
-      });
+      assert.equal(element._calculateScrollToValue(1000, {offsetHeight: 10}),
+          905);
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
index 7be041b..c46bb17 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,243 +14,253 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const Duration = {
-    HOUR: 1000 * 60 * 60,
-    DAY: 1000 * 60 * 60 * 24,
-  };
+import '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import '../../../scripts/util.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-date-formatter_html.js';
 
-  const TimeFormats = {
-    TIME_12: 'h:mm A', // 2:14 PM
-    TIME_12_WITH_SEC: 'h:mm:ss A', // 2:14:00 PM
-    TIME_24: 'HH:mm', // 14:14
-    TIME_24_WITH_SEC: 'HH:mm:ss', // 14:14:00
-  };
+const Duration = {
+  HOUR: 1000 * 60 * 60,
+  DAY: 1000 * 60 * 60 * 24,
+};
 
-  const DateFormats = {
-    STD: {
-      short: 'MMM DD', // Aug 29
-      full: 'MMM DD, YYYY', // Aug 29, 1997
-    },
-    US: {
-      short: 'MM/DD', // 08/29
-      full: 'MM/DD/YY', // 08/29/97
-    },
-    ISO: {
-      short: 'MM-DD', // 08-29
-      full: 'YYYY-MM-DD', // 1997-08-29
-    },
-    EURO: {
-      short: 'DD. MMM', // 29. Aug
-      full: 'DD.MM.YYYY', // 29.08.1997
-    },
-    UK: {
-      short: 'DD/MM', // 29/08
-      full: 'DD/MM/YYYY', // 29/08/1997
-    },
-  };
+const TimeFormats = {
+  TIME_12: 'h:mm A', // 2:14 PM
+  TIME_12_WITH_SEC: 'h:mm:ss A', // 2:14:00 PM
+  TIME_24: 'HH:mm', // 14:14
+  TIME_24_WITH_SEC: 'HH:mm:ss', // 14:14:00
+};
 
-  /**
-   * @appliesMixin Gerrit.TooltipMixin
-   * @extends Polymer.Element
-   */
-  class GrDateFormatter extends Polymer.mixinBehaviors( [
-    Gerrit.TooltipBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-date-formatter'; }
+const DateFormats = {
+  STD: {
+    short: 'MMM DD', // Aug 29
+    full: 'MMM DD, YYYY', // Aug 29, 1997
+  },
+  US: {
+    short: 'MM/DD', // 08/29
+    full: 'MM/DD/YY', // 08/29/97
+  },
+  ISO: {
+    short: 'MM-DD', // 08-29
+    full: 'YYYY-MM-DD', // 1997-08-29
+  },
+  EURO: {
+    short: 'DD. MMM', // 29. Aug
+    full: 'DD.MM.YYYY', // 29.08.1997
+  },
+  UK: {
+    short: 'DD/MM', // 29/08
+    full: 'DD/MM/YYYY', // 29/08/1997
+  },
+};
 
-    static get properties() {
-      return {
-        dateStr: {
-          type: String,
-          value: null,
-          notify: true,
-        },
-        showDateAndTime: {
-          type: Boolean,
-          value: false,
-        },
+/**
+ * @appliesMixin Gerrit.TooltipMixin
+ * @extends Polymer.Element
+ */
+class GrDateFormatter extends mixinBehaviors( [
+  Gerrit.TooltipBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-        /**
-         * When true, the detailed date appears in a GR-TOOLTIP rather than in the
-         * native browser tooltip.
-         */
-        hasTooltip: Boolean,
+  static get is() { return 'gr-date-formatter'; }
 
-        /**
-         * The title to be used as the native tooltip or by the tooltip behavior.
-         */
-        title: {
-          type: String,
-          reflectToAttribute: true,
-          computed: '_computeFullDateStr(dateStr, _timeFormat, _dateFormat)',
-        },
+  static get properties() {
+    return {
+      dateStr: {
+        type: String,
+        value: null,
+        notify: true,
+      },
+      showDateAndTime: {
+        type: Boolean,
+        value: false,
+      },
 
-        /** @type {?{short: string, full: string}} */
-        _dateFormat: Object,
-        _timeFormat: String, // No default value to prevent flickering.
-        _relative: Boolean, // No default value to prevent flickering.
-      };
-    }
+      /**
+       * When true, the detailed date appears in a GR-TOOLTIP rather than in the
+       * native browser tooltip.
+       */
+      hasTooltip: Boolean,
 
-    /** @override */
-    attached() {
-      super.attached();
-      this._loadPreferences();
-    }
+      /**
+       * The title to be used as the native tooltip or by the tooltip behavior.
+       */
+      title: {
+        type: String,
+        reflectToAttribute: true,
+        computed: '_computeFullDateStr(dateStr, _timeFormat, _dateFormat)',
+      },
 
-    _getUtcOffsetString() {
-      return ' UTC' + moment().format('Z');
-    }
+      /** @type {?{short: string, full: string}} */
+      _dateFormat: Object,
+      _timeFormat: String, // No default value to prevent flickering.
+      _relative: Boolean, // No default value to prevent flickering.
+    };
+  }
 
-    _loadPreferences() {
-      return this._getLoggedIn().then(loggedIn => {
-        if (!loggedIn) {
-          this._timeFormat = TimeFormats.TIME_24;
-          this._dateFormat = DateFormats.STD;
-          this._relative = false;
-          return;
-        }
-        return Promise.all([
-          this._loadTimeFormat(),
-          this._loadRelative(),
-        ]);
-      });
-    }
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadPreferences();
+  }
 
-    _loadTimeFormat() {
-      return this._getPreferences().then(preferences => {
-        const timeFormat = preferences && preferences.time_format;
-        const dateFormat = preferences && preferences.date_format;
-        this._decideTimeFormat(timeFormat);
-        this._decideDateFormat(dateFormat);
-      });
-    }
+  _getUtcOffsetString() {
+    return ' UTC' + moment().format('Z');
+  }
 
-    _decideTimeFormat(timeFormat) {
-      switch (timeFormat) {
-        case 'HHMM_12':
-          this._timeFormat = TimeFormats.TIME_12;
-          break;
-        case 'HHMM_24':
-          this._timeFormat = TimeFormats.TIME_24;
-          break;
-        default:
-          throw Error('Invalid time format: ' + timeFormat);
+  _loadPreferences() {
+    return this._getLoggedIn().then(loggedIn => {
+      if (!loggedIn) {
+        this._timeFormat = TimeFormats.TIME_24;
+        this._dateFormat = DateFormats.STD;
+        this._relative = false;
+        return;
       }
-    }
+      return Promise.all([
+        this._loadTimeFormat(),
+        this._loadRelative(),
+      ]);
+    });
+  }
 
-    _decideDateFormat(dateFormat) {
-      switch (dateFormat) {
-        case 'STD':
-          this._dateFormat = DateFormats.STD;
-          break;
-        case 'US':
-          this._dateFormat = DateFormats.US;
-          break;
-        case 'ISO':
-          this._dateFormat = DateFormats.ISO;
-          break;
-        case 'EURO':
-          this._dateFormat = DateFormats.EURO;
-          break;
-        case 'UK':
-          this._dateFormat = DateFormats.UK;
-          break;
-        default:
-          throw Error('Invalid date format: ' + dateFormat);
-      }
-    }
+  _loadTimeFormat() {
+    return this._getPreferences().then(preferences => {
+      const timeFormat = preferences && preferences.time_format;
+      const dateFormat = preferences && preferences.date_format;
+      this._decideTimeFormat(timeFormat);
+      this._decideDateFormat(dateFormat);
+    });
+  }
 
-    _loadRelative() {
-      return this._getPreferences().then(prefs => {
-        // prefs.relative_date_in_change_table is not set when false.
-        this._relative = !!(prefs && prefs.relative_date_in_change_table);
-      });
-    }
-
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    }
-
-    _getPreferences() {
-      return this.$.restAPI.getPreferences();
-    }
-
-    /**
-     * Return true if date is within 24 hours and on the same day.
-     */
-    _isWithinDay(now, date) {
-      const diff = -date.diff(now);
-      return diff < Duration.DAY && date.day() === now.getDay();
-    }
-
-    /**
-     * Returns true if date is from one to six months.
-     */
-    _isWithinHalfYear(now, date) {
-      const diff = -date.diff(now);
-      return (date.day() !== now.getDay() || diff >= Duration.DAY) &&
-        diff < 180 * Duration.DAY;
-    }
-
-    _computeDateStr(
-        dateStr, timeFormat, dateFormat, relative, showDateAndTime
-    ) {
-      if (!dateStr || !timeFormat || !dateFormat) { return ''; }
-      const date = moment(util.parseDate(dateStr));
-      if (!date.isValid()) { return ''; }
-      if (relative) {
-        const dateFromNow = date.fromNow();
-        if (dateFromNow === 'a few seconds ago') {
-          return 'just now';
-        } else {
-          return dateFromNow;
-        }
-      }
-      const now = new Date();
-      let format = dateFormat.full;
-      if (this._isWithinDay(now, date)) {
-        format = timeFormat;
-      } else {
-        if (this._isWithinHalfYear(now, date)) {
-          format = dateFormat.short;
-        }
-        if (this.showDateAndTime) {
-          format = `${format} ${timeFormat}`;
-        }
-      }
-      return date.format(format);
-    }
-
-    _timeToSecondsFormat(timeFormat) {
-      return timeFormat === TimeFormats.TIME_12 ?
-        TimeFormats.TIME_12_WITH_SEC :
-        TimeFormats.TIME_24_WITH_SEC;
-    }
-
-    _computeFullDateStr(dateStr, timeFormat, dateFormat) {
-      // Polymer 2: check for undefined
-      if ([
-        dateStr,
-        timeFormat,
-        dateFormat,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      if (!dateStr) { return ''; }
-      const date = moment(util.parseDate(dateStr));
-      if (!date.isValid()) { return ''; }
-      let format = dateFormat.full + ', ';
-      format += this._timeToSecondsFormat(timeFormat);
-      return date.format(format) + this._getUtcOffsetString();
+  _decideTimeFormat(timeFormat) {
+    switch (timeFormat) {
+      case 'HHMM_12':
+        this._timeFormat = TimeFormats.TIME_12;
+        break;
+      case 'HHMM_24':
+        this._timeFormat = TimeFormats.TIME_24;
+        break;
+      default:
+        throw Error('Invalid time format: ' + timeFormat);
     }
   }
 
-  customElements.define(GrDateFormatter.is, GrDateFormatter);
-})();
+  _decideDateFormat(dateFormat) {
+    switch (dateFormat) {
+      case 'STD':
+        this._dateFormat = DateFormats.STD;
+        break;
+      case 'US':
+        this._dateFormat = DateFormats.US;
+        break;
+      case 'ISO':
+        this._dateFormat = DateFormats.ISO;
+        break;
+      case 'EURO':
+        this._dateFormat = DateFormats.EURO;
+        break;
+      case 'UK':
+        this._dateFormat = DateFormats.UK;
+        break;
+      default:
+        throw Error('Invalid date format: ' + dateFormat);
+    }
+  }
+
+  _loadRelative() {
+    return this._getPreferences().then(prefs => {
+      // prefs.relative_date_in_change_table is not set when false.
+      this._relative = !!(prefs && prefs.relative_date_in_change_table);
+    });
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  /**
+   * Return true if date is within 24 hours and on the same day.
+   */
+  _isWithinDay(now, date) {
+    const diff = -date.diff(now);
+    return diff < Duration.DAY && date.day() === now.getDay();
+  }
+
+  /**
+   * Returns true if date is from one to six months.
+   */
+  _isWithinHalfYear(now, date) {
+    const diff = -date.diff(now);
+    return (date.day() !== now.getDay() || diff >= Duration.DAY) &&
+      diff < 180 * Duration.DAY;
+  }
+
+  _computeDateStr(
+      dateStr, timeFormat, dateFormat, relative, showDateAndTime
+  ) {
+    if (!dateStr || !timeFormat || !dateFormat) { return ''; }
+    const date = moment(util.parseDate(dateStr));
+    if (!date.isValid()) { return ''; }
+    if (relative) {
+      const dateFromNow = date.fromNow();
+      if (dateFromNow === 'a few seconds ago') {
+        return 'just now';
+      } else {
+        return dateFromNow;
+      }
+    }
+    const now = new Date();
+    let format = dateFormat.full;
+    if (this._isWithinDay(now, date)) {
+      format = timeFormat;
+    } else {
+      if (this._isWithinHalfYear(now, date)) {
+        format = dateFormat.short;
+      }
+      if (this.showDateAndTime) {
+        format = `${format} ${timeFormat}`;
+      }
+    }
+    return date.format(format);
+  }
+
+  _timeToSecondsFormat(timeFormat) {
+    return timeFormat === TimeFormats.TIME_12 ?
+      TimeFormats.TIME_12_WITH_SEC :
+      TimeFormats.TIME_24_WITH_SEC;
+  }
+
+  _computeFullDateStr(dateStr, timeFormat, dateFormat) {
+    // Polymer 2: check for undefined
+    if ([
+      dateStr,
+      timeFormat,
+      dateFormat,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    if (!dateStr) { return ''; }
+    const date = moment(util.parseDate(dateStr));
+    if (!date.isValid()) { return ''; }
+    let format = dateFormat.full + ', ';
+    format += this._timeToSecondsFormat(timeFormat);
+    return date.format(format) + this._getUtcOffsetString();
+  }
+}
+
+customElements.define(GrDateFormatter.is, GrDateFormatter);
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.js
index ae5a945..19aa143 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<script src="../../../scripts/util.js"></script>
-
-<dom-module id="gr-date-formatter">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         color: inherit;
@@ -34,6 +27,4 @@
       [[_computeDateStr(dateStr, _timeFormat, _dateFormat, _relative, showDateAndTime)]]
     </span>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-date-formatter.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
index 0b572bf..65f2248 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-date-formatter</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-date-formatter.html">
+<script type="module" src="./gr-date-formatter.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-date-formatter.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,416 +43,419 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-date-formatter tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-date-formatter.js';
+suite('gr-date-formatter tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  /**
+   * Parse server-formatter date and normalize into current timezone.
+   */
+  function normalizedDate(dateStr) {
+    const d = util.parseDate(dateStr);
+    d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
+    return d;
+  }
+
+  function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
+      expectedTooltip, done) {
+    // Normalize and convert the date to mimic server response.
+    dateStr = normalizedDate(dateStr)
+        .toJSON()
+        .replace('T', ' ')
+        .slice(0, -1);
+    sandbox.useFakeTimers(normalizedDate(nowStr).getTime());
+    element.dateStr = dateStr;
+    flush(() => {
+      const span = element.shadowRoot
+          .querySelector('span');
+      assert.equal(span.textContent.trim(), expected);
+      assert.equal(element.title, expectedTooltip);
+      element.showDateAndTime = true;
+      flushAsynchronousOperations();
+      assert.equal(span.textContent.trim(), expectedWithDateAndTime);
+      done();
+    });
+  }
+
+  function stubRestAPI(preferences) {
+    const loggedInPromise = Promise.resolve(preferences !== null);
+    const preferencesPromise = Promise.resolve(preferences);
+    stub('gr-rest-api-interface', {
+      getLoggedIn: sinon.stub().returns(loggedInPromise),
+      getPreferences: sinon.stub().returns(preferencesPromise),
+    });
+    return Promise.all([loggedInPromise, preferencesPromise]);
+  }
+
+  suite('STD + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'STD',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = fixture('basic');
+      sandbox.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('invalid dates are quietly rejected', () => {
+      assert.notOk((new Date('foo')).valueOf());
+      assert.equal(element._computeDateStr('foo', 'h:mm A'), '');
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('Within 24 hours on same day', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-07-29 15:34:14.985000000',
+          '15:34',
+          '15:34',
+          'Jul 29, 2015, 15:34:14', done);
     });
 
-    /**
-     * Parse server-formatter date and normalize into current timezone.
-     */
-    function normalizedDate(dateStr) {
-      const d = util.parseDate(dateStr);
-      d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
-      return d;
-    }
-
-    function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
-        expectedTooltip, done) {
-      // Normalize and convert the date to mimic server response.
-      dateStr = normalizedDate(dateStr)
-          .toJSON()
-          .replace('T', ' ')
-          .slice(0, -1);
-      sandbox.useFakeTimers(normalizedDate(nowStr).getTime());
-      element.dateStr = dateStr;
-      flush(() => {
-        const span = element.shadowRoot
-            .querySelector('span');
-        assert.equal(span.textContent.trim(), expected);
-        assert.equal(element.title, expectedTooltip);
-        element.showDateAndTime = true;
-        flushAsynchronousOperations();
-        assert.equal(span.textContent.trim(), expectedWithDateAndTime);
-        done();
-      });
-    }
-
-    function stubRestAPI(preferences) {
-      const loggedInPromise = Promise.resolve(preferences !== null);
-      const preferencesPromise = Promise.resolve(preferences);
-      stub('gr-rest-api-interface', {
-        getLoggedIn: sinon.stub().returns(loggedInPromise),
-        getPreferences: sinon.stub().returns(preferencesPromise),
-      });
-      return Promise.all([loggedInPromise, preferencesPromise]);
-    }
-
-    suite('STD + 24 hours time format preference', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'STD',
-        relative_date_in_change_table: false,
-      }).then(() => {
-        element = fixture('basic');
-        sandbox.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      }));
-
-      test('invalid dates are quietly rejected', () => {
-        assert.notOk((new Date('foo')).valueOf());
-        assert.equal(element._computeDateStr('foo', 'h:mm A'), '');
-      });
-
-      test('Within 24 hours on same day', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-07-29 15:34:14.985000000',
-            '15:34',
-            '15:34',
-            'Jul 29, 2015, 15:34:14', done);
-      });
-
-      test('Within 24 hours on different days', done => {
-        testDates('2015-07-29 03:34:14.985000000',
-            '2015-07-28 20:25:14.985000000',
-            'Jul 28',
-            'Jul 28 20:25',
-            'Jul 28, 2015, 20:25:14', done);
-      });
-
-      test('More than 24 hours but less than six months', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-06-15 03:25:14.985000000',
-            'Jun 15',
-            'Jun 15 03:25',
-            'Jun 15, 2015, 03:25:14', done);
-      });
-
-      test('More than six months', done => {
-        testDates('2015-09-15 20:34:00.000000000',
-            '2015-01-15 03:25:00.000000000',
-            'Jan 15, 2015',
-            'Jan 15, 2015 03:25',
-            'Jan 15, 2015, 03:25:00', done);
-      });
+    test('Within 24 hours on different days', done => {
+      testDates('2015-07-29 03:34:14.985000000',
+          '2015-07-28 20:25:14.985000000',
+          'Jul 28',
+          'Jul 28 20:25',
+          'Jul 28, 2015, 20:25:14', done);
     });
 
-    suite('US + 24 hours time format preference', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'US',
-        relative_date_in_change_table: false,
-      }).then(() => {
-        element = fixture('basic');
-        sandbox.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      }));
-
-      test('Within 24 hours on same day', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-07-29 15:34:14.985000000',
-            '15:34',
-            '15:34',
-            '07/29/15, 15:34:14', done);
-      });
-
-      test('Within 24 hours on different days', done => {
-        testDates('2015-07-29 03:34:14.985000000',
-            '2015-07-28 20:25:14.985000000',
-            '07/28',
-            '07/28 20:25',
-            '07/28/15, 20:25:14', done);
-      });
-
-      test('More than 24 hours but less than six months', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-06-15 03:25:14.985000000',
-            '06/15',
-            '06/15 03:25',
-            '06/15/15, 03:25:14', done);
-      });
+    test('More than 24 hours but less than six months', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-06-15 03:25:14.985000000',
+          'Jun 15',
+          'Jun 15 03:25',
+          'Jun 15, 2015, 03:25:14', done);
     });
 
-    suite('ISO + 24 hours time format preference', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'ISO',
-        relative_date_in_change_table: false,
-      }).then(() => {
-        element = fixture('basic');
-        sandbox.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      }));
-
-      test('Within 24 hours on same day', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-07-29 15:34:14.985000000',
-            '15:34',
-            '15:34',
-            '2015-07-29, 15:34:14', done);
-      });
-
-      test('Within 24 hours on different days', done => {
-        testDates('2015-07-29 03:34:14.985000000',
-            '2015-07-28 20:25:14.985000000',
-            '07-28',
-            '07-28 20:25',
-            '2015-07-28, 20:25:14', done);
-      });
-
-      test('More than 24 hours but less than six months', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-06-15 03:25:14.985000000',
-            '06-15',
-            '06-15 03:25',
-            '2015-06-15, 03:25:14', done);
-      });
-    });
-
-    suite('EURO + 24 hours time format preference', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'EURO',
-        relative_date_in_change_table: false,
-      }).then(() => {
-        element = fixture('basic');
-        sandbox.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      }));
-
-      test('Within 24 hours on same day', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-07-29 15:34:14.985000000',
-            '15:34',
-            '15:34',
-            '29.07.2015, 15:34:14', done);
-      });
-
-      test('Within 24 hours on different days', done => {
-        testDates('2015-07-29 03:34:14.985000000',
-            '2015-07-28 20:25:14.985000000',
-            '28. Jul',
-            '28. Jul 20:25',
-            '28.07.2015, 20:25:14', done);
-      });
-
-      test('More than 24 hours but less than six months', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-06-15 03:25:14.985000000',
-            '15. Jun',
-            '15. Jun 03:25',
-            '15.06.2015, 03:25:14', done);
-      });
-    });
-
-    suite('UK + 24 hours time format preference', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'UK',
-        relative_date_in_change_table: false,
-      }).then(() => {
-        element = fixture('basic');
-        sandbox.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      }));
-
-      test('Within 24 hours on same day', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-07-29 15:34:14.985000000',
-            '15:34',
-            '15:34',
-            '29/07/2015, 15:34:14', done);
-      });
-
-      test('Within 24 hours on different days', done => {
-        testDates('2015-07-29 03:34:14.985000000',
-            '2015-07-28 20:25:14.985000000',
-            '28/07',
-            '28/07 20:25',
-            '28/07/2015, 20:25:14', done);
-      });
-
-      test('More than 24 hours but less than six months', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-06-15 03:25:14.985000000',
-            '15/06',
-            '15/06 03:25',
-            '15/06/2015, 03:25:14', done);
-      });
-    });
-
-    suite('STD + 12 hours time format preference', () => {
-      setup(() =>
-        // relative_date_in_change_table is not set when false.
-        stubRestAPI(
-            {time_format: 'HHMM_12', date_format: 'STD'}
-        ).then(() => {
-          element = fixture('basic');
-          sandbox.stub(element, '_getUtcOffsetString').returns('');
-          return element._loadPreferences();
-        })
-      );
-
-      test('Within 24 hours on same day', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-07-29 15:34:14.985000000',
-            '3:34 PM',
-            '3:34 PM',
-            'Jul 29, 2015, 3:34:14 PM', done);
-      });
-    });
-
-    suite('US + 12 hours time format preference', () => {
-      setup(() =>
-        // relative_date_in_change_table is not set when false.
-        stubRestAPI(
-            {time_format: 'HHMM_12', date_format: 'US'}
-        ).then(() => {
-          element = fixture('basic');
-          sandbox.stub(element, '_getUtcOffsetString').returns('');
-          return element._loadPreferences();
-        })
-      );
-
-      test('Within 24 hours on same day', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-07-29 15:34:14.985000000',
-            '3:34 PM',
-            '3:34 PM',
-            '07/29/15, 3:34:14 PM', done);
-      });
-    });
-
-    suite('ISO + 12 hours time format preference', () => {
-      setup(() =>
-        // relative_date_in_change_table is not set when false.
-        stubRestAPI(
-            {time_format: 'HHMM_12', date_format: 'ISO'}
-        ).then(() => {
-          element = fixture('basic');
-          sandbox.stub(element, '_getUtcOffsetString').returns('');
-          return element._loadPreferences();
-        })
-      );
-
-      test('Within 24 hours on same day', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-07-29 15:34:14.985000000',
-            '3:34 PM',
-            '3:34 PM',
-            '2015-07-29, 3:34:14 PM', done);
-      });
-    });
-
-    suite('EURO + 12 hours time format preference', () => {
-      setup(() =>
-        // relative_date_in_change_table is not set when false.
-        stubRestAPI(
-            {time_format: 'HHMM_12', date_format: 'EURO'}
-        ).then(() => {
-          element = fixture('basic');
-          sandbox.stub(element, '_getUtcOffsetString').returns('');
-          return element._loadPreferences();
-        })
-      );
-
-      test('Within 24 hours on same day', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-07-29 15:34:14.985000000',
-            '3:34 PM',
-            '3:34 PM',
-            '29.07.2015, 3:34:14 PM', done);
-      });
-    });
-
-    suite('UK + 12 hours time format preference', () => {
-      setup(() =>
-        // relative_date_in_change_table is not set when false.
-        stubRestAPI(
-            {time_format: 'HHMM_12', date_format: 'UK'}
-        ).then(() => {
-          element = fixture('basic');
-          sandbox.stub(element, '_getUtcOffsetString').returns('');
-          return element._loadPreferences();
-        })
-      );
-
-      test('Within 24 hours on same day', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-07-29 15:34:14.985000000',
-            '3:34 PM',
-            '3:34 PM',
-            '29/07/2015, 3:34:14 PM', done);
-      });
-    });
-
-    suite('relative date preference', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_12',
-        date_format: 'STD',
-        relative_date_in_change_table: true,
-      }).then(() => {
-        element = fixture('basic');
-        sandbox.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      }));
-
-      test('Within 24 hours on same day', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-07-29 15:34:14.985000000',
-            '5 hours ago',
-            '5 hours ago',
-            'Jul 29, 2015, 3:34:14 PM', done);
-      });
-
-      test('More than six months', done => {
-        testDates('2015-09-15 20:34:00.000000000',
-            '2015-01-15 03:25:00.000000000',
-            '8 months ago',
-            '8 months ago',
-            'Jan 15, 2015, 3:25:00 AM', done);
-      });
-    });
-
-    suite('logged in', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_12',
-        date_format: 'US',
-        relative_date_in_change_table: true,
-      }).then(() => {
-        element = fixture('basic');
-        return element._loadPreferences();
-      }));
-
-      test('Preferences are respected', () => {
-        assert.equal(element._timeFormat, 'h:mm A');
-        assert.equal(element._dateFormat.short, 'MM/DD');
-        assert.equal(element._dateFormat.full, 'MM/DD/YY');
-        assert.isTrue(element._relative);
-      });
-    });
-
-    suite('logged out', () => {
-      setup(() => stubRestAPI(null).then(() => {
-        element = fixture('basic');
-        return element._loadPreferences();
-      }));
-
-      test('Default preferences are respected', () => {
-        assert.equal(element._timeFormat, 'HH:mm');
-        assert.equal(element._dateFormat.short, 'MMM DD');
-        assert.equal(element._dateFormat.full, 'MMM DD, YYYY');
-        assert.isFalse(element._relative);
-      });
+    test('More than six months', done => {
+      testDates('2015-09-15 20:34:00.000000000',
+          '2015-01-15 03:25:00.000000000',
+          'Jan 15, 2015',
+          'Jan 15, 2015 03:25',
+          'Jan 15, 2015, 03:25:00', done);
     });
   });
+
+  suite('US + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'US',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = fixture('basic');
+      sandbox.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-07-29 15:34:14.985000000',
+          '15:34',
+          '15:34',
+          '07/29/15, 15:34:14', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      testDates('2015-07-29 03:34:14.985000000',
+          '2015-07-28 20:25:14.985000000',
+          '07/28',
+          '07/28 20:25',
+          '07/28/15, 20:25:14', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-06-15 03:25:14.985000000',
+          '06/15',
+          '06/15 03:25',
+          '06/15/15, 03:25:14', done);
+    });
+  });
+
+  suite('ISO + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'ISO',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = fixture('basic');
+      sandbox.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-07-29 15:34:14.985000000',
+          '15:34',
+          '15:34',
+          '2015-07-29, 15:34:14', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      testDates('2015-07-29 03:34:14.985000000',
+          '2015-07-28 20:25:14.985000000',
+          '07-28',
+          '07-28 20:25',
+          '2015-07-28, 20:25:14', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-06-15 03:25:14.985000000',
+          '06-15',
+          '06-15 03:25',
+          '2015-06-15, 03:25:14', done);
+    });
+  });
+
+  suite('EURO + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'EURO',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = fixture('basic');
+      sandbox.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-07-29 15:34:14.985000000',
+          '15:34',
+          '15:34',
+          '29.07.2015, 15:34:14', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      testDates('2015-07-29 03:34:14.985000000',
+          '2015-07-28 20:25:14.985000000',
+          '28. Jul',
+          '28. Jul 20:25',
+          '28.07.2015, 20:25:14', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-06-15 03:25:14.985000000',
+          '15. Jun',
+          '15. Jun 03:25',
+          '15.06.2015, 03:25:14', done);
+    });
+  });
+
+  suite('UK + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'UK',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = fixture('basic');
+      sandbox.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-07-29 15:34:14.985000000',
+          '15:34',
+          '15:34',
+          '29/07/2015, 15:34:14', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      testDates('2015-07-29 03:34:14.985000000',
+          '2015-07-28 20:25:14.985000000',
+          '28/07',
+          '28/07 20:25',
+          '28/07/2015, 20:25:14', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-06-15 03:25:14.985000000',
+          '15/06',
+          '15/06 03:25',
+          '15/06/2015, 03:25:14', done);
+    });
+  });
+
+  suite('STD + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'STD'}
+      ).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-07-29 15:34:14.985000000',
+          '3:34 PM',
+          '3:34 PM',
+          'Jul 29, 2015, 3:34:14 PM', done);
+    });
+  });
+
+  suite('US + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'US'}
+      ).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-07-29 15:34:14.985000000',
+          '3:34 PM',
+          '3:34 PM',
+          '07/29/15, 3:34:14 PM', done);
+    });
+  });
+
+  suite('ISO + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'ISO'}
+      ).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-07-29 15:34:14.985000000',
+          '3:34 PM',
+          '3:34 PM',
+          '2015-07-29, 3:34:14 PM', done);
+    });
+  });
+
+  suite('EURO + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'EURO'}
+      ).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-07-29 15:34:14.985000000',
+          '3:34 PM',
+          '3:34 PM',
+          '29.07.2015, 3:34:14 PM', done);
+    });
+  });
+
+  suite('UK + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'UK'}
+      ).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-07-29 15:34:14.985000000',
+          '3:34 PM',
+          '3:34 PM',
+          '29/07/2015, 3:34:14 PM', done);
+    });
+  });
+
+  suite('relative date preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_12',
+      date_format: 'STD',
+      relative_date_in_change_table: true,
+    }).then(() => {
+      element = fixture('basic');
+      sandbox.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-07-29 15:34:14.985000000',
+          '5 hours ago',
+          '5 hours ago',
+          'Jul 29, 2015, 3:34:14 PM', done);
+    });
+
+    test('More than six months', done => {
+      testDates('2015-09-15 20:34:00.000000000',
+          '2015-01-15 03:25:00.000000000',
+          '8 months ago',
+          '8 months ago',
+          'Jan 15, 2015, 3:25:00 AM', done);
+    });
+  });
+
+  suite('logged in', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_12',
+      date_format: 'US',
+      relative_date_in_change_table: true,
+    }).then(() => {
+      element = fixture('basic');
+      return element._loadPreferences();
+    }));
+
+    test('Preferences are respected', () => {
+      assert.equal(element._timeFormat, 'h:mm A');
+      assert.equal(element._dateFormat.short, 'MM/DD');
+      assert.equal(element._dateFormat.full, 'MM/DD/YY');
+      assert.isTrue(element._relative);
+    });
+  });
+
+  suite('logged out', () => {
+    setup(() => stubRestAPI(null).then(() => {
+      element = fixture('basic');
+      return element._loadPreferences();
+    }));
+
+    test('Default preferences are respected', () => {
+      assert.equal(element._timeFormat, 'HH:mm');
+      assert.equal(element._dateFormat.short, 'MMM DD');
+      assert.equal(element._dateFormat.full, 'MMM DD, YYYY');
+      assert.isFalse(element._relative);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
index 8d00452..8141863 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
@@ -14,85 +14,94 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../gr-button/gr-button.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-dialog_html.js';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrDialog extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
    */
-  class GrDialog extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-dialog'; }
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
-
-    static get properties() {
-      return {
-        confirmLabel: {
-          type: String,
-          value: 'Confirm',
-        },
-        // Supplying an empty cancel label will hide the button completely.
-        cancelLabel: {
-          type: String,
-          value: 'Cancel',
-        },
-        disabled: {
-          type: Boolean,
-          value: false,
-        },
-        confirmOnEnter: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
-
-    /** @override */
-    ready() {
-      super.ready();
-      this._ensureAttribute('role', 'dialog');
-    }
-
-    _handleConfirm(e) {
-      if (this.disabled) { return; }
-
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    }
-
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    }
-
-    _handleKeydown(e) {
-      if (this.confirmOnEnter && e.keyCode === 13) { this._handleConfirm(e); }
-    }
-
-    resetFocus() {
-      this.$.confirm.focus();
-    }
-
-    _computeCancelClass(cancelLabel) {
-      return cancelLabel.length ? '' : 'hidden';
-    }
+  static get properties() {
+    return {
+      confirmLabel: {
+        type: String,
+        value: 'Confirm',
+      },
+      // Supplying an empty cancel label will hide the button completely.
+      cancelLabel: {
+        type: String,
+        value: 'Cancel',
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+      },
+      confirmOnEnter: {
+        type: Boolean,
+        value: false,
+      },
+    };
   }
 
-  customElements.define(GrDialog.is, GrDialog);
-})();
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'dialog');
+  }
+
+  _handleConfirm(e) {
+    if (this.disabled) { return; }
+
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('confirm', null, {bubbles: false});
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.fire('cancel', null, {bubbles: false});
+  }
+
+  _handleKeydown(e) {
+    if (this.confirmOnEnter && e.keyCode === 13) { this._handleConfirm(e); }
+  }
+
+  resetFocus() {
+    this.$.confirm.focus();
+  }
+
+  _computeCancelClass(cancelLabel) {
+    return cancelLabel.length ? '' : 'hidden';
+  }
+}
+
+customElements.define(GrDialog.is, GrDialog);
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js
index 475f6e2..ba85fd4 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js
@@ -1,27 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-dialog">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         color: var(--primary-text-color);
@@ -73,14 +68,12 @@
       </main>
       <footer>
         <slot name="footer"></slot>
-        <gr-button id="cancel" class$="[[_computeCancelClass(cancelLabel)]]" link on-click="_handleCancelTap">
+        <gr-button id="cancel" class\$="[[_computeCancelClass(cancelLabel)]]" link="" on-click="_handleCancelTap">
           [[cancelLabel]]
         </gr-button>
-        <gr-button id="confirm" link primary on-click="_handleConfirm" disabled="[[disabled]]">
+        <gr-button id="confirm" link="" primary="" on-click="_handleConfirm" disabled="[[disabled]]">
           [[confirmLabel]]
         </gr-button>
       </footer>
     </div>
-  </template>
-  <script src="gr-dialog.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
index ad93962..87e31a0 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dialog</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,65 +40,67 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-dialog tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-dialog.js';
+suite('gr-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('events', done => {
-      let numEvents = 0;
-      function handler() { if (++numEvents == 2) { done(); } }
-
-      element.addEventListener('confirm', handler);
-      element.addEventListener('cancel', handler);
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('gr-button[primary]'));
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('gr-button:not([primary])'));
-    });
-
-    test('confirmOnEnter', () => {
-      element.confirmOnEnter = false;
-      const handleConfirmStub = sandbox.stub(element, '_handleConfirm');
-      const handleKeydownSpy = sandbox.spy(element, '_handleKeydown');
-      MockInteractions.pressAndReleaseKeyOn(element.shadowRoot
-          .querySelector('main'),
-      13, null, 'enter');
-      flushAsynchronousOperations();
-
-      assert.isTrue(handleKeydownSpy.called);
-      assert.isFalse(handleConfirmStub.called);
-
-      element.confirmOnEnter = true;
-      MockInteractions.pressAndReleaseKeyOn(element.shadowRoot
-          .querySelector('main'),
-      13, null, 'enter');
-      flushAsynchronousOperations();
-
-      assert.isTrue(handleConfirmStub.called);
-    });
-
-    test('resetFocus', () => {
-      const focusStub = sandbox.stub(element.$.confirm, 'focus');
-      element.resetFocus();
-      assert.isTrue(focusStub.calledOnce);
-    });
-
-    test('empty cancel label hides cancel btn', () => {
-      assert.isFalse(isHidden(element.$.cancel));
-      element.cancelLabel = '';
-      flushAsynchronousOperations();
-
-      assert.isTrue(isHidden(element.$.cancel));
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('events', done => {
+    let numEvents = 0;
+    function handler() { if (++numEvents == 2) { done(); } }
+
+    element.addEventListener('confirm', handler);
+    element.addEventListener('cancel', handler);
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button[primary]'));
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button:not([primary])'));
+  });
+
+  test('confirmOnEnter', () => {
+    element.confirmOnEnter = false;
+    const handleConfirmStub = sandbox.stub(element, '_handleConfirm');
+    const handleKeydownSpy = sandbox.spy(element, '_handleKeydown');
+    MockInteractions.pressAndReleaseKeyOn(element.shadowRoot
+        .querySelector('main'),
+    13, null, 'enter');
+    flushAsynchronousOperations();
+
+    assert.isTrue(handleKeydownSpy.called);
+    assert.isFalse(handleConfirmStub.called);
+
+    element.confirmOnEnter = true;
+    MockInteractions.pressAndReleaseKeyOn(element.shadowRoot
+        .querySelector('main'),
+    13, null, 'enter');
+    flushAsynchronousOperations();
+
+    assert.isTrue(handleConfirmStub.called);
+  });
+
+  test('resetFocus', () => {
+    const focusStub = sandbox.stub(element.$.confirm, 'focus');
+    element.resetFocus();
+    assert.isTrue(focusStub.calledOnce);
+  });
+
+  test('empty cancel label hides cancel btn', () => {
+    assert.isFalse(isHidden(element.$.cancel));
+    element.cancelLabel = '';
+    flushAsynchronousOperations();
+
+    assert.isTrue(isHidden(element.$.cancel));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
index c408e5a..00f9078 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
@@ -14,72 +14,82 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrDiffPreferences extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-diff-preferences'; }
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/shared-styles.js';
+import '../gr-button/gr-button.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-select/gr-select.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-preferences_html.js';
 
-    static get properties() {
-      return {
-        hasUnsavedChanges: {
-          type: Boolean,
-          notify: true,
-          value: false,
-        },
+/** @extends Polymer.Element */
+class GrDiffPreferences extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-        /** @type {?} */
-        diffPrefs: Object,
-      };
-    }
+  static get is() { return 'gr-diff-preferences'; }
 
-    loadData() {
-      return this.$.restAPI.getDiffPreferences().then(prefs => {
-        this.diffPrefs = prefs;
-      });
-    }
+  static get properties() {
+    return {
+      hasUnsavedChanges: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
 
-    _handleDiffPrefsChanged() {
-      this.hasUnsavedChanges = true;
-    }
-
-    _handleLineWrappingTap() {
-      this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
-      this._handleDiffPrefsChanged();
-    }
-
-    _handleShowTabsTap() {
-      this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
-      this._handleDiffPrefsChanged();
-    }
-
-    _handleShowTrailingWhitespaceTap() {
-      this.set('diffPrefs.show_whitespace_errors',
-          this.$.showTrailingWhitespaceInput.checked);
-      this._handleDiffPrefsChanged();
-    }
-
-    _handleSyntaxHighlightTap() {
-      this.set('diffPrefs.syntax_highlighting',
-          this.$.syntaxHighlightInput.checked);
-      this._handleDiffPrefsChanged();
-    }
-
-    _handleAutomaticReviewTap() {
-      this.set('diffPrefs.manual_review',
-          !this.$.automaticReviewInput.checked);
-      this._handleDiffPrefsChanged();
-    }
-
-    save() {
-      return this.$.restAPI.saveDiffPreferences(this.diffPrefs).then(res => {
-        this.hasUnsavedChanges = false;
-      });
-    }
+      /** @type {?} */
+      diffPrefs: Object,
+    };
   }
 
-  customElements.define(GrDiffPreferences.is, GrDiffPreferences);
-})();
+  loadData() {
+    return this.$.restAPI.getDiffPreferences().then(prefs => {
+      this.diffPrefs = prefs;
+    });
+  }
+
+  _handleDiffPrefsChanged() {
+    this.hasUnsavedChanges = true;
+  }
+
+  _handleLineWrappingTap() {
+    this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
+    this._handleDiffPrefsChanged();
+  }
+
+  _handleShowTabsTap() {
+    this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
+    this._handleDiffPrefsChanged();
+  }
+
+  _handleShowTrailingWhitespaceTap() {
+    this.set('diffPrefs.show_whitespace_errors',
+        this.$.showTrailingWhitespaceInput.checked);
+    this._handleDiffPrefsChanged();
+  }
+
+  _handleSyntaxHighlightTap() {
+    this.set('diffPrefs.syntax_highlighting',
+        this.$.syntaxHighlightInput.checked);
+    this._handleDiffPrefsChanged();
+  }
+
+  _handleAutomaticReviewTap() {
+    this.set('diffPrefs.manual_review',
+        !this.$.automaticReviewInput.checked);
+    this._handleDiffPrefsChanged();
+  }
+
+  save() {
+    return this.$.restAPI.saveDiffPreferences(this.diffPrefs).then(res => {
+      this.hasUnsavedChanges = false;
+    });
+  }
+}
+
+customElements.define(GrDiffPreferences.is, GrDiffPreferences);
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.js
index 367e30c..7869c2c 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-select/gr-select.html">
-
-<dom-module id="gr-diff-preferences">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -34,12 +27,8 @@
       <section>
         <span class="title">Context</span>
         <span class="value">
-          <gr-select
-              id="contextSelect"
-              bind-value="{{diffPrefs.context}}">
-            <select
-                on-keypress="_handleDiffPrefsChanged"
-                on-change="_handleDiffPrefsChanged">
+          <gr-select id="contextSelect" bind-value="{{diffPrefs.context}}">
+            <select on-keypress="_handleDiffPrefsChanged" on-change="_handleDiffPrefsChanged">
               <option value="3">3 lines</option>
               <option value="10">10 lines</option>
               <option value="25">25 lines</option>
@@ -54,117 +43,55 @@
       <section>
         <span class="title">Fit to screen</span>
         <span class="value">
-          <input
-              id="lineWrappingInput"
-              type="checkbox"
-              checked$="[[diffPrefs.line_wrapping]]"
-              on-change="_handleLineWrappingTap">
+          <input id="lineWrappingInput" type="checkbox" checked\$="[[diffPrefs.line_wrapping]]" on-change="_handleLineWrappingTap">
         </span>
       </section>
       <section>
         <span class="title">Diff width</span>
         <span class="value">
-          <iron-input
-              type="number"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{diffPrefs.line_length}}"
-              on-keypress="_handleDiffPrefsChanged"
-              on-change="_handleDiffPrefsChanged">
-            <input
-                is="iron-input"
-                type="number"
-                id="columnsInput"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{diffPrefs.line_length}}"
-                on-keypress="_handleDiffPrefsChanged"
-                on-change="_handleDiffPrefsChanged">
+          <iron-input type="number" prevent-invalid-input="" allowed-pattern="[0-9]" bind-value="{{diffPrefs.line_length}}" on-keypress="_handleDiffPrefsChanged" on-change="_handleDiffPrefsChanged">
+            <input is="iron-input" type="number" id="columnsInput" prevent-invalid-input="" allowed-pattern="[0-9]" bind-value="{{diffPrefs.line_length}}" on-keypress="_handleDiffPrefsChanged" on-change="_handleDiffPrefsChanged">
           </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Tab width</span>
         <span class="value">
-          <iron-input
-              type="number"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{diffPrefs.tab_size}}"
-              on-keypress="_handleDiffPrefsChanged"
-              on-change="_handleDiffPrefsChanged">
-            <input
-                is="iron-input"
-                type="number"
-                id="tabSizeInput"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{diffPrefs.tab_size}}"
-                on-keypress="_handleDiffPrefsChanged"
-                on-change="_handleDiffPrefsChanged">
+          <iron-input type="number" prevent-invalid-input="" allowed-pattern="[0-9]" bind-value="{{diffPrefs.tab_size}}" on-keypress="_handleDiffPrefsChanged" on-change="_handleDiffPrefsChanged">
+            <input is="iron-input" type="number" id="tabSizeInput" prevent-invalid-input="" allowed-pattern="[0-9]" bind-value="{{diffPrefs.tab_size}}" on-keypress="_handleDiffPrefsChanged" on-change="_handleDiffPrefsChanged">
           </iron-input>
         </span>
       </section>
-      <section hidden$="[[!diffPrefs.font_size]]">
+      <section hidden\$="[[!diffPrefs.font_size]]">
         <span class="title">Font size</span>
         <span class="value">
-          <iron-input
-              type="number"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{diffPrefs.font_size}}"
-              on-keypress="_handleDiffPrefsChanged"
-              on-change="_handleDiffPrefsChanged">
-            <input
-                is="iron-input"
-                type="number"
-                id="fontSizeInput"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{diffPrefs.font_size}}"
-                on-keypress="_handleDiffPrefsChanged"
-                on-change="_handleDiffPrefsChanged">
+          <iron-input type="number" prevent-invalid-input="" allowed-pattern="[0-9]" bind-value="{{diffPrefs.font_size}}" on-keypress="_handleDiffPrefsChanged" on-change="_handleDiffPrefsChanged">
+            <input is="iron-input" type="number" id="fontSizeInput" prevent-invalid-input="" allowed-pattern="[0-9]" bind-value="{{diffPrefs.font_size}}" on-keypress="_handleDiffPrefsChanged" on-change="_handleDiffPrefsChanged">
           </iron-input>
         </span>
       </section>
       <section>
         <span class="title">Show tabs</span>
         <span class="value">
-          <input
-              id="showTabsInput"
-              type="checkbox"
-              checked$="[[diffPrefs.show_tabs]]"
-              on-change="_handleShowTabsTap">
+          <input id="showTabsInput" type="checkbox" checked\$="[[diffPrefs.show_tabs]]" on-change="_handleShowTabsTap">
         </span>
       </section>
       <section>
         <span class="title">Show trailing whitespace</span>
         <span class="value">
-          <input
-              id="showTrailingWhitespaceInput"
-              type="checkbox"
-              checked$="[[diffPrefs.show_whitespace_errors]]"
-              on-change="_handleShowTrailingWhitespaceTap">
+          <input id="showTrailingWhitespaceInput" type="checkbox" checked\$="[[diffPrefs.show_whitespace_errors]]" on-change="_handleShowTrailingWhitespaceTap">
         </span>
       </section>
       <section>
         <span class="title">Syntax highlighting</span>
         <span class="value">
-          <input
-              id="syntaxHighlightInput"
-              type="checkbox"
-              checked$="[[diffPrefs.syntax_highlighting]]"
-              on-change="_handleSyntaxHighlightTap">
+          <input id="syntaxHighlightInput" type="checkbox" checked\$="[[diffPrefs.syntax_highlighting]]" on-change="_handleSyntaxHighlightTap">
         </span>
       </section>
       <section>
         <span class="title">Automatically mark viewed files reviewed</span>
         <span class="value">
-          <input
-              id="automaticReviewInput"
-              type="checkbox"
-              checked$="[[!diffPrefs.manual_review]]"
-              on-change="_handleAutomaticReviewTap">
+          <input id="automaticReviewInput" type="checkbox" checked\$="[[!diffPrefs.manual_review]]" on-change="_handleAutomaticReviewTap">
         </span>
       </section>
       <section>
@@ -172,12 +99,10 @@
           <span class="title">Ignore Whitespace</span>
           <span class="value">
             <gr-select bind-value="{{diffPrefs.ignore_whitespace}}">
-              <select
-                  on-keypress="_handleDiffPrefsChanged"
-                  on-change="_handleDiffPrefsChanged">
+              <select on-keypress="_handleDiffPrefsChanged" on-change="_handleDiffPrefsChanged">
                 <option value="IGNORE_NONE">None</option>
                 <option value="IGNORE_TRAILING">Trailing</option>
-                <option value="IGNORE_LEADING_AND_TRAILING">Leading & trailing</option>
+                <option value="IGNORE_LEADING_AND_TRAILING">Leading &amp; trailing</option>
                 <option value="IGNORE_ALL">All</option>
               </select>
             </gr-select>
@@ -186,6 +111,4 @@
       </section>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-diff-preferences.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
index 3c2a7d1..0387b89 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-preferences</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-diff-preferences.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-diff-preferences.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-diff-preferences.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,93 +40,95 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-preferences tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let diffPreferences;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-diff-preferences.js';
+suite('gr-diff-preferences tests', () => {
+  let element;
+  let sandbox;
+  let diffPreferences;
 
-    function valueOf(title, fieldsetid) {
-      const sections = element.$[fieldsetid].querySelectorAll('section');
-      let titleEl;
-      for (let i = 0; i < sections.length; i++) {
-        titleEl = sections[i].querySelector('.title');
-        if (titleEl.textContent.trim() === title) {
-          return sections[i].querySelector('.value');
-        }
+  function valueOf(title, fieldsetid) {
+    const sections = element.$[fieldsetid].querySelectorAll('section');
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl.textContent.trim() === title) {
+        return sections[i].querySelector('.value');
       }
     }
+  }
 
-    setup(() => {
-      diffPreferences = {
-        context: 10,
-        line_wrapping: false,
-        line_length: 100,
-        tab_size: 8,
-        font_size: 12,
-        show_tabs: true,
-        show_whitespace_errors: true,
-        syntax_highlighting: true,
-        manual_review: false,
-        ignore_whitespace: 'IGNORE_NONE',
-      };
+  setup(() => {
+    diffPreferences = {
+      context: 10,
+      line_wrapping: false,
+      line_length: 100,
+      tab_size: 8,
+      font_size: 12,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      manual_review: false,
+      ignore_whitespace: 'IGNORE_NONE',
+    };
 
-      stub('gr-rest-api-interface', {
-        getDiffPreferences() {
-          return Promise.resolve(diffPreferences);
-        },
-      });
-
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      return element.loadData();
+    stub('gr-rest-api-interface', {
+      getDiffPreferences() {
+        return Promise.resolve(diffPreferences);
+      },
     });
 
-    teardown(() => { sandbox.restore(); });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    return element.loadData();
+  });
 
-    test('renders', () => {
-      // Rendered with the expected preferences selected.
-      assert.equal(valueOf('Context', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.context);
-      assert.equal(valueOf('Fit to screen', 'diffPreferences')
-          .firstElementChild.checked, diffPreferences.line_wrapping);
-      assert.equal(valueOf('Diff width', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.line_length);
-      assert.equal(valueOf('Tab width', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.tab_size);
-      assert.equal(valueOf('Font size', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.font_size);
-      assert.equal(valueOf('Show tabs', 'diffPreferences')
-          .firstElementChild.checked, diffPreferences.show_tabs);
-      assert.equal(valueOf('Show trailing whitespace', 'diffPreferences')
-          .firstElementChild.checked, diffPreferences.show_whitespace_errors);
-      assert.equal(valueOf('Syntax highlighting', 'diffPreferences')
-          .firstElementChild.checked, diffPreferences.syntax_highlighting);
-      assert.equal(
-          valueOf('Automatically mark viewed files reviewed', 'diffPreferences')
-              .firstElementChild.checked, !diffPreferences.manual_review);
-      assert.equal(valueOf('Ignore Whitespace', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.ignore_whitespace);
+  teardown(() => { sandbox.restore(); });
 
+  test('renders', () => {
+    // Rendered with the expected preferences selected.
+    assert.equal(valueOf('Context', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.context);
+    assert.equal(valueOf('Fit to screen', 'diffPreferences')
+        .firstElementChild.checked, diffPreferences.line_wrapping);
+    assert.equal(valueOf('Diff width', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.line_length);
+    assert.equal(valueOf('Tab width', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.tab_size);
+    assert.equal(valueOf('Font size', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.font_size);
+    assert.equal(valueOf('Show tabs', 'diffPreferences')
+        .firstElementChild.checked, diffPreferences.show_tabs);
+    assert.equal(valueOf('Show trailing whitespace', 'diffPreferences')
+        .firstElementChild.checked, diffPreferences.show_whitespace_errors);
+    assert.equal(valueOf('Syntax highlighting', 'diffPreferences')
+        .firstElementChild.checked, diffPreferences.syntax_highlighting);
+    assert.equal(
+        valueOf('Automatically mark viewed files reviewed', 'diffPreferences')
+            .firstElementChild.checked, !diffPreferences.manual_review);
+    assert.equal(valueOf('Ignore Whitespace', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.ignore_whitespace);
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('save changes', () => {
+    sandbox.stub(element.$.restAPI, 'saveDiffPreferences')
+        .returns(Promise.resolve());
+    const showTrailingWhitespaceCheckbox =
+        valueOf('Show trailing whitespace', 'diffPreferences')
+            .firstElementChild;
+    showTrailingWhitespaceCheckbox.checked = false;
+    element._handleShowTrailingWhitespaceTap();
+
+    assert.isTrue(element.hasUnsavedChanges);
+
+    // Save the change.
+    return element.save().then(() => {
       assert.isFalse(element.hasUnsavedChanges);
     });
-
-    test('save changes', () => {
-      sandbox.stub(element.$.restAPI, 'saveDiffPreferences')
-          .returns(Promise.resolve());
-      const showTrailingWhitespaceCheckbox =
-          valueOf('Show trailing whitespace', 'diffPreferences')
-              .firstElementChild;
-      showTrailingWhitespaceCheckbox.checked = false;
-      element._handleShowTrailingWhitespaceTap();
-
-      assert.isTrue(element.hasUnsavedChanges);
-
-      // Save the change.
-      return element.save().then(() => {
-        assert.isFalse(element.hasUnsavedChanges);
-      });
-    });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
index 04df531..e9befaf 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
@@ -14,82 +14,93 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @extends Polymer.Element
-   */
-  class GrDownloadCommands extends Polymer.mixinBehaviors( [
-    Gerrit.RESTClientBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-download-commands'; }
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import '@polymer/paper-tabs/paper-tabs.js';
+import '../gr-shell-command/gr-shell-command.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-download-commands_html.js';
 
-    static get properties() {
-      return {
-        commands: Array,
-        _loggedIn: {
-          type: Boolean,
-          value: false,
-          observer: '_loggedInChanged',
-        },
-        schemes: Array,
-        selectedScheme: {
-          type: String,
-          notify: true,
-        },
-      };
-    }
+/**
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @extends Polymer.Element
+ */
+class GrDownloadCommands extends mixinBehaviors( [
+  Gerrit.RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-      });
-    }
+  static get is() { return 'gr-download-commands'; }
 
-    focusOnCopy() {
-      this.shadowRoot.querySelector('gr-shell-command').focusOnCopy();
-    }
+  static get properties() {
+    return {
+      commands: Array,
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+        observer: '_loggedInChanged',
+      },
+      schemes: Array,
+      selectedScheme: {
+        type: String,
+        notify: true,
+      },
+    };
+  }
 
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    }
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+  }
 
-    _loggedInChanged(loggedIn) {
-      if (!loggedIn) { return; }
-      return this.$.restAPI.getPreferences().then(prefs => {
-        if (prefs.download_scheme) {
-          // Note (issue 5180): normalize the download scheme with lower-case.
-          this.selectedScheme = prefs.download_scheme.toLowerCase();
-        }
-      });
-    }
+  focusOnCopy() {
+    this.shadowRoot.querySelector('gr-shell-command').focusOnCopy();
+  }
 
-    _handleTabChange(e) {
-      const scheme = this.schemes[e.detail.value];
-      if (scheme && scheme !== this.selectedScheme) {
-        this.set('selectedScheme', scheme);
-        if (this._loggedIn) {
-          this.$.restAPI.savePreferences(
-              {download_scheme: this.selectedScheme});
-        }
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _loggedInChanged(loggedIn) {
+    if (!loggedIn) { return; }
+    return this.$.restAPI.getPreferences().then(prefs => {
+      if (prefs.download_scheme) {
+        // Note (issue 5180): normalize the download scheme with lower-case.
+        this.selectedScheme = prefs.download_scheme.toLowerCase();
       }
-    }
+    });
+  }
 
-    _computeSelected(schemes, selectedScheme) {
-      return (schemes.findIndex(scheme => scheme === selectedScheme) || 0) +
-          '';
-    }
-
-    _computeShowTabs(schemes) {
-      return schemes.length > 1 ? '' : 'hidden';
+  _handleTabChange(e) {
+    const scheme = this.schemes[e.detail.value];
+    if (scheme && scheme !== this.selectedScheme) {
+      this.set('selectedScheme', scheme);
+      if (this._loggedIn) {
+        this.$.restAPI.savePreferences(
+            {download_scheme: this.selectedScheme});
+      }
     }
   }
 
-  customElements.define(GrDownloadCommands.is, GrDownloadCommands);
-})();
+  _computeSelected(schemes, selectedScheme) {
+    return (schemes.findIndex(scheme => scheme === selectedScheme) || 0) +
+        '';
+  }
+
+  _computeShowTabs(schemes) {
+    return schemes.length > 1 ? '' : 'hidden';
+  }
+}
+
+customElements.define(GrDownloadCommands.is, GrDownloadCommands);
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.js
index 14a65b2..12a8d01 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.js
@@ -1,31 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="/bower_components/paper-tabs/paper-tabs.html">
-<link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-download-commands">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       paper-tabs {
         height: 3rem;
@@ -61,26 +52,16 @@
       }
     </style>
     <div class="schemes">
-      <paper-tabs
-          id="downloadTabs"
-          class$="[[_computeShowTabs(schemes)]]"
-          selected="[[_computeSelected(schemes, selectedScheme)]]"
-          on-selected-changed="_handleTabChange">
+      <paper-tabs id="downloadTabs" class\$="[[_computeShowTabs(schemes)]]" selected="[[_computeSelected(schemes, selectedScheme)]]" on-selected-changed="_handleTabChange">
         <template is="dom-repeat" items="[[schemes]]" as="scheme">
-          <paper-tab data-scheme$="[[scheme]]">[[scheme]]</paper-tab>
+          <paper-tab data-scheme\$="[[scheme]]">[[scheme]]</paper-tab>
         </template>
       </paper-tabs>
     </div>
-    <div class="commands" hidden$="[[!schemes.length]]" hidden>
-      <template is="dom-repeat"
-          items="[[commands]]"
-          as="command">
-        <gr-shell-command
-            label=[[command.title]]
-            command=[[command.command]]></gr-shell-command>
+    <div class="commands" hidden\$="[[!schemes.length]]" hidden="">
+      <template is="dom-repeat" items="[[commands]]" as="command">
+        <gr-shell-command label="[[command.title]]" command="[[command.command]]"></gr-shell-command>
       </template>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-download-commands.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
index 4e37f9e..a39a433 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-download-commands</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-download-commands.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-download-commands.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-download-commands.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,123 +40,125 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-download-commands', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    const SCHEMES = ['http', 'repo', 'ssh'];
-    const COMMANDS = [{
-      title: 'Checkout',
-      command: `git fetch http://andybons@localhost:8080/a/test-project
-          refs/changes/05/5/1 && git checkout FETCH_HEAD`,
-    }, {
-      title: 'Cherry Pick',
-      command: `git fetch http://andybons@localhost:8080/a/test-project
-          refs/changes/05/5/1 && git cherry-pick FETCH_HEAD`,
-    }, {
-      title: 'Format Patch',
-      command: `git fetch http://andybons@localhost:8080/a/test-project
-          refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD`,
-    }, {
-      title: 'Pull',
-      command: `git pull http://andybons@localhost:8080/a/test-project
-          refs/changes/05/5/1`,
-    }];
-    const SELECTED_SCHEME = 'http';
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-download-commands.js';
+suite('gr-download-commands', () => {
+  let element;
+  let sandbox;
+  const SCHEMES = ['http', 'repo', 'ssh'];
+  const COMMANDS = [{
+    title: 'Checkout',
+    command: `git fetch http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git checkout FETCH_HEAD`,
+  }, {
+    title: 'Cherry Pick',
+    command: `git fetch http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git cherry-pick FETCH_HEAD`,
+  }, {
+    title: 'Format Patch',
+    command: `git fetch http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD`,
+  }, {
+    title: 'Pull',
+    command: `git pull http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1`,
+  }];
+  const SELECTED_SCHEME = 'http';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('unauthenticated', () => {
+    setup(done => {
+      element = fixture('basic');
+      element.schemes = SCHEMES;
+      element.commands = COMMANDS;
+      element.selectedScheme = SELECTED_SCHEME;
+      flushAsynchronousOperations();
+      flush(done);
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('focusOnCopy', () => {
+      const focusStub = sandbox.stub(element.shadowRoot
+          .querySelector('gr-shell-command'),
+      'focusOnCopy');
+      element.focusOnCopy();
+      assert.isTrue(focusStub.called);
     });
 
-    suite('unauthenticated', () => {
-      setup(done => {
-        element = fixture('basic');
-        element.schemes = SCHEMES;
-        element.commands = COMMANDS;
-        element.selectedScheme = SELECTED_SCHEME;
-        flushAsynchronousOperations();
-        flush(done);
+    test('element visibility', () => {
+      assert.isFalse(isHidden(element.shadowRoot
+          .querySelector('paper-tabs')));
+      assert.isFalse(isHidden(element.shadowRoot
+          .querySelector('.commands')));
+
+      element.schemes = [];
+      assert.isTrue(isHidden(element.shadowRoot
+          .querySelector('paper-tabs')));
+      assert.isTrue(isHidden(element.shadowRoot
+          .querySelector('.commands')));
+    });
+
+    test('tab selection', done => {
+      assert.equal(element.$.downloadTabs.selected, '0');
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('[data-scheme="ssh"]'));
+      flushAsynchronousOperations();
+      assert.equal(element.selectedScheme, 'ssh');
+      assert.equal(element.$.downloadTabs.selected, '2');
+      done();
+    });
+
+    test('loads scheme from preferences', done => {
+      stub('gr-rest-api-interface', {
+        getPreferences() {
+          return Promise.resolve({download_scheme: 'repo'});
+        },
       });
-
-      test('focusOnCopy', () => {
-        const focusStub = sandbox.stub(element.shadowRoot
-            .querySelector('gr-shell-command'),
-        'focusOnCopy');
-        element.focusOnCopy();
-        assert.isTrue(focusStub.called);
-      });
-
-      test('element visibility', () => {
-        assert.isFalse(isHidden(element.shadowRoot
-            .querySelector('paper-tabs')));
-        assert.isFalse(isHidden(element.shadowRoot
-            .querySelector('.commands')));
-
-        element.schemes = [];
-        assert.isTrue(isHidden(element.shadowRoot
-            .querySelector('paper-tabs')));
-        assert.isTrue(isHidden(element.shadowRoot
-            .querySelector('.commands')));
-      });
-
-      test('tab selection', done => {
-        assert.equal(element.$.downloadTabs.selected, '0');
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('[data-scheme="ssh"]'));
-        flushAsynchronousOperations();
-        assert.equal(element.selectedScheme, 'ssh');
-        assert.equal(element.$.downloadTabs.selected, '2');
+      element._loggedIn = true;
+      assert.isTrue(element.$.restAPI.getPreferences.called);
+      element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
+        assert.equal(element.selectedScheme, 'repo');
         done();
       });
+    });
 
-      test('loads scheme from preferences', done => {
-        stub('gr-rest-api-interface', {
-          getPreferences() {
-            return Promise.resolve({download_scheme: 'repo'});
-          },
-        });
-        element._loggedIn = true;
-        assert.isTrue(element.$.restAPI.getPreferences.called);
-        element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
-          assert.equal(element.selectedScheme, 'repo');
-          done();
-        });
+    test('normalize scheme from preferences', done => {
+      stub('gr-rest-api-interface', {
+        getPreferences() {
+          return Promise.resolve({download_scheme: 'REPO'});
+        },
       });
-
-      test('normalize scheme from preferences', done => {
-        stub('gr-rest-api-interface', {
-          getPreferences() {
-            return Promise.resolve({download_scheme: 'REPO'});
-          },
-        });
-        element._loggedIn = true;
-        element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
-          assert.equal(element.selectedScheme, 'repo');
-          done();
-        });
-      });
-
-      test('saves scheme to preferences', () => {
-        element._loggedIn = true;
-        const savePrefsStub = sandbox.stub(element.$.restAPI, 'savePreferences',
-            () => Promise.resolve());
-
-        flushAsynchronousOperations();
-
-        const repoTab = element.shadowRoot
-            .querySelector('paper-tab[data-scheme="repo"]');
-
-        MockInteractions.tap(repoTab);
-
-        assert.isTrue(savePrefsStub.called);
-        assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
-            repoTab.getAttribute('data-scheme'));
+      element._loggedIn = true;
+      element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
+        assert.equal(element.selectedScheme, 'repo');
+        done();
       });
     });
+
+    test('saves scheme to preferences', () => {
+      element._loggedIn = true;
+      const savePrefsStub = sandbox.stub(element.$.restAPI, 'savePreferences',
+          () => Promise.resolve());
+
+      flushAsynchronousOperations();
+
+      const repoTab = element.shadowRoot
+          .querySelector('paper-tab[data-scheme="repo"]');
+
+      MockInteractions.tap(repoTab);
+
+      assert.isTrue(savePrefsStub.called);
+      assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
+          repoTab.getAttribute('data-scheme'));
+    });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
index 06b4a72..6b250de 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
@@ -14,123 +14,135 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
+import '@polymer/iron-dropdown/iron-dropdown.js';
+import '@polymer/paper-item/paper-item.js';
+import '@polymer/paper-listbox/paper-listbox.js';
+import '../../../styles/shared-styles.js';
+import '../gr-button/gr-button.js';
+import '../gr-date-formatter/gr-date-formatter.js';
+import '../gr-select/gr-select.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-dropdown-list_html.js';
+
+/**
+ * fired when the selected value of the dropdown changes
+ *
+ * @event {change}
+ */
+
+const Defs = {};
+
+/**
+ * Requred values are text and value. mobileText and triggerText will
+ * fall back to text if not provided.
+ *
+ * If bottomText is not provided, nothing will display on the second
+ * line.
+ *
+ * If date is not provided, nothing will be displayed in its place.
+ *
+ * @typedef {{
+ *    text: string,
+ *    value: (string|number),
+ *    bottomText: (string|undefined),
+ *    triggerText: (string|undefined),
+ *    mobileText: (string|undefined),
+ *    date: (!Date|undefined),
+ * }}
+ */
+Defs.item;
+
+/** @extends Polymer.Element */
+class GrDropdownList extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-dropdown-list'; }
   /**
-   * fired when the selected value of the dropdown changes
+   * Fired when the selected value changes
    *
-   * @event {change}
+   * @event value-change
+   *
+   * @property {string|number} value
    */
 
-  const Defs = {};
-
-  /**
-   * Requred values are text and value. mobileText and triggerText will
-   * fall back to text if not provided.
-   *
-   * If bottomText is not provided, nothing will display on the second
-   * line.
-   *
-   * If date is not provided, nothing will be displayed in its place.
-   *
-   * @typedef {{
-   *    text: string,
-   *    value: (string|number),
-   *    bottomText: (string|undefined),
-   *    triggerText: (string|undefined),
-   *    mobileText: (string|undefined),
-   *    date: (!Date|undefined),
-   * }}
-   */
-  Defs.item;
-
-  /** @extends Polymer.Element */
-  class GrDropdownList extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-dropdown-list'; }
-    /**
-     * Fired when the selected value changes
-     *
-     * @event value-change
-     *
-     * @property {string|number} value
-     */
-
-    static get properties() {
-      return {
-        initialCount: Number,
-        /** @type {!Array<!Defs.item>} */
-        items: Object,
-        text: String,
-        disabled: {
-          type: Boolean,
-          value: false,
-        },
-        value: {
-          type: String,
-          notify: true,
-        },
-      };
-    }
-
-    static get observers() {
-      return [
-        '_handleValueChange(value, items)',
-      ];
-    }
-
-    /**
-     * Handle a click on the iron-dropdown element.
-     *
-     * @param {!Event} e
-     */
-    _handleDropdownClick(e) {
-      // async is needed so that that the click event is fired before the
-      // dropdown closes (This was a bug for touch devices).
-      this.async(() => {
-        this.$.dropdown.close();
-      }, 1);
-    }
-
-    /**
-     * Handle a click on the button to open the dropdown.
-     *
-     * @param {!Event} e
-     */
-    _showDropdownTapHandler(e) {
-      this._open();
-    }
-
-    /**
-     * Open the dropdown.
-     */
-    _open() {
-      this.$.dropdown.open();
-    }
-
-    _computeMobileText(item) {
-      return item.mobileText ? item.mobileText : item.text;
-    }
-
-    _handleValueChange(value, items) {
-      // Polymer 2: check for undefined
-      if ([value, items].some(arg => arg === undefined)) {
-        return;
-      }
-
-      if (!value) { return; }
-      const selectedObj = items.find(item => item.value + '' === value + '');
-      if (!selectedObj) { return; }
-      this.text = selectedObj.triggerText? selectedObj.triggerText :
-        selectedObj.text;
-      this.dispatchEvent(new CustomEvent('value-change', {
-        detail: {value},
-        bubbles: false,
-      }));
-    }
+  static get properties() {
+    return {
+      initialCount: Number,
+      /** @type {!Array<!Defs.item>} */
+      items: Object,
+      text: String,
+      disabled: {
+        type: Boolean,
+        value: false,
+      },
+      value: {
+        type: String,
+        notify: true,
+      },
+    };
   }
 
-  customElements.define(GrDropdownList.is, GrDropdownList);
-})();
+  static get observers() {
+    return [
+      '_handleValueChange(value, items)',
+    ];
+  }
+
+  /**
+   * Handle a click on the iron-dropdown element.
+   *
+   * @param {!Event} e
+   */
+  _handleDropdownClick(e) {
+    // async is needed so that that the click event is fired before the
+    // dropdown closes (This was a bug for touch devices).
+    this.async(() => {
+      this.$.dropdown.close();
+    }, 1);
+  }
+
+  /**
+   * Handle a click on the button to open the dropdown.
+   *
+   * @param {!Event} e
+   */
+  _showDropdownTapHandler(e) {
+    this._open();
+  }
+
+  /**
+   * Open the dropdown.
+   */
+  _open() {
+    this.$.dropdown.open();
+  }
+
+  _computeMobileText(item) {
+    return item.mobileText ? item.mobileText : item.text;
+  }
+
+  _handleValueChange(value, items) {
+    // Polymer 2: check for undefined
+    if ([value, items].some(arg => arg === undefined)) {
+      return;
+    }
+
+    if (!value) { return; }
+    const selectedObj = items.find(item => item.value + '' === value + '');
+    if (!selectedObj) { return; }
+    this.text = selectedObj.triggerText? selectedObj.triggerText :
+      selectedObj.text;
+    this.dispatchEvent(new CustomEvent('value-change', {
+      detail: {value},
+      bubbles: false,
+    }));
+  }
+}
+
+customElements.define(GrDropdownList.is, GrDropdownList);
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.js
index 7586876..3b454c2 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.js
@@ -1,33 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="/bower_components/paper-item/paper-item.html">
-<link rel="import" href="/bower_components/paper-listbox/paper-listbox.html">
-
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-
-<dom-module id="gr-dropdown-list">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: inline-block;
@@ -128,38 +117,17 @@
         }
       }
     </style>
-    <gr-button
-        disabled="[[disabled]]"
-        down-arrow
-        link
-        id="trigger"
-        class="dropdown-trigger"
-        on-click="_showDropdownTapHandler"
-        slot="dropdown-trigger">
+    <gr-button disabled="[[disabled]]" down-arrow="" link="" id="trigger" class="dropdown-trigger" on-click="_showDropdownTapHandler" slot="dropdown-trigger">
       <span id="triggerText">[[text]]</span>
     </gr-button>
-    <iron-dropdown
-        id="dropdown"
-        vertical-align="top"
-        allow-outside-scroll="true"
-        on-click="_handleDropdownClick">
-      <paper-listbox
-          class="dropdown-content"
-          slot="dropdown-content"
-          attr-for-selected="data-value"
-          selected="{{value}}"
-          on-tap="_handleDropdownTap">
-        <template is="dom-repeat"
-            items="[[items]]"
-            initial-count="[[initialCount]]">
-          <paper-item
-              disabled="[[item.disabled]]"
-              data-value$="[[item.value]]">
+    <iron-dropdown id="dropdown" vertical-align="top" allow-outside-scroll="true" on-click="_handleDropdownClick">
+      <paper-listbox class="dropdown-content" slot="dropdown-content" attr-for-selected="data-value" selected="{{value}}" on-tap="_handleDropdownTap">
+        <template is="dom-repeat" items="[[items]]" initial-count="[[initialCount]]">
+          <paper-item disabled="[[item.disabled]]" data-value\$="[[item.value]]">
             <div class="topContent">
               <div>[[item.text]]</div>
               <template is="dom-if" if="[[item.date]]">
-                  <gr-date-formatter
-                      date-str="[[item.date]]"></gr-date-formatter>
+                  <gr-date-formatter date-str="[[item.date]]"></gr-date-formatter>
               </template>
             </div>
             <template is="dom-if" if="[[item.bottomText]]">
@@ -174,14 +142,10 @@
     <gr-select bind-value="{{value}}">
       <select>
         <template is="dom-repeat" items="[[items]]">
-          <option
-              disabled$="[[item.disabled]]"
-              value="[[item.value]]">
+          <option disabled\$="[[item.disabled]]" value="[[item.value]]">
             [[_computeMobileText(item)]]
           </option>
         </template>
       </select>
     </gr-select>
-  </template>
-  <script src="gr-dropdown-list.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
index 40e43fd..b3fda65 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dropdown-list</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-dropdown-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-dropdown-list.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-dropdown-list.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,140 +40,143 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-dropdown-list tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-dropdown-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-dropdown-list tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
     });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('tap on trigger opens menu', () => {
-      sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(element.$.trigger);
-      assert.isTrue(element.$.dropdown.opened);
-    });
+  test('tap on trigger opens menu', () => {
+    sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
+    assert.isFalse(element.$.dropdown.opened);
+    MockInteractions.tap(element.$.trigger);
+    assert.isTrue(element.$.dropdown.opened);
+  });
 
-    test('_computeMobileText', () => {
-      const item = {
+  test('_computeMobileText', () => {
+    const item = {
+      value: 1,
+      text: 'text',
+    };
+    assert.equal(element._computeMobileText(item), item.text);
+    item.mobileText = 'mobile text';
+    assert.equal(element._computeMobileText(item), item.mobileText);
+  });
+
+  test('options are selected and laid out correctly', done => {
+    element.value = 2;
+    element.items = [
+      {
         value: 1,
-        text: 'text',
-      };
-      assert.equal(element._computeMobileText(item), item.text);
-      item.mobileText = 'mobile text';
-      assert.equal(element._computeMobileText(item), item.mobileText);
-    });
+        text: 'Top Text 1',
+      },
+      {
+        value: 2,
+        bottomText: 'Bottom Text 2',
+        triggerText: 'Button Text 2',
+        text: 'Top Text 2',
+        mobileText: 'Mobile Text 2',
+      },
+      {
+        value: 3,
+        disabled: true,
+        bottomText: 'Bottom Text 3',
+        triggerText: 'Button Text 3',
+        date: '2017-08-18 23:11:42.569000000',
+        text: 'Top Text 3',
+        mobileText: 'Mobile Text 3',
+      },
+    ];
+    assert.equal(element.shadowRoot
+        .querySelector('paper-listbox').selected, element.value);
+    assert.equal(element.text, 'Button Text 2');
+    flush(() => {
+      const items = dom(element.root).querySelectorAll('paper-item');
+      const mobileItems = dom(element.root).querySelectorAll('option');
+      assert.equal(items.length, 3);
+      assert.equal(mobileItems.length, 3);
 
-    test('options are selected and laid out correctly', done => {
-      element.value = 2;
-      element.items = [
-        {
-          value: 1,
-          text: 'Top Text 1',
-        },
-        {
-          value: 2,
-          bottomText: 'Bottom Text 2',
-          triggerText: 'Button Text 2',
-          text: 'Top Text 2',
-          mobileText: 'Mobile Text 2',
-        },
-        {
-          value: 3,
-          disabled: true,
-          bottomText: 'Bottom Text 3',
-          triggerText: 'Button Text 3',
-          date: '2017-08-18 23:11:42.569000000',
-          text: 'Top Text 3',
-          mobileText: 'Mobile Text 3',
-        },
-      ];
-      assert.equal(element.shadowRoot
-          .querySelector('paper-listbox').selected, element.value);
-      assert.equal(element.text, 'Button Text 2');
-      flush(() => {
-        const items = Polymer.dom(element.root).querySelectorAll('paper-item');
-        const mobileItems = Polymer.dom(element.root).querySelectorAll('option');
-        assert.equal(items.length, 3);
-        assert.equal(mobileItems.length, 3);
+      // First Item
+      // The first item should be disabled, has no bottom text, and no date.
+      assert.isFalse(!!items[0].disabled);
+      assert.isFalse(mobileItems[0].disabled);
+      assert.isFalse(items[0].classList.contains('iron-selected'));
+      assert.isFalse(mobileItems[0].selected);
 
-        // First Item
-        // The first item should be disabled, has no bottom text, and no date.
-        assert.isFalse(!!items[0].disabled);
-        assert.isFalse(mobileItems[0].disabled);
-        assert.isFalse(items[0].classList.contains('iron-selected'));
-        assert.isFalse(mobileItems[0].selected);
+      assert.isNotOk(dom(items[0]).querySelector('gr-date-formatter'));
+      assert.isNotOk(dom(items[0]).querySelector('.bottomContent'));
+      assert.equal(items[0].dataset.value, element.items[0].value);
+      assert.equal(mobileItems[0].value, element.items[0].value);
+      assert.equal(dom(items[0]).querySelector('.topContent div')
+          .innerText, element.items[0].text);
 
-        assert.isNotOk(Polymer.dom(items[0]).querySelector('gr-date-formatter'));
-        assert.isNotOk(Polymer.dom(items[0]).querySelector('.bottomContent'));
-        assert.equal(items[0].dataset.value, element.items[0].value);
-        assert.equal(mobileItems[0].value, element.items[0].value);
-        assert.equal(Polymer.dom(items[0]).querySelector('.topContent div')
-            .innerText, element.items[0].text);
+      // Since no mobile specific text, it should fall back to text.
+      assert.equal(mobileItems[0].text, element.items[0].text);
 
-        // Since no mobile specific text, it should fall back to text.
-        assert.equal(mobileItems[0].text, element.items[0].text);
+      // Second Item
+      // The second item should have top text, bottom text, and no date.
+      assert.isFalse(!!items[1].disabled);
+      assert.isFalse(mobileItems[1].disabled);
+      assert.isTrue(items[1].classList.contains('iron-selected'));
+      assert.isTrue(mobileItems[1].selected);
 
-        // Second Item
-        // The second item should have top text, bottom text, and no date.
-        assert.isFalse(!!items[1].disabled);
-        assert.isFalse(mobileItems[1].disabled);
-        assert.isTrue(items[1].classList.contains('iron-selected'));
-        assert.isTrue(mobileItems[1].selected);
+      assert.isNotOk(dom(items[1]).querySelector('gr-date-formatter'));
+      assert.isOk(dom(items[1]).querySelector('.bottomContent'));
+      assert.equal(items[1].dataset.value, element.items[1].value);
+      assert.equal(mobileItems[1].value, element.items[1].value);
+      assert.equal(dom(items[1]).querySelector('.topContent div')
+          .innerText, element.items[1].text);
 
-        assert.isNotOk(Polymer.dom(items[1]).querySelector('gr-date-formatter'));
-        assert.isOk(Polymer.dom(items[1]).querySelector('.bottomContent'));
-        assert.equal(items[1].dataset.value, element.items[1].value);
-        assert.equal(mobileItems[1].value, element.items[1].value);
-        assert.equal(Polymer.dom(items[1]).querySelector('.topContent div')
-            .innerText, element.items[1].text);
+      // Since there is mobile specific text, it should that.
+      assert.equal(mobileItems[1].text, element.items[1].mobileText);
 
-        // Since there is mobile specific text, it should that.
-        assert.equal(mobileItems[1].text, element.items[1].mobileText);
+      // Since this item is selected, and it has triggerText defined, that
+      // should be used.
+      assert.equal(element.text, element.items[1].triggerText);
 
-        // Since this item is selected, and it has triggerText defined, that
-        // should be used.
-        assert.equal(element.text, element.items[1].triggerText);
+      // Third item
+      // The third item should be disabled, and have a date, and bottom content.
+      assert.isTrue(!!items[2].disabled);
+      assert.isTrue(mobileItems[2].disabled);
+      assert.isFalse(items[2].classList.contains('iron-selected'));
+      assert.isFalse(mobileItems[2].selected);
 
-        // Third item
-        // The third item should be disabled, and have a date, and bottom content.
-        assert.isTrue(!!items[2].disabled);
-        assert.isTrue(mobileItems[2].disabled);
-        assert.isFalse(items[2].classList.contains('iron-selected'));
-        assert.isFalse(mobileItems[2].selected);
+      assert.isOk(dom(items[2]).querySelector('gr-date-formatter'));
+      assert.isOk(dom(items[2]).querySelector('.bottomContent'));
+      assert.equal(items[2].dataset.value, element.items[2].value);
+      assert.equal(mobileItems[2].value, element.items[2].value);
+      assert.equal(dom(items[2]).querySelector('.topContent div')
+          .innerText, element.items[2].text);
 
-        assert.isOk(Polymer.dom(items[2]).querySelector('gr-date-formatter'));
-        assert.isOk(Polymer.dom(items[2]).querySelector('.bottomContent'));
-        assert.equal(items[2].dataset.value, element.items[2].value);
-        assert.equal(mobileItems[2].value, element.items[2].value);
-        assert.equal(Polymer.dom(items[2]).querySelector('.topContent div')
-            .innerText, element.items[2].text);
+      // Since there is mobile specific text, it should that.
+      assert.equal(mobileItems[2].text, element.items[2].mobileText);
 
-        // Since there is mobile specific text, it should that.
-        assert.equal(mobileItems[2].text, element.items[2].mobileText);
+      // Select a new item.
+      MockInteractions.tap(items[0]);
+      flushAsynchronousOperations();
+      assert.equal(element.value, 1);
+      assert.isTrue(items[0].classList.contains('iron-selected'));
+      assert.isTrue(mobileItems[0].selected);
 
-        // Select a new item.
-        MockInteractions.tap(items[0]);
-        flushAsynchronousOperations();
-        assert.equal(element.value, 1);
-        assert.isTrue(items[0].classList.contains('iron-selected'));
-        assert.isTrue(mobileItems[0].selected);
-
-        // Since no triggerText, the fallback is used.
-        assert.equal(element.text, element.items[0].text);
-        done();
-      });
+      // Since no triggerText, the fallback is used.
+      assert.equal(element.text, element.items[0].text);
+      done();
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index 531f2e3..b4190dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -14,309 +14,324 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  const REL_NOOPENER = 'noopener';
-  const REL_EXTERNAL = 'external';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../../../scripts/bundled-polymer.js';
+import '@polymer/iron-dropdown/iron-dropdown.js';
+import '../gr-button/gr-button.js';
+import '../gr-cursor-manager/gr-cursor-manager.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-tooltip-content/gr-tooltip-content.js';
+import '../../../styles/shared-styles.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-dropdown_html.js';
+
+const REL_NOOPENER = 'noopener';
+const REL_EXTERNAL = 'external';
+
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @extends Polymer.Element
+ */
+class GrDropdown extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-dropdown'; }
+  /**
+   * Fired when a non-link dropdown item with the given ID is tapped.
+   *
+   * @event tap-item-<id>
+   */
 
   /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @extends Polymer.Element
+   * Fired when a non-link dropdown item is tapped.
+   *
+   * @event tap-item
    */
-  class GrDropdown extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-dropdown'; }
-    /**
-     * Fired when a non-link dropdown item with the given ID is tapped.
-     *
-     * @event tap-item-<id>
-     */
 
-    /**
-     * Fired when a non-link dropdown item is tapped.
-     *
-     * @event tap-item
-     */
+  static get properties() {
+    return {
+      items: {
+        type: Array,
+        observer: '_resetCursorStops',
+      },
+      downArrow: Boolean,
+      topContent: Object,
+      horizontalAlign: {
+        type: String,
+        value: 'left',
+      },
 
-    static get properties() {
-      return {
-        items: {
-          type: Array,
-          observer: '_resetCursorStops',
-        },
-        downArrow: Boolean,
-        topContent: Object,
-        horizontalAlign: {
-          type: String,
-          value: 'left',
-        },
+      /**
+       * Style the dropdown trigger as a link (rather than a button).
+       */
+      link: {
+        type: Boolean,
+        value: false,
+      },
 
-        /**
-         * Style the dropdown trigger as a link (rather than a button).
-         */
-        link: {
-          type: Boolean,
-          value: false,
-        },
+      verticalOffset: {
+        type: Number,
+        value: 40,
+      },
 
-        verticalOffset: {
-          type: Number,
-          value: 40,
-        },
+      /**
+       * List the IDs of dropdown buttons to be disabled. (Note this only
+       * diisables bittons and not link entries.)
+       */
+      disabledIds: {
+        type: Array,
+        value() { return []; },
+      },
 
-        /**
-         * List the IDs of dropdown buttons to be disabled. (Note this only
-         * diisables bittons and not link entries.)
-         */
-        disabledIds: {
-          type: Array,
-          value() { return []; },
-        },
+      /**
+       * The elements of the list.
+       */
+      _listElements: {
+        type: Array,
+        value() { return []; },
+      },
+    };
+  }
 
-        /**
-         * The elements of the list.
-         */
-        _listElements: {
-          type: Array,
-          value() { return []; },
-        },
-      };
-    }
+  get keyBindings() {
+    return {
+      'down': '_handleDown',
+      'enter space': '_handleEnter',
+      'tab': '_handleTab',
+      'up': '_handleUp',
+    };
+  }
 
-    get keyBindings() {
-      return {
-        'down': '_handleDown',
-        'enter space': '_handleEnter',
-        'tab': '_handleTab',
-        'up': '_handleUp',
-      };
-    }
-
-    /**
-     * Handle the up key.
-     *
-     * @param {!Event} e
-     */
-    _handleUp(e) {
-      if (this.$.dropdown.opened) {
-        e.preventDefault();
-        e.stopPropagation();
-        this.$.cursor.previous();
-      } else {
-        this._open();
-      }
-    }
-
-    /**
-     * Handle the down key.
-     *
-     * @param {!Event} e
-     */
-    _handleDown(e) {
-      if (this.$.dropdown.opened) {
-        e.preventDefault();
-        e.stopPropagation();
-        this.$.cursor.next();
-      } else {
-        this._open();
-      }
-    }
-
-    /**
-     * Handle the tab key.
-     *
-     * @param {!Event} e
-     */
-    _handleTab(e) {
-      if (this.$.dropdown.opened) {
-        // Tab in a native select is a no-op. Emulate this.
-        e.preventDefault();
-        e.stopPropagation();
-      }
-    }
-
-    /**
-     * Handle the enter key.
-     *
-     * @param {!Event} e
-     */
-    _handleEnter(e) {
+  /**
+   * Handle the up key.
+   *
+   * @param {!Event} e
+   */
+  _handleUp(e) {
+    if (this.$.dropdown.opened) {
       e.preventDefault();
       e.stopPropagation();
-      if (this.$.dropdown.opened) {
-        // TODO(milutin): This solution is not particularly robust in general.
-        // Since gr-tooltip-content click on shadow dom is not propagated down,
-        // we have to target `a` inside it.
-        const el = this.$.cursor.target.querySelector(':not([hidden]) a');
-        if (el) { el.click(); }
-      } else {
-        this._open();
-      }
-    }
-
-    /**
-     * Handle a click on the iron-dropdown element.
-     *
-     * @param {!Event} e
-     */
-    _handleDropdownClick(e) {
-      this._close();
-    }
-
-    /**
-     * Hanlde a click on the button to open the dropdown.
-     *
-     * @param {!Event} e
-     */
-    _dropdownTriggerTapHandler(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      if (this.$.dropdown.opened) {
-        this._close();
-      } else {
-        this._open();
-      }
-    }
-
-    /**
-     * Open the dropdown and initialize the cursor.
-     */
-    _open() {
-      this.$.dropdown.open();
-      this._resetCursorStops();
-      this.$.cursor.setCursorAtIndex(0);
-      this.$.cursor.target.focus();
-    }
-
-    _close() {
-      // async is needed so that that the click event is fired before the
-      // dropdown closes (This was a bug for touch devices).
-      this.async(() => {
-        this.$.dropdown.close();
-      }, 1);
-    }
-
-    /**
-     * Get the class for a top-content item based on the given boolean.
-     *
-     * @param {boolean} bold Whether the item is bold.
-     * @return {string} The class for the top-content item.
-     */
-    _getClassIfBold(bold) {
-      return bold ? 'bold-text' : '';
-    }
-
-    /**
-     * Build a URL for the given host and path. The base URL will be only added,
-     * if it is not already included in the path.
-     *
-     * @param {!string} host
-     * @param {!string} path
-     * @return {!string} The scheme-relative URL.
-     */
-    _computeURLHelper(host, path) {
-      const base = path.startsWith(this.getBaseUrl()) ?
-        '' : this.getBaseUrl();
-      return '//' + host + base + path;
-    }
-
-    /**
-     * Build a scheme-relative URL for the current host. Will include the base
-     * URL if one is present. Note: the URL will be scheme-relative but absolute
-     * with regard to the host.
-     *
-     * @param {!string} path The path for the URL.
-     * @return {!string} The scheme-relative URL.
-     */
-    _computeRelativeURL(path) {
-      const host = window.location.host;
-      return this._computeURLHelper(host, path);
-    }
-
-    /**
-     * Compute the URL for a link object.
-     *
-     * @param {!Object} link The object describing the link.
-     * @return {!string} The URL.
-     */
-    _computeLinkURL(link) {
-      if (typeof link.url === 'undefined') {
-        return '';
-      }
-      if (link.target || !link.url.startsWith('/')) {
-        return link.url;
-      }
-      return this._computeRelativeURL(link.url);
-    }
-
-    /**
-     * Compute the value for the rel attribute of an anchor for the given link
-     * object. If the link has a target value, then the rel must be "noopener"
-     * for security reasons.
-     *
-     * @param {!Object} link The object describing the link.
-     * @return {?string} The rel value for the link.
-     */
-    _computeLinkRel(link) {
-      // Note: noopener takes precedence over external.
-      if (link.target) { return REL_NOOPENER; }
-      if (link.external) { return REL_EXTERNAL; }
-      return null;
-    }
-
-    /**
-     * Handle a click on an item of the dropdown.
-     *
-     * @param {!Event} e
-     */
-    _handleItemTap(e) {
-      const id = e.target.getAttribute('data-id');
-      const item = this.items.find(item => item.id === id);
-      if (id && !this.disabledIds.includes(id)) {
-        if (item) {
-          this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
-        }
-        this.dispatchEvent(new CustomEvent('tap-item-' + id));
-      }
-    }
-
-    /**
-     * If a dropdown item is shown as a button, get the class for the button.
-     *
-     * @param {string} id
-     * @param {!Object} disabledIdsRecord The change record for the disabled IDs
-     *     list.
-     * @return {!string} The class for the item button.
-     */
-    _computeDisabledClass(id, disabledIdsRecord) {
-      return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
-    }
-
-    /**
-     * Recompute the stops for the dropdown item cursor.
-     */
-    _resetCursorStops() {
-      if (this.items && this.items.length > 0 && this.$.dropdown.opened) {
-        Polymer.dom.flush();
-        this._listElements = Array.from(
-            Polymer.dom(this.root).querySelectorAll('li'));
-      }
-    }
-
-    _computeHasTooltip(tooltip) {
-      return !!tooltip;
-    }
-
-    _computeIsDownload(link) {
-      return !!link.download;
+      this.$.cursor.previous();
+    } else {
+      this._open();
     }
   }
 
-  customElements.define(GrDropdown.is, GrDropdown);
-})();
+  /**
+   * Handle the down key.
+   *
+   * @param {!Event} e
+   */
+  _handleDown(e) {
+    if (this.$.dropdown.opened) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.$.cursor.next();
+    } else {
+      this._open();
+    }
+  }
+
+  /**
+   * Handle the tab key.
+   *
+   * @param {!Event} e
+   */
+  _handleTab(e) {
+    if (this.$.dropdown.opened) {
+      // Tab in a native select is a no-op. Emulate this.
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  }
+
+  /**
+   * Handle the enter key.
+   *
+   * @param {!Event} e
+   */
+  _handleEnter(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    if (this.$.dropdown.opened) {
+      // TODO(milutin): This solution is not particularly robust in general.
+      // Since gr-tooltip-content click on shadow dom is not propagated down,
+      // we have to target `a` inside it.
+      const el = this.$.cursor.target.querySelector(':not([hidden]) a');
+      if (el) { el.click(); }
+    } else {
+      this._open();
+    }
+  }
+
+  /**
+   * Handle a click on the iron-dropdown element.
+   *
+   * @param {!Event} e
+   */
+  _handleDropdownClick(e) {
+    this._close();
+  }
+
+  /**
+   * Hanlde a click on the button to open the dropdown.
+   *
+   * @param {!Event} e
+   */
+  _dropdownTriggerTapHandler(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    if (this.$.dropdown.opened) {
+      this._close();
+    } else {
+      this._open();
+    }
+  }
+
+  /**
+   * Open the dropdown and initialize the cursor.
+   */
+  _open() {
+    this.$.dropdown.open();
+    this._resetCursorStops();
+    this.$.cursor.setCursorAtIndex(0);
+    this.$.cursor.target.focus();
+  }
+
+  _close() {
+    // async is needed so that that the click event is fired before the
+    // dropdown closes (This was a bug for touch devices).
+    this.async(() => {
+      this.$.dropdown.close();
+    }, 1);
+  }
+
+  /**
+   * Get the class for a top-content item based on the given boolean.
+   *
+   * @param {boolean} bold Whether the item is bold.
+   * @return {string} The class for the top-content item.
+   */
+  _getClassIfBold(bold) {
+    return bold ? 'bold-text' : '';
+  }
+
+  /**
+   * Build a URL for the given host and path. The base URL will be only added,
+   * if it is not already included in the path.
+   *
+   * @param {!string} host
+   * @param {!string} path
+   * @return {!string} The scheme-relative URL.
+   */
+  _computeURLHelper(host, path) {
+    const base = path.startsWith(this.getBaseUrl()) ?
+      '' : this.getBaseUrl();
+    return '//' + host + base + path;
+  }
+
+  /**
+   * Build a scheme-relative URL for the current host. Will include the base
+   * URL if one is present. Note: the URL will be scheme-relative but absolute
+   * with regard to the host.
+   *
+   * @param {!string} path The path for the URL.
+   * @return {!string} The scheme-relative URL.
+   */
+  _computeRelativeURL(path) {
+    const host = window.location.host;
+    return this._computeURLHelper(host, path);
+  }
+
+  /**
+   * Compute the URL for a link object.
+   *
+   * @param {!Object} link The object describing the link.
+   * @return {!string} The URL.
+   */
+  _computeLinkURL(link) {
+    if (typeof link.url === 'undefined') {
+      return '';
+    }
+    if (link.target || !link.url.startsWith('/')) {
+      return link.url;
+    }
+    return this._computeRelativeURL(link.url);
+  }
+
+  /**
+   * Compute the value for the rel attribute of an anchor for the given link
+   * object. If the link has a target value, then the rel must be "noopener"
+   * for security reasons.
+   *
+   * @param {!Object} link The object describing the link.
+   * @return {?string} The rel value for the link.
+   */
+  _computeLinkRel(link) {
+    // Note: noopener takes precedence over external.
+    if (link.target) { return REL_NOOPENER; }
+    if (link.external) { return REL_EXTERNAL; }
+    return null;
+  }
+
+  /**
+   * Handle a click on an item of the dropdown.
+   *
+   * @param {!Event} e
+   */
+  _handleItemTap(e) {
+    const id = e.target.getAttribute('data-id');
+    const item = this.items.find(item => item.id === id);
+    if (id && !this.disabledIds.includes(id)) {
+      if (item) {
+        this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
+      }
+      this.dispatchEvent(new CustomEvent('tap-item-' + id));
+    }
+  }
+
+  /**
+   * If a dropdown item is shown as a button, get the class for the button.
+   *
+   * @param {string} id
+   * @param {!Object} disabledIdsRecord The change record for the disabled IDs
+   *     list.
+   * @return {!string} The class for the item button.
+   */
+  _computeDisabledClass(id, disabledIdsRecord) {
+    return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
+  }
+
+  /**
+   * Recompute the stops for the dropdown item cursor.
+   */
+  _resetCursorStops() {
+    if (this.items && this.items.length > 0 && this.$.dropdown.opened) {
+      flush();
+      this._listElements = Array.from(
+          dom(this.root).querySelectorAll('li'));
+    }
+  }
+
+  _computeHasTooltip(tooltip) {
+    return !!tooltip;
+  }
+
+  _computeIsDownload(link) {
+    return !!link.download;
+  }
+}
+
+customElements.define(GrDropdown.is, GrDropdown);
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.js
index 5d28390..99028af 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.js
@@ -1,32 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-dropdown">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: inline-block;
@@ -97,72 +87,32 @@
         font-weight: var(--font-weight-bold);
       }
     </style>
-    <gr-button
-        link="[[link]]"
-        class="dropdown-trigger" id="trigger"
-        down-arrow="[[downArrow]]"
-        on-click="_dropdownTriggerTapHandler">
+    <gr-button link="[[link]]" class="dropdown-trigger" id="trigger" down-arrow="[[downArrow]]" on-click="_dropdownTriggerTapHandler">
       <slot></slot>
     </gr-button>
-    <iron-dropdown id="dropdown"
-        vertical-align="top"
-        vertical-offset="[[verticalOffset]]"
-        allow-outside-scroll="true"
-        horizontal-align="[[horizontalAlign]]"
-        on-click="_handleDropdownClick">
+    <iron-dropdown id="dropdown" vertical-align="top" vertical-offset="[[verticalOffset]]" allow-outside-scroll="true" horizontal-align="[[horizontalAlign]]" on-click="_handleDropdownClick">
       <div class="dropdown-content" slot="dropdown-content">
         <ul>
           <template is="dom-if" if="[[topContent]]">
             <div class="topContent">
-              <template
-                  is="dom-repeat"
-                  items="[[topContent]]"
-                  as="item"
-                  initial-count="75">
-                <div
-                    class$="[[_getClassIfBold(item.bold)]] top-item"
-                    tabindex="-1">
+              <template is="dom-repeat" items="[[topContent]]" as="item" initial-count="75">
+                <div class\$="[[_getClassIfBold(item.bold)]] top-item" tabindex="-1">
                   [[item.text]]
                 </div>
               </template>
             </div>
           </template>
-          <template
-              is="dom-repeat"
-              items="[[items]]"
-              as="link"
-              initial-count="75">
+          <template is="dom-repeat" items="[[items]]" as="link" initial-count="75">
             <li tabindex="-1">
-              <gr-tooltip-content
-                  has-tooltip="[[_computeHasTooltip(link.tooltip)]]"
-                  title$="[[link.tooltip]]">
-                <span
-                    class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
-                    data-id$="[[link.id]]"
-                    on-click="_handleItemTap"
-                    hidden$="[[link.url]]"
-                    tabindex="-1">[[link.name]]</span>
-                <a
-                    class="itemAction"
-                    href$="[[_computeLinkURL(link)]]"
-                    download$="[[_computeIsDownload(link)]]"
-                    rel$="[[_computeLinkRel(link)]]"
-                    target$="[[link.target]]"
-                    hidden$="[[!link.url]]"
-                    tabindex="-1">[[link.name]]</a>
+              <gr-tooltip-content has-tooltip="[[_computeHasTooltip(link.tooltip)]]" title\$="[[link.tooltip]]">
+                <span class\$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]" data-id\$="[[link.id]]" on-click="_handleItemTap" hidden\$="[[link.url]]" tabindex="-1">[[link.name]]</span>
+                <a class="itemAction" href\$="[[_computeLinkURL(link)]]" download\$="[[_computeIsDownload(link)]]" rel\$="[[_computeLinkRel(link)]]" target\$="[[link.target]]" hidden\$="[[!link.url]]" tabindex="-1">[[link.name]]</a>
               </gr-tooltip-content>
             </li>
           </template>
         </ul>
       </div>
     </iron-dropdown>
-    <gr-cursor-manager
-        id="cursor"
-        cursor-target-class="selected"
-        scroll-behavior="never"
-        focus-on-move
-        stops="[[_listElements]]"></gr-cursor-manager>
+    <gr-cursor-manager id="cursor" cursor-target-class="selected" scroll-behavior="never" focus-on-move="" stops="[[_listElements]]"></gr-cursor-manager>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-dropdown.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index 2d7f090..dcbab4d 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dropdown</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-dropdown.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-dropdown.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-dropdown.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,176 +40,179 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-dropdown tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-dropdown.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-dropdown tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_computeIsDownload', () => {
+    assert.isTrue(element._computeIsDownload({download: true}));
+    assert.isFalse(element._computeIsDownload({download: false}));
+  });
+
+  test('tap on trigger opens menu, then closes', () => {
+    sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
+    sandbox.stub(element, '_close', () => { element.$.dropdown.close(); });
+    assert.isFalse(element.$.dropdown.opened);
+    MockInteractions.tap(element.$.trigger);
+    assert.isTrue(element.$.dropdown.opened);
+    MockInteractions.tap(element.$.trigger);
+    assert.isFalse(element.$.dropdown.opened);
+  });
+
+  test('_computeURLHelper', () => {
+    const path = '/test';
+    const host = 'http://www.testsite.com';
+    const computedPath = element._computeURLHelper(host, path);
+    assert.equal(computedPath, '//http://www.testsite.com/test');
+  });
+
+  test('link URLs', () => {
+    assert.equal(
+        element._computeLinkURL({url: 'http://example.com/test'}),
+        'http://example.com/test');
+    assert.equal(
+        element._computeLinkURL({url: 'https://example.com/test'}),
+        'https://example.com/test');
+    assert.equal(
+        element._computeLinkURL({url: '/test'}),
+        '//' + window.location.host + '/test');
+    assert.equal(
+        element._computeLinkURL({url: '/test', target: '_blank'}),
+        '/test');
+  });
+
+  test('link rel', () => {
+    let link = {url: '/test'};
+    assert.isNull(element._computeLinkRel(link));
+
+    link = {url: '/test', target: '_blank'};
+    assert.equal(element._computeLinkRel(link), 'noopener');
+
+    link = {url: '/test', external: true};
+    assert.equal(element._computeLinkRel(link), 'external');
+
+    link = {url: '/test', target: '_blank', external: true};
+    assert.equal(element._computeLinkRel(link), 'noopener');
+  });
+
+  test('_getClassIfBold', () => {
+    let bold = true;
+    assert.equal(element._getClassIfBold(bold), 'bold-text');
+
+    bold = false;
+    assert.equal(element._getClassIfBold(bold), '');
+  });
+
+  test('Top text exists and is bolded correctly', () => {
+    element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
+    flushAsynchronousOperations();
+    const topItems = dom(element.root).querySelectorAll('.top-item');
+    assert.equal(topItems.length, 2);
+    assert.isTrue(topItems[0].classList.contains('bold-text'));
+    assert.isFalse(topItems[1].classList.contains('bold-text'));
+  });
+
+  test('non link items', () => {
+    const item0 = {name: 'item one', id: 'foo'};
+    element.items = [item0, {name: 'item two', id: 'bar'}];
+    const fooTapped = sandbox.stub();
+    const tapped = sandbox.stub();
+    element.addEventListener('tap-item-foo', fooTapped);
+    element.addEventListener('tap-item', tapped);
+    flushAsynchronousOperations();
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.itemAction'));
+    assert.isTrue(fooTapped.called);
+    assert.isTrue(tapped.called);
+    assert.deepEqual(tapped.lastCall.args[0].detail, item0);
+  });
+
+  test('disabled non link item', () => {
+    element.items = [{name: 'item one', id: 'foo'}];
+    element.disabledIds = ['foo'];
+
+    const stub = sandbox.stub();
+    const tapped = sandbox.stub();
+    element.addEventListener('tap-item-foo', stub);
+    element.addEventListener('tap-item', tapped);
+    flushAsynchronousOperations();
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.itemAction'));
+    assert.isFalse(stub.called);
+    assert.isFalse(tapped.called);
+  });
+
+  test('properly sets tooltips', () => {
+    element.items = [
+      {name: 'item one', id: 'foo', tooltip: 'hello'},
+      {name: 'item two', id: 'bar'},
+    ];
+    element.disabledIds = [];
+    flushAsynchronousOperations();
+    const tooltipContents = dom(element.root)
+        .querySelectorAll('iron-dropdown li gr-tooltip-content');
+    assert.equal(tooltipContents.length, 2);
+    assert.isTrue(tooltipContents[0].hasTooltip);
+    assert.equal(tooltipContents[0].getAttribute('title'), 'hello');
+    assert.isFalse(tooltipContents[1].hasTooltip);
+  });
+
+  suite('keyboard navigation', () => {
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_computeIsDownload', () => {
-      assert.isTrue(element._computeIsDownload({download: true}));
-      assert.isFalse(element._computeIsDownload({download: false}));
-    });
-
-    test('tap on trigger opens menu, then closes', () => {
-      sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
-      sandbox.stub(element, '_close', () => { element.$.dropdown.close(); });
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(element.$.trigger);
-      assert.isTrue(element.$.dropdown.opened);
-      MockInteractions.tap(element.$.trigger);
-      assert.isFalse(element.$.dropdown.opened);
-    });
-
-    test('_computeURLHelper', () => {
-      const path = '/test';
-      const host = 'http://www.testsite.com';
-      const computedPath = element._computeURLHelper(host, path);
-      assert.equal(computedPath, '//http://www.testsite.com/test');
-    });
-
-    test('link URLs', () => {
-      assert.equal(
-          element._computeLinkURL({url: 'http://example.com/test'}),
-          'http://example.com/test');
-      assert.equal(
-          element._computeLinkURL({url: 'https://example.com/test'}),
-          'https://example.com/test');
-      assert.equal(
-          element._computeLinkURL({url: '/test'}),
-          '//' + window.location.host + '/test');
-      assert.equal(
-          element._computeLinkURL({url: '/test', target: '_blank'}),
-          '/test');
-    });
-
-    test('link rel', () => {
-      let link = {url: '/test'};
-      assert.isNull(element._computeLinkRel(link));
-
-      link = {url: '/test', target: '_blank'};
-      assert.equal(element._computeLinkRel(link), 'noopener');
-
-      link = {url: '/test', external: true};
-      assert.equal(element._computeLinkRel(link), 'external');
-
-      link = {url: '/test', target: '_blank', external: true};
-      assert.equal(element._computeLinkRel(link), 'noopener');
-    });
-
-    test('_getClassIfBold', () => {
-      let bold = true;
-      assert.equal(element._getClassIfBold(bold), 'bold-text');
-
-      bold = false;
-      assert.equal(element._getClassIfBold(bold), '');
-    });
-
-    test('Top text exists and is bolded correctly', () => {
-      element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
-      flushAsynchronousOperations();
-      const topItems = Polymer.dom(element.root).querySelectorAll('.top-item');
-      assert.equal(topItems.length, 2);
-      assert.isTrue(topItems[0].classList.contains('bold-text'));
-      assert.isFalse(topItems[1].classList.contains('bold-text'));
-    });
-
-    test('non link items', () => {
-      const item0 = {name: 'item one', id: 'foo'};
-      element.items = [item0, {name: 'item two', id: 'bar'}];
-      const fooTapped = sandbox.stub();
-      const tapped = sandbox.stub();
-      element.addEventListener('tap-item-foo', fooTapped);
-      element.addEventListener('tap-item', tapped);
-      flushAsynchronousOperations();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.itemAction'));
-      assert.isTrue(fooTapped.called);
-      assert.isTrue(tapped.called);
-      assert.deepEqual(tapped.lastCall.args[0].detail, item0);
-    });
-
-    test('disabled non link item', () => {
-      element.items = [{name: 'item one', id: 'foo'}];
-      element.disabledIds = ['foo'];
-
-      const stub = sandbox.stub();
-      const tapped = sandbox.stub();
-      element.addEventListener('tap-item-foo', stub);
-      element.addEventListener('tap-item', tapped);
-      flushAsynchronousOperations();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.itemAction'));
-      assert.isFalse(stub.called);
-      assert.isFalse(tapped.called);
-    });
-
-    test('properly sets tooltips', () => {
       element.items = [
-        {name: 'item one', id: 'foo', tooltip: 'hello'},
+        {name: 'item one', id: 'foo'},
         {name: 'item two', id: 'bar'},
       ];
-      element.disabledIds = [];
       flushAsynchronousOperations();
-      const tooltipContents = Polymer.dom(element.root)
-          .querySelectorAll('iron-dropdown li gr-tooltip-content');
-      assert.equal(tooltipContents.length, 2);
-      assert.isTrue(tooltipContents[0].hasTooltip);
-      assert.equal(tooltipContents[0].getAttribute('title'), 'hello');
-      assert.isFalse(tooltipContents[1].hasTooltip);
     });
 
-    suite('keyboard navigation', () => {
-      setup(() => {
-        element.items = [
-          {name: 'item one', id: 'foo'},
-          {name: 'item two', id: 'bar'},
-        ];
-        flushAsynchronousOperations();
-      });
+    test('down', () => {
+      const stub = sandbox.stub(element.$.cursor, 'next');
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      assert.isTrue(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      assert.isTrue(stub.called);
+    });
 
-      test('down', () => {
-        const stub = sandbox.stub(element.$.cursor, 'next');
-        assert.isFalse(element.$.dropdown.opened);
-        MockInteractions.pressAndReleaseKeyOn(element, 40);
-        assert.isTrue(element.$.dropdown.opened);
-        MockInteractions.pressAndReleaseKeyOn(element, 40);
-        assert.isTrue(stub.called);
-      });
+    test('up', () => {
+      const stub = sandbox.stub(element.$.cursor, 'previous');
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      assert.isTrue(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      assert.isTrue(stub.called);
+    });
 
-      test('up', () => {
-        const stub = sandbox.stub(element.$.cursor, 'previous');
-        assert.isFalse(element.$.dropdown.opened);
-        MockInteractions.pressAndReleaseKeyOn(element, 38);
-        assert.isTrue(element.$.dropdown.opened);
-        MockInteractions.pressAndReleaseKeyOn(element, 38);
-        assert.isTrue(stub.called);
-      });
+    test('enter/space', () => {
+      // Because enter and space are handled by the same fn, we need only to
+      // test one.
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+      assert.isTrue(element.$.dropdown.opened);
 
-      test('enter/space', () => {
-        // Because enter and space are handled by the same fn, we need only to
-        // test one.
-        assert.isFalse(element.$.dropdown.opened);
-        MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
-        assert.isTrue(element.$.dropdown.opened);
-
-        const el = element.$.cursor.target.querySelector(':not([hidden]) a');
-        const stub = sandbox.stub(el, 'click');
-        MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
-        assert.isTrue(stub.called);
-      });
+      const el = element.$.cursor.target.querySelector(':not([hidden]) a');
+      const stub = sandbox.stub(el, 'click');
+      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+      assert.isTrue(stub.called);
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
index 4510d3f..a417e3f 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
@@ -14,152 +14,163 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const RESTORED_MESSAGE = 'Content restored from a previous edit.';
-  const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../gr-storage/gr-storage.js';
+import '../gr-button/gr-button.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-editable-content_html.js';
+
+const RESTORED_MESSAGE = 'Content restored from a previous edit.';
+const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrEditableContent extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-editable-content'; }
+  /**
+   * Fired when the save button is pressed.
+   *
+   * @event editable-content-save
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when the cancel button is pressed.
+   *
+   * @event editable-content-cancel
    */
-  class GrEditableContent extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-editable-content'; }
-    /**
-     * Fired when the save button is pressed.
-     *
-     * @event editable-content-save
-     */
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event editable-content-cancel
-     */
+  /**
+   * Fired when content is restored from storage.
+   *
+   * @event show-alert
+   */
 
-    /**
-     * Fired when content is restored from storage.
-     *
-     * @event show-alert
-     */
-
-    static get properties() {
-      return {
-        content: {
-          notify: true,
-          type: String,
-        },
-        disabled: {
-          reflectToAttribute: true,
-          type: Boolean,
-          value: false,
-        },
-        editing: {
-          observer: '_editingChanged',
-          type: Boolean,
-          value: false,
-        },
-        removeZeroWidthSpace: Boolean,
-        // If no storage key is provided, content is not stored.
-        storageKey: String,
-        _saveDisabled: {
-          computed: '_computeSaveDisabled(disabled, content, _newContent)',
-          type: Boolean,
-          value: true,
-        },
-        _newContent: {
-          type: String,
-          observer: '_newContentChanged',
-        },
-      };
-    }
-
-    focusTextarea() {
-      this.shadowRoot.querySelector('iron-autogrow-textarea').textarea.focus();
-    }
-
-    _newContentChanged(newContent, oldContent) {
-      if (!this.storageKey) { return; }
-
-      this.debounce('store', () => {
-        if (newContent.length) {
-          this.$.storage.setEditableContentItem(this.storageKey, newContent);
-        } else {
-          // This does not really happen, because we don't clear newContent
-          // after saving (see below). So this only occurs when the user clears
-          // all the content in the editable textarea. But <gr-storage> cleans
-          // up itself after one day, so we are not so concerned about leaving
-          // some garbage behind.
-          this.$.storage.eraseEditableContentItem(this.storageKey);
-        }
-      }, STORAGE_DEBOUNCE_INTERVAL_MS);
-    }
-
-    _editingChanged(editing) {
-      // This method is for initializing _newContent when you start editing.
-      // Restoring content from local storage is not perfect and has
-      // some issues:
-      //
-      // 1. When you start editing in multiple tabs, then we are vulnerable to
-      // race conditions between the tabs.
-      // 2. The stored content is keyed by revision, so when you upload a new
-      // patchset and click "reload" and then click "cancel" on the content-
-      // editable, then you won't be able to recover the content anymore.
-      //
-      // Because of these issues we believe that it is better to only recover
-      // content from local storage when you enter editing mode for the first
-      // time. Otherwise it is better to just keep the last editing state from
-      // the same session.
-      if (!editing || this._newContent) {
-        return;
-      }
-
-      let content;
-      if (this.storageKey) {
-        const storedContent =
-            this.$.storage.getEditableContentItem(this.storageKey);
-        if (storedContent && storedContent.message) {
-          content = storedContent.message;
-          this.dispatchEvent(new CustomEvent('show-alert', {
-            detail: {message: RESTORED_MESSAGE},
-            bubbles: true,
-            composed: true,
-          }));
-        }
-      }
-      if (!content) {
-        content = this.content || '';
-      }
-
-      // TODO(wyatta) switch linkify sequence, see issue 5526.
-      this._newContent = this.removeZeroWidthSpace ?
-        content.replace(/^R=\u200B/gm, 'R=') :
-        content;
-    }
-
-    _computeSaveDisabled(disabled, content, newContent) {
-      return disabled || !newContent || content === newContent;
-    }
-
-    _handleSave(e) {
-      e.preventDefault();
-      this.fire('editable-content-save', {content: this._newContent});
-      // It would be nice, if we would set this._newContent = undefined here,
-      // but we can only do that when we are sure that the save operation has
-      // succeeded.
-    }
-
-    _handleCancel(e) {
-      e.preventDefault();
-      this.editing = false;
-      this.fire('editable-content-cancel');
-    }
+  static get properties() {
+    return {
+      content: {
+        notify: true,
+        type: String,
+      },
+      disabled: {
+        reflectToAttribute: true,
+        type: Boolean,
+        value: false,
+      },
+      editing: {
+        observer: '_editingChanged',
+        type: Boolean,
+        value: false,
+      },
+      removeZeroWidthSpace: Boolean,
+      // If no storage key is provided, content is not stored.
+      storageKey: String,
+      _saveDisabled: {
+        computed: '_computeSaveDisabled(disabled, content, _newContent)',
+        type: Boolean,
+        value: true,
+      },
+      _newContent: {
+        type: String,
+        observer: '_newContentChanged',
+      },
+    };
   }
 
-  customElements.define(GrEditableContent.is, GrEditableContent);
-})();
+  focusTextarea() {
+    this.shadowRoot.querySelector('iron-autogrow-textarea').textarea.focus();
+  }
+
+  _newContentChanged(newContent, oldContent) {
+    if (!this.storageKey) { return; }
+
+    this.debounce('store', () => {
+      if (newContent.length) {
+        this.$.storage.setEditableContentItem(this.storageKey, newContent);
+      } else {
+        // This does not really happen, because we don't clear newContent
+        // after saving (see below). So this only occurs when the user clears
+        // all the content in the editable textarea. But <gr-storage> cleans
+        // up itself after one day, so we are not so concerned about leaving
+        // some garbage behind.
+        this.$.storage.eraseEditableContentItem(this.storageKey);
+      }
+    }, STORAGE_DEBOUNCE_INTERVAL_MS);
+  }
+
+  _editingChanged(editing) {
+    // This method is for initializing _newContent when you start editing.
+    // Restoring content from local storage is not perfect and has
+    // some issues:
+    //
+    // 1. When you start editing in multiple tabs, then we are vulnerable to
+    // race conditions between the tabs.
+    // 2. The stored content is keyed by revision, so when you upload a new
+    // patchset and click "reload" and then click "cancel" on the content-
+    // editable, then you won't be able to recover the content anymore.
+    //
+    // Because of these issues we believe that it is better to only recover
+    // content from local storage when you enter editing mode for the first
+    // time. Otherwise it is better to just keep the last editing state from
+    // the same session.
+    if (!editing || this._newContent) {
+      return;
+    }
+
+    let content;
+    if (this.storageKey) {
+      const storedContent =
+          this.$.storage.getEditableContentItem(this.storageKey);
+      if (storedContent && storedContent.message) {
+        content = storedContent.message;
+        this.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {message: RESTORED_MESSAGE},
+          bubbles: true,
+          composed: true,
+        }));
+      }
+    }
+    if (!content) {
+      content = this.content || '';
+    }
+
+    // TODO(wyatta) switch linkify sequence, see issue 5526.
+    this._newContent = this.removeZeroWidthSpace ?
+      content.replace(/^R=\u200B/gm, 'R=') :
+      content;
+  }
+
+  _computeSaveDisabled(disabled, content, newContent) {
+    return disabled || !newContent || content === newContent;
+  }
+
+  _handleSave(e) {
+    e.preventDefault();
+    this.fire('editable-content-save', {content: this._newContent});
+    // It would be nice, if we would set this._newContent = undefined here,
+    // but we can only do that when we are sure that the save operation has
+    // succeeded.
+  }
+
+  _handleCancel(e) {
+    e.preventDefault();
+    this.editing = false;
+    this.fire('editable-content-cancel');
+  }
+}
+
+customElements.define(GrEditableContent.is, GrEditableContent);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.js
index 627f948..e0e5047 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-storage/gr-storage.html">
-<link rel="import" href="../gr-button/gr-button.html">
-
-<dom-module id="gr-editable-content">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -60,24 +53,15 @@
         justify-content: space-between;
       }
     </style>
-    <div class="viewer" hidden$="[[editing]]">
+    <div class="viewer" hidden\$="[[editing]]">
       <slot></slot>
     </div>
-    <div class="editor" hidden$="[[!editing]]">
-      <iron-autogrow-textarea
-          autocomplete="on"
-          bind-value="{{_newContent}}"
-          disabled="[[disabled]]"></iron-autogrow-textarea>
+    <div class="editor" hidden\$="[[!editing]]">
+      <iron-autogrow-textarea autocomplete="on" bind-value="{{_newContent}}" disabled="[[disabled]]"></iron-autogrow-textarea>
       <div class="editButtons">
-        <gr-button primary
-            on-click="_handleSave"
-            disabled="[[_saveDisabled]]">Save</gr-button>
-        <gr-button
-            on-click="_handleCancel"
-            disabled="[[disabled]]">Cancel</gr-button>
+        <gr-button primary="" on-click="_handleSave" disabled="[[_saveDisabled]]">Save</gr-button>
+        <gr-button on-click="_handleCancel" disabled="[[disabled]]">Cancel</gr-button>
       </div>
     </div>
     <gr-storage id="storage"></gr-storage>
-  </template>
-  <script src="gr-editable-content.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
index d34ff78..8f3b4d3 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
@@ -19,12 +19,12 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editable-content</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 <!-- Can't use absolute path below for mock-interaction.js.
 Web component tester(wct) has a built-in http server and it serves "/components" directory (which is
 actually /node_modules directory). Also, wct patches some files to load modules from /components.
@@ -33,9 +33,14 @@
 -->
 <script src="../../../node_modules/iron-test-helpers/mock-interactions.js"></script>
 
-<link rel="import" href="gr-editable-content.html">
+<script type="module" src="./gr-editable-content.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-editable-content.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -43,128 +48,130 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-editable-content tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-editable-content.js';
+suite('gr-editable-content tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('save event', done => {
+    element.content = '';
+    element._newContent = 'foo';
+    element.addEventListener('editable-content-save', e => {
+      assert.equal(e.detail.content, 'foo');
+      done();
+    });
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button[primary]'));
+  });
+
+  test('cancel event', done => {
+    element.addEventListener('editable-content-cancel', () => {
+      done();
+    });
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button:not([primary])'));
+  });
+
+  test('enabling editing keeps old content', () => {
+    element.content = 'current content';
+    element._newContent = 'old content';
+    element.editing = true;
+    assert.equal(element._newContent, 'old content');
+  });
+
+  test('disabling editing does not update edit field contents', () => {
+    element.content = 'current content';
+    element.editing = true;
+    element._newContent = 'stale content';
+    element.editing = false;
+    assert.equal(element._newContent, 'stale content');
+  });
+
+  test('zero width spaces are removed properly', () => {
+    element.removeZeroWidthSpace = true;
+    element.content = 'R=\u200Btest@google.com';
+    element.editing = true;
+    assert.equal(element._newContent, 'R=test@google.com');
+  });
+
+  suite('editing', () => {
     setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('save event', done => {
-      element.content = '';
-      element._newContent = 'foo';
-      element.addEventListener('editable-content-save', e => {
-        assert.equal(e.detail.content, 'foo');
-        done();
-      });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('gr-button[primary]'));
-    });
-
-    test('cancel event', done => {
-      element.addEventListener('editable-content-cancel', () => {
-        done();
-      });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('gr-button:not([primary])'));
-    });
-
-    test('enabling editing keeps old content', () => {
-      element.content = 'current content';
-      element._newContent = 'old content';
-      element.editing = true;
-      assert.equal(element._newContent, 'old content');
-    });
-
-    test('disabling editing does not update edit field contents', () => {
       element.content = 'current content';
       element.editing = true;
-      element._newContent = 'stale content';
-      element.editing = false;
-      assert.equal(element._newContent, 'stale content');
     });
 
-    test('zero width spaces are removed properly', () => {
-      element.removeZeroWidthSpace = true;
-      element.content = 'R=\u200Btest@google.com';
-      element.editing = true;
-      assert.equal(element._newContent, 'R=test@google.com');
+    test('save button is disabled initially', () => {
+      assert.isTrue(element.shadowRoot
+          .querySelector('gr-button[primary]').disabled);
     });
 
-    suite('editing', () => {
-      setup(() => {
-        element.content = 'current content';
-        element.editing = true;
-      });
-
-      test('save button is disabled initially', () => {
-        assert.isTrue(element.shadowRoot
-            .querySelector('gr-button[primary]').disabled);
-      });
-
-      test('save button is enabled when content changes', () => {
-        element._newContent = 'new content';
-        assert.isFalse(element.shadowRoot
-            .querySelector('gr-button[primary]').disabled);
-      });
-    });
-
-    suite('storageKey and related behavior', () => {
-      let dispatchSpy;
-      setup(() => {
-        element.content = 'current content';
-        element.storageKey = 'test';
-        dispatchSpy = sandbox.spy(element, 'dispatchEvent');
-      });
-
-      test('editing toggled to true, has stored data', () => {
-        sandbox.stub(element.$.storage, 'getEditableContentItem')
-            .returns({message: 'stored content'});
-        element.editing = true;
-
-        assert.equal(element._newContent, 'stored content');
-        assert.isTrue(dispatchSpy.called);
-        assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
-      });
-
-      test('editing toggled to true, has no stored data', () => {
-        sandbox.stub(element.$.storage, 'getEditableContentItem')
-            .returns({});
-        element.editing = true;
-
-        assert.equal(element._newContent, 'current content');
-        assert.isFalse(dispatchSpy.called);
-      });
-
-      test('edits are cached', () => {
-        const storeStub =
-            sandbox.stub(element.$.storage, 'setEditableContentItem');
-        const eraseStub =
-            sandbox.stub(element.$.storage, 'eraseEditableContentItem');
-        element.editing = true;
-
-        element._newContent = 'new content';
-        flushAsynchronousOperations();
-        element.flushDebouncer('store');
-
-        assert.isTrue(storeStub.called);
-        assert.deepEqual(
-            [element.storageKey, element._newContent],
-            storeStub.lastCall.args);
-
-        element._newContent = '';
-        flushAsynchronousOperations();
-        element.flushDebouncer('store');
-
-        assert.isTrue(eraseStub.called);
-        assert.deepEqual([element.storageKey], eraseStub.lastCall.args);
-      });
+    test('save button is enabled when content changes', () => {
+      element._newContent = 'new content';
+      assert.isFalse(element.shadowRoot
+          .querySelector('gr-button[primary]').disabled);
     });
   });
+
+  suite('storageKey and related behavior', () => {
+    let dispatchSpy;
+    setup(() => {
+      element.content = 'current content';
+      element.storageKey = 'test';
+      dispatchSpy = sandbox.spy(element, 'dispatchEvent');
+    });
+
+    test('editing toggled to true, has stored data', () => {
+      sandbox.stub(element.$.storage, 'getEditableContentItem')
+          .returns({message: 'stored content'});
+      element.editing = true;
+
+      assert.equal(element._newContent, 'stored content');
+      assert.isTrue(dispatchSpy.called);
+      assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
+    });
+
+    test('editing toggled to true, has no stored data', () => {
+      sandbox.stub(element.$.storage, 'getEditableContentItem')
+          .returns({});
+      element.editing = true;
+
+      assert.equal(element._newContent, 'current content');
+      assert.isFalse(dispatchSpy.called);
+    });
+
+    test('edits are cached', () => {
+      const storeStub =
+          sandbox.stub(element.$.storage, 'setEditableContentItem');
+      const eraseStub =
+          sandbox.stub(element.$.storage, 'eraseEditableContentItem');
+      element.editing = true;
+
+      element._newContent = 'new content';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isTrue(storeStub.called);
+      assert.deepEqual(
+          [element.storageKey, element._newContent],
+          storeStub.lastCall.args);
+
+      element._newContent = '';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isTrue(eraseStub.called);
+      assert.deepEqual([element.storageKey], eraseStub.lastCall.args);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
index ef5bb8c..3de5a64 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -14,193 +14,207 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const AWAIT_MAX_ITERS = 10;
-  const AWAIT_STEP = 5;
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {IronOverlayBehaviorImpl} from '@polymer/iron-overlay-behavior/iron-overlay-behavior.js';
+import '@polymer/iron-dropdown/iron-dropdown.js';
+import '@polymer/paper-input/paper-input.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../gr-button/gr-button.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-editable-label_html.js';
+
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @extends Polymer.Element
+ */
+class GrEditableLabel extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-editable-label'; }
+  /**
+   * Fired when the value is changed.
+   *
+   * @event changed
+   */
+
+  static get properties() {
+    return {
+      labelText: String,
+      editing: {
+        type: Boolean,
+        value: false,
+      },
+      value: {
+        type: String,
+        notify: true,
+        value: '',
+        observer: '_updateTitle',
+      },
+      placeholder: {
+        type: String,
+        value: '',
+      },
+      readOnly: {
+        type: Boolean,
+        value: false,
+      },
+      uppercase: {
+        type: Boolean,
+        reflectToAttribute: true,
+        value: false,
+      },
+      maxLength: Number,
+      _inputText: String,
+      // This is used to push the iron-input element up on the page, so
+      // the input is placed in approximately the same position as the
+      // trigger.
+      _verticalOffset: {
+        type: Number,
+        readOnly: true,
+        value: -30,
+      },
+    };
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('tabindex', '0');
+  }
+
+  get keyBindings() {
+    return {
+      enter: '_handleEnter',
+      esc: '_handleEsc',
+    };
+  }
+
+  _usePlaceholder(value, placeholder) {
+    return (!value || !value.length) && placeholder;
+  }
+
+  _computeLabel(value, placeholder) {
+    if (this._usePlaceholder(value, placeholder)) {
+      return placeholder;
+    }
+    return value;
+  }
+
+  _showDropdown() {
+    if (this.readOnly || this.editing) { return; }
+    return this._open().then(() => {
+      this._nativeInput.focus();
+      if (!this.$.input.value) { return; }
+      this._nativeInput.setSelectionRange(0, this.$.input.value.length);
+    });
+  }
+
+  open() {
+    return this._open().then(() => {
+      this._nativeInput.focus();
+    });
+  }
+
+  _open(...args) {
+    this.$.dropdown.open();
+    this._inputText = this.value;
+    this.editing = true;
+
+    return new Promise(resolve => {
+      IronOverlayBehaviorImpl.open.apply(this.$.dropdown, args);
+      this._awaitOpen(resolve);
+    });
+  }
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @extends Polymer.Element
+   * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
+   * opening. Eventually replace with a direct way to listen to the overlay.
    */
-  class GrEditableLabel extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-editable-label'; }
-    /**
-     * Fired when the value is changed.
-     *
-     * @event changed
-     */
+  _awaitOpen(fn) {
+    let iters = 0;
+    const step = () => {
+      this.async(() => {
+        if (this.$.dropdown.style.display !== 'none') {
+          fn.call(this);
+        } else if (iters++ < AWAIT_MAX_ITERS) {
+          step.call(this);
+        }
+      }, AWAIT_STEP);
+    };
+    step.call(this);
+  }
 
-    static get properties() {
-      return {
-        labelText: String,
-        editing: {
-          type: Boolean,
-          value: false,
-        },
-        value: {
-          type: String,
-          notify: true,
-          value: '',
-          observer: '_updateTitle',
-        },
-        placeholder: {
-          type: String,
-          value: '',
-        },
-        readOnly: {
-          type: Boolean,
-          value: false,
-        },
-        uppercase: {
-          type: Boolean,
-          reflectToAttribute: true,
-          value: false,
-        },
-        maxLength: Number,
-        _inputText: String,
-        // This is used to push the iron-input element up on the page, so
-        // the input is placed in approximately the same position as the
-        // trigger.
-        _verticalOffset: {
-          type: Number,
-          readOnly: true,
-          value: -30,
-        },
-      };
-    }
+  _id() {
+    return this.getAttribute('id') || 'global';
+  }
 
-    /** @override */
-    ready() {
-      super.ready();
-      this._ensureAttribute('tabindex', '0');
-    }
+  _save() {
+    if (!this.editing) { return; }
+    this.$.dropdown.close();
+    this.value = this._inputText;
+    this.editing = false;
+    this.fire('changed', this.value);
+  }
 
-    get keyBindings() {
-      return {
-        enter: '_handleEnter',
-        esc: '_handleEsc',
-      };
-    }
+  _cancel() {
+    if (!this.editing) { return; }
+    this.$.dropdown.close();
+    this.editing = false;
+    this._inputText = this.value;
+  }
 
-    _usePlaceholder(value, placeholder) {
-      return (!value || !value.length) && placeholder;
-    }
+  get _nativeInput() {
+    // In Polymer 2, the namespace of nativeInput
+    // changed from input to nativeInput
+    return this.$.input.$.nativeInput || this.$.input.$.input;
+  }
 
-    _computeLabel(value, placeholder) {
-      if (this._usePlaceholder(value, placeholder)) {
-        return placeholder;
-      }
-      return value;
-    }
-
-    _showDropdown() {
-      if (this.readOnly || this.editing) { return; }
-      return this._open().then(() => {
-        this._nativeInput.focus();
-        if (!this.$.input.value) { return; }
-        this._nativeInput.setSelectionRange(0, this.$.input.value.length);
-      });
-    }
-
-    open() {
-      return this._open().then(() => {
-        this._nativeInput.focus();
-      });
-    }
-
-    _open(...args) {
-      this.$.dropdown.open();
-      this._inputText = this.value;
-      this.editing = true;
-
-      return new Promise(resolve => {
-        Polymer.IronOverlayBehaviorImpl.open.apply(this.$.dropdown, args);
-        this._awaitOpen(resolve);
-      });
-    }
-
-    /**
-     * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
-     * opening. Eventually replace with a direct way to listen to the overlay.
-     */
-    _awaitOpen(fn) {
-      let iters = 0;
-      const step = () => {
-        this.async(() => {
-          if (this.$.dropdown.style.display !== 'none') {
-            fn.call(this);
-          } else if (iters++ < AWAIT_MAX_ITERS) {
-            step.call(this);
-          }
-        }, AWAIT_STEP);
-      };
-      step.call(this);
-    }
-
-    _id() {
-      return this.getAttribute('id') || 'global';
-    }
-
-    _save() {
-      if (!this.editing) { return; }
-      this.$.dropdown.close();
-      this.value = this._inputText;
-      this.editing = false;
-      this.fire('changed', this.value);
-    }
-
-    _cancel() {
-      if (!this.editing) { return; }
-      this.$.dropdown.close();
-      this.editing = false;
-      this._inputText = this.value;
-    }
-
-    get _nativeInput() {
-      // In Polymer 2, the namespace of nativeInput
-      // changed from input to nativeInput
-      return this.$.input.$.nativeInput || this.$.input.$.input;
-    }
-
-    _handleEnter(e) {
-      e = this.getKeyboardEvent(e);
-      const target = Polymer.dom(e).rootTarget;
-      if (target === this._nativeInput) {
-        e.preventDefault();
-        this._save();
-      }
-    }
-
-    _handleEsc(e) {
-      e = this.getKeyboardEvent(e);
-      const target = Polymer.dom(e).rootTarget;
-      if (target === this._nativeInput) {
-        e.preventDefault();
-        this._cancel();
-      }
-    }
-
-    _computeLabelClass(readOnly, value, placeholder) {
-      const classes = [];
-      if (!readOnly) { classes.push('editable'); }
-      if (this._usePlaceholder(value, placeholder)) {
-        classes.push('placeholder');
-      }
-      return classes.join(' ');
-    }
-
-    _updateTitle(value) {
-      this.setAttribute('title', this._computeLabel(value, this.placeholder));
+  _handleEnter(e) {
+    e = this.getKeyboardEvent(e);
+    const target = dom(e).rootTarget;
+    if (target === this._nativeInput) {
+      e.preventDefault();
+      this._save();
     }
   }
 
-  customElements.define(GrEditableLabel.is, GrEditableLabel);
-})();
+  _handleEsc(e) {
+    e = this.getKeyboardEvent(e);
+    const target = dom(e).rootTarget;
+    if (target === this._nativeInput) {
+      e.preventDefault();
+      this._cancel();
+    }
+  }
+
+  _computeLabelClass(readOnly, value, placeholder) {
+    const classes = [];
+    if (!readOnly) { classes.push('editable'); }
+    if (this._usePlaceholder(value, placeholder)) {
+      classes.push('placeholder');
+    }
+    return classes.join(' ');
+  }
+
+  _updateTitle(value) {
+    this.setAttribute('title', this._computeLabel(value, this.placeholder));
+  }
+}
+
+customElements.define(GrEditableLabel.is, GrEditableLabel);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.js
index 8d0d1c37..9bc31a3 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.js
@@ -1,31 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="/bower_components/iron-overlay-behavior/iron-overlay-behavior.html">
-<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="/bower_components/paper-input/paper-input.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-button/gr-button.html">
-
-<dom-module id="gr-editable-label">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         align-items: center;
@@ -78,30 +69,16 @@
         --paper-input-container-focus-color: var(--link-color);
       }
     </style>
-      <label
-          class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
-          title$="[[_computeLabel(value, placeholder)]]"
-          on-click="_showDropdown">[[_computeLabel(value, placeholder)]]</label>
-      <iron-dropdown id="dropdown"
-          vertical-align="auto"
-          horizontal-align="auto"
-          vertical-offset="[[_verticalOffset]]"
-          allow-outside-scroll="true"
-          on-iron-overlay-canceled="_cancel">
+      <label class\$="[[_computeLabelClass(readOnly, value, placeholder)]]" title\$="[[_computeLabel(value, placeholder)]]" on-click="_showDropdown">[[_computeLabel(value, placeholder)]]</label>
+      <iron-dropdown id="dropdown" vertical-align="auto" horizontal-align="auto" vertical-offset="[[_verticalOffset]]" allow-outside-scroll="true" on-iron-overlay-canceled="_cancel">
         <div class="dropdown-content" slot="dropdown-content">
           <div class="inputContainer">
-            <paper-input
-                id="input"
-                label="[[labelText]]"
-                maxlength="[[maxLength]]"
-                value="{{_inputText}}"></paper-input>
+            <paper-input id="input" label="[[labelText]]" maxlength="[[maxLength]]" value="{{_inputText}}"></paper-input>
             <div class="buttons">
-              <gr-button link id="cancelBtn" on-click="_cancel">cancel</gr-button>
-              <gr-button link id="saveBtn" on-click="_save">save</gr-button>
+              <gr-button link="" id="cancelBtn" on-click="_cancel">cancel</gr-button>
+              <gr-button link="" id="saveBtn" on-click="_save">save</gr-button>
             </div>
           </div>
         </div>
     </iron-dropdown>
-  </template>
-  <script src="gr-editable-label.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
index ccf5e3b..29e6619 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
@@ -19,12 +19,12 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editable-label</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 <!-- Can't use absolute path below for mock-interaction.js.
 Web component tester(wct) has a built-in http server and it serves "/components" directory (which is
 actually /node_modules directory). Also, wct patches some files to load modules from /components.
@@ -33,9 +33,14 @@
 -->
 <script src="../../../node_modules/iron-test-helpers/mock-interactions.js"></script>
 
-<link rel="import" href="gr-editable-label.html">
+<script type="module" src="./gr-editable-label.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-editable-label.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -60,197 +65,200 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-editable-label tests', async () => {
-    await readyToTest();
-    let element;
-    let elementNoPlaceholder;
-    let input;
-    let label;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-editable-label.js';
+import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-editable-label tests', () => {
+  let element;
+  let elementNoPlaceholder;
+  let input;
+  let label;
+  let sandbox;
 
-    setup(done => {
-      element = fixture('basic');
-      elementNoPlaceholder = fixture('no-placeholder');
+  setup(done => {
+    element = fixture('basic');
+    elementNoPlaceholder = fixture('no-placeholder');
 
-      label = element.shadowRoot
-          .querySelector('label');
-      sandbox = sinon.sandbox.create();
-      flush(() => {
-        // In Polymer 2 inputElement isn't nativeInput anymore
-        input = element.$.input.$.nativeInput || element.$.input.inputElement;
-        done();
-      });
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('element render', () => {
-      // The dropdown is closed and the label is visible:
-      assert.isFalse(element.$.dropdown.opened);
-      assert.isTrue(label.classList.contains('editable'));
-      assert.equal(label.textContent, 'value text');
-      const focusSpy = sandbox.spy(input, 'focus');
-      const showSpy = sandbox.spy(element, '_showDropdown');
-
-      MockInteractions.tap(label);
-
-      return showSpy.lastCall.returnValue.then(() => {
-        // The dropdown is open (which covers up the label):
-        assert.isTrue(element.$.dropdown.opened);
-        assert.isTrue(focusSpy.called);
-        assert.equal(input.value, 'value text');
-      });
-    });
-
-    test('title with placeholder', done => {
-      assert.equal(element.title, 'value text');
-      element.value = '';
-
-      element.async(() => {
-        assert.equal(element.title, 'label text');
-        done();
-      });
-    });
-
-    test('title without placeholder', done => {
-      assert.equal(elementNoPlaceholder.title, '');
-      element.value = 'value text';
-
-      element.async(() => {
-        assert.equal(element.title, 'value text');
-        done();
-      });
-    });
-
-    test('edit value', done => {
-      const editedStub = sandbox.stub();
-      element.addEventListener('changed', editedStub);
-      assert.isFalse(element.editing);
-
-      MockInteractions.tap(label);
-
-      Polymer.dom.flush();
-
-      assert.isTrue(element.editing);
-      element._inputText = 'new text';
-
-      assert.isFalse(editedStub.called);
-
-      element.async(() => {
-        assert.isTrue(editedStub.called);
-        assert.equal(input.value, 'new text');
-        assert.isFalse(element.editing);
-        done();
-      });
-
-      // Press enter:
-      MockInteractions.keyDownOn(input, 13);
-    });
-
-    test('save button', done => {
-      const editedStub = sandbox.stub();
-      element.addEventListener('changed', editedStub);
-      assert.isFalse(element.editing);
-
-      MockInteractions.tap(label);
-
-      Polymer.dom.flush();
-
-      assert.isTrue(element.editing);
-      element._inputText = 'new text';
-
-      assert.isFalse(editedStub.called);
-
-      element.async(() => {
-        assert.isTrue(editedStub.called);
-        assert.equal(input.value, 'new text');
-        assert.isFalse(element.editing);
-        done();
-      });
-
-      // Press enter:
-      MockInteractions.tap(element.$.saveBtn, 13);
-    });
-
-    test('edit and then escape key', done => {
-      const editedStub = sandbox.stub();
-      element.addEventListener('changed', editedStub);
-      assert.isFalse(element.editing);
-
-      MockInteractions.tap(label);
-
-      Polymer.dom.flush();
-
-      assert.isTrue(element.editing);
-      element._inputText = 'new text';
-
-      assert.isFalse(editedStub.called);
-
-      element.async(() => {
-        assert.isFalse(editedStub.called);
-        // Text changes sould be discarded.
-        assert.equal(input.value, 'value text');
-        assert.isFalse(element.editing);
-        done();
-      });
-
-      // Press escape:
-      MockInteractions.keyDownOn(input, 27);
-    });
-
-    test('cancel button', done => {
-      const editedStub = sandbox.stub();
-      element.addEventListener('changed', editedStub);
-      assert.isFalse(element.editing);
-
-      MockInteractions.tap(label);
-
-      Polymer.dom.flush();
-
-      assert.isTrue(element.editing);
-      element._inputText = 'new text';
-
-      assert.isFalse(editedStub.called);
-
-      element.async(() => {
-        assert.isFalse(editedStub.called);
-        // Text changes sould be discarded.
-        assert.equal(input.value, 'value text');
-        assert.isFalse(element.editing);
-        done();
-      });
-
-      // Press escape:
-      MockInteractions.tap(element.$.cancelBtn);
-    });
-
-    suite('gr-editable-label read-only tests', () => {
-      let element;
-      let label;
-
-      setup(() => {
-        element = fixture('read-only');
-        label = element.shadowRoot
-            .querySelector('label');
-      });
-
-      test('disallows edit when read-only', () => {
-        // The dropdown is closed.
-        assert.isFalse(element.$.dropdown.opened);
-        MockInteractions.tap(label);
-
-        Polymer.dom.flush();
-
-        // The dropdown is still closed.
-        assert.isFalse(element.$.dropdown.opened);
-      });
-
-      test('label is not marked as editable', () => {
-        assert.isFalse(label.classList.contains('editable'));
-      });
+    label = element.shadowRoot
+        .querySelector('label');
+    sandbox = sinon.sandbox.create();
+    flush(() => {
+      // In Polymer 2 inputElement isn't nativeInput anymore
+      input = element.$.input.$.nativeInput || element.$.input.inputElement;
+      done();
     });
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('element render', () => {
+    // The dropdown is closed and the label is visible:
+    assert.isFalse(element.$.dropdown.opened);
+    assert.isTrue(label.classList.contains('editable'));
+    assert.equal(label.textContent, 'value text');
+    const focusSpy = sandbox.spy(input, 'focus');
+    const showSpy = sandbox.spy(element, '_showDropdown');
+
+    MockInteractions.tap(label);
+
+    return showSpy.lastCall.returnValue.then(() => {
+      // The dropdown is open (which covers up the label):
+      assert.isTrue(element.$.dropdown.opened);
+      assert.isTrue(focusSpy.called);
+      assert.equal(input.value, 'value text');
+    });
+  });
+
+  test('title with placeholder', done => {
+    assert.equal(element.title, 'value text');
+    element.value = '';
+
+    element.async(() => {
+      assert.equal(element.title, 'label text');
+      done();
+    });
+  });
+
+  test('title without placeholder', done => {
+    assert.equal(elementNoPlaceholder.title, '');
+    element.value = 'value text';
+
+    element.async(() => {
+      assert.equal(element.title, 'value text');
+      done();
+    });
+  });
+
+  test('edit value', done => {
+    const editedStub = sandbox.stub();
+    element.addEventListener('changed', editedStub);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+
+    flush$0();
+
+    assert.isTrue(element.editing);
+    element._inputText = 'new text';
+
+    assert.isFalse(editedStub.called);
+
+    element.async(() => {
+      assert.isTrue(editedStub.called);
+      assert.equal(input.value, 'new text');
+      assert.isFalse(element.editing);
+      done();
+    });
+
+    // Press enter:
+    MockInteractions.keyDownOn(input, 13);
+  });
+
+  test('save button', done => {
+    const editedStub = sandbox.stub();
+    element.addEventListener('changed', editedStub);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+
+    flush$0();
+
+    assert.isTrue(element.editing);
+    element._inputText = 'new text';
+
+    assert.isFalse(editedStub.called);
+
+    element.async(() => {
+      assert.isTrue(editedStub.called);
+      assert.equal(input.value, 'new text');
+      assert.isFalse(element.editing);
+      done();
+    });
+
+    // Press enter:
+    MockInteractions.tap(element.$.saveBtn, 13);
+  });
+
+  test('edit and then escape key', done => {
+    const editedStub = sandbox.stub();
+    element.addEventListener('changed', editedStub);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+
+    flush$0();
+
+    assert.isTrue(element.editing);
+    element._inputText = 'new text';
+
+    assert.isFalse(editedStub.called);
+
+    element.async(() => {
+      assert.isFalse(editedStub.called);
+      // Text changes sould be discarded.
+      assert.equal(input.value, 'value text');
+      assert.isFalse(element.editing);
+      done();
+    });
+
+    // Press escape:
+    MockInteractions.keyDownOn(input, 27);
+  });
+
+  test('cancel button', done => {
+    const editedStub = sandbox.stub();
+    element.addEventListener('changed', editedStub);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+
+    flush$0();
+
+    assert.isTrue(element.editing);
+    element._inputText = 'new text';
+
+    assert.isFalse(editedStub.called);
+
+    element.async(() => {
+      assert.isFalse(editedStub.called);
+      // Text changes sould be discarded.
+      assert.equal(input.value, 'value text');
+      assert.isFalse(element.editing);
+      done();
+    });
+
+    // Press escape:
+    MockInteractions.tap(element.$.cancelBtn);
+  });
+
+  suite('gr-editable-label read-only tests', () => {
+    let element;
+    let label;
+
+    setup(() => {
+      element = fixture('read-only');
+      label = element.shadowRoot
+          .querySelector('label');
+    });
+
+    test('disallows edit when read-only', () => {
+      // The dropdown is closed.
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.tap(label);
+
+      flush$0();
+
+      // The dropdown is still closed.
+      assert.isFalse(element.$.dropdown.opened);
+    });
+
+    test('label is not marked as editable', () => {
+      assert.isFalse(label.classList.contains('editable'));
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html b/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
index 305702a..a58df2e 100644
--- a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
@@ -19,13 +19,18 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-api-interface</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../gr-js-api-interface/gr-js-api-interface.html">
+<script src="../../../node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../gr-js-api-interface/gr-js-api-interface.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-js-api-interface/gr-js-api-interface.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -33,118 +38,120 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-event-interface tests', async () => {
-    await readyToTest();
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../gr-js-api-interface/gr-js-api-interface.js';
+suite('gr-event-interface tests', () => {
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('test on Gerrit', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
+      fixture('basic');
+      Gerrit.removeAllListeners();
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('communicate between plugin and Gerrit', done => {
+      const eventName = 'test-plugin-event';
+      let p;
+      Gerrit.on(eventName, e => {
+        assert.equal(e.value, 'test');
+        assert.equal(e.plugin, p);
+        done();
+      });
+      Gerrit.install(plugin => {
+        p = plugin;
+        Gerrit.emit(eventName, {value: 'test', plugin});
+      }, '0.1',
+      'http://test.com/plugins/testplugin/static/test.js');
     });
 
-    suite('test on Gerrit', () => {
-      setup(() => {
-        fixture('basic');
-        Gerrit.removeAllListeners();
+    test('listen on events from core', done => {
+      const eventName = 'test-plugin-event';
+      Gerrit.on(eventName, e => {
+        assert.equal(e.value, 'test');
+        done();
       });
 
-      test('communicate between plugin and Gerrit', done => {
-        const eventName = 'test-plugin-event';
-        let p;
+      Gerrit.emit(eventName, {value: 'test'});
+    });
+
+    test('communicate across plugins', done => {
+      const eventName = 'test-plugin-event';
+      Gerrit.install(plugin => {
         Gerrit.on(eventName, e => {
-          assert.equal(e.value, 'test');
-          assert.equal(e.plugin, p);
+          assert.equal(e.plugin.getPluginName(), 'testB');
           done();
         });
-        Gerrit.install(plugin => {
-          p = plugin;
-          Gerrit.emit(eventName, {value: 'test', plugin});
-        }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-      });
+      }, '0.1',
+      'http://test.com/plugins/testA/static/testA.js');
 
-      test('listen on events from core', done => {
-        const eventName = 'test-plugin-event';
-        Gerrit.on(eventName, e => {
-          assert.equal(e.value, 'test');
-          done();
-        });
-
-        Gerrit.emit(eventName, {value: 'test'});
-      });
-
-      test('communicate across plugins', done => {
-        const eventName = 'test-plugin-event';
-        Gerrit.install(plugin => {
-          Gerrit.on(eventName, e => {
-            assert.equal(e.plugin.getPluginName(), 'testB');
-            done();
-          });
-        }, '0.1',
-        'http://test.com/plugins/testA/static/testA.js');
-
-        Gerrit.install(plugin => {
-          Gerrit.emit(eventName, {plugin});
-        }, '0.1',
-        'http://test.com/plugins/testB/static/testB.js');
-      });
-    });
-
-    suite('test on interfaces', () => {
-      let testObj;
-
-      class TestClass extends EventEmitter {
-      }
-
-      setup(() => {
-        testObj = new TestClass();
-      });
-
-      test('on', () => {
-        const cbStub = sinon.stub();
-        testObj.on('test', cbStub);
-        testObj.emit('test');
-        testObj.emit('test');
-        assert.isTrue(cbStub.calledTwice);
-      });
-
-      test('once', () => {
-        const cbStub = sinon.stub();
-        testObj.once('test', cbStub);
-        testObj.emit('test');
-        testObj.emit('test');
-        assert.isTrue(cbStub.calledOnce);
-      });
-
-      test('unsubscribe', () => {
-        const cbStub = sinon.stub();
-        const unsubscribe = testObj.on('test', cbStub);
-        testObj.emit('test');
-        unsubscribe();
-        testObj.emit('test');
-        assert.isTrue(cbStub.calledOnce);
-      });
-
-      test('off', () => {
-        const cbStub = sinon.stub();
-        testObj.on('test', cbStub);
-        testObj.emit('test');
-        testObj.off('test', cbStub);
-        testObj.emit('test');
-        assert.isTrue(cbStub.calledOnce);
-      });
-
-      test('removeAllListeners', () => {
-        const cbStub = sinon.stub();
-        testObj.on('test', cbStub);
-        testObj.removeAllListeners('test');
-        testObj.emit('test');
-        assert.isTrue(cbStub.notCalled);
-      });
+      Gerrit.install(plugin => {
+        Gerrit.emit(eventName, {plugin});
+      }, '0.1',
+      'http://test.com/plugins/testB/static/testB.js');
     });
   });
+
+  suite('test on interfaces', () => {
+    let testObj;
+
+    class TestClass extends EventEmitter {
+    }
+
+    setup(() => {
+      testObj = new TestClass();
+    });
+
+    test('on', () => {
+      const cbStub = sinon.stub();
+      testObj.on('test', cbStub);
+      testObj.emit('test');
+      testObj.emit('test');
+      assert.isTrue(cbStub.calledTwice);
+    });
+
+    test('once', () => {
+      const cbStub = sinon.stub();
+      testObj.once('test', cbStub);
+      testObj.emit('test');
+      testObj.emit('test');
+      assert.isTrue(cbStub.calledOnce);
+    });
+
+    test('unsubscribe', () => {
+      const cbStub = sinon.stub();
+      const unsubscribe = testObj.on('test', cbStub);
+      testObj.emit('test');
+      unsubscribe();
+      testObj.emit('test');
+      assert.isTrue(cbStub.calledOnce);
+    });
+
+    test('off', () => {
+      const cbStub = sinon.stub();
+      testObj.on('test', cbStub);
+      testObj.emit('test');
+      testObj.off('test', cbStub);
+      testObj.emit('test');
+      assert.isTrue(cbStub.calledOnce);
+    });
+
+    test('removeAllListeners', () => {
+      const cbStub = sinon.stub();
+      testObj.on('test', cbStub);
+      testObj.removeAllListeners('test');
+      testObj.emit('test');
+      assert.isTrue(cbStub.notCalled);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
index d4995cc..0d19f00 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
@@ -14,225 +14,231 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrFixedPanel extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-fixed-panel'; }
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-fixed-panel_html.js';
 
-    static get properties() {
-      return {
-        floatingDisabled: {
-          type: Boolean,
-          value: false,
-        },
-        readyForMeasure: {
-          type: Boolean,
-          observer: '_readyForMeasureObserver',
-        },
-        keepOnScroll: {
-          type: Boolean,
-          value: false,
-        },
-        _isMeasured: {
-          type: Boolean,
-          value: false,
-        },
+/** @extends Polymer.Element */
+class GrFixedPanel extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-        /**
-         * Initial offset from the top of the document, in pixels.
-         */
-        _topInitial: Number,
+  static get is() { return 'gr-fixed-panel'; }
 
-        /**
-         * Current offset from the top of the window, in pixels.
-         */
-        _topLast: Number,
+  static get properties() {
+    return {
+      floatingDisabled: {
+        type: Boolean,
+        value: false,
+      },
+      readyForMeasure: {
+        type: Boolean,
+        observer: '_readyForMeasureObserver',
+      },
+      keepOnScroll: {
+        type: Boolean,
+        value: false,
+      },
+      _isMeasured: {
+        type: Boolean,
+        value: false,
+      },
 
-        _headerHeight: Number,
-        _headerFloating: {
-          type: Boolean,
-          value: false,
-        },
-        _observer: {
-          type: Object,
-          value: null,
-        },
-        /**
-         * If place before any other content defines how much
-         * of the content below it is covered by this panel
-         */
-        floatingHeight: {
-          type: Number,
-          value: 0,
-          notify: true,
-        },
+      /**
+       * Initial offset from the top of the document, in pixels.
+       */
+      _topInitial: Number,
 
-        _webComponentsReady: Boolean,
-      };
+      /**
+       * Current offset from the top of the window, in pixels.
+       */
+      _topLast: Number,
+
+      _headerHeight: Number,
+      _headerFloating: {
+        type: Boolean,
+        value: false,
+      },
+      _observer: {
+        type: Object,
+        value: null,
+      },
+      /**
+       * If place before any other content defines how much
+       * of the content below it is covered by this panel
+       */
+      floatingHeight: {
+        type: Number,
+        value: 0,
+        notify: true,
+      },
+
+      _webComponentsReady: Boolean,
+    };
+  }
+
+  static get observers() {
+    return [
+      '_updateFloatingHeight(floatingDisabled, _isMeasured, _headerHeight)',
+    ];
+  }
+
+  _updateFloatingHeight(floatingDisabled, isMeasured, headerHeight) {
+    if ([
+      floatingDisabled,
+      isMeasured,
+      headerHeight,
+    ].some(arg => arg === undefined)) {
+      return;
     }
+    this.floatingHeight =
+        (!floatingDisabled && isMeasured) ? headerHeight : 0;
+  }
 
-    static get observers() {
-      return [
-        '_updateFloatingHeight(floatingDisabled, _isMeasured, _headerHeight)',
-      ];
+  /** @override */
+  attached() {
+    super.attached();
+    if (this.floatingDisabled) {
+      return;
     }
-
-    _updateFloatingHeight(floatingDisabled, isMeasured, headerHeight) {
-      if ([
-        floatingDisabled,
-        isMeasured,
-        headerHeight,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-      this.floatingHeight =
-          (!floatingDisabled && isMeasured) ? headerHeight : 0;
+    // Enable content measure unless blocked by param.
+    if (this.readyForMeasure !== false) {
+      this.readyForMeasure = true;
     }
+    this.listen(window, 'resize', 'update');
+    this.listen(window, 'scroll', '_updateOnScroll');
+    this._observer = new MutationObserver(this.update.bind(this));
+    this._observer.observe(this.$.header, {childList: true, subtree: true});
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      if (this.floatingDisabled) {
-        return;
-      }
-      // Enable content measure unless blocked by param.
-      if (this.readyForMeasure !== false) {
-        this.readyForMeasure = true;
-      }
-      this.listen(window, 'resize', 'update');
-      this.listen(window, 'scroll', '_updateOnScroll');
-      this._observer = new MutationObserver(this.update.bind(this));
-      this._observer.observe(this.$.header, {childList: true, subtree: true});
-    }
-
-    /** @override */
-    detached() {
-      super.detached();
-      this.unlisten(window, 'scroll', '_updateOnScroll');
-      this.unlisten(window, 'resize', 'update');
-      if (this._observer) {
-        this._observer.disconnect();
-      }
-    }
-
-    _readyForMeasureObserver(readyForMeasure) {
-      if (readyForMeasure) {
-        this.update();
-      }
-    }
-
-    _computeHeaderClass(headerFloating, topLast) {
-      const fixedAtTop = this.keepOnScroll && topLast === 0;
-      return [
-        headerFloating ? 'floating' : '',
-        fixedAtTop ? 'fixedAtTop' : '',
-      ].join(' ');
-    }
-
-    unfloat() {
-      if (this.floatingDisabled) {
-        return;
-      }
-      this.$.header.style.top = '';
-      this._headerFloating = false;
-      this.updateStyles({'--header-height': ''});
-    }
-
-    update() {
-      this.debounce('update', () => {
-        this._updateDebounced();
-      }, 100);
-    }
-
-    _updateOnScroll() {
-      this.debounce('update', () => {
-        this._updateDebounced();
-      });
-    }
-
-    _updateDebounced() {
-      if (this.floatingDisabled) {
-        return;
-      }
-      this._isMeasured = false;
-      this._maybeFloatHeader();
-      this._reposition();
-    }
-
-    _getElementTop() {
-      return this.getBoundingClientRect().top;
-    }
-
-    _reposition() {
-      if (!this._headerFloating) {
-        return;
-      }
-      const header = this.$.header;
-      // Since the outer element is relative positioned, can  use its top
-      // to determine how to position the inner header element.
-      const elemTop = this._getElementTop();
-      let newTop;
-      if (this.keepOnScroll && elemTop < 0) {
-        // Should stick to the top.
-        newTop = 0;
-      } else {
-        // Keep in line with the outer element.
-        newTop = elemTop;
-      }
-      // Initialize top style if it doesn't exist yet.
-      if (!header.style.top && this._topLast === newTop) {
-        header.style.top = newTop;
-      }
-      if (this._topLast !== newTop) {
-        if (newTop === undefined) {
-          header.style.top = '';
-        } else {
-          header.style.top = newTop + 'px';
-        }
-        this._topLast = newTop;
-      }
-    }
-
-    _measure() {
-      if (this._isMeasured) {
-        return; // Already measured.
-      }
-      const rect = this.$.header.getBoundingClientRect();
-      if (rect.height === 0 && rect.width === 0) {
-        return; // Not ready for measurement yet.
-      }
-      const top = document.body.scrollTop + rect.top;
-      this._topLast = top;
-      this._headerHeight = rect.height;
-      this._topInitial =
-        this.getBoundingClientRect().top + document.body.scrollTop;
-      this._isMeasured = true;
-    }
-
-    _isFloatingNeeded() {
-      return this.keepOnScroll ||
-        document.body.scrollWidth > document.body.clientWidth;
-    }
-
-    _maybeFloatHeader() {
-      if (!this._isFloatingNeeded()) {
-        return;
-      }
-      this._measure();
-      if (this._isMeasured) {
-        this._floatHeader();
-      }
-    }
-
-    _floatHeader() {
-      this.updateStyles({'--header-height': this._headerHeight + 'px'});
-      this._headerFloating = true;
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'scroll', '_updateOnScroll');
+    this.unlisten(window, 'resize', 'update');
+    if (this._observer) {
+      this._observer.disconnect();
     }
   }
 
-  customElements.define(GrFixedPanel.is, GrFixedPanel);
-})();
+  _readyForMeasureObserver(readyForMeasure) {
+    if (readyForMeasure) {
+      this.update();
+    }
+  }
+
+  _computeHeaderClass(headerFloating, topLast) {
+    const fixedAtTop = this.keepOnScroll && topLast === 0;
+    return [
+      headerFloating ? 'floating' : '',
+      fixedAtTop ? 'fixedAtTop' : '',
+    ].join(' ');
+  }
+
+  unfloat() {
+    if (this.floatingDisabled) {
+      return;
+    }
+    this.$.header.style.top = '';
+    this._headerFloating = false;
+    this.updateStyles({'--header-height': ''});
+  }
+
+  update() {
+    this.debounce('update', () => {
+      this._updateDebounced();
+    }, 100);
+  }
+
+  _updateOnScroll() {
+    this.debounce('update', () => {
+      this._updateDebounced();
+    });
+  }
+
+  _updateDebounced() {
+    if (this.floatingDisabled) {
+      return;
+    }
+    this._isMeasured = false;
+    this._maybeFloatHeader();
+    this._reposition();
+  }
+
+  _getElementTop() {
+    return this.getBoundingClientRect().top;
+  }
+
+  _reposition() {
+    if (!this._headerFloating) {
+      return;
+    }
+    const header = this.$.header;
+    // Since the outer element is relative positioned, can  use its top
+    // to determine how to position the inner header element.
+    const elemTop = this._getElementTop();
+    let newTop;
+    if (this.keepOnScroll && elemTop < 0) {
+      // Should stick to the top.
+      newTop = 0;
+    } else {
+      // Keep in line with the outer element.
+      newTop = elemTop;
+    }
+    // Initialize top style if it doesn't exist yet.
+    if (!header.style.top && this._topLast === newTop) {
+      header.style.top = newTop;
+    }
+    if (this._topLast !== newTop) {
+      if (newTop === undefined) {
+        header.style.top = '';
+      } else {
+        header.style.top = newTop + 'px';
+      }
+      this._topLast = newTop;
+    }
+  }
+
+  _measure() {
+    if (this._isMeasured) {
+      return; // Already measured.
+    }
+    const rect = this.$.header.getBoundingClientRect();
+    if (rect.height === 0 && rect.width === 0) {
+      return; // Not ready for measurement yet.
+    }
+    const top = document.body.scrollTop + rect.top;
+    this._topLast = top;
+    this._headerHeight = rect.height;
+    this._topInitial =
+      this.getBoundingClientRect().top + document.body.scrollTop;
+    this._isMeasured = true;
+  }
+
+  _isFloatingNeeded() {
+    return this.keepOnScroll ||
+      document.body.scrollWidth > document.body.clientWidth;
+  }
+
+  _maybeFloatHeader() {
+    if (!this._isFloatingNeeded()) {
+      return;
+    }
+    this._measure();
+    if (this._isMeasured) {
+      this._floatHeader();
+    }
+  }
+
+  _floatHeader() {
+    this.updateStyles({'--header-height': this._headerHeight + 'px'});
+    this._headerFloating = true;
+  }
+}
+
+customElements.define(GrFixedPanel.is, GrFixedPanel);
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.js
index 14285b4..69ae735 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.js
@@ -1,25 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-fixed-panel">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         box-sizing: border-box;
@@ -44,9 +41,7 @@
         box-shadow: var(--elevation-level-2);
       }
     </style>
-    <header id="header" class$="[[_computeHeaderClass(_headerFloating, _topLast)]]">
+    <header id="header" class\$="[[_computeHeaderClass(_headerFloating, _topLast)]]">
       <slot></slot>
     </header>
-  </template>
-  <script src="gr-fixed-panel.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
index 7ae0265..e2f77c5 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
@@ -19,13 +19,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-fixed-panel</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-fixed-panel.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-fixed-panel.js"></script>
 <style>
   /* Prevent horizontal scrolling on page.
    New version of web-component-tester creates body with margins */
@@ -35,7 +35,12 @@
   }
 </style>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-fixed-panel.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -45,83 +50,85 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-fixed-panel', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-fixed-panel.js';
+suite('gr-fixed-panel', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.readyForMeasure = true;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('can be disabled with floatingDisabled', () => {
+    element.floatingDisabled = true;
+    sandbox.stub(element, '_reposition');
+    window.dispatchEvent(new CustomEvent('resize'));
+    element.flushDebouncer('update');
+    assert.isFalse(element._reposition.called);
+  });
+
+  test('header is the height of the content', () => {
+    assert.equal(element.getBoundingClientRect().height, 100);
+  });
+
+  test('scroll triggers _reposition', () => {
+    sandbox.stub(element, '_reposition');
+    window.dispatchEvent(new CustomEvent('scroll'));
+    element.flushDebouncer('update');
+    assert.isTrue(element._reposition.called);
+  });
+
+  suite('_reposition', () => {
+    const getHeaderTop = function() {
+      return element.$.header.style.top;
+    };
+
+    const emulateScrollY = function(distance) {
+      element._getElementTop.returns(element._headerTopInitial - distance);
+      element._updateDebounced();
+      element.flushDebouncer('scroll');
+    };
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.readyForMeasure = true;
+      element._headerTopInitial = 10;
+      sandbox.stub(element, '_getElementTop')
+          .returns(element._headerTopInitial);
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('scrolls header along with document', () => {
+      emulateScrollY(20);
+      // No top property is set when !_headerFloating.
+      assert.equal(getHeaderTop(), '');
     });
 
-    test('can be disabled with floatingDisabled', () => {
-      element.floatingDisabled = true;
-      sandbox.stub(element, '_reposition');
-      window.dispatchEvent(new CustomEvent('resize'));
-      element.flushDebouncer('update');
-      assert.isFalse(element._reposition.called);
+    test('does not stick to the top by default', () => {
+      emulateScrollY(150);
+      // No top property is set when !_headerFloating.
+      assert.equal(getHeaderTop(), '');
     });
 
-    test('header is the height of the content', () => {
-      assert.equal(element.getBoundingClientRect().height, 100);
+    test('sticks to the top if enabled', () => {
+      element.keepOnScroll = true;
+      emulateScrollY(120);
+      assert.equal(getHeaderTop(), '0px');
     });
 
-    test('scroll triggers _reposition', () => {
-      sandbox.stub(element, '_reposition');
-      window.dispatchEvent(new CustomEvent('scroll'));
-      element.flushDebouncer('update');
-      assert.isTrue(element._reposition.called);
-    });
-
-    suite('_reposition', () => {
-      const getHeaderTop = function() {
-        return element.$.header.style.top;
-      };
-
-      const emulateScrollY = function(distance) {
-        element._getElementTop.returns(element._headerTopInitial - distance);
-        element._updateDebounced();
-        element.flushDebouncer('scroll');
-      };
-
-      setup(() => {
-        element._headerTopInitial = 10;
-        sandbox.stub(element, '_getElementTop')
-            .returns(element._headerTopInitial);
-      });
-
-      test('scrolls header along with document', () => {
-        emulateScrollY(20);
-        // No top property is set when !_headerFloating.
-        assert.equal(getHeaderTop(), '');
-      });
-
-      test('does not stick to the top by default', () => {
-        emulateScrollY(150);
-        // No top property is set when !_headerFloating.
-        assert.equal(getHeaderTop(), '');
-      });
-
-      test('sticks to the top if enabled', () => {
-        element.keepOnScroll = true;
-        emulateScrollY(120);
-        assert.equal(getHeaderTop(), '0px');
-      });
-
-      test('drops a shadow when fixed to the top', () => {
-        element.keepOnScroll = true;
-        emulateScrollY(5);
-        assert.isFalse(element.$.header.classList.contains('fixedAtTop'));
-        emulateScrollY(120);
-        assert.isTrue(element.$.header.classList.contains('fixedAtTop'));
-      });
+    test('drops a shadow when fixed to the top', () => {
+      element.keepOnScroll = true;
+      emulateScrollY(5);
+      assert.isFalse(element.$.header.classList.contains('fixedAtTop'));
+      emulateScrollY(120);
+      assert.isTrue(element.$.header.classList.contains('fixedAtTop'));
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
index e62fcc8..139e09c 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
@@ -14,288 +14,296 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // eslint-disable-next-line no-unused-vars
-  const QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
-  const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
+import '../gr-linked-text/gr-linked-text.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-formatted-text_html.js';
 
-  /** @extends Polymer.Element */
-  class GrFormattedText extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-formatted-text'; }
+// eslint-disable-next-line no-unused-vars
+const QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
+const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
 
-    static get properties() {
-      return {
-        content: {
-          type: String,
-          observer: '_contentChanged',
-        },
-        config: Object,
-        noTrailingMargin: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrFormattedText extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    static get observers() {
-      return [
-        '_contentOrConfigChanged(content, config)',
-      ];
-    }
+  static get is() { return 'gr-formatted-text'; }
 
-    /** @override */
-    ready() {
-      super.ready();
-      if (this.noTrailingMargin) {
-        this.classList.add('noTrailingMargin');
-      }
-    }
+  static get properties() {
+    return {
+      content: {
+        type: String,
+        observer: '_contentChanged',
+      },
+      config: Object,
+      noTrailingMargin: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-    _contentChanged(content) {
-      // In the case where the config may not be set (perhaps due to the
-      // request for it still being in flight), set the content anyway to
-      // prevent waiting on the config to display the text.
-      if (this.config) { return; }
-      this._contentOrConfigChanged(content);
-    }
+  static get observers() {
+    return [
+      '_contentOrConfigChanged(content, config)',
+    ];
+  }
 
-    /**
-     * Given a source string, update the DOM inside #container.
-     */
-    _contentOrConfigChanged(content) {
-      const container = Polymer.dom(this.$.container);
-
-      // Remove existing content.
-      while (container.firstChild) {
-        container.removeChild(container.firstChild);
-      }
-
-      // Add new content.
-      for (const node of this._computeNodes(this._computeBlocks(content))) {
-        container.appendChild(node);
-      }
-    }
-
-    /**
-     * Given a source string, parse into an array of block objects. Each block
-     * has a `type` property which takes any of the follwoing values.
-     * * 'paragraph'
-     * * 'quote' (Block quote.)
-     * * 'pre' (Pre-formatted text.)
-     * * 'list' (Unordered list.)
-     * * 'code' (code blocks.)
-     *
-     * For blocks of type 'paragraph', 'pre' and 'code' there is a `text`
-     * property that maps to a string of the block's content.
-     *
-     * For blocks of type 'list', there is an `items` property that maps to a
-     * list of strings representing the list items.
-     *
-     * For blocks of type 'quote', there is a `blocks` property that maps to a
-     * list of blocks contained in the quote.
-     *
-     * NOTE: Strings appearing in all block objects are NOT escaped.
-     *
-     * @param {string} content
-     * @return {!Array<!Object>}
-     */
-    _computeBlocks(content) {
-      if (!content) { return []; }
-
-      const result = [];
-      const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n');
-
-      for (let i = 0; i < lines.length; i++) {
-        if (!lines[i].length) {
-          continue;
-        }
-
-        if (this._isCodeMarkLine(lines[i])) {
-          // handle multi-line code
-          let nextI = i+1;
-          while (!this._isCodeMarkLine(lines[nextI]) && nextI < lines.length) {
-            nextI++;
-          }
-
-          if (this._isCodeMarkLine(lines[nextI])) {
-            result.push({
-              type: 'code',
-              text: lines.slice(i+1, nextI).join('\n'),
-            });
-            i = nextI;
-            continue;
-          }
-
-          // otherwise treat it as regular line and continue
-          // check for other cases
-        }
-
-        if (this._isSingleLineCode(lines[i])) {
-          // no guard check as _isSingleLineCode tested on the pattern
-          const codeContent = lines[i].match(CODE_MARKER_PATTERN)[2];
-          result.push({type: 'code', text: codeContent});
-        } else if (this._isList(lines[i])) {
-          let nextI = i + 1;
-          while (this._isList(lines[nextI])) {
-            nextI++;
-          }
-          result.push(this._makeList(lines.slice(i, nextI)));
-          i = nextI - 1;
-        } else if (this._isQuote(lines[i])) {
-          let nextI = i + 1;
-          while (this._isQuote(lines[nextI])) {
-            nextI++;
-          }
-          const blockLines = lines.slice(i, nextI)
-              .map(l => l.replace(/^[ ]?>[ ]?/, ''));
-          result.push({
-            type: 'quote',
-            blocks: this._computeBlocks(blockLines.join('\n')),
-          });
-          i = nextI - 1;
-        } else if (this._isPreFormat(lines[i])) {
-          let nextI = i + 1;
-          // include pre or all regular lines but stop at next new line
-          while (this._isPreFormat(lines[nextI])
-           || (this._isRegularLine(lines[nextI]) && lines[nextI].length)) {
-            nextI++;
-          }
-          result.push({
-            type: 'pre',
-            text: lines.slice(i, nextI).join('\n'),
-          });
-          i = nextI - 1;
-        } else {
-          let nextI = i + 1;
-          while (this._isRegularLine(lines[nextI])) {
-            nextI++;
-          }
-          result.push({
-            type: 'paragraph',
-            text: lines.slice(i, nextI).join('\n'),
-          });
-          i = nextI - 1;
-        }
-      }
-
-      return result;
-    }
-
-    /**
-     * Take a block of comment text that contains a list, generate appropriate
-     * block objects and append them to the output list.
-     *
-     * * Item one.
-     * * Item two.
-     * * item three.
-     *
-     * TODO(taoalpha): maybe we should also support nested list
-     *
-     * @param {!Array<string>} lines The block containing the list.
-     */
-    _makeList(lines) {
-      const block = {type: 'list', items: []};
-      let line;
-
-      for (let i = 0; i < lines.length; i++) {
-        line = lines[i];
-        line = line.substring(1).trim();
-        block.items.push(line);
-      }
-      return block;
-    }
-
-    _isRegularLine(line) {
-      // line can not be recognized by existing patterns
-      if (line === undefined) return false;
-      return !this._isQuote(line) && !this._isCodeMarkLine(line)
-      && !this._isSingleLineCode(line) && !this._isList(line) &&
-      !this._isPreFormat(line);
-    }
-
-    _isQuote(line) {
-      return line && (line.startsWith('> ') || line.startsWith(' > '));
-    }
-
-    _isCodeMarkLine(line) {
-      return line && line.trim() === '```';
-    }
-
-    _isSingleLineCode(line) {
-      return line && CODE_MARKER_PATTERN.test(line);
-    }
-
-    _isPreFormat(line) {
-      return line && /^[ \t]/.test(line);
-    }
-
-    _isList(line) {
-      return line && /^[-*] /.test(line);
-    }
-
-    /**
-     * @param {string} content
-     * @param {boolean=} opt_isPre
-     */
-    _makeLinkedText(content, opt_isPre) {
-      const text = document.createElement('gr-linked-text');
-      text.config = this.config;
-      text.content = content;
-      text.pre = true;
-      if (opt_isPre) {
-        text.classList.add('pre');
-      }
-      return text;
-    }
-
-    /**
-     * Map an array of block objects to an array of DOM nodes.
-     *
-     * @param  {!Array<!Object>} blocks
-     * @return {!Array<!HTMLElement>}
-     */
-    _computeNodes(blocks) {
-      return blocks.map(block => {
-        if (block.type === 'paragraph') {
-          const p = document.createElement('p');
-          p.appendChild(this._makeLinkedText(block.text));
-          return p;
-        }
-
-        if (block.type === 'quote') {
-          const bq = document.createElement('blockquote');
-          for (const node of this._computeNodes(block.blocks)) {
-            bq.appendChild(node);
-          }
-          return bq;
-        }
-
-        if (block.type === 'code') {
-          const code = document.createElement('code');
-          code.textContent = block.text;
-          return code;
-        }
-
-        if (block.type === 'pre') {
-          return this._makeLinkedText(block.text, true);
-        }
-
-        if (block.type === 'list') {
-          const ul = document.createElement('ul');
-          for (const item of block.items) {
-            const li = document.createElement('li');
-            li.appendChild(this._makeLinkedText(item));
-            ul.appendChild(li);
-          }
-          return ul;
-        }
-      });
+  /** @override */
+  ready() {
+    super.ready();
+    if (this.noTrailingMargin) {
+      this.classList.add('noTrailingMargin');
     }
   }
 
-  customElements.define(GrFormattedText.is, GrFormattedText);
-})();
+  _contentChanged(content) {
+    // In the case where the config may not be set (perhaps due to the
+    // request for it still being in flight), set the content anyway to
+    // prevent waiting on the config to display the text.
+    if (this.config) { return; }
+    this._contentOrConfigChanged(content);
+  }
+
+  /**
+   * Given a source string, update the DOM inside #container.
+   */
+  _contentOrConfigChanged(content) {
+    const container = dom(this.$.container);
+
+    // Remove existing content.
+    while (container.firstChild) {
+      container.removeChild(container.firstChild);
+    }
+
+    // Add new content.
+    for (const node of this._computeNodes(this._computeBlocks(content))) {
+      container.appendChild(node);
+    }
+  }
+
+  /**
+   * Given a source string, parse into an array of block objects. Each block
+   * has a `type` property which takes any of the follwoing values.
+   * * 'paragraph'
+   * * 'quote' (Block quote.)
+   * * 'pre' (Pre-formatted text.)
+   * * 'list' (Unordered list.)
+   * * 'code' (code blocks.)
+   *
+   * For blocks of type 'paragraph', 'pre' and 'code' there is a `text`
+   * property that maps to a string of the block's content.
+   *
+   * For blocks of type 'list', there is an `items` property that maps to a
+   * list of strings representing the list items.
+   *
+   * For blocks of type 'quote', there is a `blocks` property that maps to a
+   * list of blocks contained in the quote.
+   *
+   * NOTE: Strings appearing in all block objects are NOT escaped.
+   *
+   * @param {string} content
+   * @return {!Array<!Object>}
+   */
+  _computeBlocks(content) {
+    if (!content) { return []; }
+
+    const result = [];
+    const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n');
+
+    for (let i = 0; i < lines.length; i++) {
+      if (!lines[i].length) {
+        continue;
+      }
+
+      if (this._isCodeMarkLine(lines[i])) {
+        // handle multi-line code
+        let nextI = i+1;
+        while (!this._isCodeMarkLine(lines[nextI]) && nextI < lines.length) {
+          nextI++;
+        }
+
+        if (this._isCodeMarkLine(lines[nextI])) {
+          result.push({
+            type: 'code',
+            text: lines.slice(i+1, nextI).join('\n'),
+          });
+          i = nextI;
+          continue;
+        }
+
+        // otherwise treat it as regular line and continue
+        // check for other cases
+      }
+
+      if (this._isSingleLineCode(lines[i])) {
+        // no guard check as _isSingleLineCode tested on the pattern
+        const codeContent = lines[i].match(CODE_MARKER_PATTERN)[2];
+        result.push({type: 'code', text: codeContent});
+      } else if (this._isList(lines[i])) {
+        let nextI = i + 1;
+        while (this._isList(lines[nextI])) {
+          nextI++;
+        }
+        result.push(this._makeList(lines.slice(i, nextI)));
+        i = nextI - 1;
+      } else if (this._isQuote(lines[i])) {
+        let nextI = i + 1;
+        while (this._isQuote(lines[nextI])) {
+          nextI++;
+        }
+        const blockLines = lines.slice(i, nextI)
+            .map(l => l.replace(/^[ ]?>[ ]?/, ''));
+        result.push({
+          type: 'quote',
+          blocks: this._computeBlocks(blockLines.join('\n')),
+        });
+        i = nextI - 1;
+      } else if (this._isPreFormat(lines[i])) {
+        let nextI = i + 1;
+        // include pre or all regular lines but stop at next new line
+        while (this._isPreFormat(lines[nextI])
+         || (this._isRegularLine(lines[nextI]) && lines[nextI].length)) {
+          nextI++;
+        }
+        result.push({
+          type: 'pre',
+          text: lines.slice(i, nextI).join('\n'),
+        });
+        i = nextI - 1;
+      } else {
+        let nextI = i + 1;
+        while (this._isRegularLine(lines[nextI])) {
+          nextI++;
+        }
+        result.push({
+          type: 'paragraph',
+          text: lines.slice(i, nextI).join('\n'),
+        });
+        i = nextI - 1;
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * Take a block of comment text that contains a list, generate appropriate
+   * block objects and append them to the output list.
+   *
+   * * Item one.
+   * * Item two.
+   * * item three.
+   *
+   * TODO(taoalpha): maybe we should also support nested list
+   *
+   * @param {!Array<string>} lines The block containing the list.
+   */
+  _makeList(lines) {
+    const block = {type: 'list', items: []};
+    let line;
+
+    for (let i = 0; i < lines.length; i++) {
+      line = lines[i];
+      line = line.substring(1).trim();
+      block.items.push(line);
+    }
+    return block;
+  }
+
+  _isRegularLine(line) {
+    // line can not be recognized by existing patterns
+    if (line === undefined) return false;
+    return !this._isQuote(line) && !this._isCodeMarkLine(line)
+    && !this._isSingleLineCode(line) && !this._isList(line) &&
+    !this._isPreFormat(line);
+  }
+
+  _isQuote(line) {
+    return line && (line.startsWith('> ') || line.startsWith(' > '));
+  }
+
+  _isCodeMarkLine(line) {
+    return line && line.trim() === '```';
+  }
+
+  _isSingleLineCode(line) {
+    return line && CODE_MARKER_PATTERN.test(line);
+  }
+
+  _isPreFormat(line) {
+    return line && /^[ \t]/.test(line);
+  }
+
+  _isList(line) {
+    return line && /^[-*] /.test(line);
+  }
+
+  /**
+   * @param {string} content
+   * @param {boolean=} opt_isPre
+   */
+  _makeLinkedText(content, opt_isPre) {
+    const text = document.createElement('gr-linked-text');
+    text.config = this.config;
+    text.content = content;
+    text.pre = true;
+    if (opt_isPre) {
+      text.classList.add('pre');
+    }
+    return text;
+  }
+
+  /**
+   * Map an array of block objects to an array of DOM nodes.
+   *
+   * @param  {!Array<!Object>} blocks
+   * @return {!Array<!HTMLElement>}
+   */
+  _computeNodes(blocks) {
+    return blocks.map(block => {
+      if (block.type === 'paragraph') {
+        const p = document.createElement('p');
+        p.appendChild(this._makeLinkedText(block.text));
+        return p;
+      }
+
+      if (block.type === 'quote') {
+        const bq = document.createElement('blockquote');
+        for (const node of this._computeNodes(block.blocks)) {
+          bq.appendChild(node);
+        }
+        return bq;
+      }
+
+      if (block.type === 'code') {
+        const code = document.createElement('code');
+        code.textContent = block.text;
+        return code;
+      }
+
+      if (block.type === 'pre') {
+        return this._makeLinkedText(block.text, true);
+      }
+
+      if (block.type === 'list') {
+        const ul = document.createElement('ul');
+        for (const item of block.items) {
+          const li = document.createElement('li');
+          li.appendChild(this._makeLinkedText(item));
+          ul.appendChild(li);
+        }
+        return ul;
+      }
+    });
+  }
+}
+
+customElements.define(GrFormattedText.is, GrFormattedText);
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js
index 0c254ee..a30b65a 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js
@@ -1,25 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../gr-linked-text/gr-linked-text.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-formatted-text">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -66,6 +63,4 @@
 
     </style>
     <div id="container"></div>
-  </template>
-  <script src="gr-formatted-text.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
index a6d8524..56bd44ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editable-label</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-formatted-text.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-formatted-text.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-formatted-text.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,395 +40,397 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-formatted-text tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-formatted-text.js';
+suite('gr-formatted-text tests', () => {
+  let element;
+  let sandbox;
 
-    function assertBlock(result, index, type, text) {
-      assert.equal(result[index].type, type);
-      assert.equal(result[index].text, text);
-    }
+  function assertBlock(result, index, type, text) {
+    assert.equal(result[index].type, type);
+    assert.equal(result[index].text, text);
+  }
 
-    function assertListBlock(result, resultIndex, itemIndex, text) {
-      assert.equal(result[resultIndex].type, 'list');
-      assert.equal(result[resultIndex].items[itemIndex], text);
-    }
+  function assertListBlock(result, resultIndex, itemIndex, text) {
+    assert.equal(result[resultIndex].type, 'list');
+    assert.equal(result[resultIndex].items[itemIndex], text);
+  }
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('parse null undefined and empty', () => {
-      assert.lengthOf(element._computeBlocks(null), 0);
-      assert.lengthOf(element._computeBlocks(undefined), 0);
-      assert.lengthOf(element._computeBlocks(''), 0);
-    });
-
-    test('parse simple', () => {
-      const comment = 'Para1';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertBlock(result, 0, 'paragraph', comment);
-    });
-
-    test('parse multiline para', () => {
-      const comment = 'Para 1\nStill para 1';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertBlock(result, 0, 'paragraph', comment);
-    });
-
-    test('parse para break without special blocks', () => {
-      const comment = 'Para 1\n\nPara 2\n\nPara 3';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertBlock(result, 0, 'paragraph', comment);
-    });
-
-    test('parse quote', () => {
-      const comment = '> Quote text';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assert.equal(result[0].type, 'quote');
-      assert.lengthOf(result[0].blocks, 1);
-      assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
-    });
-
-    test('parse quote lead space', () => {
-      const comment = ' > Quote text';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assert.equal(result[0].type, 'quote');
-      assert.lengthOf(result[0].blocks, 1);
-      assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
-    });
-
-    test('parse multiline quote', () => {
-      const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assert.equal(result[0].type, 'quote');
-      assert.lengthOf(result[0].blocks, 1);
-      assertBlock(result[0].blocks, 0, 'paragraph',
-          'Quote line 1\nQuote line 2\nQuote line 3');
-    });
-
-    test('parse pre', () => {
-      const comment = '    Four space indent.';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertBlock(result, 0, 'pre', comment);
-    });
-
-    test('parse one space pre', () => {
-      const comment = ' One space indent.\n Another line.';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertBlock(result, 0, 'pre', comment);
-    });
-
-    test('parse tab pre', () => {
-      const comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertBlock(result, 0, 'pre', comment);
-    });
-
-    test('parse star list', () => {
-      const comment = '* Item 1\n* Item 2\n* Item 3';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertListBlock(result, 0, 0, 'Item 1');
-      assertListBlock(result, 0, 1, 'Item 2');
-      assertListBlock(result, 0, 2, 'Item 3');
-    });
-
-    test('parse dash list', () => {
-      const comment = '- Item 1\n- Item 2\n- Item 3';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertListBlock(result, 0, 0, 'Item 1');
-      assertListBlock(result, 0, 1, 'Item 2');
-      assertListBlock(result, 0, 2, 'Item 3');
-    });
-
-    test('parse mixed list', () => {
-      const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertListBlock(result, 0, 0, 'Item 1');
-      assertListBlock(result, 0, 1, 'Item 2');
-      assertListBlock(result, 0, 2, 'Item 3');
-      assertListBlock(result, 0, 3, 'Item 4');
-    });
-
-    test('parse mixed block types', () => {
-      const comment = 'Paragraph\nacross\na\nfew\nlines.' +
-          '\n\n' +
-          '> Quote\n> across\n> not many lines.' +
-          '\n\n' +
-          'Another paragraph' +
-          '\n\n' +
-          '* Series\n* of\n* list\n* items' +
-          '\n\n' +
-          'Yet another paragraph' +
-          '\n\n' +
-          '\tPreformatted text.' +
-          '\n\n' +
-          'Parting words.';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 7);
-      assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.\n');
-
-      assert.equal(result[1].type, 'quote');
-      assert.lengthOf(result[1].blocks, 1);
-      assertBlock(result[1].blocks, 0, 'paragraph',
-          'Quote\nacross\nnot many lines.');
-
-      assertBlock(result, 2, 'paragraph', 'Another paragraph\n');
-      assertListBlock(result, 3, 0, 'Series');
-      assertListBlock(result, 3, 1, 'of');
-      assertListBlock(result, 3, 2, 'list');
-      assertListBlock(result, 3, 3, 'items');
-      assertBlock(result, 4, 'paragraph', 'Yet another paragraph\n');
-      assertBlock(result, 5, 'pre', '\tPreformatted text.');
-      assertBlock(result, 6, 'paragraph', 'Parting words.');
-    });
-
-    test('bullet list 1', () => {
-      const comment = 'A\n\n* line 1\n* 2nd line';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'paragraph', 'A\n');
-      assertListBlock(result, 1, 0, 'line 1');
-      assertListBlock(result, 1, 1, '2nd line');
-    });
-
-    test('bullet list 2', () => {
-      const comment = 'A\n* line 1\n* 2nd line\n\nB';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 3);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertListBlock(result, 1, 0, 'line 1');
-      assertListBlock(result, 1, 1, '2nd line');
-      assertBlock(result, 2, 'paragraph', 'B');
-    });
-
-    test('bullet list 3', () => {
-      const comment = '* line 1\n* 2nd line\n\nB';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertListBlock(result, 0, 0, 'line 1');
-      assertListBlock(result, 0, 1, '2nd line');
-      assertBlock(result, 1, 'paragraph', 'B');
-    });
-
-    test('bullet list 4', () => {
-      const comment = 'To see this bug, you have to:\n' +
-          '* Be on IMAP or EAS (not on POP)\n' +
-          '* Be very unlucky\n';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
-      assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
-      assertListBlock(result, 1, 1, 'Be very unlucky');
-    });
-
-    test('bullet list 5', () => {
-      const comment = 'To see this bug,\n' +
-          'you have to:\n' +
-          '* Be on IMAP or EAS (not on POP)\n' +
-          '* Be very unlucky\n';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'paragraph', 'To see this bug,\nyou have to:');
-      assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
-      assertListBlock(result, 1, 1, 'Be very unlucky');
-    });
-
-    test('dash list 1', () => {
-      const comment = 'A\n- line 1\n- 2nd line';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertListBlock(result, 1, 0, 'line 1');
-      assertListBlock(result, 1, 1, '2nd line');
-    });
-
-    test('dash list 2', () => {
-      const comment = 'A\n- line 1\n- 2nd line\n\nB';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 3);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertListBlock(result, 1, 0, 'line 1');
-      assertListBlock(result, 1, 1, '2nd line');
-      assertBlock(result, 2, 'paragraph', 'B');
-    });
-
-    test('dash list 3', () => {
-      const comment = '- line 1\n- 2nd line\n\nB';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertListBlock(result, 0, 0, 'line 1');
-      assertListBlock(result, 0, 1, '2nd line');
-      assertBlock(result, 1, 'paragraph', 'B');
-    });
-
-    test('nested list will NOT be recognized', () => {
-      // will be rendered as two separate lists
-      const comment = '- line 1\n  - line with indentation\n- line 2';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 3);
-      assertListBlock(result, 0, 0, 'line 1');
-      assert.equal(result[1].type, 'pre');
-      assertListBlock(result, 2, 0, 'line 2');
-    });
-
-    test('pre format 1', () => {
-      const comment = 'A\n  This is pre\n  formatted';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
-    });
-
-    test('pre format 2', () => {
-      const comment = 'A\n  This is pre\n  formatted\n\nbut this is not';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 3);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
-      assertBlock(result, 2, 'paragraph', 'but this is not');
-    });
-
-    test('pre format 3', () => {
-      const comment = 'A\n  Q\n    <R>\n  S\n\nB';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 3);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertBlock(result, 1, 'pre', '  Q\n    <R>\n  S');
-      assertBlock(result, 2, 'paragraph', 'B');
-    });
-
-    test('pre format 4', () => {
-      const comment = '  Q\n    <R>\n  S\n\nB';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
-      assertBlock(result, 1, 'paragraph', 'B');
-    });
-
-    test('quote 1', () => {
-      const comment = '> I\'m happy\n > with quotes!\n\nSee above.';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assert.equal(result[0].type, 'quote');
-      assert.lengthOf(result[0].blocks, 1);
-      assertBlock(result[0].blocks, 0, 'paragraph', 'I\'m happy\nwith quotes!');
-      assertBlock(result, 1, 'paragraph', 'See above.');
-    });
-
-    test('quote 2', () => {
-      const comment = 'See this said:\n > a quoted\n > string block\n\nOK?';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 3);
-      assertBlock(result, 0, 'paragraph', 'See this said:');
-      assert.equal(result[1].type, 'quote');
-      assert.lengthOf(result[1].blocks, 1);
-      assertBlock(result[1].blocks, 0, 'paragraph', 'a quoted\nstring block');
-      assertBlock(result, 2, 'paragraph', 'OK?');
-    });
-
-    test('nested quotes', () => {
-      const comment = ' > > prior\n > \n > next\n';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assert.equal(result[0].type, 'quote');
-      assert.lengthOf(result[0].blocks, 2);
-      assert.equal(result[0].blocks[0].type, 'quote');
-      assert.lengthOf(result[0].blocks[0].blocks, 1);
-      assertBlock(result[0].blocks[0].blocks, 0, 'paragraph', 'prior');
-      assertBlock(result[0].blocks, 1, 'paragraph', 'next');
-    });
-
-    test('code 1', () => {
-      const comment = '```\n// test code\n```';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assert.equal(result[0].type, 'code');
-      assert.equal(result[0].text, '// test code');
-    });
-
-    test('code 2', () => {
-      const comment = 'test code\n```// test code```';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assert.equal(result[0].type, 'paragraph');
-      assert.equal(result[0].text, 'test code');
-      assert.equal(result[1].type, 'code');
-      assert.equal(result[1].text, '// test code');
-    });
-
-    test('code 3', () => {
-      const comment = 'test code\n```// test code```';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assert.equal(result[0].type, 'paragraph');
-      assert.equal(result[0].text, 'test code');
-      assert.equal(result[1].type, 'code');
-      assert.equal(result[1].text, '// test code');
-    });
-
-    test('not a code', () => {
-      const comment = 'test code\n```// test code';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assert.equal(result[0].type, 'paragraph');
-      assert.equal(result[0].text, 'test code\n```// test code');
-    });
-
-    test('not a code 2', () => {
-      const comment = 'test code\n```\n// test code';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assert.equal(result[0].type, 'paragraph');
-      assert.equal(result[0].text, 'test code');
-      assert.equal(result[1].type, 'paragraph');
-      assert.equal(result[1].text, '```\n// test code');
-    });
-
-    test('mix all 1', () => {
-      const comment = ' bullets:\n- bullet 1\n- bullet 2\n\ncode example:\n' +
-        '```// test code```\n\n> reference is here';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 5);
-      assert.equal(result[0].type, 'pre');
-      assert.equal(result[1].type, 'list');
-      assert.equal(result[2].type, 'paragraph');
-      assert.equal(result[3].type, 'code');
-      assert.equal(result[4].type, 'quote');
-    });
-
-    test('_computeNodes called without config', () => {
-      const computeNodesSpy = sandbox.spy(element, '_computeNodes');
-      element.content = 'some text';
-      assert.isTrue(computeNodesSpy.called);
-    });
-
-    test('_contentOrConfigChanged called with config', () => {
-      const contentStub = sandbox.stub(element, '_contentChanged');
-      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
-      element.content = 'some text';
-      element.config = {};
-      assert.isTrue(contentStub.called);
-      assert.isTrue(contentConfigStub.called);
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('parse null undefined and empty', () => {
+    assert.lengthOf(element._computeBlocks(null), 0);
+    assert.lengthOf(element._computeBlocks(undefined), 0);
+    assert.lengthOf(element._computeBlocks(''), 0);
+  });
+
+  test('parse simple', () => {
+    const comment = 'Para1';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'paragraph', comment);
+  });
+
+  test('parse multiline para', () => {
+    const comment = 'Para 1\nStill para 1';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'paragraph', comment);
+  });
+
+  test('parse para break without special blocks', () => {
+    const comment = 'Para 1\n\nPara 2\n\nPara 3';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'paragraph', comment);
+  });
+
+  test('parse quote', () => {
+    const comment = '> Quote text';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 1);
+    assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
+  });
+
+  test('parse quote lead space', () => {
+    const comment = ' > Quote text';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 1);
+    assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
+  });
+
+  test('parse multiline quote', () => {
+    const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 1);
+    assertBlock(result[0].blocks, 0, 'paragraph',
+        'Quote line 1\nQuote line 2\nQuote line 3');
+  });
+
+  test('parse pre', () => {
+    const comment = '    Four space indent.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'pre', comment);
+  });
+
+  test('parse one space pre', () => {
+    const comment = ' One space indent.\n Another line.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'pre', comment);
+  });
+
+  test('parse tab pre', () => {
+    const comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'pre', comment);
+  });
+
+  test('parse star list', () => {
+    const comment = '* Item 1\n* Item 2\n* Item 3';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result, 0, 0, 'Item 1');
+    assertListBlock(result, 0, 1, 'Item 2');
+    assertListBlock(result, 0, 2, 'Item 3');
+  });
+
+  test('parse dash list', () => {
+    const comment = '- Item 1\n- Item 2\n- Item 3';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result, 0, 0, 'Item 1');
+    assertListBlock(result, 0, 1, 'Item 2');
+    assertListBlock(result, 0, 2, 'Item 3');
+  });
+
+  test('parse mixed list', () => {
+    const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result, 0, 0, 'Item 1');
+    assertListBlock(result, 0, 1, 'Item 2');
+    assertListBlock(result, 0, 2, 'Item 3');
+    assertListBlock(result, 0, 3, 'Item 4');
+  });
+
+  test('parse mixed block types', () => {
+    const comment = 'Paragraph\nacross\na\nfew\nlines.' +
+        '\n\n' +
+        '> Quote\n> across\n> not many lines.' +
+        '\n\n' +
+        'Another paragraph' +
+        '\n\n' +
+        '* Series\n* of\n* list\n* items' +
+        '\n\n' +
+        'Yet another paragraph' +
+        '\n\n' +
+        '\tPreformatted text.' +
+        '\n\n' +
+        'Parting words.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 7);
+    assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.\n');
+
+    assert.equal(result[1].type, 'quote');
+    assert.lengthOf(result[1].blocks, 1);
+    assertBlock(result[1].blocks, 0, 'paragraph',
+        'Quote\nacross\nnot many lines.');
+
+    assertBlock(result, 2, 'paragraph', 'Another paragraph\n');
+    assertListBlock(result, 3, 0, 'Series');
+    assertListBlock(result, 3, 1, 'of');
+    assertListBlock(result, 3, 2, 'list');
+    assertListBlock(result, 3, 3, 'items');
+    assertBlock(result, 4, 'paragraph', 'Yet another paragraph\n');
+    assertBlock(result, 5, 'pre', '\tPreformatted text.');
+    assertBlock(result, 6, 'paragraph', 'Parting words.');
+  });
+
+  test('bullet list 1', () => {
+    const comment = 'A\n\n* line 1\n* 2nd line';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'A\n');
+    assertListBlock(result, 1, 0, 'line 1');
+    assertListBlock(result, 1, 1, '2nd line');
+  });
+
+  test('bullet list 2', () => {
+    const comment = 'A\n* line 1\n* 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertListBlock(result, 1, 0, 'line 1');
+    assertListBlock(result, 1, 1, '2nd line');
+    assertBlock(result, 2, 'paragraph', 'B');
+  });
+
+  test('bullet list 3', () => {
+    const comment = '* line 1\n* 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertListBlock(result, 0, 0, 'line 1');
+    assertListBlock(result, 0, 1, '2nd line');
+    assertBlock(result, 1, 'paragraph', 'B');
+  });
+
+  test('bullet list 4', () => {
+    const comment = 'To see this bug, you have to:\n' +
+        '* Be on IMAP or EAS (not on POP)\n' +
+        '* Be very unlucky\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
+    assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
+    assertListBlock(result, 1, 1, 'Be very unlucky');
+  });
+
+  test('bullet list 5', () => {
+    const comment = 'To see this bug,\n' +
+        'you have to:\n' +
+        '* Be on IMAP or EAS (not on POP)\n' +
+        '* Be very unlucky\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'To see this bug,\nyou have to:');
+    assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
+    assertListBlock(result, 1, 1, 'Be very unlucky');
+  });
+
+  test('dash list 1', () => {
+    const comment = 'A\n- line 1\n- 2nd line';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertListBlock(result, 1, 0, 'line 1');
+    assertListBlock(result, 1, 1, '2nd line');
+  });
+
+  test('dash list 2', () => {
+    const comment = 'A\n- line 1\n- 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertListBlock(result, 1, 0, 'line 1');
+    assertListBlock(result, 1, 1, '2nd line');
+    assertBlock(result, 2, 'paragraph', 'B');
+  });
+
+  test('dash list 3', () => {
+    const comment = '- line 1\n- 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertListBlock(result, 0, 0, 'line 1');
+    assertListBlock(result, 0, 1, '2nd line');
+    assertBlock(result, 1, 'paragraph', 'B');
+  });
+
+  test('nested list will NOT be recognized', () => {
+    // will be rendered as two separate lists
+    const comment = '- line 1\n  - line with indentation\n- line 2';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertListBlock(result, 0, 0, 'line 1');
+    assert.equal(result[1].type, 'pre');
+    assertListBlock(result, 2, 0, 'line 2');
+  });
+
+  test('pre format 1', () => {
+    const comment = 'A\n  This is pre\n  formatted';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
+  });
+
+  test('pre format 2', () => {
+    const comment = 'A\n  This is pre\n  formatted\n\nbut this is not';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
+    assertBlock(result, 2, 'paragraph', 'but this is not');
+  });
+
+  test('pre format 3', () => {
+    const comment = 'A\n  Q\n    <R>\n  S\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertBlock(result, 1, 'pre', '  Q\n    <R>\n  S');
+    assertBlock(result, 2, 'paragraph', 'B');
+  });
+
+  test('pre format 4', () => {
+    const comment = '  Q\n    <R>\n  S\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
+    assertBlock(result, 1, 'paragraph', 'B');
+  });
+
+  test('quote 1', () => {
+    const comment = '> I\'m happy\n > with quotes!\n\nSee above.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 1);
+    assertBlock(result[0].blocks, 0, 'paragraph', 'I\'m happy\nwith quotes!');
+    assertBlock(result, 1, 'paragraph', 'See above.');
+  });
+
+  test('quote 2', () => {
+    const comment = 'See this said:\n > a quoted\n > string block\n\nOK?';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'See this said:');
+    assert.equal(result[1].type, 'quote');
+    assert.lengthOf(result[1].blocks, 1);
+    assertBlock(result[1].blocks, 0, 'paragraph', 'a quoted\nstring block');
+    assertBlock(result, 2, 'paragraph', 'OK?');
+  });
+
+  test('nested quotes', () => {
+    const comment = ' > > prior\n > \n > next\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 2);
+    assert.equal(result[0].blocks[0].type, 'quote');
+    assert.lengthOf(result[0].blocks[0].blocks, 1);
+    assertBlock(result[0].blocks[0].blocks, 0, 'paragraph', 'prior');
+    assertBlock(result[0].blocks, 1, 'paragraph', 'next');
+  });
+
+  test('code 1', () => {
+    const comment = '```\n// test code\n```';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'code');
+    assert.equal(result[0].text, '// test code');
+  });
+
+  test('code 2', () => {
+    const comment = 'test code\n```// test code```';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assert.equal(result[0].type, 'paragraph');
+    assert.equal(result[0].text, 'test code');
+    assert.equal(result[1].type, 'code');
+    assert.equal(result[1].text, '// test code');
+  });
+
+  test('code 3', () => {
+    const comment = 'test code\n```// test code```';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assert.equal(result[0].type, 'paragraph');
+    assert.equal(result[0].text, 'test code');
+    assert.equal(result[1].type, 'code');
+    assert.equal(result[1].text, '// test code');
+  });
+
+  test('not a code', () => {
+    const comment = 'test code\n```// test code';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'paragraph');
+    assert.equal(result[0].text, 'test code\n```// test code');
+  });
+
+  test('not a code 2', () => {
+    const comment = 'test code\n```\n// test code';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assert.equal(result[0].type, 'paragraph');
+    assert.equal(result[0].text, 'test code');
+    assert.equal(result[1].type, 'paragraph');
+    assert.equal(result[1].text, '```\n// test code');
+  });
+
+  test('mix all 1', () => {
+    const comment = ' bullets:\n- bullet 1\n- bullet 2\n\ncode example:\n' +
+      '```// test code```\n\n> reference is here';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 5);
+    assert.equal(result[0].type, 'pre');
+    assert.equal(result[1].type, 'list');
+    assert.equal(result[2].type, 'paragraph');
+    assert.equal(result[3].type, 'code');
+    assert.equal(result[4].type, 'quote');
+  });
+
+  test('_computeNodes called without config', () => {
+    const computeNodesSpy = sandbox.spy(element, '_computeNodes');
+    element.content = 'some text';
+    assert.isTrue(computeNodesSpy.called);
+  });
+
+  test('_contentOrConfigChanged called with config', () => {
+    const contentStub = sandbox.stub(element, '_contentChanged');
+    const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    element.content = 'some text';
+    element.config = {};
+    assert.isTrue(contentStub.called);
+    assert.isTrue(contentConfigStub.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
index ce34d3a..ce2303f 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
@@ -14,322 +14,331 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
-  const HOVER_CLASS = 'hovered';
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../styles/shared-styles.js';
+import '../../../scripts/rootElement.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-hovercard_html.js';
+
+const HOVER_CLASS = 'hovered';
+
+/**
+ * When the hovercard is positioned diagonally (bottom-left, bottom-right,
+ * top-left, or top-right), we add additional (invisible) padding so that the
+ * area that a user can hover over to access the hovercard is larger.
+ */
+const DIAGONAL_OVERFLOW = 15;
+
+/** @extends Polymer.Element */
+class GrHovercard extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-hovercard'; }
+
+  static get properties() {
+    return {
+    /**
+     * @type {?}
+     */
+      _target: Object,
+
+      /**
+       * Determines whether or not the hovercard is visible.
+       *
+       * @type {boolean}
+       */
+      _isShowing: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * The `id` of the element that the hovercard is anchored to.
+       *
+       * @type {string}
+       */
+      for: {
+        type: String,
+        observer: '_forChanged',
+      },
+
+      /**
+       * The spacing between the top of the hovercard and the element it is
+       * anchored to.
+       *
+       * @type {number}
+       */
+      offset: {
+        type: Number,
+        value: 14,
+      },
+
+      /**
+       * Positions the hovercard to the top, right, bottom, left, bottom-left,
+       * bottom-right, top-left, or top-right of its content.
+       *
+       * @type {string}
+       */
+      position: {
+        type: String,
+        value: 'bottom',
+      },
+
+      container: Object,
+      /**
+       * ID for the container element.
+       *
+       * @type {string}
+       */
+      containerId: {
+        type: String,
+        value: 'gr-hovercard-container',
+      },
+    };
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    if (!this._target) { this._target = this.target; }
+    this.listen(this._target, 'mouseenter', 'show');
+    this.listen(this._target, 'focus', 'show');
+    this.listen(this._target, 'mouseleave', 'hide');
+    this.listen(this._target, 'blur', 'hide');
+    this.listen(this._target, 'click', 'hide');
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('mouseleave',
+        e => this.hide(e));
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    // First, check to see if the container has already been created.
+    this.container = Gerrit.getRootElement()
+        .querySelector('#' + this.containerId);
+
+    if (this.container) { return; }
+
+    // If it does not exist, create and initialize the hovercard container.
+    this.container = document.createElement('div');
+    this.container.setAttribute('id', this.containerId);
+    Gerrit.getRootElement().appendChild(this.container);
+  }
+
+  removeListeners() {
+    this.unlisten(this._target, 'mouseenter', 'show');
+    this.unlisten(this._target, 'focus', 'show');
+    this.unlisten(this._target, 'mouseleave', 'hide');
+    this.unlisten(this._target, 'blur', 'hide');
+    this.unlisten(this._target, 'click', 'hide');
+  }
 
   /**
-   * When the hovercard is positioned diagonally (bottom-left, bottom-right,
-   * top-left, or top-right), we add additional (invisible) padding so that the
-   * area that a user can hover over to access the hovercard is larger.
+   * Returns the target element that the hovercard is anchored to (the `id` of
+   * the `for` property).
+   *
+   * @type {HTMLElement}
    */
-  const DIAGONAL_OVERFLOW = 15;
+  get target() {
+    const parentNode = dom(this).parentNode;
+    // If the parentNode is a document fragment, then we need to use the host.
+    const ownerRoot = dom(this).getOwnerRoot();
+    let target;
+    if (this.for) {
+      target = dom(ownerRoot).querySelector('#' + this.for);
+    } else {
+      target = parentNode.nodeType == Node.DOCUMENT_FRAGMENT_NODE ?
+        ownerRoot.host :
+        parentNode;
+    }
+    return target;
+  }
 
-  /** @extends Polymer.Element */
-  class GrHovercard extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-hovercard'; }
-
-    static get properties() {
-      return {
-      /**
-       * @type {?}
-       */
-        _target: Object,
-
-        /**
-         * Determines whether or not the hovercard is visible.
-         *
-         * @type {boolean}
-         */
-        _isShowing: {
-          type: Boolean,
-          value: false,
-        },
-        /**
-         * The `id` of the element that the hovercard is anchored to.
-         *
-         * @type {string}
-         */
-        for: {
-          type: String,
-          observer: '_forChanged',
-        },
-
-        /**
-         * The spacing between the top of the hovercard and the element it is
-         * anchored to.
-         *
-         * @type {number}
-         */
-        offset: {
-          type: Number,
-          value: 14,
-        },
-
-        /**
-         * Positions the hovercard to the top, right, bottom, left, bottom-left,
-         * bottom-right, top-left, or top-right of its content.
-         *
-         * @type {string}
-         */
-        position: {
-          type: String,
-          value: 'bottom',
-        },
-
-        container: Object,
-        /**
-         * ID for the container element.
-         *
-         * @type {string}
-         */
-        containerId: {
-          type: String,
-          value: 'gr-hovercard-container',
-        },
-      };
+  /**
+   * Hides/closes the hovercard. This occurs when the user triggers the
+   * `mouseleave` event on the hovercard's `target` element (as long as the
+   * user is not hovering over the hovercard).
+   *
+   * @param {Event} e DOM Event (e.g. `mouseleave` event)
+   */
+  hide(e) {
+    const targetRect = this._target.getBoundingClientRect();
+    const x = e.clientX;
+    const y = e.clientY;
+    if (x > targetRect.left && x < targetRect.right && y > targetRect.top &&
+        y < targetRect.bottom) {
+      // Sometimes the hovercard itself obscures the mouse pointer, and
+      // that generates a mouseleave event. We don't want to hide the hovercard
+      // in that situation.
+      return;
     }
 
-    /** @override */
-    attached() {
-      super.attached();
-      if (!this._target) { this._target = this.target; }
-      this.listen(this._target, 'mouseenter', 'show');
-      this.listen(this._target, 'focus', 'show');
-      this.listen(this._target, 'mouseleave', 'hide');
-      this.listen(this._target, 'blur', 'hide');
-      this.listen(this._target, 'click', 'hide');
+    // If the hovercard is already hidden or the user is now hovering over the
+    //  hovercard or the user is returning from the hovercard but now hovering
+    //  over the target (to stop an annoying flicker effect), just return.
+    if (!this._isShowing || e.toElement === this ||
+        (e.fromElement === this && e.toElement === this._target)) {
+      return;
     }
 
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('mouseleave',
-          e => this.hide(e));
-    }
+    // Mark that the hovercard is not visible and do not allow focusing
+    this._isShowing = false;
 
-    /** @override */
-    ready() {
-      super.ready();
-      // First, check to see if the container has already been created.
-      this.container = Gerrit.getRootElement()
-          .querySelector('#' + this.containerId);
+    // Clear styles in preparation for the next time we need to show the card
+    this.classList.remove(HOVER_CLASS);
 
-      if (this.container) { return; }
+    // Reset and remove the hovercard from the DOM
+    this.style.cssText = '';
+    this.$.hovercard.setAttribute('tabindex', -1);
 
-      // If it does not exist, create and initialize the hovercard container.
-      this.container = document.createElement('div');
-      this.container.setAttribute('id', this.containerId);
-      Gerrit.getRootElement().appendChild(this.container);
-    }
-
-    removeListeners() {
-      this.unlisten(this._target, 'mouseenter', 'show');
-      this.unlisten(this._target, 'focus', 'show');
-      this.unlisten(this._target, 'mouseleave', 'hide');
-      this.unlisten(this._target, 'blur', 'hide');
-      this.unlisten(this._target, 'click', 'hide');
-    }
-
-    /**
-     * Returns the target element that the hovercard is anchored to (the `id` of
-     * the `for` property).
-     *
-     * @type {HTMLElement}
-     */
-    get target() {
-      const parentNode = Polymer.dom(this).parentNode;
-      // If the parentNode is a document fragment, then we need to use the host.
-      const ownerRoot = Polymer.dom(this).getOwnerRoot();
-      let target;
-      if (this.for) {
-        target = Polymer.dom(ownerRoot).querySelector('#' + this.for);
-      } else {
-        target = parentNode.nodeType == Node.DOCUMENT_FRAGMENT_NODE ?
-          ownerRoot.host :
-          parentNode;
-      }
-      return target;
-    }
-
-    /**
-     * Hides/closes the hovercard. This occurs when the user triggers the
-     * `mouseleave` event on the hovercard's `target` element (as long as the
-     * user is not hovering over the hovercard).
-     *
-     * @param {Event} e DOM Event (e.g. `mouseleave` event)
-     */
-    hide(e) {
-      const targetRect = this._target.getBoundingClientRect();
-      const x = e.clientX;
-      const y = e.clientY;
-      if (x > targetRect.left && x < targetRect.right && y > targetRect.top &&
-          y < targetRect.bottom) {
-        // Sometimes the hovercard itself obscures the mouse pointer, and
-        // that generates a mouseleave event. We don't want to hide the hovercard
-        // in that situation.
-        return;
-      }
-
-      // If the hovercard is already hidden or the user is now hovering over the
-      //  hovercard or the user is returning from the hovercard but now hovering
-      //  over the target (to stop an annoying flicker effect), just return.
-      if (!this._isShowing || e.toElement === this ||
-          (e.fromElement === this && e.toElement === this._target)) {
-        return;
-      }
-
-      // Mark that the hovercard is not visible and do not allow focusing
-      this._isShowing = false;
-
-      // Clear styles in preparation for the next time we need to show the card
-      this.classList.remove(HOVER_CLASS);
-
-      // Reset and remove the hovercard from the DOM
-      this.style.cssText = '';
-      this.$.hovercard.setAttribute('tabindex', -1);
-
-      // Remove the hovercard from the container, given that it is still a child
-      // of the container.
-      if (this.container.contains(this)) {
-        this.container.removeChild(this);
-      }
-    }
-
-    /**
-     * Shows/opens the hovercard. This occurs when the user triggers the
-     * `mousenter` event on the hovercard's `target` element.
-     *
-     * @param {Event} e DOM Event (e.g., `mouseenter` event)
-     */
-    show(e) {
-      if (this._isShowing) {
-        return;
-      }
-
-      // Mark that the hovercard is now visible
-      this._isShowing = true;
-      this.setAttribute('tabindex', 0);
-
-      // Add it to the DOM and calculate its position
-      this.container.appendChild(this);
-      this.updatePosition();
-
-      // Trigger the transition
-      this.classList.add(HOVER_CLASS);
-    }
-
-    /**
-     * Updates the hovercard's position based on the `position` attribute
-     * and the current position of the `target` element.
-     *
-     * The hovercard is supposed to stay open if the user hovers over it.
-     * To keep it open when the user moves away from the target, the bounding
-     * rects of the target and hovercard must touch or overlap.
-     *
-     * NOTE: You do not need to directly call this method unless you need to
-     * update the position of the tooltip while it is already visible (the
-     * target element has moved and the tooltip is still open).
-     */
-    updatePosition() {
-      if (!this._target) { return; }
-
-      // Calculate the necessary measurements and positions
-      const parentRect = document.documentElement.getBoundingClientRect();
-      const targetRect = this._target.getBoundingClientRect();
-      const thisRect = this.getBoundingClientRect();
-
-      const targetLeft = targetRect.left - parentRect.left;
-      const targetTop = targetRect.top - parentRect.top;
-
-      let hovercardLeft;
-      let hovercardTop;
-      const diagonalPadding = this.offset + DIAGONAL_OVERFLOW;
-      let cssText = '';
-
-      // Find the top and left position values based on the position attribute
-      // of the hovercard.
-      switch (this.position) {
-        case 'top':
-          hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
-          hovercardTop = targetTop - thisRect.height - this.offset;
-          cssText += `padding-bottom:${this.offset
-          }px; margin-bottom:-${this.offset}px;`;
-          break;
-        case 'bottom':
-          hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
-          hovercardTop = targetTop + targetRect.height + this.offset;
-          cssText +=
-              `padding-top:${this.offset}px; margin-top:-${this.offset}px;`;
-          break;
-        case 'left':
-          hovercardLeft = targetLeft - thisRect.width - this.offset;
-          hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
-          cssText +=
-              `padding-right:${this.offset}px; margin-right:-${this.offset}px;`;
-          break;
-        case 'right':
-          hovercardLeft = targetRect.right + this.offset;
-          hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
-          cssText +=
-              `padding-left:${this.offset}px; margin-left:-${this.offset}px;`;
-          break;
-        case 'bottom-right':
-          hovercardLeft = targetRect.left + targetRect.width + this.offset;
-          hovercardTop = targetRect.top + targetRect.height + this.offset;
-          cssText += `padding-top:${diagonalPadding}px;`;
-          cssText += `padding-left:${diagonalPadding}px;`;
-          cssText += `margin-left:-${diagonalPadding}px;`;
-          cssText += `margin-top:-${diagonalPadding}px;`;
-          break;
-        case 'bottom-left':
-          hovercardLeft = targetRect.left - thisRect.width - this.offset;
-          hovercardTop = targetRect.top + targetRect.height + this.offset;
-          cssText += `padding-top:${diagonalPadding}px;`;
-          cssText += `padding-right:${diagonalPadding}px;`;
-          cssText += `margin-right:-${diagonalPadding}px;`;
-          cssText += `margin-top:-${diagonalPadding}px;`;
-          break;
-        case 'top-left':
-          hovercardLeft = targetRect.left - thisRect.width - this.offset;
-          hovercardTop = targetRect.top - thisRect.height - this.offset;
-          cssText += `padding-bottom:${diagonalPadding}px;`;
-          cssText += `padding-right:${diagonalPadding}px;`;
-          cssText += `margin-bottom:-${diagonalPadding}px;`;
-          cssText += `margin-right:-${diagonalPadding}px;`;
-          break;
-        case 'top-right':
-          hovercardLeft = targetRect.left + targetRect.width + this.offset;
-          hovercardTop = targetRect.top - thisRect.height - this.offset;
-          cssText += `padding-bottom:${diagonalPadding}px;`;
-          cssText += `padding-left:${diagonalPadding}px;`;
-          cssText += `margin-bottom:-${diagonalPadding}px;`;
-          cssText += `margin-left:-${diagonalPadding}px;`;
-          break;
-      }
-
-      // Prevent hovercard from appearing outside the viewport.
-      // TODO(kaspern): fix hovercard appearing outside viewport on bottom and
-      // right.
-      if (hovercardLeft < 0) { hovercardLeft = 0; }
-      if (hovercardTop < 0) { hovercardTop = 0; }
-      // Set the hovercard's position
-      cssText += `left:${hovercardLeft}px; top:${hovercardTop}px;`;
-      this.style.cssText = cssText;
-    }
-
-    /**
-     * Responds to a change in the `for` value and gets the updated `target`
-     * element for the hovercard.
-     *
-     * @private
-     */
-    _forChanged() {
-      this._target = this.target;
+    // Remove the hovercard from the container, given that it is still a child
+    // of the container.
+    if (this.container.contains(this)) {
+      this.container.removeChild(this);
     }
   }
 
-  customElements.define(GrHovercard.is, GrHovercard);
-})();
+  /**
+   * Shows/opens the hovercard. This occurs when the user triggers the
+   * `mousenter` event on the hovercard's `target` element.
+   *
+   * @param {Event} e DOM Event (e.g., `mouseenter` event)
+   */
+  show(e) {
+    if (this._isShowing) {
+      return;
+    }
+
+    // Mark that the hovercard is now visible
+    this._isShowing = true;
+    this.setAttribute('tabindex', 0);
+
+    // Add it to the DOM and calculate its position
+    this.container.appendChild(this);
+    this.updatePosition();
+
+    // Trigger the transition
+    this.classList.add(HOVER_CLASS);
+  }
+
+  /**
+   * Updates the hovercard's position based on the `position` attribute
+   * and the current position of the `target` element.
+   *
+   * The hovercard is supposed to stay open if the user hovers over it.
+   * To keep it open when the user moves away from the target, the bounding
+   * rects of the target and hovercard must touch or overlap.
+   *
+   * NOTE: You do not need to directly call this method unless you need to
+   * update the position of the tooltip while it is already visible (the
+   * target element has moved and the tooltip is still open).
+   */
+  updatePosition() {
+    if (!this._target) { return; }
+
+    // Calculate the necessary measurements and positions
+    const parentRect = document.documentElement.getBoundingClientRect();
+    const targetRect = this._target.getBoundingClientRect();
+    const thisRect = this.getBoundingClientRect();
+
+    const targetLeft = targetRect.left - parentRect.left;
+    const targetTop = targetRect.top - parentRect.top;
+
+    let hovercardLeft;
+    let hovercardTop;
+    const diagonalPadding = this.offset + DIAGONAL_OVERFLOW;
+    let cssText = '';
+
+    // Find the top and left position values based on the position attribute
+    // of the hovercard.
+    switch (this.position) {
+      case 'top':
+        hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
+        hovercardTop = targetTop - thisRect.height - this.offset;
+        cssText += `padding-bottom:${this.offset
+        }px; margin-bottom:-${this.offset}px;`;
+        break;
+      case 'bottom':
+        hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
+        hovercardTop = targetTop + targetRect.height + this.offset;
+        cssText +=
+            `padding-top:${this.offset}px; margin-top:-${this.offset}px;`;
+        break;
+      case 'left':
+        hovercardLeft = targetLeft - thisRect.width - this.offset;
+        hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
+        cssText +=
+            `padding-right:${this.offset}px; margin-right:-${this.offset}px;`;
+        break;
+      case 'right':
+        hovercardLeft = targetRect.right + this.offset;
+        hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
+        cssText +=
+            `padding-left:${this.offset}px; margin-left:-${this.offset}px;`;
+        break;
+      case 'bottom-right':
+        hovercardLeft = targetRect.left + targetRect.width + this.offset;
+        hovercardTop = targetRect.top + targetRect.height + this.offset;
+        cssText += `padding-top:${diagonalPadding}px;`;
+        cssText += `padding-left:${diagonalPadding}px;`;
+        cssText += `margin-left:-${diagonalPadding}px;`;
+        cssText += `margin-top:-${diagonalPadding}px;`;
+        break;
+      case 'bottom-left':
+        hovercardLeft = targetRect.left - thisRect.width - this.offset;
+        hovercardTop = targetRect.top + targetRect.height + this.offset;
+        cssText += `padding-top:${diagonalPadding}px;`;
+        cssText += `padding-right:${diagonalPadding}px;`;
+        cssText += `margin-right:-${diagonalPadding}px;`;
+        cssText += `margin-top:-${diagonalPadding}px;`;
+        break;
+      case 'top-left':
+        hovercardLeft = targetRect.left - thisRect.width - this.offset;
+        hovercardTop = targetRect.top - thisRect.height - this.offset;
+        cssText += `padding-bottom:${diagonalPadding}px;`;
+        cssText += `padding-right:${diagonalPadding}px;`;
+        cssText += `margin-bottom:-${diagonalPadding}px;`;
+        cssText += `margin-right:-${diagonalPadding}px;`;
+        break;
+      case 'top-right':
+        hovercardLeft = targetRect.left + targetRect.width + this.offset;
+        hovercardTop = targetRect.top - thisRect.height - this.offset;
+        cssText += `padding-bottom:${diagonalPadding}px;`;
+        cssText += `padding-left:${diagonalPadding}px;`;
+        cssText += `margin-bottom:-${diagonalPadding}px;`;
+        cssText += `margin-left:-${diagonalPadding}px;`;
+        break;
+    }
+
+    // Prevent hovercard from appearing outside the viewport.
+    // TODO(kaspern): fix hovercard appearing outside viewport on bottom and
+    // right.
+    if (hovercardLeft < 0) { hovercardLeft = 0; }
+    if (hovercardTop < 0) { hovercardTop = 0; }
+    // Set the hovercard's position
+    cssText += `left:${hovercardLeft}px; top:${hovercardTop}px;`;
+    this.style.cssText = cssText;
+  }
+
+  /**
+   * Responds to a change in the `for` value and gets the updated `target`
+   * element for the hovercard.
+   *
+   * @private
+   */
+  _forChanged() {
+    this._target = this.target;
+  }
+}
+
+customElements.define(GrHovercard.is, GrHovercard);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js
index c666227..2969bdb 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js
@@ -1,27 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../styles/shared-styles.html">
-<script src="../../../scripts/rootElement.js"></script>
-
-<dom-module id="gr-hovercard">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         box-sizing: border-box;
@@ -44,6 +39,4 @@
     <div id="hovercard" role="tooltip" tabindex="-1">
       <slot></slot>
     </div>
-  </template>
-  <script src="gr-hovercard.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
index e091fcf..ed087ca 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
@@ -19,12 +19,12 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-hovercard</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 <!-- Can't use absolute path below for mock-interaction.js.
 Web component tester(wct) has a built-in http server and it serves "/components" directory (which is
 actually /node_modules directory). Also, wct patches some files to load modules from /components.
@@ -33,9 +33,14 @@
 -->
 <script src="../../../node_modules/iron-test-helpers/mock-interactions.js"></script>
 
-<link rel="import" href="gr-hovercard.html">
+<script type="module" src="./gr-hovercard.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-hovercard.js';
+void(0);
+</script>
 
 <button id="foo">Hello</button>
 <test-fixture id="basic">
@@ -44,87 +49,90 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-hovercard tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    // For css animations
-    const TRANSITION_TIME = 500;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-hovercard.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-hovercard tests', () => {
+  let element;
+  let sandbox;
+  // For css animations
+  const TRANSITION_TIME = 500;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('updatePosition', () => {
-      // Test that the correct style properties have at least been set.
-      element.position = 'bottom';
-      element.updatePosition();
-      assert.typeOf(element.style.getPropertyValue('left'), 'string');
-      assert.typeOf(element.style.getPropertyValue('top'), 'string');
-      assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
-      assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
-
-      const parentRect = document.documentElement.getBoundingClientRect();
-      const targetRect = element._target.getBoundingClientRect();
-      const thisRect = element.getBoundingClientRect();
-
-      const targetLeft = targetRect.left - parentRect.left;
-      const targetTop = targetRect.top - parentRect.top;
-
-      const pixelCompare = pixel =>
-        Math.round(parseInt(pixel.substring(0, pixel.length - 1)), 10);
-
-      assert.equal(
-          pixelCompare(element.style.left),
-          pixelCompare(
-              (targetLeft + (targetRect.width - thisRect.width) / 2) + 'px'));
-      assert.equal(
-          pixelCompare(element.style.top),
-          pixelCompare(
-              (targetTop + targetRect.height + element.offset) + 'px'));
-    });
-
-    test('hide', done => {
-      element.hide({});
-      setTimeout(() => {
-        const style = getComputedStyle(element);
-        assert.isFalse(element._isShowing);
-        assert.isFalse(element.classList.contains('hovered'));
-        assert.equal(style.opacity, '0');
-        assert.equal(style.visibility, 'hidden');
-        assert.notEqual(element.container, Polymer.dom(element).parentNode);
-        done();
-      }, TRANSITION_TIME);
-    });
-
-    test('show', done => {
-      element.show({});
-      setTimeout(() => {
-        const style = getComputedStyle(element);
-        assert.isTrue(element._isShowing);
-        assert.isTrue(element.classList.contains('hovered'));
-        assert.equal(style.opacity, '1');
-        assert.equal(style.visibility, 'visible');
-        done();
-      }, TRANSITION_TIME);
-    });
-
-    test('card shows on enter and hides on leave', done => {
-      const button = Polymer.dom(document).querySelector('button');
-      assert.isFalse(element._isShowing);
-      button.addEventListener('mouseenter', event => {
-        assert.isTrue(element._isShowing);
-        button.dispatchEvent(new CustomEvent('mouseleave'));
-      });
-      button.addEventListener('mouseleave', event => {
-        assert.isFalse(element._isShowing);
-        done();
-      });
-      button.dispatchEvent(new CustomEvent('mouseenter'));
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('updatePosition', () => {
+    // Test that the correct style properties have at least been set.
+    element.position = 'bottom';
+    element.updatePosition();
+    assert.typeOf(element.style.getPropertyValue('left'), 'string');
+    assert.typeOf(element.style.getPropertyValue('top'), 'string');
+    assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
+    assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
+
+    const parentRect = document.documentElement.getBoundingClientRect();
+    const targetRect = element._target.getBoundingClientRect();
+    const thisRect = element.getBoundingClientRect();
+
+    const targetLeft = targetRect.left - parentRect.left;
+    const targetTop = targetRect.top - parentRect.top;
+
+    const pixelCompare = pixel =>
+      Math.round(parseInt(pixel.substring(0, pixel.length - 1)), 10);
+
+    assert.equal(
+        pixelCompare(element.style.left),
+        pixelCompare(
+            (targetLeft + (targetRect.width - thisRect.width) / 2) + 'px'));
+    assert.equal(
+        pixelCompare(element.style.top),
+        pixelCompare(
+            (targetTop + targetRect.height + element.offset) + 'px'));
+  });
+
+  test('hide', done => {
+    element.hide({});
+    setTimeout(() => {
+      const style = getComputedStyle(element);
+      assert.isFalse(element._isShowing);
+      assert.isFalse(element.classList.contains('hovered'));
+      assert.equal(style.opacity, '0');
+      assert.equal(style.visibility, 'hidden');
+      assert.notEqual(element.container, dom(element).parentNode);
+      done();
+    }, TRANSITION_TIME);
+  });
+
+  test('show', done => {
+    element.show({});
+    setTimeout(() => {
+      const style = getComputedStyle(element);
+      assert.isTrue(element._isShowing);
+      assert.isTrue(element.classList.contains('hovered'));
+      assert.equal(style.opacity, '1');
+      assert.equal(style.visibility, 'visible');
+      done();
+    }, TRANSITION_TIME);
+  });
+
+  test('card shows on enter and hides on leave', done => {
+    const button = dom(document).querySelector('button');
+    assert.isFalse(element._isShowing);
+    button.addEventListener('mouseenter', event => {
+      assert.isTrue(element._isShowing);
+      button.dispatchEvent(new CustomEvent('mouseleave'));
+    });
+    button.addEventListener('mouseleave', event => {
+      assert.isFalse(element._isShowing);
+      done();
+    });
+    button.dispatchEvent(new CustomEvent('mouseenter'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
index 2245b98..5d7da6c 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
@@ -1,75 +1,76 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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 '@polymer/iron-icon/iron-icon.js';
+import '@polymer/iron-iconset-svg/iron-iconset-svg.js';
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
-<link rel="import" href="/bower_components/iron-iconset-svg/iron-iconset-svg.html">
-
-<iron-iconset-svg name="gr-icons" size="24">
+$_documentContainer.innerHTML = `<iron-iconset-svg name="gr-icons" size="24">
   <svg>
     <defs>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"/></g>
+      <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/></g>
+      <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#unfold_more -->
-      <g id="unfold-more"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"/></g>
+      <g id="unfold-more"><path d="M0 0h24v24H0z" fill="none"></path><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></g>
+      <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/></g>
+      <g id="settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="create"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></g>
+      <g id="create"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="star"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></g>
+      <g id="star"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="star-border"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"/></g>
+      <g id="star-border"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="close"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></g>
+      <g id="close"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="chevron-left"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></g>
+      <g id="chevron-left"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></g>
+      <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="more-vert"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></g>
+      <g id="more-vert"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></g>
+      <g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
-      <g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"/></g>
+      <g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
-      <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></g>
+      <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
-      <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"/><path d="M0 0h24v24H0V0z" fill="none"/></g>
+      <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#mode_comment-->
-      <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"/><path d="M0 0h24v24H0z" fill="none"/></g>
+      <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="error"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="lightbulb-outline"><path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
-      <g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"/></g>
+      <g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
-      <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.12, 1.2)"/></g>
+      <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.12, 1.2)"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></g>
+      <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
-      <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></g>
+      <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="robot"><path d="M4.137453,5.61015591 L4.54835569,1.5340419 C4.5717665,1.30180904 4.76724872,1.12504213 5.00065859,1.12504213 C5.23327176,1.12504213 5.42730868,1.30282046 5.44761309,1.53454578 L5.76084628,5.10933916 C6.16304484,5.03749412 6.57714381,5 7,5 L17,5 C20.8659932,5 24,8.13400675 24,12 L24,15.1250421 C24,18.9910354 20.8659932,22.1250421 17,22.1250421 L7,22.1250421 C3.13400675,22.1250421 2.19029351e-15,18.9910354 0,15.1250421 L0,12 C-3.48556243e-16,9.15382228 1.69864167,6.70438358 4.137453,5.61015591 Z M5.77553049,6.12504213 C3.04904264,6.69038358 1,9.10590202 1,12 L1,15.1250421 C1,18.4387506 3.6862915,21.1250421 7,21.1250421 L17,21.1250421 C20.3137085,21.1250421 23,18.4387506 23,15.1250421 L23,12 C23,8.6862915 20.3137085,6 17,6 L7,6 C6.60617231,6 6.2212068,6.03794347 5.84855971,6.11037415 L5.84984496,6.12504213 L5.77553049,6.12504213 Z M6.93003717,6.95027711 L17.1232083,6.95027711 C19.8638332,6.95027711 22.0855486,9.17199258 22.0855486,11.9126175 C22.0855486,14.6532424 19.8638332,16.8749579 17.1232083,16.8749579 L6.93003717,16.8749579 C4.18941226,16.8749579 1.9676968,14.6532424 1.9676968,11.9126175 C1.9676968,9.17199258 4.18941226,6.95027711 6.93003717,6.95027711 Z M7.60124392,14.0779303 C9.03787127,14.0779303 10.2024878,12.9691885 10.2024878,11.6014862 C10.2024878,10.2337839 9.03787127,9.12504213 7.60124392,9.12504213 C6.16461657,9.12504213 5,10.2337839 5,11.6014862 C5,12.9691885 6.16461657,14.0779303 7.60124392,14.0779303 Z M16.617997,14.1098288 C18.0638768,14.1098288 19.2359939,12.9939463 19.2359939,11.6174355 C19.2359939,10.2409246 18.0638768,9.12504213 16.617997,9.12504213 C15.1721172,9.12504213 14,10.2409246 14,11.6174355 C14,12.9939463 15.1721172,14.1098288 16.617997,14.1098288 Z M9.79751216,18.1250421 L15,18.1250421 L15,19.1250421 C15,19.6773269 14.5522847,20.1250421 14,20.1250421 L10.7975122,20.1250421 C10.2452274,20.1250421 9.79751216,19.6773269 9.79751216,19.1250421 L9.79751216,18.1250421 Z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
@@ -90,9 +91,54 @@
       <!-- This is a custom PolyGerrit SVG -->
       <g id="submit"><path d="M22.23,5 L11.65,15.58 L7.47000001,11.41 L6.06000001,12.82 L11.65,18.41 L23.649,6.41 L22.23,5 Z M16.58,5 L10.239,11.34 L11.65,12.75 L17.989,6.41 L16.58,5 Z M0.400000006,12.82 L5.99000001,18.41 L7.40000001,17 L1.82000001,11.41 L0.400000006,12.82 Z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
-      <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></g>
+      <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
-      <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"/></g>
+      <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"></path></g>
     </defs>
   </svg>
-</iron-iconset-svg>
+</iron-iconset-svg>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from material.io https://material.io/icons/#unfold_more */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full*/
+/* This SVG is a copy from material.io https://material.io/icons/#mode_comment*/
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This is a custom PolyGerrit SVG */
+/* This is a custom PolyGerrit SVG */
+/* This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js */
+/* This is a custom PolyGerrit SVG */
+/* This is a custom PolyGerrit SVG */
+/* This is a custom PolyGerrit SVG */
+/* This is a custom PolyGerrit SVG */
+/* This is a custom PolyGerrit SVG */
+/* This is a custom PolyGerrit SVG */
+/* This is a custom PolyGerrit SVG */
+/* This is a custom PolyGerrit SVG */
+/* This is a custom PolyGerrit SVG */
+/* This is a custom PolyGerrit SVG */
+/* This is a custom PolyGerrit SVG */
+/* This is a custom PolyGerrit SVG */
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
index ea5740f..c044327 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation-actions-context</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../diff/gr-diff-highlight/gr-annotation.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../diff/gr-diff-highlight/gr-annotation.js"></script>
 
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html"/>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-js-api-interface.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../diff/gr-diff-highlight/gr-annotation.js';
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,68 +43,71 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-annotation-actions-context tests', async () => {
-    await readyToTest();
-    let instance;
-    let sandbox;
-    let el;
-    let lineNumberEl;
-    let plugin;
+<script type="module">
+import '../../diff/gr-diff-highlight/gr-annotation.js';
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+suite('gr-annotation-actions-context tests', () => {
+  let instance;
+  let sandbox;
+  let el;
+  let lineNumberEl;
+  let plugin;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
 
-      const str = 'lorem ipsum blah blah';
-      const line = {text: str};
-      el = document.createElement('div');
-      el.textContent = str;
-      el.setAttribute('data-side', 'right');
-      lineNumberEl = document.createElement('td');
-      lineNumberEl.classList.add('right');
-      document.body.appendChild(el);
-      instance = new GrAnnotationActionsContext(
-          el, lineNumberEl, line, 'dummy/path', '123', '1');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('test annotateRange', () => {
-      const annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      const start = 0;
-      const end = 100;
-      const cssStyleObject = plugin.styles().css('background-color: #000000');
-
-      // Assert annotateElement is not called when side is different.
-      instance.annotateRange(start, end, cssStyleObject, 'left');
-      assert.equal(annotateElementSpy.callCount, 0);
-
-      // Assert annotateElement is called once when side is the same.
-      instance.annotateRange(start, end, cssStyleObject, 'right');
-      assert.equal(annotateElementSpy.callCount, 1);
-      const args = annotateElementSpy.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], start);
-      assert.equal(args[2], end);
-      assert.equal(args[3], cssStyleObject.getClassName(el));
-    });
-
-    test('test annotateLineNumber', () => {
-      const cssStyleObject = plugin.styles().css('background-color: #000000');
-
-      const className = cssStyleObject.getClassName(lineNumberEl);
-
-      // Assert that css class is *not* applied when side is different.
-      instance.annotateLineNumber(cssStyleObject, 'left');
-      assert.isFalse(lineNumberEl.classList.contains(className));
-
-      // Assert that css class is applied when side is the same.
-      instance.annotateLineNumber(cssStyleObject, 'right');
-      assert.isTrue(lineNumberEl.classList.contains(className));
-    });
+    const str = 'lorem ipsum blah blah';
+    const line = {text: str};
+    el = document.createElement('div');
+    el.textContent = str;
+    el.setAttribute('data-side', 'right');
+    lineNumberEl = document.createElement('td');
+    lineNumberEl.classList.add('right');
+    document.body.appendChild(el);
+    instance = new GrAnnotationActionsContext(
+        el, lineNumberEl, line, 'dummy/path', '123', '1');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('test annotateRange', () => {
+    const annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+    const start = 0;
+    const end = 100;
+    const cssStyleObject = plugin.styles().css('background-color: #000000');
+
+    // Assert annotateElement is not called when side is different.
+    instance.annotateRange(start, end, cssStyleObject, 'left');
+    assert.equal(annotateElementSpy.callCount, 0);
+
+    // Assert annotateElement is called once when side is the same.
+    instance.annotateRange(start, end, cssStyleObject, 'right');
+    assert.equal(annotateElementSpy.callCount, 1);
+    const args = annotateElementSpy.getCalls()[0].args;
+    assert.equal(args[0], el);
+    assert.equal(args[1], start);
+    assert.equal(args[2], end);
+    assert.equal(args[3], cssStyleObject.getClassName(el));
+  });
+
+  test('test annotateLineNumber', () => {
+    const cssStyleObject = plugin.styles().css('background-color: #000000');
+
+    const className = cssStyleObject.getClassName(lineNumberEl);
+
+    // Assert that css class is *not* applied when side is different.
+    instance.annotateLineNumber(cssStyleObject, 'left');
+    assert.isFalse(lineNumberEl.classList.contains(className));
+
+    // Assert that css class is applied when side is the same.
+    instance.annotateLineNumber(cssStyleObject, 'right');
+    assert.isTrue(lineNumberEl.classList.contains(className));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
index b8c4f83..061f22c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
@@ -19,13 +19,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation-actions-js-api-js-api</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../change/gr-change-actions/gr-change-actions.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../change/gr-change-actions/gr-change-actions.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -38,153 +38,155 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-annotation-actions-js-api tests', async () => {
-    await readyToTest();
-    let annotationActions;
-    let sandbox;
-    let plugin;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../change/gr-change-actions/gr-change-actions.js';
+suite('gr-annotation-actions-js-api tests', () => {
+  let annotationActions;
+  let sandbox;
+  let plugin;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      annotationActions = plugin.annotationApi();
-    });
-
-    teardown(() => {
-      annotationActions = null;
-      sandbox.restore();
-    });
-
-    test('add/get layer', () => {
-      const str = 'lorem ipsum blah blah';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const changeNum = 1234;
-      const patchNum = 2;
-      let testLayerFuncCalled = false;
-
-      const testLayerFunc = context => {
-        testLayerFuncCalled = true;
-        assert.equal(context.line, line);
-        assert.equal(context.changeNum, changeNum);
-        assert.equal(context.patchNum, 2);
-      };
-      annotationActions.addLayer(testLayerFunc);
-
-      const annotationLayer = annotationActions.getLayer(
-          '/dummy/path', changeNum, patchNum);
-
-      const lineNumberEl = document.createElement('td');
-      annotationLayer.annotate(el, lineNumberEl, line);
-      assert.isTrue(testLayerFuncCalled);
-    });
-
-    test('add notifier', () => {
-      const path1 = '/dummy/path1';
-      const path2 = '/dummy/path2';
-      const annotationLayer1 = annotationActions.getLayer(path1, 1, 2);
-      const annotationLayer2 = annotationActions.getLayer(path2, 1, 2);
-      const layer1Spy = sandbox.spy(annotationLayer1, 'notifyListeners');
-      const layer2Spy = sandbox.spy(annotationLayer2, 'notifyListeners');
-
-      let notify;
-      let notifyFuncCalled;
-      const notifyFunc = n => {
-        notifyFuncCalled = true;
-        notify = n;
-      };
-      annotationActions.addNotifier(notifyFunc);
-      assert.isTrue(notifyFuncCalled);
-
-      // Assert that no layers are invoked with a different path.
-      notify('/dummy/path3', 0, 10, 'right');
-      assert.isFalse(layer1Spy.called);
-      assert.isFalse(layer2Spy.called);
-
-      // Assert that only the 1st layer is invoked with path1.
-      notify(path1, 0, 10, 'right');
-      assert.isTrue(layer1Spy.called);
-      assert.isFalse(layer2Spy.called);
-
-      // Reset spies.
-      layer1Spy.reset();
-      layer2Spy.reset();
-
-      // Assert that only the 2nd layer is invoked with path2.
-      notify(path2, 0, 20, 'left');
-      assert.isFalse(layer1Spy.called);
-      assert.isTrue(layer2Spy.called);
-    });
-
-    test('toggle checkbox', () => {
-      const fakeEl = {content: fixture('basic')};
-      const hookStub = {onAttached: sandbox.stub()};
-      sandbox.stub(plugin, 'hook').returns(hookStub);
-
-      let checkbox;
-      let onAttachedFuncCalled = false;
-      const onAttachedFunc = c => {
-        checkbox = c;
-        onAttachedFuncCalled = true;
-      };
-      annotationActions.enableToggleCheckbox('test label', onAttachedFunc);
-      const emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
-      emulateAttached();
-
-      // Assert that onAttachedFunc is called and HTML elements have the
-      // expected state.
-      assert.isTrue(onAttachedFuncCalled);
-      assert.equal(checkbox.id, 'annotation-checkbox');
-      assert.isTrue(checkbox.disabled);
-      assert.equal(document.getElementById('annotation-label').textContent,
-          'test label');
-      assert.isFalse(document.getElementById('annotation-span').hidden);
-
-      // Assert that error is shown if we try to enable checkbox again.
-      onAttachedFuncCalled = false;
-      annotationActions.enableToggleCheckbox('test label2', onAttachedFunc);
-      const errorStub = sandbox.stub(
-          console, 'error', (msg, err) => undefined);
-      emulateAttached();
-      assert.isTrue(
-          errorStub.calledWith(
-              'annotation-span is already enabled. Cannot re-enable.'));
-      // Assert that onAttachedFunc is not called and the label has not changed.
-      assert.isFalse(onAttachedFuncCalled);
-      assert.equal(document.getElementById('annotation-label').textContent,
-          'test label');
-    });
-
-    test('layer notify listeners', () => {
-      const annotationLayer = annotationActions.getLayer(
-          '/dummy/path', 1, 2);
-      let listenerCalledTimes = 0;
-      const startRange = 10;
-      const endRange = 20;
-      const side = 'right';
-      const listener = (st, end, s) => {
-        listenerCalledTimes++;
-        assert.equal(st, startRange);
-        assert.equal(end, endRange);
-        assert.equal(s, side);
-      };
-
-      // Notify with 0 listeners added.
-      annotationLayer.notifyListeners(startRange, endRange, side);
-      assert.equal(listenerCalledTimes, 0);
-
-      // Add 1 listener.
-      annotationLayer.addListener(listener);
-      annotationLayer.notifyListeners(startRange, endRange, side);
-      assert.equal(listenerCalledTimes, 1);
-
-      // Add 1 more listener. Total 2 listeners.
-      annotationLayer.addListener(listener);
-      annotationLayer.notifyListeners(startRange, endRange, side);
-      assert.equal(listenerCalledTimes, 3);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    annotationActions = plugin.annotationApi();
   });
+
+  teardown(() => {
+    annotationActions = null;
+    sandbox.restore();
+  });
+
+  test('add/get layer', () => {
+    const str = 'lorem ipsum blah blah';
+    const line = {text: str};
+    const el = document.createElement('div');
+    el.textContent = str;
+    const changeNum = 1234;
+    const patchNum = 2;
+    let testLayerFuncCalled = false;
+
+    const testLayerFunc = context => {
+      testLayerFuncCalled = true;
+      assert.equal(context.line, line);
+      assert.equal(context.changeNum, changeNum);
+      assert.equal(context.patchNum, 2);
+    };
+    annotationActions.addLayer(testLayerFunc);
+
+    const annotationLayer = annotationActions.getLayer(
+        '/dummy/path', changeNum, patchNum);
+
+    const lineNumberEl = document.createElement('td');
+    annotationLayer.annotate(el, lineNumberEl, line);
+    assert.isTrue(testLayerFuncCalled);
+  });
+
+  test('add notifier', () => {
+    const path1 = '/dummy/path1';
+    const path2 = '/dummy/path2';
+    const annotationLayer1 = annotationActions.getLayer(path1, 1, 2);
+    const annotationLayer2 = annotationActions.getLayer(path2, 1, 2);
+    const layer1Spy = sandbox.spy(annotationLayer1, 'notifyListeners');
+    const layer2Spy = sandbox.spy(annotationLayer2, 'notifyListeners');
+
+    let notify;
+    let notifyFuncCalled;
+    const notifyFunc = n => {
+      notifyFuncCalled = true;
+      notify = n;
+    };
+    annotationActions.addNotifier(notifyFunc);
+    assert.isTrue(notifyFuncCalled);
+
+    // Assert that no layers are invoked with a different path.
+    notify('/dummy/path3', 0, 10, 'right');
+    assert.isFalse(layer1Spy.called);
+    assert.isFalse(layer2Spy.called);
+
+    // Assert that only the 1st layer is invoked with path1.
+    notify(path1, 0, 10, 'right');
+    assert.isTrue(layer1Spy.called);
+    assert.isFalse(layer2Spy.called);
+
+    // Reset spies.
+    layer1Spy.reset();
+    layer2Spy.reset();
+
+    // Assert that only the 2nd layer is invoked with path2.
+    notify(path2, 0, 20, 'left');
+    assert.isFalse(layer1Spy.called);
+    assert.isTrue(layer2Spy.called);
+  });
+
+  test('toggle checkbox', () => {
+    const fakeEl = {content: fixture('basic')};
+    const hookStub = {onAttached: sandbox.stub()};
+    sandbox.stub(plugin, 'hook').returns(hookStub);
+
+    let checkbox;
+    let onAttachedFuncCalled = false;
+    const onAttachedFunc = c => {
+      checkbox = c;
+      onAttachedFuncCalled = true;
+    };
+    annotationActions.enableToggleCheckbox('test label', onAttachedFunc);
+    const emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
+    emulateAttached();
+
+    // Assert that onAttachedFunc is called and HTML elements have the
+    // expected state.
+    assert.isTrue(onAttachedFuncCalled);
+    assert.equal(checkbox.id, 'annotation-checkbox');
+    assert.isTrue(checkbox.disabled);
+    assert.equal(document.getElementById('annotation-label').textContent,
+        'test label');
+    assert.isFalse(document.getElementById('annotation-span').hidden);
+
+    // Assert that error is shown if we try to enable checkbox again.
+    onAttachedFuncCalled = false;
+    annotationActions.enableToggleCheckbox('test label2', onAttachedFunc);
+    const errorStub = sandbox.stub(
+        console, 'error', (msg, err) => undefined);
+    emulateAttached();
+    assert.isTrue(
+        errorStub.calledWith(
+            'annotation-span is already enabled. Cannot re-enable.'));
+    // Assert that onAttachedFunc is not called and the label has not changed.
+    assert.isFalse(onAttachedFuncCalled);
+    assert.equal(document.getElementById('annotation-label').textContent,
+        'test label');
+  });
+
+  test('layer notify listeners', () => {
+    const annotationLayer = annotationActions.getLayer(
+        '/dummy/path', 1, 2);
+    let listenerCalledTimes = 0;
+    const startRange = 10;
+    const endRange = 20;
+    const side = 'right';
+    const listener = (st, end, s) => {
+      listenerCalledTimes++;
+      assert.equal(st, startRange);
+      assert.equal(end, endRange);
+      assert.equal(s, side);
+    };
+
+    // Notify with 0 listeners added.
+    annotationLayer.notifyListeners(startRange, endRange, side);
+    assert.equal(listenerCalledTimes, 0);
+
+    // Add 1 listener.
+    annotationLayer.addListener(listener);
+    annotationLayer.notifyListeners(startRange, endRange, side);
+    assert.equal(listenerCalledTimes, 1);
+
+    // Add 1 more listener. Total 2 listeners.
+    annotationLayer.addListener(listener);
+    annotationLayer.notifyListeners(startRange, endRange, side);
+    assert.equal(listenerCalledTimes, 3);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
index d70a8d2..154d287 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
@@ -19,70 +19,77 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-api-interface</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-js-api-interface.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+void(0);
+</script>
 
-<script>
-  const PRELOADED_PROTOCOL = 'preloaded:';
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+const PRELOADED_PROTOCOL = 'preloaded:';
 
-  suite('gr-api-utils tests', async () => {
-    await readyToTest();
-    suite('test getPluginNameFromUrl', () => {
-      const {getPluginNameFromUrl} = window._apiUtils;
+suite('gr-api-utils tests', () => {
+  suite('test getPluginNameFromUrl', () => {
+    const {getPluginNameFromUrl} = window._apiUtils;
 
-      test('with empty string', () => {
-        assert.equal(getPluginNameFromUrl(''), null);
-      });
+    test('with empty string', () => {
+      assert.equal(getPluginNameFromUrl(''), null);
+    });
 
-      test('with invalid url', () => {
-        assert.equal(getPluginNameFromUrl('test'), null);
-      });
+    test('with invalid url', () => {
+      assert.equal(getPluginNameFromUrl('test'), null);
+    });
 
-      test('with random invalid url', () => {
-        assert.equal(getPluginNameFromUrl('http://example.com'), null);
-        assert.equal(
-            getPluginNameFromUrl('http://example.com/static/a.html'),
-            null
-        );
-      });
+    test('with random invalid url', () => {
+      assert.equal(getPluginNameFromUrl('http://example.com'), null);
+      assert.equal(
+          getPluginNameFromUrl('http://example.com/static/a.html'),
+          null
+      );
+    });
 
-      test('with valid urls', () => {
-        assert.equal(
-            getPluginNameFromUrl('http://example.com/plugins/a.html'),
-            'a'
-        );
-        assert.equal(
-            getPluginNameFromUrl('http://example.com/plugins/a/static/t.html'),
-            'a'
-        );
-      });
+    test('with valid urls', () => {
+      assert.equal(
+          getPluginNameFromUrl('http://example.com/plugins/a.html'),
+          'a'
+      );
+      assert.equal(
+          getPluginNameFromUrl('http://example.com/plugins/a/static/t.html'),
+          'a'
+      );
+    });
 
-      test('with preloaded urls', () => {
-        assert.equal(getPluginNameFromUrl(`${PRELOADED_PROTOCOL}a`), 'a');
-      });
+    test('with preloaded urls', () => {
+      assert.equal(getPluginNameFromUrl(`${PRELOADED_PROTOCOL}a`), 'a');
+    });
 
-      test('with gerrit-theme override', () => {
-        assert.equal(
-            getPluginNameFromUrl('http://example.com/static/gerrit-theme.html'),
-            'gerrit-theme'
-        );
-      });
+    test('with gerrit-theme override', () => {
+      assert.equal(
+          getPluginNameFromUrl('http://example.com/static/gerrit-theme.html'),
+          'gerrit-theme'
+      );
+    });
 
-      test('with ASSETS_PATH', () => {
-        window.ASSETS_PATH = 'http://cdn.com/2';
-        assert.equal(
-            getPluginNameFromUrl(`${window.ASSETS_PATH}/plugins/a.html`),
-            'a'
-        );
-        window.ASSETS_PATH = undefined;
-      });
+    test('with ASSETS_PATH', () => {
+      window.ASSETS_PATH = 'http://cdn.com/2';
+      assert.equal(
+          getPluginNameFromUrl(`${window.ASSETS_PATH}/plugins/a.html`),
+          'a'
+      );
+      window.ASSETS_PATH = undefined;
     });
   });
+});
 </script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
index 91e1a49..1425f71 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -19,19 +19,24 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-actions-js-api</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 <!--
 This must refer to the element this interface is wrapping around. Otherwise
 breaking changes to gr-change-actions won’t be noticed.
 -->
-<link rel="import" href="../../change/gr-change-actions/gr-change-actions.html">
+<script type="module" src="../../change/gr-change-actions/gr-change-actions.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../change/gr-change-actions/gr-change-actions.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -39,191 +44,194 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-js-api-interface tests', async () => {
-    await readyToTest();
-    let element;
-    let changeActions;
-    let plugin;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../change/gr-change-actions/gr-change-actions.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-js-api-interface tests', () => {
+  let element;
+  let changeActions;
+  let plugin;
 
-    // Because deepEqual doesn’t behave in Safari.
-    function assertArraysEqual(actual, expected) {
-      assert.equal(actual.length, expected.length);
-      for (let i = 0; i < actual.length; i++) {
-        assert.equal(actual[i], expected[i]);
-      }
+  // Because deepEqual doesn’t behave in Safari.
+  function assertArraysEqual(actual, expected) {
+    assert.equal(actual.length, expected.length);
+    for (let i = 0; i < actual.length; i++) {
+      assert.equal(actual[i], expected[i]);
     }
+  }
 
-    suite('early init', () => {
-      setup(() => {
-        Gerrit._testOnly_resetPlugins();
-        Gerrit.install(p => { plugin = p; }, '0.1',
-            'http://test.com/plugins/testplugin/static/test.js');
-        // Mimic all plugins loaded.
-        Gerrit._loadPlugins([]);
-        changeActions = plugin.changeActions();
-        element = fixture('basic');
+  suite('early init', () => {
+    setup(() => {
+      Gerrit._testOnly_resetPlugins();
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      // Mimic all plugins loaded.
+      Gerrit._loadPlugins([]);
+      changeActions = plugin.changeActions();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      changeActions = null;
+      Gerrit._testOnly_resetPlugins();
+    });
+
+    test('does not throw', ()=> {
+      assert.doesNotThrow(() => {
+        changeActions.add('change', 'foo');
       });
+    });
+  });
 
-      teardown(() => {
-        changeActions = null;
-        Gerrit._testOnly_resetPlugins();
-      });
+  suite('normal init', () => {
+    setup(() => {
+      Gerrit._testOnly_resetPlugins();
+      element = fixture('basic');
+      sinon.stub(element, '_editStatusChanged');
+      element.change = {};
+      element._hasKnownChainState = false;
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeActions = plugin.changeActions();
+      // Mimic all plugins loaded.
+      Gerrit._loadPlugins([]);
+    });
 
-      test('does not throw', ()=> {
-        assert.doesNotThrow(() => {
-          changeActions.add('change', 'foo');
+    teardown(() => {
+      changeActions = null;
+      Gerrit._testOnly_resetPlugins();
+    });
+
+    test('property existence', () => {
+      const properties = [
+        'ActionType',
+        'ChangeActions',
+        'RevisionActions',
+      ];
+      for (const p of properties) {
+        assertArraysEqual(changeActions[p], element[p]);
+      }
+    });
+
+    test('add/remove primary action keys', () => {
+      element.primaryActionKeys = [];
+      changeActions.addPrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['foo']);
+      changeActions.addPrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['foo']);
+      changeActions.addPrimaryActionKey('bar');
+      assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
+      changeActions.removePrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('baz');
+      assertArraysEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('bar');
+      assertArraysEqual(element.primaryActionKeys, []);
+    });
+
+    test('action buttons', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      const handler = sinon.spy();
+      changeActions.addTapListener(key, handler);
+      flush(() => {
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
+        assert(handler.calledOnce);
+        changeActions.removeTapListener(key, handler);
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
+        assert(handler.calledOnce);
+        changeActions.remove(key);
+        flush(() => {
+          assert.isNull(element.shadowRoot
+              .querySelector('[data-action-key="' + key + '"]'));
+          done();
         });
       });
     });
 
-    suite('normal init', () => {
-      setup(() => {
-        Gerrit._testOnly_resetPlugins();
-        element = fixture('basic');
-        sinon.stub(element, '_editStatusChanged');
-        element.change = {};
-        element._hasKnownChainState = false;
-        Gerrit.install(p => { plugin = p; }, '0.1',
-            'http://test.com/plugins/testplugin/static/test.js');
-        changeActions = plugin.changeActions();
-        // Mimic all plugins loaded.
-        Gerrit._loadPlugins([]);
-      });
-
-      teardown(() => {
-        changeActions = null;
-        Gerrit._testOnly_resetPlugins();
-      });
-
-      test('property existence', () => {
-        const properties = [
-          'ActionType',
-          'ChangeActions',
-          'RevisionActions',
-        ];
-        for (const p of properties) {
-          assertArraysEqual(changeActions[p], element[p]);
-        }
-      });
-
-      test('add/remove primary action keys', () => {
-        element.primaryActionKeys = [];
-        changeActions.addPrimaryActionKey('foo');
-        assertArraysEqual(element.primaryActionKeys, ['foo']);
-        changeActions.addPrimaryActionKey('foo');
-        assertArraysEqual(element.primaryActionKeys, ['foo']);
-        changeActions.addPrimaryActionKey('bar');
-        assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
-        changeActions.removePrimaryActionKey('foo');
-        assertArraysEqual(element.primaryActionKeys, ['bar']);
-        changeActions.removePrimaryActionKey('baz');
-        assertArraysEqual(element.primaryActionKeys, ['bar']);
-        changeActions.removePrimaryActionKey('bar');
-        assertArraysEqual(element.primaryActionKeys, []);
-      });
-
-      test('action buttons', done => {
-        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-        const handler = sinon.spy();
-        changeActions.addTapListener(key, handler);
+    test('action button properties', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        const button = element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]');
+        assert.isOk(button);
+        assert.equal(button.getAttribute('data-label'), 'Bork!');
+        assert.isNotOk(button.disabled);
+        changeActions.setLabel(key, 'Yo');
+        changeActions.setTitle(key, 'Yo hint');
+        changeActions.setEnabled(key, false);
+        changeActions.setIcon(key, 'pupper');
         flush(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('[data-action-key="' + key + '"]'));
-          assert(handler.calledOnce);
-          changeActions.removeTapListener(key, handler);
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('[data-action-key="' + key + '"]'));
-          assert(handler.calledOnce);
-          changeActions.remove(key);
-          flush(() => {
-            assert.isNull(element.shadowRoot
-                .querySelector('[data-action-key="' + key + '"]'));
-            done();
-          });
+          assert.equal(button.getAttribute('data-label'), 'Yo');
+          assert.equal(button.getAttribute('title'), 'Yo hint');
+          assert.isTrue(button.disabled);
+          assert.equal(dom(button).querySelector('iron-icon').icon,
+              'gr-icons:pupper');
+          done();
         });
       });
+    });
 
-      test('action button properties', done => {
-        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+    test('hide action buttons', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        const button = element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]');
+        assert.isOk(button);
+        assert.isFalse(button.hasAttribute('hidden'));
+        changeActions.setActionHidden(
+            changeActions.ActionType.REVISION, key, true);
         flush(() => {
           const button = element.shadowRoot
               .querySelector('[data-action-key="' + key + '"]');
-          assert.isOk(button);
-          assert.equal(button.getAttribute('data-label'), 'Bork!');
-          assert.isNotOk(button.disabled);
-          changeActions.setLabel(key, 'Yo');
-          changeActions.setTitle(key, 'Yo hint');
-          changeActions.setEnabled(key, false);
-          changeActions.setIcon(key, 'pupper');
-          flush(() => {
-            assert.equal(button.getAttribute('data-label'), 'Yo');
-            assert.equal(button.getAttribute('title'), 'Yo hint');
-            assert.isTrue(button.disabled);
-            assert.equal(Polymer.dom(button).querySelector('iron-icon').icon,
-                'gr-icons:pupper');
-            done();
-          });
+          assert.isNotOk(button);
+          done();
         });
       });
+    });
 
-      test('hide action buttons', done => {
-        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+    test('move action button to overflow', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        assert.isTrue(element.$.moreActions.hidden);
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
+        changeActions.setActionOverflow(
+            changeActions.ActionType.REVISION, key, true);
         flush(() => {
-          const button = element.shadowRoot
-              .querySelector('[data-action-key="' + key + '"]');
-          assert.isOk(button);
-          assert.isFalse(button.hasAttribute('hidden'));
-          changeActions.setActionHidden(
-              changeActions.ActionType.REVISION, key, true);
-          flush(() => {
-            const button = element.shadowRoot
-                .querySelector('[data-action-key="' + key + '"]');
-            assert.isNotOk(button);
-            done();
-          });
-        });
-      });
-
-      test('move action button to overflow', done => {
-        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-        flush(() => {
-          assert.isTrue(element.$.moreActions.hidden);
-          assert.isOk(element.shadowRoot
+          assert.isNotOk(element.shadowRoot
               .querySelector('[data-action-key="' + key + '"]'));
-          changeActions.setActionOverflow(
-              changeActions.ActionType.REVISION, key, true);
-          flush(() => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="' + key + '"]'));
-            assert.isFalse(element.$.moreActions.hidden);
-            assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
-            done();
-          });
+          assert.isFalse(element.$.moreActions.hidden);
+          assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
+          done();
         });
       });
+    });
 
-      test('change actions priority', done => {
-        const key1 =
-          changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-        const key2 =
-          changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
+    test('change actions priority', done => {
+      const key1 =
+        changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      const key2 =
+        changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
+      flush(() => {
+        let buttons =
+          dom(element.root).querySelectorAll('[data-action-key]');
+        assert.equal(buttons[0].getAttribute('data-action-key'), key1);
+        assert.equal(buttons[1].getAttribute('data-action-key'), key2);
+        changeActions.setActionPriority(
+            changeActions.ActionType.REVISION, key1, 10);
         flush(() => {
-          let buttons =
-            Polymer.dom(element.root).querySelectorAll('[data-action-key]');
-          assert.equal(buttons[0].getAttribute('data-action-key'), key1);
-          assert.equal(buttons[1].getAttribute('data-action-key'), key2);
-          changeActions.setActionPriority(
-              changeActions.ActionType.REVISION, key1, 10);
-          flush(() => {
-            buttons =
-              Polymer.dom(element.root).querySelectorAll('[data-action-key]');
-            assert.equal(buttons[0].getAttribute('data-action-key'), key2);
-            assert.equal(buttons[1].getAttribute('data-action-key'), key1);
-            done();
-          });
+          buttons =
+            dom(element.root).querySelectorAll('[data-action-key]');
+          assert.equal(buttons[0].getAttribute('data-action-key'), key2);
+          assert.equal(buttons[1].getAttribute('data-action-key'), key1);
+          done();
         });
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
index 3147746..3a1c51c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
@@ -19,19 +19,24 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-reply-js-api</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 <!--
 This must refer to the element this interface is wrapping around. Otherwise
 breaking changes to gr-reply-dialog won’t be noticed.
 -->
-<link rel="import" href="../../change/gr-reply-dialog/gr-reply-dialog.html">
+<script type="module" src="../../change/gr-reply-dialog/gr-reply-dialog.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../change/gr-reply-dialog/gr-reply-dialog.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -39,86 +44,88 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-reply-js-api tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let changeReply;
-    let plugin;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../change/gr-reply-dialog/gr-reply-dialog.js';
+suite('gr-change-reply-js-api tests', () => {
+  let element;
+  let sandbox;
+  let changeReply;
+  let plugin;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve(null); },
+    });
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('early init', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getAccount() { return Promise.resolve(null); },
-      });
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeReply = plugin.changeReply();
+      element = fixture('basic');
     });
 
     teardown(() => {
-      sandbox.restore();
+      changeReply = null;
     });
 
-    suite('early init', () => {
-      setup(() => {
-        Gerrit.install(p => { plugin = p; }, '0.1',
-            'http://test.com/plugins/testplugin/static/test.js');
-        changeReply = plugin.changeReply();
-        element = fixture('basic');
-      });
+    test('works', () => {
+      sandbox.stub(element, 'getLabelValue').returns('+123');
+      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
 
-      teardown(() => {
-        changeReply = null;
-      });
+      sandbox.stub(element, 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert.isTrue(
+          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
 
-      test('works', () => {
-        sandbox.stub(element, 'getLabelValue').returns('+123');
-        assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+      sandbox.stub(element, 'send');
+      changeReply.send(false);
+      assert.isTrue(element.send.calledWithExactly(false));
 
-        sandbox.stub(element, 'setLabelValue');
-        changeReply.setLabelValue('My-Label', '+1337');
-        assert.isTrue(
-            element.setLabelValue.calledWithExactly('My-Label', '+1337'));
-
-        sandbox.stub(element, 'send');
-        changeReply.send(false);
-        assert.isTrue(element.send.calledWithExactly(false));
-
-        sandbox.stub(element, 'setPluginMessage');
-        changeReply.showMessage('foobar');
-        assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
-      });
-    });
-
-    suite('normal init', () => {
-      setup(() => {
-        element = fixture('basic');
-        Gerrit.install(p => { plugin = p; }, '0.1',
-            'http://test.com/plugins/testplugin/static/test.js');
-        changeReply = plugin.changeReply();
-      });
-
-      teardown(() => {
-        changeReply = null;
-      });
-
-      test('works', () => {
-        sandbox.stub(element, 'getLabelValue').returns('+123');
-        assert.equal(changeReply.getLabelValue('My-Label'), '+123');
-
-        sandbox.stub(element, 'setLabelValue');
-        changeReply.setLabelValue('My-Label', '+1337');
-        assert.isTrue(
-            element.setLabelValue.calledWithExactly('My-Label', '+1337'));
-
-        sandbox.stub(element, 'send');
-        changeReply.send(false);
-        assert.isTrue(element.send.calledWithExactly(false));
-
-        sandbox.stub(element, 'setPluginMessage');
-        changeReply.showMessage('foobar');
-        assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
-      });
+      sandbox.stub(element, 'setPluginMessage');
+      changeReply.showMessage('foobar');
+      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
     });
   });
+
+  suite('normal init', () => {
+    setup(() => {
+      element = fixture('basic');
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeReply = plugin.changeReply();
+    });
+
+    teardown(() => {
+      changeReply = null;
+    });
+
+    test('works', () => {
+      sandbox.stub(element, 'getLabelValue').returns('+123');
+      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+      sandbox.stub(element, 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert.isTrue(
+          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+
+      sandbox.stub(element, 'send');
+      changeReply.send(false);
+      assert.isTrue(element.send.calledWithExactly(false));
+
+      sandbox.stub(element, 'setPluginMessage');
+      changeReply.showMessage('foobar');
+      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html
index ee95a5e..57f0646 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-api-interface</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-js-api-interface.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,68 +40,70 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-gerrit tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let sendStub;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+suite('gr-gerrit tests', () => {
+  let element;
+  let sandbox;
+  let sendStub;
 
-    setup(() => {
-      window.clock = sinon.useFakeTimers();
-      sandbox = sinon.sandbox.create();
-      sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-      stub('gr-rest-api-interface', {
-        getAccount() {
-          return Promise.resolve({name: 'Judy Hopps'});
-        },
-        send(...args) {
-          return sendStub(...args);
-        },
-      });
-      element = fixture('basic');
+  setup(() => {
+    window.clock = sinon.useFakeTimers();
+    sandbox = sinon.sandbox.create();
+    sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve({name: 'Judy Hopps'});
+      },
+      send(...args) {
+        return sendStub(...args);
+      },
+    });
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    window.clock.restore();
+    sandbox.restore();
+    element._removeEventCallbacks();
+    Gerrit._testOnly_resetPlugins();
+  });
+
+  suite('proxy methods', () => {
+    test('Gerrit._isPluginEnabled proxy to pluginLoader', () => {
+      const stubFn = sandbox.stub();
+      sandbox.stub(
+          Gerrit._pluginLoader,
+          'isPluginEnabled',
+          (...args) => stubFn(...args)
+      );
+      Gerrit._isPluginEnabled('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
     });
 
-    teardown(() => {
-      window.clock.restore();
-      sandbox.restore();
-      element._removeEventCallbacks();
-      Gerrit._testOnly_resetPlugins();
+    test('Gerrit._isPluginLoaded proxy to pluginLoader', () => {
+      const stubFn = sandbox.stub();
+      sandbox.stub(
+          Gerrit._pluginLoader,
+          'isPluginLoaded',
+          (...args) => stubFn(...args)
+      );
+      Gerrit._isPluginLoaded('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
     });
 
-    suite('proxy methods', () => {
-      test('Gerrit._isPluginEnabled proxy to pluginLoader', () => {
-        const stubFn = sandbox.stub();
-        sandbox.stub(
-            Gerrit._pluginLoader,
-            'isPluginEnabled',
-            (...args) => stubFn(...args)
-        );
-        Gerrit._isPluginEnabled('test_plugin');
-        assert.isTrue(stubFn.calledWith('test_plugin'));
-      });
-
-      test('Gerrit._isPluginLoaded proxy to pluginLoader', () => {
-        const stubFn = sandbox.stub();
-        sandbox.stub(
-            Gerrit._pluginLoader,
-            'isPluginLoaded',
-            (...args) => stubFn(...args)
-        );
-        Gerrit._isPluginLoaded('test_plugin');
-        assert.isTrue(stubFn.calledWith('test_plugin'));
-      });
-
-      test('Gerrit._isPluginPreloaded proxy to pluginLoader', () => {
-        const stubFn = sandbox.stub();
-        sandbox.stub(
-            Gerrit._pluginLoader,
-            'isPluginPreloaded',
-            (...args) => stubFn(...args)
-        );
-        Gerrit._isPluginPreloaded('test_plugin');
-        assert.isTrue(stubFn.calledWith('test_plugin'));
-      });
+    test('Gerrit._isPluginPreloaded proxy to pluginLoader', () => {
+      const stubFn = sandbox.stub();
+      sandbox.stub(
+          Gerrit._pluginLoader,
+          'isPluginPreloaded',
+          (...args) => stubFn(...args)
+      );
+      Gerrit._isPluginPreloaded('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.html
deleted file mode 100644
index 922fa57..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.html
+++ /dev/null
@@ -1,24 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-
-<dom-module id="gr-js-api-interface">
-  <script src="gr-js-api-interface-element.js"></script>
-</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js
index 393dc77..2523d47 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * 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.
@@ -14,306 +14,311 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Note: for new events, naming convention should be: `a-b`
-  const EventType = {
-    HISTORY: 'history',
-    LABEL_CHANGE: 'labelchange',
-    SHOW_CHANGE: 'showchange',
-    SUBMIT_CHANGE: 'submitchange',
-    SHOW_REVISION_ACTIONS: 'show-revision-actions',
-    COMMIT_MSG_EDIT: 'commitmsgedit',
-    COMMENT: 'comment',
-    REVERT: 'revert',
-    REVERT_SUBMISSION: 'revert_submission',
-    POST_REVERT: 'postrevert',
-    ANNOTATE_DIFF: 'annotatediff',
-    ADMIN_MENU_LINKS: 'admin-menu-links',
-    HIGHLIGHTJS_LOADED: 'highlightjs-loaded',
-  };
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 
-  const Element = {
-    CHANGE_ACTIONS: 'changeactions',
-    REPLY_DIALOG: 'replydialog',
-  };
+// Note: for new events, naming convention should be: `a-b`
+const EventType = {
+  HISTORY: 'history',
+  LABEL_CHANGE: 'labelchange',
+  SHOW_CHANGE: 'showchange',
+  SUBMIT_CHANGE: 'submitchange',
+  SHOW_REVISION_ACTIONS: 'show-revision-actions',
+  COMMIT_MSG_EDIT: 'commitmsgedit',
+  COMMENT: 'comment',
+  REVERT: 'revert',
+  REVERT_SUBMISSION: 'revert_submission',
+  POST_REVERT: 'postrevert',
+  ANNOTATE_DIFF: 'annotatediff',
+  ADMIN_MENU_LINKS: 'admin-menu-links',
+  HIGHLIGHTJS_LOADED: 'highlightjs-loaded',
+};
 
-  /**
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @extends Polymer.Element
-   */
-  class GrJsApiInterface extends Polymer.mixinBehaviors( [
-    Gerrit.PatchSetBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-js-api-interface'; }
+const Element = {
+  CHANGE_ACTIONS: 'changeactions',
+  REPLY_DIALOG: 'replydialog',
+};
 
-    constructor() {
-      super();
-      this.Element = Element;
-      this.EventType = EventType;
-    }
+/**
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @extends Polymer.Element
+ */
+class GrJsApiInterface extends mixinBehaviors( [
+  Gerrit.PatchSetBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get is() { return 'gr-js-api-interface'; }
 
-    static get properties() {
-      return {
-        _elements: {
-          type: Object,
-          value: {}, // Shared across all instances.
-        },
-        _eventCallbacks: {
-          type: Object,
-          value: {}, // Shared across all instances.
-        },
-      };
-    }
+  constructor() {
+    super();
+    this.Element = Element;
+    this.EventType = EventType;
+  }
 
-    handleEvent(type, detail) {
-      Gerrit.awaitPluginsLoaded().then(() => {
-        switch (type) {
-          case EventType.HISTORY:
-            this._handleHistory(detail);
-            break;
-          case EventType.SHOW_CHANGE:
-            this._handleShowChange(detail);
-            break;
-          case EventType.COMMENT:
-            this._handleComment(detail);
-            break;
-          case EventType.LABEL_CHANGE:
-            this._handleLabelChange(detail);
-            break;
-          case EventType.SHOW_REVISION_ACTIONS:
-            this._handleShowRevisionActions(detail);
-            break;
-          case EventType.HIGHLIGHTJS_LOADED:
-            this._handleHighlightjsLoaded(detail);
-            break;
-          default:
-            console.warn('handleEvent called with unsupported event type:',
-                type);
-            break;
-        }
-      });
-    }
+  static get properties() {
+    return {
+      _elements: {
+        type: Object,
+        value: {}, // Shared across all instances.
+      },
+      _eventCallbacks: {
+        type: Object,
+        value: {}, // Shared across all instances.
+      },
+    };
+  }
 
-    addElement(key, el) {
-      this._elements[key] = el;
-    }
-
-    getElement(key) {
-      return this._elements[key];
-    }
-
-    addEventCallback(eventName, callback) {
-      if (!this._eventCallbacks[eventName]) {
-        this._eventCallbacks[eventName] = [];
-      }
-      this._eventCallbacks[eventName].push(callback);
-    }
-
-    canSubmitChange(change, revision) {
-      const submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
-      const cancelSubmit = submitCallbacks.some(callback => {
-        try {
-          return callback(change, revision) === false;
-        } catch (err) {
-          console.error(err);
-        }
-        return false;
-      });
-
-      return !cancelSubmit;
-    }
-
-    _removeEventCallbacks() {
-      for (const k in EventType) {
-        if (!EventType.hasOwnProperty(k)) { continue; }
-        this._eventCallbacks[EventType[k]] = [];
-      }
-    }
-
-    _handleHistory(detail) {
-      for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
-        try {
-          cb(detail.path);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    }
-
-    _handleShowChange(detail) {
-      // Note (issue 8221) Shallow clone the change object and add a mergeable
-      // getter with deprecation warning. This makes the change detail appear as
-      // though SKIP_MERGEABLE was not set, so that plugins that expect it can
-      // still access.
-      //
-      // This clone and getter can be removed after plugins migrate to use
-      // info.mergeable.
-      //
-      // assign on getter with existing property will report error
-      // see Issue: 12286
-      const change = Object.assign({}, detail.change, {
-        get mergeable() {
-          console.warn('Accessing change.mergeable from SHOW_CHANGE is ' +
-              'deprecated! Use info.mergeable instead.');
-          return detail.info && detail.info.mergeable;
-        },
-      });
-      const patchNum = detail.patchNum;
-      const info = detail.info;
-
-      let revision;
-      for (const rev of Object.values(change.revisions || {})) {
-        if (this.patchNumEquals(rev._number, patchNum)) {
-          revision = rev;
+  handleEvent(type, detail) {
+    Gerrit.awaitPluginsLoaded().then(() => {
+      switch (type) {
+        case EventType.HISTORY:
+          this._handleHistory(detail);
           break;
-        }
+        case EventType.SHOW_CHANGE:
+          this._handleShowChange(detail);
+          break;
+        case EventType.COMMENT:
+          this._handleComment(detail);
+          break;
+        case EventType.LABEL_CHANGE:
+          this._handleLabelChange(detail);
+          break;
+        case EventType.SHOW_REVISION_ACTIONS:
+          this._handleShowRevisionActions(detail);
+          break;
+        case EventType.HIGHLIGHTJS_LOADED:
+          this._handleHighlightjsLoaded(detail);
+          break;
+        default:
+          console.warn('handleEvent called with unsupported event type:',
+              type);
+          break;
       }
+    });
+  }
 
-      for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
-        try {
-          cb(change, revision, info);
-        } catch (err) {
-          console.error(err);
-        }
+  addElement(key, el) {
+    this._elements[key] = el;
+  }
+
+  getElement(key) {
+    return this._elements[key];
+  }
+
+  addEventCallback(eventName, callback) {
+    if (!this._eventCallbacks[eventName]) {
+      this._eventCallbacks[eventName] = [];
+    }
+    this._eventCallbacks[eventName].push(callback);
+  }
+
+  canSubmitChange(change, revision) {
+    const submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
+    const cancelSubmit = submitCallbacks.some(callback => {
+      try {
+        return callback(change, revision) === false;
+      } catch (err) {
+        console.error(err);
       }
-    }
+      return false;
+    });
 
-    /**
-     * @param {!{change: !Object, revisionActions: !Object}} detail
-     */
-    _handleShowRevisionActions(detail) {
-      const registeredCallbacks = this._getEventCallbacks(
-          EventType.SHOW_REVISION_ACTIONS
-      );
-      for (const cb of registeredCallbacks) {
-        try {
-          cb(detail.revisionActions, detail.change);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    }
+    return !cancelSubmit;
+  }
 
-    handleCommitMessage(change, msg) {
-      for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
-        try {
-          cb(change, msg);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    }
-
-    _handleComment(detail) {
-      for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
-        try {
-          cb(detail.node);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    }
-
-    _handleLabelChange(detail) {
-      for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
-        try {
-          cb(detail.change);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    }
-
-    _handleHighlightjsLoaded(detail) {
-      for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
-        try {
-          cb(detail.hljs);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    }
-
-    modifyRevertMsg(change, revertMsg, origMsg) {
-      for (const cb of this._getEventCallbacks(EventType.REVERT)) {
-        try {
-          revertMsg = cb(change, revertMsg, origMsg);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-      return revertMsg;
-    }
-
-    modifyRevertSubmissionMsg(change, revertSubmissionMsg, origMsg) {
-      for (const cb of this._getEventCallbacks(EventType.REVERT_SUBMISSION)) {
-        try {
-          revertSubmissionMsg = cb(change, revertSubmissionMsg, origMsg);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-      return revertSubmissionMsg;
-    }
-
-    getDiffLayers(path, changeNum, patchNum) {
-      const layers = [];
-      for (const annotationApi of
-        this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
-        try {
-          const layer = annotationApi.getLayer(path, changeNum, patchNum);
-          layers.push(layer);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-      return layers;
-    }
-
-    /**
-     * Retrieves coverage data possibly provided by a plugin.
-     *
-     * Will wait for plugins to be loaded. If multiple plugins offer a coverage
-     * provider, the first one is returned. If no plugin offers a coverage provider,
-     * will resolve to null.
-     *
-     * @return {!Promise<?GrAnnotationActionsInterface>}
-     */
-    getCoverageAnnotationApi() {
-      return Gerrit.awaitPluginsLoaded()
-          .then(() => this._getEventCallbacks(EventType.ANNOTATE_DIFF)
-              .find(api => api.getCoverageProvider()));
-    }
-
-    getAdminMenuLinks() {
-      const links = [];
-      for (const adminApi of
-        this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
-        links.push(...adminApi.getMenuLinks());
-      }
-      return links;
-    }
-
-    getLabelValuesPostRevert(change) {
-      let labels = {};
-      for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
-        try {
-          labels = cb(change);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-      return labels;
-    }
-
-    _getEventCallbacks(type) {
-      return this._eventCallbacks[type] || [];
+  _removeEventCallbacks() {
+    for (const k in EventType) {
+      if (!EventType.hasOwnProperty(k)) { continue; }
+      this._eventCallbacks[EventType[k]] = [];
     }
   }
 
-  customElements.define(GrJsApiInterface.is, GrJsApiInterface);
-})();
+  _handleHistory(detail) {
+    for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
+      try {
+        cb(detail.path);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  _handleShowChange(detail) {
+    // Note (issue 8221) Shallow clone the change object and add a mergeable
+    // getter with deprecation warning. This makes the change detail appear as
+    // though SKIP_MERGEABLE was not set, so that plugins that expect it can
+    // still access.
+    //
+    // This clone and getter can be removed after plugins migrate to use
+    // info.mergeable.
+    //
+    // assign on getter with existing property will report error
+    // see Issue: 12286
+    const change = Object.assign({}, detail.change, {
+      get mergeable() {
+        console.warn('Accessing change.mergeable from SHOW_CHANGE is ' +
+            'deprecated! Use info.mergeable instead.');
+        return detail.info && detail.info.mergeable;
+      },
+    });
+    const patchNum = detail.patchNum;
+    const info = detail.info;
+
+    let revision;
+    for (const rev of Object.values(change.revisions || {})) {
+      if (this.patchNumEquals(rev._number, patchNum)) {
+        revision = rev;
+        break;
+      }
+    }
+
+    for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
+      try {
+        cb(change, revision, info);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  /**
+   * @param {!{change: !Object, revisionActions: !Object}} detail
+   */
+  _handleShowRevisionActions(detail) {
+    const registeredCallbacks = this._getEventCallbacks(
+        EventType.SHOW_REVISION_ACTIONS
+    );
+    for (const cb of registeredCallbacks) {
+      try {
+        cb(detail.revisionActions, detail.change);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  handleCommitMessage(change, msg) {
+    for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
+      try {
+        cb(change, msg);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  _handleComment(detail) {
+    for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
+      try {
+        cb(detail.node);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  _handleLabelChange(detail) {
+    for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
+      try {
+        cb(detail.change);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  _handleHighlightjsLoaded(detail) {
+    for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
+      try {
+        cb(detail.hljs);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  modifyRevertMsg(change, revertMsg, origMsg) {
+    for (const cb of this._getEventCallbacks(EventType.REVERT)) {
+      try {
+        revertMsg = cb(change, revertMsg, origMsg);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+    return revertMsg;
+  }
+
+  modifyRevertSubmissionMsg(change, revertSubmissionMsg, origMsg) {
+    for (const cb of this._getEventCallbacks(EventType.REVERT_SUBMISSION)) {
+      try {
+        revertSubmissionMsg = cb(change, revertSubmissionMsg, origMsg);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+    return revertSubmissionMsg;
+  }
+
+  getDiffLayers(path, changeNum, patchNum) {
+    const layers = [];
+    for (const annotationApi of
+      this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
+      try {
+        const layer = annotationApi.getLayer(path, changeNum, patchNum);
+        layers.push(layer);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+    return layers;
+  }
+
+  /**
+   * Retrieves coverage data possibly provided by a plugin.
+   *
+   * Will wait for plugins to be loaded. If multiple plugins offer a coverage
+   * provider, the first one is returned. If no plugin offers a coverage provider,
+   * will resolve to null.
+   *
+   * @return {!Promise<?GrAnnotationActionsInterface>}
+   */
+  getCoverageAnnotationApi() {
+    return Gerrit.awaitPluginsLoaded()
+        .then(() => this._getEventCallbacks(EventType.ANNOTATE_DIFF)
+            .find(api => api.getCoverageProvider()));
+  }
+
+  getAdminMenuLinks() {
+    const links = [];
+    for (const adminApi of
+      this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
+      links.push(...adminApi.getMenuLinks());
+    }
+    return links;
+  }
+
+  getLabelValuesPostRevert(change) {
+    let labels = {};
+    for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
+      try {
+        labels = cb(change);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+    return labels;
+  }
+
+  _getEventCallbacks(type) {
+    return this._eventCallbacks[type] || [];
+  }
+}
+
+customElements.define(GrJsApiInterface.is, GrJsApiInterface);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
deleted file mode 100644
index a4909ec..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ /dev/null
@@ -1,51 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../plugins/gr-admin-api/gr-admin-api.html">
-<link rel="import" href="../../plugins/gr-attribute-helper/gr-attribute-helper.html">
-<link rel="import" href="../../plugins/gr-change-metadata-api/gr-change-metadata-api.html">
-<link rel="import" href="../../plugins/gr-dom-hooks/gr-dom-hooks.html">
-<link rel="import" href="../../plugins/gr-event-helper/gr-event-helper.html">
-<link rel="import" href="../../plugins/gr-popup-interface/gr-popup-interface.html">
-<link rel="import" href="../../plugins/gr-repo-api/gr-repo-api.html">
-<link rel="import" href="../../plugins/gr-settings-api/gr-settings-api.html">
-<link rel="import" href="../../plugins/gr-styles-api/gr-styles-api.html">
-<link rel="import" href="../../plugins/gr-theme-api/gr-theme-api.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-<!--
-  Note: the order matters as files depend on each other.
-  1. gr-api-utils will be used in multiple files below.
-  2. gr-gerrit depends on gr-plugin-loader, gr-public-js-api and
-    also gr-plugin-endpoints
-  3. gr-public-js-api depends on gr-plugin-rest-api
--->
-<script src="gr-api-utils.js"></script>
-<script src="../gr-event-interface/gr-event-interface.js"></script>
-<script src="gr-annotation-actions-context.js"></script>
-<script src="gr-annotation-actions-js-api.js"></script>
-<script src="gr-change-actions-js-api.js"></script>
-<script src="gr-change-reply-js-api.js"></script>
-<link rel="import" href="./gr-js-api-interface-element.html">
-<script src="gr-plugin-endpoints.js"></script>
-<script src="gr-plugin-action-context.js"></script>
-<script src="gr-plugin-rest-api.js"></script>
-<script src="gr-public-js-api.js"></script>
-<script src="gr-plugin-loader.js"></script>
-<script src="gr-gerrit.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
new file mode 100644
index 0000000..6b7b13e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
@@ -0,0 +1,58 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 '../../../scripts/bundled-polymer.js';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../plugins/gr-admin-api/gr-admin-api.js';
+import '../../plugins/gr-attribute-helper/gr-attribute-helper.js';
+import '../../plugins/gr-change-metadata-api/gr-change-metadata-api.js';
+import '../../plugins/gr-dom-hooks/gr-dom-hooks.js';
+import '../../plugins/gr-event-helper/gr-event-helper.js';
+import '../../plugins/gr-popup-interface/gr-popup-interface.js';
+import '../../plugins/gr-repo-api/gr-repo-api.js';
+import '../../plugins/gr-settings-api/gr-settings-api.js';
+import '../../plugins/gr-styles-api/gr-styles-api.js';
+import '../../plugins/gr-theme-api/gr-theme-api.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import './gr-api-utils.js';
+import '../gr-event-interface/gr-event-interface.js';
+import './gr-annotation-actions-context.js';
+import './gr-annotation-actions-js-api.js';
+import './gr-change-actions-js-api.js';
+import './gr-change-reply-js-api.js';
+import './gr-js-api-interface-element.js';
+import './gr-plugin-endpoints.js';
+import './gr-plugin-action-context.js';
+import './gr-plugin-rest-api.js';
+import './gr-public-js-api.js';
+import './gr-plugin-loader.js';
+import './gr-gerrit.js';
+
+/*
+  Note: the order matters as files depend on each other.
+  1. gr-api-utils will be used in multiple files below.
+  2. gr-gerrit depends on gr-plugin-loader, gr-public-js-api and
+    also gr-plugin-endpoints
+  3. gr-public-js-api depends on gr-plugin-rest-api
+*/
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index efc7206..04ad490 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-api-interface</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-js-api-interface.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,539 +40,541 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-js-api-interface tests', async () => {
-    await readyToTest();
-    const {PLUGIN_LOADING_TIMEOUT_MS} = window._apiUtils;
-    let element;
-    let plugin;
-    let errorStub;
-    let sandbox;
-    let getResponseObjectStub;
-    let sendStub;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+suite('gr-js-api-interface tests', () => {
+  const {PLUGIN_LOADING_TIMEOUT_MS} = window._apiUtils;
+  let element;
+  let plugin;
+  let errorStub;
+  let sandbox;
+  let getResponseObjectStub;
+  let sendStub;
 
-    const throwErrFn = function() {
-      throw Error('Unfortunately, this handler has stopped');
+  const throwErrFn = function() {
+    throw Error('Unfortunately, this handler has stopped');
+  };
+
+  setup(() => {
+    window.clock = sinon.useFakeTimers();
+    sandbox = sinon.sandbox.create();
+    getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
+    sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve({name: 'Judy Hopps'});
+      },
+      getResponseObject: getResponseObjectStub,
+      send(...args) {
+        return sendStub(...args);
+      },
+    });
+    element = fixture('basic');
+    errorStub = sandbox.stub(console, 'error');
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    Gerrit._loadPlugins([]);
+  });
+
+  teardown(() => {
+    window.clock.restore();
+    sandbox.restore();
+    element._removeEventCallbacks();
+    plugin = null;
+  });
+
+  test('url', () => {
+    assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
+    assert.equal(plugin.url('/static/test.js'),
+        'http://test.com/plugins/testplugin/static/test.js');
+  });
+
+  test('url for preloaded plugin without ASSETS_PATH', () => {
+    let plugin;
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'preloaded:testpluginB');
+    assert.equal(plugin.url(),
+        `${window.location.origin}/plugins/testpluginB/`);
+    assert.equal(plugin.url('/static/test.js'),
+        `${window.location.origin}/plugins/testpluginB/static/test.js`);
+  });
+
+  test('url for preloaded plugin without ASSETS_PATH', () => {
+    const oldAssetsPath = window.ASSETS_PATH;
+    window.ASSETS_PATH = 'http://test.com';
+    let plugin;
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'preloaded:testpluginC');
+    assert.equal(plugin.url(), `${window.ASSETS_PATH}/plugins/testpluginC/`);
+    assert.equal(plugin.url('/static/test.js'),
+        `${window.ASSETS_PATH}/plugins/testpluginC/static/test.js`);
+    window.ASSETS_PATH = oldAssetsPath;
+  });
+
+  test('_send on failure rejects with response text', () => {
+    sendStub.returns(Promise.resolve(
+        {status: 400, text() { return Promise.resolve('text'); }}));
+    return plugin._send().catch(r => {
+      assert.equal(r.message, 'text');
+    });
+  });
+
+  test('_send on failure without text rejects with code', () => {
+    sendStub.returns(Promise.resolve(
+        {status: 400, text() { return Promise.resolve(null); }}));
+    return plugin._send().catch(r => {
+      assert.equal(r.message, '400');
+    });
+  });
+
+  test('get', () => {
+    const response = {foo: 'foo'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return plugin.get('/url', r => {
+      assert.isTrue(sendStub.calledWith(
+          'GET', 'http://test.com/plugins/testplugin/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('get using Promise', () => {
+    const response = {foo: 'foo'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return plugin.get('/url', r => 'rubbish').then(r => {
+      assert.isTrue(sendStub.calledWith(
+          'GET', 'http://test.com/plugins/testplugin/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('post', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return plugin.post('/url', payload, r => {
+      assert.isTrue(sendStub.calledWith(
+          'POST', 'http://test.com/plugins/testplugin/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('put', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return plugin.put('/url', payload, r => {
+      assert.isTrue(sendStub.calledWith(
+          'PUT', 'http://test.com/plugins/testplugin/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('delete works', () => {
+    const response = {status: 204};
+    sendStub.returns(Promise.resolve(response));
+    return plugin.delete('/url', r => {
+      assert.isTrue(sendStub.calledWithExactly(
+          'DELETE', 'http://test.com/plugins/testplugin/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('delete fails', () => {
+    sendStub.returns(Promise.resolve(
+        {status: 400, text() { return Promise.resolve('text'); }}));
+    return plugin.delete('/url', r => {
+      throw new Error('Should not resolve');
+    }).catch(err => {
+      assert.isTrue(sendStub.calledWith(
+          'DELETE', 'http://test.com/plugins/testplugin/url'));
+      assert.equal('text', err.message);
+    });
+  });
+
+  test('history event', done => {
+    plugin.on(element.EventType.HISTORY, throwErrFn);
+    plugin.on(element.EventType.HISTORY, path => {
+      assert.equal(path, '/path/to/awesomesauce');
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.HISTORY,
+        {path: '/path/to/awesomesauce'});
+  });
+
+  test('showchange event', done => {
+    const testChange = {
+      _number: 42,
+      revisions: {def: {_number: 2}, abc: {_number: 1}},
     };
+    const expectedChange = Object.assign({mergeable: false}, testChange);
+    plugin.on(element.EventType.SHOW_CHANGE, throwErrFn);
+    plugin.on(element.EventType.SHOW_CHANGE, (change, revision, info) => {
+      assert.deepEqual(change, expectedChange);
+      assert.deepEqual(revision, testChange.revisions.abc);
+      assert.deepEqual(info, {mergeable: false});
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.SHOW_CHANGE,
+        {change: testChange, patchNum: 1, info: {mergeable: false}});
+  });
+
+  test('show-revision-actions event', done => {
+    const testChange = {
+      _number: 42,
+      revisions: {def: {_number: 2}, abc: {_number: 1}},
+    };
+    plugin.on(element.EventType.SHOW_REVISION_ACTIONS, throwErrFn);
+    plugin.on(element.EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
+      assert.deepEqual(change, testChange);
+      assert.deepEqual(actions, {test: {}});
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.SHOW_REVISION_ACTIONS,
+        {change: testChange, revisionActions: {test: {}}});
+  });
+
+  test('handleEvent awaits plugins load', done => {
+    const testChange = {
+      _number: 42,
+      revisions: {def: {_number: 2}, abc: {_number: 1}},
+    };
+    const spy = sandbox.spy();
+    Gerrit._loadPlugins(['plugins/test.html']);
+    plugin.on(element.EventType.SHOW_CHANGE, spy);
+    element.handleEvent(element.EventType.SHOW_CHANGE,
+        {change: testChange, patchNum: 1});
+    assert.isFalse(spy.called);
+
+    // Timeout on loading plugins
+    window.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+    flush(() => {
+      assert.isTrue(spy.called);
+      done();
+    });
+  });
+
+  test('comment event', done => {
+    const testCommentNode = {foo: 'bar'};
+    plugin.on(element.EventType.COMMENT, throwErrFn);
+    plugin.on(element.EventType.COMMENT, commentNode => {
+      assert.deepEqual(commentNode, testCommentNode);
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.COMMENT, {node: testCommentNode});
+  });
+
+  test('revert event', () => {
+    function appendToRevertMsg(c, revertMsg, originalMsg) {
+      return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
+    }
+
+    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(element.EventType.REVERT, throwErrFn);
+    plugin.on(element.EventType.REVERT, appendToRevertMsg);
+    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+        'test\n> origTest\ninfo');
+    assert.isTrue(errorStub.calledOnce);
+
+    plugin.on(element.EventType.REVERT, appendToRevertMsg);
+    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+        'test\n> origTest\ninfo\n> origTest\ninfo');
+    assert.isTrue(errorStub.calledTwice);
+  });
+
+  test('postrevert event', () => {
+    function getLabels(c) {
+      return {'Code-Review': 1};
+    }
+
+    assert.deepEqual(element.getLabelValuesPostRevert(null), {});
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(element.EventType.POST_REVERT, throwErrFn);
+    plugin.on(element.EventType.POST_REVERT, getLabels);
+    assert.deepEqual(
+        element.getLabelValuesPostRevert(null), {'Code-Review': 1});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('commitmsgedit event', done => {
+    const testMsg = 'Test CL commit message';
+    plugin.on(element.EventType.COMMIT_MSG_EDIT, throwErrFn);
+    plugin.on(element.EventType.COMMIT_MSG_EDIT, (change, msg) => {
+      assert.deepEqual(msg, testMsg);
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleCommitMessage(null, testMsg);
+  });
+
+  test('labelchange event', done => {
+    const testChange = {_number: 42};
+    plugin.on(element.EventType.LABEL_CHANGE, throwErrFn);
+    plugin.on(element.EventType.LABEL_CHANGE, change => {
+      assert.deepEqual(change, testChange);
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.LABEL_CHANGE, {change: testChange});
+  });
+
+  test('submitchange', () => {
+    plugin.on(element.EventType.SUBMIT_CHANGE, throwErrFn);
+    plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
+    assert.isTrue(element.canSubmitChange());
+    assert.isTrue(errorStub.calledOnce);
+    plugin.on(element.EventType.SUBMIT_CHANGE, () => false);
+    plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
+    assert.isFalse(element.canSubmitChange());
+    assert.isTrue(errorStub.calledTwice);
+  });
+
+  test('highlightjs-loaded event', done => {
+    const testHljs = {_number: 42};
+    plugin.on(element.EventType.HIGHLIGHTJS_LOADED, throwErrFn);
+    plugin.on(element.EventType.HIGHLIGHTJS_LOADED, hljs => {
+      assert.deepEqual(hljs, testHljs);
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
+  });
+
+  test('getLoggedIn', done => {
+    // fake fetch for authCheck
+    sandbox.stub(window, 'fetch', () => Promise.resolve({status: 204}));
+    plugin.restApi().getLoggedIn()
+        .then(loggedIn => {
+          assert.isTrue(loggedIn);
+          done();
+        });
+  });
+
+  test('attributeHelper', () => {
+    assert.isOk(plugin.attributeHelper());
+  });
+
+  test('deprecated.install', () => {
+    plugin.deprecated.install();
+    assert.strictEqual(plugin.popup, plugin.deprecated.popup);
+    assert.strictEqual(plugin.onAction, plugin.deprecated.onAction);
+    assert.notStrictEqual(plugin.install, plugin.deprecated.install);
+  });
+
+  test('getAdminMenuLinks', () => {
+    const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
+    const getCallbacksStub = sandbox.stub(element, '_getEventCallbacks')
+        .returns([
+          {getMenuLinks: () => [links[0]]},
+          {getMenuLinks: () => [links[1]]},
+        ]);
+    const result = element.getAdminMenuLinks();
+    assert.deepEqual(result, links);
+    assert.isTrue(getCallbacksStub.calledOnce);
+    assert.equal(getCallbacksStub.lastCall.args[0],
+        element.EventType.ADMIN_MENU_LINKS);
+  });
+
+  suite('test plugin with base url', () => {
+    let baseUrlPlugin;
 
     setup(() => {
-      window.clock = sinon.useFakeTimers();
-      sandbox = sinon.sandbox.create();
-      getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
-      sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-      stub('gr-rest-api-interface', {
-        getAccount() {
-          return Promise.resolve({name: 'Judy Hopps'});
-        },
-        getResponseObject: getResponseObjectStub,
-        send(...args) {
-          return sendStub(...args);
-        },
-      });
-      element = fixture('basic');
-      errorStub = sandbox.stub(console, 'error');
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      Gerrit._loadPlugins([]);
-    });
+      sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/r');
 
-    teardown(() => {
-      window.clock.restore();
-      sandbox.restore();
-      element._removeEventCallbacks();
-      plugin = null;
+      Gerrit.install(p => { baseUrlPlugin = p; }, '0.1',
+          'http://test.com/r/plugins/baseurlplugin/static/test.js');
     });
 
     test('url', () => {
-      assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
-      assert.equal(plugin.url('/static/test.js'),
-          'http://test.com/plugins/testplugin/static/test.js');
+      assert.notEqual(baseUrlPlugin.url(),
+          'http://test.com/plugins/baseurlplugin/');
+      assert.equal(baseUrlPlugin.url(),
+          'http://test.com/r/plugins/baseurlplugin/');
+      assert.equal(baseUrlPlugin.url('/static/test.js'),
+          'http://test.com/r/plugins/baseurlplugin/static/test.js');
+    });
+  });
+
+  suite('popup', () => {
+    test('popup(element) is deprecated', () => {
+      plugin.popup(document.createElement('div'));
+      assert.isTrue(console.error.calledOnce);
     });
 
-    test('url for preloaded plugin without ASSETS_PATH', () => {
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'preloaded:testpluginB');
-      assert.equal(plugin.url(),
-          `${window.location.origin}/plugins/testpluginB/`);
-      assert.equal(plugin.url('/static/test.js'),
-          `${window.location.origin}/plugins/testpluginB/static/test.js`);
+    test('popup(moduleName) creates popup with component', () => {
+      const openStub = sandbox.stub();
+      sandbox.stub(window, 'GrPopupInterface').returns({
+        open: openStub,
+      });
+      plugin.popup('some-name');
+      assert.isTrue(openStub.calledOnce);
+      assert.isTrue(GrPopupInterface.calledWith(plugin, 'some-name'));
     });
 
-    test('url for preloaded plugin without ASSETS_PATH', () => {
-      const oldAssetsPath = window.ASSETS_PATH;
-      window.ASSETS_PATH = 'http://test.com';
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'preloaded:testpluginC');
-      assert.equal(plugin.url(), `${window.ASSETS_PATH}/plugins/testpluginC/`);
-      assert.equal(plugin.url('/static/test.js'),
-          `${window.ASSETS_PATH}/plugins/testpluginC/static/test.js`);
-      window.ASSETS_PATH = oldAssetsPath;
+    test('deprecated.popup(element) creates popup with element', () => {
+      const el = document.createElement('div');
+      el.textContent = 'some text here';
+      const openStub = sandbox.stub(GrPopupInterface.prototype, 'open');
+      openStub.returns(Promise.resolve({
+        _getElement() {
+          return document.createElement('div');
+        }}));
+      plugin.deprecated.popup(el);
+      assert.isTrue(openStub.calledOnce);
     });
+  });
 
-    test('_send on failure rejects with response text', () => {
-      sendStub.returns(Promise.resolve(
-          {status: 400, text() { return Promise.resolve('text'); }}));
-      return plugin._send().catch(r => {
-        assert.equal(r.message, 'text');
+  suite('onAction', () => {
+    let change;
+    let revision;
+    let actionDetails;
+
+    setup(() => {
+      change = {};
+      revision = {};
+      actionDetails = {__key: 'some'};
+      sandbox.stub(plugin, 'on').callsArgWith(1, change, revision);
+      sandbox.stub(plugin, 'changeActions').returns({
+        addTapListener: sandbox.stub().callsArg(1),
+        getActionDetails: () => actionDetails,
       });
     });
 
-    test('_send on failure without text rejects with code', () => {
-      sendStub.returns(Promise.resolve(
-          {status: 400, text() { return Promise.resolve(null); }}));
-      return plugin._send().catch(r => {
-        assert.equal(r.message, '400');
+    test('returns GrPluginActionContext', () => {
+      const stub = sandbox.stub();
+      plugin.deprecated.onAction('change', 'foo', ctx => {
+        assert.isTrue(ctx instanceof GrPluginActionContext);
+        assert.strictEqual(ctx.change, change);
+        assert.strictEqual(ctx.revision, revision);
+        assert.strictEqual(ctx.action, actionDetails);
+        assert.strictEqual(ctx.plugin, plugin);
+        stub();
       });
+      assert.isTrue(stub.called);
     });
 
-    test('get', () => {
-      const response = {foo: 'foo'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return plugin.get('/url', r => {
-        assert.isTrue(sendStub.calledWith(
-            'GET', 'http://test.com/plugins/testplugin/url'));
-        assert.strictEqual(r, response);
-      });
+    test('other actions', () => {
+      const stub = sandbox.stub();
+      plugin.deprecated.onAction('project', 'foo', stub);
+      plugin.deprecated.onAction('edit', 'foo', stub);
+      plugin.deprecated.onAction('branch', 'foo', stub);
+      assert.isFalse(stub.called);
+    });
+  });
+
+  suite('screen', () => {
+    test('screenUrl()', () => {
+      sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/base');
+      assert.equal(plugin.screenUrl(), 'http://test.com/base/x/testplugin');
+      assert.equal(
+          plugin.screenUrl('foo'), 'http://test.com/base/x/testplugin/foo');
     });
 
-    test('get using Promise', () => {
-      const response = {foo: 'foo'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return plugin.get('/url', r => 'rubbish').then(r => {
-        assert.isTrue(sendStub.calledWith(
-            'GET', 'http://test.com/plugins/testplugin/url'));
-        assert.strictEqual(r, response);
-      });
+    test('deprecated works', () => {
+      const stub = sandbox.stub();
+      const hookStub = {onAttached: sandbox.stub()};
+      sandbox.stub(plugin, 'hook').returns(hookStub);
+      plugin.deprecated.screen('foo', stub);
+      assert.isTrue(plugin.hook.calledWith('testplugin-screen-foo'));
+      const fakeEl = {style: {display: ''}};
+      hookStub.onAttached.callArgWith(0, fakeEl);
+      assert.isTrue(stub.called);
+      assert.equal(fakeEl.style.display, 'none');
     });
 
-    test('post', () => {
-      const payload = {foo: 'foo'};
-      const response = {bar: 'bar'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return plugin.post('/url', payload, r => {
-        assert.isTrue(sendStub.calledWith(
-            'POST', 'http://test.com/plugins/testplugin/url', payload));
-        assert.strictEqual(r, response);
-      });
+    test('works', () => {
+      sandbox.stub(plugin, 'registerCustomComponent');
+      plugin.screen('foo', 'some-module');
+      assert.isTrue(plugin.registerCustomComponent.calledWith(
+          'testplugin-screen-foo', 'some-module'));
+    });
+  });
+
+  suite('panel', () => {
+    let fakeEl;
+    let emulateAttached;
+
+    setup(()=> {
+      fakeEl = {change: {}, revision: {}};
+      const hookStub = {onAttached: sandbox.stub()};
+      sandbox.stub(plugin, 'hook').returns(hookStub);
+      emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
     });
 
-    test('put', () => {
-      const payload = {foo: 'foo'};
-      const response = {bar: 'bar'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return plugin.put('/url', payload, r => {
-        assert.isTrue(sendStub.calledWith(
-            'PUT', 'http://test.com/plugins/testplugin/url', payload));
-        assert.strictEqual(r, response);
-      });
+    test('plugin.panel is deprecated', () => {
+      plugin.panel('rubbish');
+      assert.isTrue(console.error.called);
     });
 
-    test('delete works', () => {
-      const response = {status: 204};
-      sendStub.returns(Promise.resolve(response));
-      return plugin.delete('/url', r => {
-        assert.isTrue(sendStub.calledWithExactly(
-            'DELETE', 'http://test.com/plugins/testplugin/url'));
-        assert.strictEqual(r, response);
-      });
-    });
-
-    test('delete fails', () => {
-      sendStub.returns(Promise.resolve(
-          {status: 400, text() { return Promise.resolve('text'); }}));
-      return plugin.delete('/url', r => {
-        throw new Error('Should not resolve');
-      }).catch(err => {
-        assert.isTrue(sendStub.calledWith(
-            'DELETE', 'http://test.com/plugins/testplugin/url'));
-        assert.equal('text', err.message);
-      });
-    });
-
-    test('history event', done => {
-      plugin.on(element.EventType.HISTORY, throwErrFn);
-      plugin.on(element.EventType.HISTORY, path => {
-        assert.equal(path, '/path/to/awesomesauce');
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleEvent(element.EventType.HISTORY,
-          {path: '/path/to/awesomesauce'});
-    });
-
-    test('showchange event', done => {
-      const testChange = {
-        _number: 42,
-        revisions: {def: {_number: 2}, abc: {_number: 1}},
-      };
-      const expectedChange = Object.assign({mergeable: false}, testChange);
-      plugin.on(element.EventType.SHOW_CHANGE, throwErrFn);
-      plugin.on(element.EventType.SHOW_CHANGE, (change, revision, info) => {
-        assert.deepEqual(change, expectedChange);
-        assert.deepEqual(revision, testChange.revisions.abc);
-        assert.deepEqual(info, {mergeable: false});
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleEvent(element.EventType.SHOW_CHANGE,
-          {change: testChange, patchNum: 1, info: {mergeable: false}});
-    });
-
-    test('show-revision-actions event', done => {
-      const testChange = {
-        _number: 42,
-        revisions: {def: {_number: 2}, abc: {_number: 1}},
-      };
-      plugin.on(element.EventType.SHOW_REVISION_ACTIONS, throwErrFn);
-      plugin.on(element.EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
-        assert.deepEqual(change, testChange);
-        assert.deepEqual(actions, {test: {}});
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleEvent(element.EventType.SHOW_REVISION_ACTIONS,
-          {change: testChange, revisionActions: {test: {}}});
-    });
-
-    test('handleEvent awaits plugins load', done => {
-      const testChange = {
-        _number: 42,
-        revisions: {def: {_number: 2}, abc: {_number: 1}},
-      };
-      const spy = sandbox.spy();
-      Gerrit._loadPlugins(['plugins/test.html']);
-      plugin.on(element.EventType.SHOW_CHANGE, spy);
-      element.handleEvent(element.EventType.SHOW_CHANGE,
-          {change: testChange, patchNum: 1});
-      assert.isFalse(spy.called);
-
-      // Timeout on loading plugins
-      window.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
-
-      flush(() => {
-        assert.isTrue(spy.called);
-        done();
-      });
-    });
-
-    test('comment event', done => {
-      const testCommentNode = {foo: 'bar'};
-      plugin.on(element.EventType.COMMENT, throwErrFn);
-      plugin.on(element.EventType.COMMENT, commentNode => {
-        assert.deepEqual(commentNode, testCommentNode);
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleEvent(element.EventType.COMMENT, {node: testCommentNode});
-    });
-
-    test('revert event', () => {
-      function appendToRevertMsg(c, revertMsg, originalMsg) {
-        return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
-      }
-
-      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
-      assert.equal(errorStub.callCount, 0);
-
-      plugin.on(element.EventType.REVERT, throwErrFn);
-      plugin.on(element.EventType.REVERT, appendToRevertMsg);
-      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-          'test\n> origTest\ninfo');
-      assert.isTrue(errorStub.calledOnce);
-
-      plugin.on(element.EventType.REVERT, appendToRevertMsg);
-      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-          'test\n> origTest\ninfo\n> origTest\ninfo');
-      assert.isTrue(errorStub.calledTwice);
-    });
-
-    test('postrevert event', () => {
-      function getLabels(c) {
-        return {'Code-Review': 1};
-      }
-
-      assert.deepEqual(element.getLabelValuesPostRevert(null), {});
-      assert.equal(errorStub.callCount, 0);
-
-      plugin.on(element.EventType.POST_REVERT, throwErrFn);
-      plugin.on(element.EventType.POST_REVERT, getLabels);
-      assert.deepEqual(
-          element.getLabelValuesPostRevert(null), {'Code-Review': 1});
-      assert.isTrue(errorStub.calledOnce);
-    });
-
-    test('commitmsgedit event', done => {
-      const testMsg = 'Test CL commit message';
-      plugin.on(element.EventType.COMMIT_MSG_EDIT, throwErrFn);
-      plugin.on(element.EventType.COMMIT_MSG_EDIT, (change, msg) => {
-        assert.deepEqual(msg, testMsg);
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleCommitMessage(null, testMsg);
-    });
-
-    test('labelchange event', done => {
-      const testChange = {_number: 42};
-      plugin.on(element.EventType.LABEL_CHANGE, throwErrFn);
-      plugin.on(element.EventType.LABEL_CHANGE, change => {
-        assert.deepEqual(change, testChange);
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleEvent(element.EventType.LABEL_CHANGE, {change: testChange});
-    });
-
-    test('submitchange', () => {
-      plugin.on(element.EventType.SUBMIT_CHANGE, throwErrFn);
-      plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
-      assert.isTrue(element.canSubmitChange());
-      assert.isTrue(errorStub.calledOnce);
-      plugin.on(element.EventType.SUBMIT_CHANGE, () => false);
-      plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
-      assert.isFalse(element.canSubmitChange());
-      assert.isTrue(errorStub.calledTwice);
-    });
-
-    test('highlightjs-loaded event', done => {
-      const testHljs = {_number: 42};
-      plugin.on(element.EventType.HIGHLIGHTJS_LOADED, throwErrFn);
-      plugin.on(element.EventType.HIGHLIGHTJS_LOADED, hljs => {
-        assert.deepEqual(hljs, testHljs);
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleEvent(element.EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
-    });
-
-    test('getLoggedIn', done => {
-      // fake fetch for authCheck
-      sandbox.stub(window, 'fetch', () => Promise.resolve({status: 204}));
-      plugin.restApi().getLoggedIn()
-          .then(loggedIn => {
-            assert.isTrue(loggedIn);
-            done();
-          });
-    });
-
-    test('attributeHelper', () => {
-      assert.isOk(plugin.attributeHelper());
-    });
-
-    test('deprecated.install', () => {
-      plugin.deprecated.install();
-      assert.strictEqual(plugin.popup, plugin.deprecated.popup);
-      assert.strictEqual(plugin.onAction, plugin.deprecated.onAction);
-      assert.notStrictEqual(plugin.install, plugin.deprecated.install);
-    });
-
-    test('getAdminMenuLinks', () => {
-      const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
-      const getCallbacksStub = sandbox.stub(element, '_getEventCallbacks')
-          .returns([
-            {getMenuLinks: () => [links[0]]},
-            {getMenuLinks: () => [links[1]]},
-          ]);
-      const result = element.getAdminMenuLinks();
-      assert.deepEqual(result, links);
-      assert.isTrue(getCallbacksStub.calledOnce);
-      assert.equal(getCallbacksStub.lastCall.args[0],
-          element.EventType.ADMIN_MENU_LINKS);
-    });
-
-    suite('test plugin with base url', () => {
-      let baseUrlPlugin;
-
-      setup(() => {
-        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/r');
-
-        Gerrit.install(p => { baseUrlPlugin = p; }, '0.1',
-            'http://test.com/r/plugins/baseurlplugin/static/test.js');
-      });
-
-      test('url', () => {
-        assert.notEqual(baseUrlPlugin.url(),
-            'http://test.com/plugins/baseurlplugin/');
-        assert.equal(baseUrlPlugin.url(),
-            'http://test.com/r/plugins/baseurlplugin/');
-        assert.equal(baseUrlPlugin.url('/static/test.js'),
-            'http://test.com/r/plugins/baseurlplugin/static/test.js');
-      });
-    });
-
-    suite('popup', () => {
-      test('popup(element) is deprecated', () => {
-        plugin.popup(document.createElement('div'));
-        assert.isTrue(console.error.calledOnce);
-      });
-
-      test('popup(moduleName) creates popup with component', () => {
-        const openStub = sandbox.stub();
-        sandbox.stub(window, 'GrPopupInterface').returns({
-          open: openStub,
-        });
-        plugin.popup('some-name');
-        assert.isTrue(openStub.calledOnce);
-        assert.isTrue(GrPopupInterface.calledWith(plugin, 'some-name'));
-      });
-
-      test('deprecated.popup(element) creates popup with element', () => {
-        const el = document.createElement('div');
-        el.textContent = 'some text here';
-        const openStub = sandbox.stub(GrPopupInterface.prototype, 'open');
-        openStub.returns(Promise.resolve({
-          _getElement() {
-            return document.createElement('div');
-          }}));
-        plugin.deprecated.popup(el);
-        assert.isTrue(openStub.calledOnce);
-      });
-    });
-
-    suite('onAction', () => {
-      let change;
-      let revision;
-      let actionDetails;
-
-      setup(() => {
-        change = {};
-        revision = {};
-        actionDetails = {__key: 'some'};
-        sandbox.stub(plugin, 'on').callsArgWith(1, change, revision);
-        sandbox.stub(plugin, 'changeActions').returns({
-          addTapListener: sandbox.stub().callsArg(1),
-          getActionDetails: () => actionDetails,
-        });
-      });
-
-      test('returns GrPluginActionContext', () => {
-        const stub = sandbox.stub();
-        plugin.deprecated.onAction('change', 'foo', ctx => {
-          assert.isTrue(ctx instanceof GrPluginActionContext);
-          assert.strictEqual(ctx.change, change);
-          assert.strictEqual(ctx.revision, revision);
-          assert.strictEqual(ctx.action, actionDetails);
-          assert.strictEqual(ctx.plugin, plugin);
-          stub();
-        });
-        assert.isTrue(stub.called);
-      });
-
-      test('other actions', () => {
-        const stub = sandbox.stub();
-        plugin.deprecated.onAction('project', 'foo', stub);
-        plugin.deprecated.onAction('edit', 'foo', stub);
-        plugin.deprecated.onAction('branch', 'foo', stub);
-        assert.isFalse(stub.called);
-      });
-    });
-
-    suite('screen', () => {
-      test('screenUrl()', () => {
-        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/base');
-        assert.equal(plugin.screenUrl(), 'http://test.com/base/x/testplugin');
-        assert.equal(
-            plugin.screenUrl('foo'), 'http://test.com/base/x/testplugin/foo');
-      });
-
-      test('deprecated works', () => {
-        const stub = sandbox.stub();
-        const hookStub = {onAttached: sandbox.stub()};
-        sandbox.stub(plugin, 'hook').returns(hookStub);
-        plugin.deprecated.screen('foo', stub);
-        assert.isTrue(plugin.hook.calledWith('testplugin-screen-foo'));
-        const fakeEl = {style: {display: ''}};
-        hookStub.onAttached.callArgWith(0, fakeEl);
-        assert.isTrue(stub.called);
-        assert.equal(fakeEl.style.display, 'none');
-      });
-
-      test('works', () => {
-        sandbox.stub(plugin, 'registerCustomComponent');
-        plugin.screen('foo', 'some-module');
-        assert.isTrue(plugin.registerCustomComponent.calledWith(
-            'testplugin-screen-foo', 'some-module'));
-      });
-    });
-
-    suite('panel', () => {
-      let fakeEl;
-      let emulateAttached;
-
-      setup(()=> {
-        fakeEl = {change: {}, revision: {}};
-        const hookStub = {onAttached: sandbox.stub()};
-        sandbox.stub(plugin, 'hook').returns(hookStub);
-        emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
-      });
-
-      test('plugin.panel is deprecated', () => {
-        plugin.panel('rubbish');
-        assert.isTrue(console.error.called);
-      });
-
-      [
-        ['CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', 'change-view-integration'],
-        ['CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK', 'change-metadata-item'],
-      ].forEach(([panelName, endpointName]) => {
-        test(`deprecated.panel works for ${panelName}`, () => {
-          const callback = sandbox.stub();
-          plugin.deprecated.panel(panelName, callback);
-          assert.isTrue(plugin.hook.calledWith(endpointName));
-          emulateAttached();
-          assert.isTrue(callback.called);
-          const args = callback.args[0][0];
-          assert.strictEqual(args.body, fakeEl);
-          assert.strictEqual(args.p.CHANGE_INFO, fakeEl.change);
-          assert.strictEqual(args.p.REVISION_INFO, fakeEl.revision);
-        });
-      });
-    });
-
-    suite('settingsScreen', () => {
-      test('plugin.settingsScreen is deprecated', () => {
-        plugin.settingsScreen('rubbish');
-        assert.isTrue(console.error.called);
-      });
-
-      test('plugin.settings() returns GrSettingsApi', () => {
-        assert.isOk(plugin.settings());
-        assert.isTrue(plugin.settings() instanceof GrSettingsApi);
-      });
-
-      test('plugin.deprecated.settingsScreen() works', () => {
-        const hookStub = {onAttached: sandbox.stub()};
-        sandbox.stub(plugin, 'hook').returns(hookStub);
-        const fakeSettings = {};
-        fakeSettings.title = sandbox.stub().returns(fakeSettings);
-        fakeSettings.token = sandbox.stub().returns(fakeSettings);
-        fakeSettings.module = sandbox.stub().returns(fakeSettings);
-        fakeSettings.build = sandbox.stub().returns(hookStub);
-        sandbox.stub(plugin, 'settings').returns(fakeSettings);
+    [
+      ['CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', 'change-view-integration'],
+      ['CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK', 'change-metadata-item'],
+    ].forEach(([panelName, endpointName]) => {
+      test(`deprecated.panel works for ${panelName}`, () => {
         const callback = sandbox.stub();
-
-        plugin.deprecated.settingsScreen('path', 'menu', callback);
-        assert.isTrue(fakeSettings.title.calledWith('menu'));
-        assert.isTrue(fakeSettings.token.calledWith('path'));
-        assert.isTrue(fakeSettings.module.calledWith('div'));
-        assert.equal(fakeSettings.build.callCount, 1);
-
-        const fakeBody = {};
-        const fakeEl = {
-          style: {
-            display: '',
-          },
-          querySelector: sandbox.stub().returns(fakeBody),
-        };
-        // Emulate settings screen attached
-        hookStub.onAttached.callArgWith(0, fakeEl);
+        plugin.deprecated.panel(panelName, callback);
+        assert.isTrue(plugin.hook.calledWith(endpointName));
+        emulateAttached();
         assert.isTrue(callback.called);
         const args = callback.args[0][0];
-        assert.strictEqual(args.body, fakeBody);
-        assert.equal(fakeEl.style.display, 'none');
+        assert.strictEqual(args.body, fakeEl);
+        assert.strictEqual(args.p.CHANGE_INFO, fakeEl.change);
+        assert.strictEqual(args.p.REVISION_INFO, fakeEl.revision);
       });
     });
   });
+
+  suite('settingsScreen', () => {
+    test('plugin.settingsScreen is deprecated', () => {
+      plugin.settingsScreen('rubbish');
+      assert.isTrue(console.error.called);
+    });
+
+    test('plugin.settings() returns GrSettingsApi', () => {
+      assert.isOk(plugin.settings());
+      assert.isTrue(plugin.settings() instanceof GrSettingsApi);
+    });
+
+    test('plugin.deprecated.settingsScreen() works', () => {
+      const hookStub = {onAttached: sandbox.stub()};
+      sandbox.stub(plugin, 'hook').returns(hookStub);
+      const fakeSettings = {};
+      fakeSettings.title = sandbox.stub().returns(fakeSettings);
+      fakeSettings.token = sandbox.stub().returns(fakeSettings);
+      fakeSettings.module = sandbox.stub().returns(fakeSettings);
+      fakeSettings.build = sandbox.stub().returns(hookStub);
+      sandbox.stub(plugin, 'settings').returns(fakeSettings);
+      const callback = sandbox.stub();
+
+      plugin.deprecated.settingsScreen('path', 'menu', callback);
+      assert.isTrue(fakeSettings.title.calledWith('menu'));
+      assert.isTrue(fakeSettings.token.calledWith('path'));
+      assert.isTrue(fakeSettings.module.calledWith('div'));
+      assert.equal(fakeSettings.build.callCount, 1);
+
+      const fakeBody = {};
+      const fakeEl = {
+        style: {
+          display: '',
+        },
+        querySelector: sandbox.stub().returns(fakeBody),
+      };
+      // Emulate settings screen attached
+      hookStub.onAttached.callArgWith(0, fakeEl);
+      assert.isTrue(callback.called);
+      const args = callback.args[0][0];
+      assert.strictEqual(args.body, fakeBody);
+      assert.equal(fakeEl.style.display, 'none');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
index c8fb9b1..3ba2acc 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-action-context</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-js-api-interface.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,126 +40,129 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-plugin-action-context tests', async () => {
-    await readyToTest();
-    let instance;
-    let sandbox;
-    let plugin;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-plugin-action-context tests', () => {
+  let instance;
+  let sandbox;
+  let plugin;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      instance = new GrPluginActionContext(plugin);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    instance = new GrPluginActionContext(plugin);
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('popup() and hide()', () => {
-      const popupApiStub = {
-        close: sandbox.stub(),
-      };
-      sandbox.stub(plugin.deprecated, 'popup').returns(popupApiStub);
-      const el = {};
-      instance.popup(el);
-      assert.isTrue(instance.plugin.deprecated.popup.calledWith(el));
+  test('popup() and hide()', () => {
+    const popupApiStub = {
+      close: sandbox.stub(),
+    };
+    sandbox.stub(plugin.deprecated, 'popup').returns(popupApiStub);
+    const el = {};
+    instance.popup(el);
+    assert.isTrue(instance.plugin.deprecated.popup.calledWith(el));
 
-      instance.hide();
-      assert.isTrue(popupApiStub.close.called);
-    });
+    instance.hide();
+    assert.isTrue(popupApiStub.close.called);
+  });
 
-    test('textfield', () => {
-      assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
-    });
+  test('textfield', () => {
+    assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
+  });
 
-    test('br', () => {
-      assert.equal(instance.br().tagName, 'BR');
-    });
+  test('br', () => {
+    assert.equal(instance.br().tagName, 'BR');
+  });
 
-    test('msg', () => {
-      const el = instance.msg('foobar');
-      assert.equal(el.tagName, 'GR-LABEL');
-      assert.equal(el.textContent, 'foobar');
-    });
+  test('msg', () => {
+    const el = instance.msg('foobar');
+    assert.equal(el.tagName, 'GR-LABEL');
+    assert.equal(el.textContent, 'foobar');
+  });
 
-    test('div', () => {
-      const el1 = document.createElement('span');
-      el1.textContent = 'foo';
-      const el2 = document.createElement('div');
-      el2.textContent = 'bar';
-      const div = instance.div(el1, el2);
-      assert.equal(div.tagName, 'DIV');
-      assert.equal(div.textContent, 'foobar');
-    });
+  test('div', () => {
+    const el1 = document.createElement('span');
+    el1.textContent = 'foo';
+    const el2 = document.createElement('div');
+    el2.textContent = 'bar';
+    const div = instance.div(el1, el2);
+    assert.equal(div.tagName, 'DIV');
+    assert.equal(div.textContent, 'foobar');
+  });
 
-    test('button', done => {
-      const clickStub = sandbox.stub();
-      const button = instance.button('foo', {onclick: clickStub});
-      // If you don't attach a Polymer element to the DOM, then the ready()
-      // callback will not be called and then e.g. this.$ is undefined.
-      Polymer.dom(document.body).appendChild(button);
-      MockInteractions.tap(button);
-      flush(() => {
-        assert.isTrue(clickStub.called);
-        assert.equal(button.textContent, 'foo');
-        done();
-      });
-    });
-
-    test('checkbox', () => {
-      const el = instance.checkbox();
-      assert.equal(el.tagName, 'INPUT');
-      assert.equal(el.type, 'checkbox');
-    });
-
-    test('label', () => {
-      const fakeMsg = {};
-      const fakeCheckbox = {};
-      sandbox.stub(instance, 'div');
-      sandbox.stub(instance, 'msg').returns(fakeMsg);
-      instance.label(fakeCheckbox, 'foo');
-      assert.isTrue(instance.div.calledWithExactly(fakeCheckbox, fakeMsg));
-    });
-
-    test('call', () => {
-      instance.action = {
-        method: 'METHOD',
-        __key: 'key',
-        __url: '/changes/1/revisions/2/foo~bar',
-      };
-      const sendStub = sandbox.stub().returns(Promise.resolve());
-      sandbox.stub(plugin, 'restApi').returns({
-        send: sendStub,
-      });
-      const payload = {foo: 'foo'};
-      const successStub = sandbox.stub();
-      instance.call(payload, successStub);
-      assert.isTrue(sendStub.calledWith(
-          'METHOD', '/changes/1/revisions/2/foo~bar', payload));
-    });
-
-    test('call error', done => {
-      instance.action = {
-        method: 'METHOD',
-        __key: 'key',
-        __url: '/changes/1/revisions/2/foo~bar',
-      };
-      const sendStub = sandbox.stub().returns(Promise.reject(new Error('boom')));
-      sandbox.stub(plugin, 'restApi').returns({
-        send: sendStub,
-      });
-      const errorStub = sandbox.stub();
-      document.addEventListener('show-alert', errorStub);
-      instance.call();
-      flush(() => {
-        assert.isTrue(errorStub.calledOnce);
-        assert.equal(errorStub.args[0][0].detail.message,
-            'Plugin network error: Error: boom');
-        done();
-      });
+  test('button', done => {
+    const clickStub = sandbox.stub();
+    const button = instance.button('foo', {onclick: clickStub});
+    // If you don't attach a Polymer element to the DOM, then the ready()
+    // callback will not be called and then e.g. this.$ is undefined.
+    dom(document.body).appendChild(button);
+    MockInteractions.tap(button);
+    flush(() => {
+      assert.isTrue(clickStub.called);
+      assert.equal(button.textContent, 'foo');
+      done();
     });
   });
+
+  test('checkbox', () => {
+    const el = instance.checkbox();
+    assert.equal(el.tagName, 'INPUT');
+    assert.equal(el.type, 'checkbox');
+  });
+
+  test('label', () => {
+    const fakeMsg = {};
+    const fakeCheckbox = {};
+    sandbox.stub(instance, 'div');
+    sandbox.stub(instance, 'msg').returns(fakeMsg);
+    instance.label(fakeCheckbox, 'foo');
+    assert.isTrue(instance.div.calledWithExactly(fakeCheckbox, fakeMsg));
+  });
+
+  test('call', () => {
+    instance.action = {
+      method: 'METHOD',
+      __key: 'key',
+      __url: '/changes/1/revisions/2/foo~bar',
+    };
+    const sendStub = sandbox.stub().returns(Promise.resolve());
+    sandbox.stub(plugin, 'restApi').returns({
+      send: sendStub,
+    });
+    const payload = {foo: 'foo'};
+    const successStub = sandbox.stub();
+    instance.call(payload, successStub);
+    assert.isTrue(sendStub.calledWith(
+        'METHOD', '/changes/1/revisions/2/foo~bar', payload));
+  });
+
+  test('call error', done => {
+    instance.action = {
+      method: 'METHOD',
+      __key: 'key',
+      __url: '/changes/1/revisions/2/foo~bar',
+    };
+    const sendStub = sandbox.stub().returns(Promise.reject(new Error('boom')));
+    sandbox.stub(plugin, 'restApi').returns({
+      send: sendStub,
+    });
+    const errorStub = sandbox.stub();
+    document.addEventListener('show-alert', errorStub);
+    instance.call();
+    flush(() => {
+      assert.isTrue(errorStub.calledOnce);
+      assert.equal(errorStub.args[0][0].detail.message,
+          'Plugin network error: Error: boom');
+      done();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
index 5c0d69a..94bb771 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
@@ -19,132 +19,139 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-endpoints</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-js-api-interface.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+void(0);
+</script>
 
-<script>
-  suite('gr-plugin-endpoints tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let instance;
-    let pluginFoo;
-    let pluginBar;
-    let domHook;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+suite('gr-plugin-endpoints tests', () => {
+  let sandbox;
+  let instance;
+  let pluginFoo;
+  let pluginBar;
+  let domHook;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      domHook = {};
-      instance = new GrPluginEndpoints();
-      Gerrit.install(p => { pluginFoo = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/foo.html');
-      instance.registerModule(
-          pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
-      Gerrit.install(p => { pluginBar = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/bar.html');
-      instance.registerModule(
-          pluginBar, 'a-place', 'style', 'bar-module', domHook);
-      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    domHook = {};
+    instance = new GrPluginEndpoints();
+    Gerrit.install(p => { pluginFoo = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/foo.html');
+    instance.registerModule(
+        pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
+    Gerrit.install(p => { pluginBar = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/bar.html');
+    instance.registerModule(
+        pluginBar, 'a-place', 'style', 'bar-module', domHook);
+    sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('getDetails all', () => {
-      assert.deepEqual(instance.getDetails('a-place'), [
-        {
-          moduleName: 'foo-module',
-          plugin: pluginFoo,
-          pluginUrl: pluginFoo._url,
-          type: 'decorate',
-          domHook,
-        },
-        {
-          moduleName: 'bar-module',
-          plugin: pluginBar,
-          pluginUrl: pluginBar._url,
-          type: 'style',
-          domHook,
-        },
-      ]);
-    });
-
-    test('getDetails by type', () => {
-      assert.deepEqual(instance.getDetails('a-place', {type: 'style'}), [
-        {
-          moduleName: 'bar-module',
-          plugin: pluginBar,
-          pluginUrl: pluginBar._url,
-          type: 'style',
-          domHook,
-        },
-      ]);
-    });
-
-    test('getDetails by module', () => {
-      assert.deepEqual(
-          instance.getDetails('a-place', {moduleName: 'foo-module'}),
-          [
-            {
-              moduleName: 'foo-module',
-              plugin: pluginFoo,
-              pluginUrl: pluginFoo._url,
-              type: 'decorate',
-              domHook,
-            },
-          ]);
-    });
-
-    test('getModules', () => {
-      assert.deepEqual(
-          instance.getModules('a-place'), ['foo-module', 'bar-module']);
-    });
-
-    test('getPlugins', () => {
-      assert.deepEqual(
-          instance.getPlugins('a-place'), [pluginFoo._url]);
-    });
-
-    test('onNewEndpoint', () => {
-      const newModuleStub = sandbox.stub();
-      instance.onNewEndpoint('a-place', newModuleStub);
-      instance.registerModule(
-          pluginFoo, 'a-place', 'replace', 'zaz-module', domHook);
-      assert.deepEqual(newModuleStub.lastCall.args[0], {
-        moduleName: 'zaz-module',
+  test('getDetails all', () => {
+    assert.deepEqual(instance.getDetails('a-place'), [
+      {
+        moduleName: 'foo-module',
         plugin: pluginFoo,
         pluginUrl: pluginFoo._url,
-        type: 'replace',
+        type: 'decorate',
         domHook,
-      });
-    });
+      },
+      {
+        moduleName: 'bar-module',
+        plugin: pluginBar,
+        pluginUrl: pluginBar._url,
+        type: 'style',
+        domHook,
+      },
+    ]);
+  });
 
-    test('reuse dom hooks', () => {
-      instance.registerModule(
-          pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
-      assert.deepEqual(instance.getDetails('a-place'), [
-        {
-          moduleName: 'foo-module',
-          plugin: pluginFoo,
-          pluginUrl: pluginFoo._url,
-          type: 'decorate',
-          domHook,
-        },
-        {
-          moduleName: 'bar-module',
-          plugin: pluginBar,
-          pluginUrl: pluginBar._url,
-          type: 'style',
-          domHook,
-        },
-      ]);
+  test('getDetails by type', () => {
+    assert.deepEqual(instance.getDetails('a-place', {type: 'style'}), [
+      {
+        moduleName: 'bar-module',
+        plugin: pluginBar,
+        pluginUrl: pluginBar._url,
+        type: 'style',
+        domHook,
+      },
+    ]);
+  });
+
+  test('getDetails by module', () => {
+    assert.deepEqual(
+        instance.getDetails('a-place', {moduleName: 'foo-module'}),
+        [
+          {
+            moduleName: 'foo-module',
+            plugin: pluginFoo,
+            pluginUrl: pluginFoo._url,
+            type: 'decorate',
+            domHook,
+          },
+        ]);
+  });
+
+  test('getModules', () => {
+    assert.deepEqual(
+        instance.getModules('a-place'), ['foo-module', 'bar-module']);
+  });
+
+  test('getPlugins', () => {
+    assert.deepEqual(
+        instance.getPlugins('a-place'), [pluginFoo._url]);
+  });
+
+  test('onNewEndpoint', () => {
+    const newModuleStub = sandbox.stub();
+    instance.onNewEndpoint('a-place', newModuleStub);
+    instance.registerModule(
+        pluginFoo, 'a-place', 'replace', 'zaz-module', domHook);
+    assert.deepEqual(newModuleStub.lastCall.args[0], {
+      moduleName: 'zaz-module',
+      plugin: pluginFoo,
+      pluginUrl: pluginFoo._url,
+      type: 'replace',
+      domHook,
     });
   });
+
+  test('reuse dom hooks', () => {
+    instance.registerModule(
+        pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
+    assert.deepEqual(instance.getDetails('a-place'), [
+      {
+        moduleName: 'foo-module',
+        plugin: pluginFoo,
+        pluginUrl: pluginFoo._url,
+        type: 'decorate',
+        domHook,
+      },
+      {
+        moduleName: 'bar-module',
+        plugin: pluginBar,
+        pluginUrl: pluginBar._url,
+        type: 'style',
+        domHook,
+      },
+    ]);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
index 1a9174f..46914dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-host</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-js-api-interface.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,531 +40,533 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-plugin-loader tests', async () => {
-    await readyToTest();
-    const {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} = window._apiUtils;
-    let plugin;
-    let sandbox;
-    let url;
-    let sendStub;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+suite('gr-plugin-loader tests', () => {
+  const {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} = window._apiUtils;
+  let plugin;
+  let sandbox;
+  let url;
+  let sendStub;
 
+  setup(() => {
+    window.clock = sinon.useFakeTimers();
+    sandbox = sinon.sandbox.create();
+    sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve({name: 'Judy Hopps'});
+      },
+      send(...args) {
+        return sendStub(...args);
+      },
+    });
+    sandbox.stub(document.body, 'appendChild');
+    fixture('basic');
+    url = window.location.origin;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    window.clock.restore();
+    Gerrit._testOnly_resetPlugins();
+  });
+
+  test('reuse plugin for install calls', () => {
+    Gerrit.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+
+    let otherPlugin;
+    Gerrit.install(p => { otherPlugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    assert.strictEqual(plugin, otherPlugin);
+  });
+
+  test('flushes preinstalls if provided', () => {
+    assert.doesNotThrow(() => {
+      Gerrit._testOnly_flushPreinstalls();
+    });
+    window.Gerrit.flushPreinstalls = sandbox.stub();
+    Gerrit._testOnly_flushPreinstalls();
+    assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
+    delete window.Gerrit.flushPreinstalls;
+  });
+
+  test('versioning', () => {
+    const callback = sandbox.spy();
+    Gerrit.install(callback, '0.0pre-alpha');
+    assert(callback.notCalled);
+  });
+
+  test('report pluginsLoaded', done => {
+    stub('gr-reporting', {
+      pluginsLoaded() {
+        done();
+      },
+    });
+    Gerrit._loadPlugins([]);
+  });
+
+  test('arePluginsLoaded', done => {
+    assert.isFalse(Gerrit._arePluginsLoaded());
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    Gerrit._loadPlugins(plugins);
+    assert.isFalse(Gerrit._arePluginsLoaded());
+    // Timeout on loading plugins
+    window.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+    flush(() => {
+      assert.isTrue(Gerrit._arePluginsLoaded());
+      done();
+    });
+  });
+
+  test('plugins installed successfully', done => {
+    sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+      Gerrit.install(() => void 0, undefined, url);
+    });
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+    Gerrit._loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+      assert.isTrue(Gerrit._arePluginsLoaded());
+      done();
+    });
+  });
+
+  test('isPluginEnabled and isPluginLoaded', done => {
+    sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+      Gerrit.install(() => void 0, undefined, url);
+    });
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+      'bar/static/test.js',
+    ];
+    Gerrit._loadPlugins(plugins);
+    assert.isTrue(
+        plugins.every(plugin => Gerrit._pluginLoader.isPluginEnabled(plugin))
+    );
+
+    flush(() => {
+      assert.isTrue(Gerrit._arePluginsLoaded());
+      assert.isTrue(
+          plugins.every(plugin => Gerrit._pluginLoader.isPluginLoaded(plugin))
+      );
+
+      done();
+    });
+  });
+
+  test('plugins installed mixed result, 1 fail 1 succeed', done => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sandbox.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+      Gerrit.install(() => {
+        if (url === plugins[0]) {
+          throw new Error('failed');
+        }
+      }, undefined, url);
+    });
+
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    Gerrit._loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+      assert.isTrue(Gerrit._arePluginsLoaded());
+      assert.isTrue(alertStub.calledOnce);
+      done();
+    });
+  });
+
+  test('isPluginEnabled and isPluginLoaded for mixed results', done => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sandbox.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+      Gerrit.install(() => {
+        if (url === plugins[0]) {
+          throw new Error('failed');
+        }
+      }, undefined, url);
+    });
+
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    Gerrit._loadPlugins(plugins);
+    assert.isTrue(
+        plugins.every(plugin => Gerrit._pluginLoader.isPluginEnabled(plugin))
+    );
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+      assert.isTrue(Gerrit._arePluginsLoaded());
+      assert.isTrue(alertStub.calledOnce);
+      assert.isTrue(Gerrit._pluginLoader.isPluginLoaded(plugins[1]));
+      assert.isFalse(Gerrit._pluginLoader.isPluginLoaded(plugins[0]));
+      done();
+    });
+  });
+
+  test('plugins installed all failed', done => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sandbox.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+      Gerrit.install(() => {
+        throw new Error('failed');
+      }, undefined, url);
+    });
+
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    Gerrit._loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
+      assert.isTrue(Gerrit._arePluginsLoaded());
+      assert.isTrue(alertStub.calledTwice);
+      done();
+    });
+  });
+
+  test('plugins installed failed becasue of wrong version', done => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sandbox.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+      Gerrit.install(() => {
+      }, url === plugins[0] ? '' : 'alpha', url);
+    });
+
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    Gerrit._loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
+      assert.isTrue(Gerrit._arePluginsLoaded());
+      assert.isTrue(alertStub.calledOnce);
+      done();
+    });
+  });
+
+  test('multiple assets for same plugin installed successfully', done => {
+    sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+      Gerrit.install(() => void 0, undefined, url);
+    });
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/foo/static/test2.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+    Gerrit._loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+      assert.isTrue(Gerrit._arePluginsLoaded());
+      done();
+    });
+  });
+
+  suite('plugin path and url', () => {
+    let importHtmlPluginStub;
+    let loadJsPluginStub;
     setup(() => {
-      window.clock = sinon.useFakeTimers();
-      sandbox = sinon.sandbox.create();
-      sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-      stub('gr-rest-api-interface', {
-        getAccount() {
-          return Promise.resolve({name: 'Judy Hopps'});
-        },
-        send(...args) {
-          return sendStub(...args);
-        },
+      importHtmlPluginStub = sandbox.stub();
+      sandbox.stub(Gerrit._pluginLoader, '_loadHtmlPlugin', url => {
+        importHtmlPluginStub(url);
       });
-      sandbox.stub(document.body, 'appendChild');
-      fixture('basic');
-      url = window.location.origin;
+      loadJsPluginStub = sandbox.stub();
+      sandbox.stub(Gerrit._pluginLoader, '_createScriptTag', url => {
+        loadJsPluginStub(url);
+      });
+    });
+
+    test('invalid plugin path', () => {
+      const failToLoadStub = sandbox.stub();
+      sandbox.stub(Gerrit._pluginLoader, '_failToLoad', (...args) => {
+        failToLoadStub(...args);
+      });
+
+      Gerrit._loadPlugins([
+        'foo/bar',
+      ]);
+
+      assert.isTrue(failToLoadStub.calledOnce);
+      assert.isTrue(failToLoadStub.calledWithExactly(
+          'Unrecognized plugin path foo/bar',
+          'foo/bar'
+      ));
+    });
+
+    test('relative path for plugins', () => {
+      Gerrit._loadPlugins([
+        'foo/bar.js',
+        'foo/bar.html',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`${url}/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`${url}/foo/bar.js`)
+      );
+    });
+
+    test('relative path should honor getBaseUrl', () => {
+      const testUrl = '/test';
+      sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl', () => testUrl);
+
+      Gerrit._loadPlugins([
+        'foo/bar.js',
+        'foo/bar.html',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(
+              `${url}${testUrl}/foo/bar.html`
+          )
+      );
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`${url}${testUrl}/foo/bar.js`)
+      );
+    });
+
+    test('absolute path for plugins', () => {
+      Gerrit._loadPlugins([
+        'http://e.com/foo/bar.js',
+        'http://e.com/foo/bar.html',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`)
+      );
+    });
+  });
+
+  suite('With ASSETS_PATH', () => {
+    let importHtmlPluginStub;
+    let loadJsPluginStub;
+    setup(() => {
+      window.ASSETS_PATH = 'https://cdn.com';
+      importHtmlPluginStub = sandbox.stub();
+      sandbox.stub(Gerrit._pluginLoader, '_loadHtmlPlugin', url => {
+        importHtmlPluginStub(url);
+      });
+      loadJsPluginStub = sandbox.stub();
+      sandbox.stub(Gerrit._pluginLoader, '_createScriptTag', url => {
+        loadJsPluginStub(url);
+      });
     });
 
     teardown(() => {
-      sandbox.restore();
-      window.clock.restore();
-      Gerrit._testOnly_resetPlugins();
+      window.ASSETS_PATH = '';
     });
 
-    test('reuse plugin for install calls', () => {
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-
-      let otherPlugin;
-      Gerrit.install(p => { otherPlugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      assert.strictEqual(plugin, otherPlugin);
-    });
-
-    test('flushes preinstalls if provided', () => {
-      assert.doesNotThrow(() => {
-        Gerrit._testOnly_flushPreinstalls();
-      });
-      window.Gerrit.flushPreinstalls = sandbox.stub();
-      Gerrit._testOnly_flushPreinstalls();
-      assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
-      delete window.Gerrit.flushPreinstalls;
-    });
-
-    test('versioning', () => {
-      const callback = sandbox.spy();
-      Gerrit.install(callback, '0.0pre-alpha');
-      assert(callback.notCalled);
-    });
-
-    test('report pluginsLoaded', done => {
-      stub('gr-reporting', {
-        pluginsLoaded() {
-          done();
-        },
-      });
-      Gerrit._loadPlugins([]);
-    });
-
-    test('arePluginsLoaded', done => {
-      assert.isFalse(Gerrit._arePluginsLoaded());
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-
-      Gerrit._loadPlugins(plugins);
-      assert.isFalse(Gerrit._arePluginsLoaded());
-      // Timeout on loading plugins
-      window.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
-
-      flush(() => {
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        done();
-      });
-    });
-
-    test('plugins installed successfully', done => {
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => void 0, undefined, url);
-      });
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-      Gerrit._loadPlugins(plugins);
-
-      flush(() => {
-        assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        done();
-      });
-    });
-
-    test('isPluginEnabled and isPluginLoaded', done => {
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => void 0, undefined, url);
-      });
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-        'bar/static/test.js',
-      ];
-      Gerrit._loadPlugins(plugins);
-      assert.isTrue(
-          plugins.every(plugin => Gerrit._pluginLoader.isPluginEnabled(plugin))
-      );
-
-      flush(() => {
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        assert.isTrue(
-            plugins.every(plugin => Gerrit._pluginLoader.isPluginLoaded(plugin))
-        );
-
-        done();
-      });
-    });
-
-    test('plugins installed mixed result, 1 fail 1 succeed', done => {
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-
-      const alertStub = sandbox.stub();
-      document.addEventListener('show-alert', alertStub);
-
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => {
-          if (url === plugins[0]) {
-            throw new Error('failed');
-          }
-        }, undefined, url);
-      });
-
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      Gerrit._loadPlugins(plugins);
-
-      flush(() => {
-        assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        assert.isTrue(alertStub.calledOnce);
-        done();
-      });
-    });
-
-    test('isPluginEnabled and isPluginLoaded for mixed results', done => {
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-
-      const alertStub = sandbox.stub();
-      document.addEventListener('show-alert', alertStub);
-
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => {
-          if (url === plugins[0]) {
-            throw new Error('failed');
-          }
-        }, undefined, url);
-      });
-
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      Gerrit._loadPlugins(plugins);
-      assert.isTrue(
-          plugins.every(plugin => Gerrit._pluginLoader.isPluginEnabled(plugin))
-      );
-
-      flush(() => {
-        assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        assert.isTrue(alertStub.calledOnce);
-        assert.isTrue(Gerrit._pluginLoader.isPluginLoaded(plugins[1]));
-        assert.isFalse(Gerrit._pluginLoader.isPluginLoaded(plugins[0]));
-        done();
-      });
-    });
-
-    test('plugins installed all failed', done => {
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-
-      const alertStub = sandbox.stub();
-      document.addEventListener('show-alert', alertStub);
-
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => {
-          throw new Error('failed');
-        }, undefined, url);
-      });
-
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      Gerrit._loadPlugins(plugins);
-
-      flush(() => {
-        assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        assert.isTrue(alertStub.calledTwice);
-        done();
-      });
-    });
-
-    test('plugins installed failed becasue of wrong version', done => {
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-
-      const alertStub = sandbox.stub();
-      document.addEventListener('show-alert', alertStub);
-
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => {
-        }, url === plugins[0] ? '' : 'alpha', url);
-      });
-
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      Gerrit._loadPlugins(plugins);
-
-      flush(() => {
-        assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        assert.isTrue(alertStub.calledOnce);
-        done();
-      });
-    });
-
-    test('multiple assets for same plugin installed successfully', done => {
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => void 0, undefined, url);
-      });
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/foo/static/test2.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-      Gerrit._loadPlugins(plugins);
-
-      flush(() => {
-        assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        done();
-      });
-    });
-
-    suite('plugin path and url', () => {
-      let importHtmlPluginStub;
-      let loadJsPluginStub;
-      setup(() => {
-        importHtmlPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_loadHtmlPlugin', url => {
-          importHtmlPluginStub(url);
-        });
-        loadJsPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_createScriptTag', url => {
-          loadJsPluginStub(url);
-        });
-      });
-
-      test('invalid plugin path', () => {
-        const failToLoadStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_failToLoad', (...args) => {
-          failToLoadStub(...args);
-        });
-
-        Gerrit._loadPlugins([
-          'foo/bar',
-        ]);
-
-        assert.isTrue(failToLoadStub.calledOnce);
-        assert.isTrue(failToLoadStub.calledWithExactly(
-            'Unrecognized plugin path foo/bar',
-            'foo/bar'
-        ));
-      });
-
-      test('relative path for plugins', () => {
-        Gerrit._loadPlugins([
-          'foo/bar.js',
-          'foo/bar.html',
-        ]);
-
-        assert.isTrue(importHtmlPluginStub.calledOnce);
-        assert.isTrue(
-            importHtmlPluginStub.calledWithExactly(`${url}/foo/bar.html`)
-        );
-        assert.isTrue(loadJsPluginStub.calledOnce);
-        assert.isTrue(
-            loadJsPluginStub.calledWithExactly(`${url}/foo/bar.js`)
-        );
-      });
-
-      test('relative path should honor getBaseUrl', () => {
-        const testUrl = '/test';
-        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl', () => testUrl);
-
-        Gerrit._loadPlugins([
-          'foo/bar.js',
-          'foo/bar.html',
-        ]);
-
-        assert.isTrue(importHtmlPluginStub.calledOnce);
-        assert.isTrue(loadJsPluginStub.calledOnce);
-        assert.isTrue(
-            importHtmlPluginStub.calledWithExactly(
-                `${url}${testUrl}/foo/bar.html`
-            )
-        );
-        assert.isTrue(
-            loadJsPluginStub.calledWithExactly(`${url}${testUrl}/foo/bar.js`)
-        );
-      });
-
-      test('absolute path for plugins', () => {
-        Gerrit._loadPlugins([
-          'http://e.com/foo/bar.js',
-          'http://e.com/foo/bar.html',
-        ]);
-
-        assert.isTrue(importHtmlPluginStub.calledOnce);
-        assert.isTrue(
-            importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
-        );
-        assert.isTrue(loadJsPluginStub.calledOnce);
-        assert.isTrue(
-            loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`)
-        );
-      });
-    });
-
-    suite('With ASSETS_PATH', () => {
-      let importHtmlPluginStub;
-      let loadJsPluginStub;
-      setup(() => {
-        window.ASSETS_PATH = 'https://cdn.com';
-        importHtmlPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_loadHtmlPlugin', url => {
-          importHtmlPluginStub(url);
-        });
-        loadJsPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_createScriptTag', url => {
-          loadJsPluginStub(url);
-        });
-      });
-
-      teardown(() => {
-        window.ASSETS_PATH = '';
-      });
-
-      test('Should try load plugins from assets path instead', () => {
-        Gerrit._loadPlugins([
-          'foo/bar.js',
-          'foo/bar.html',
-        ]);
-
-        assert.isTrue(importHtmlPluginStub.calledOnce);
-        assert.isTrue(
-            importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
-        );
-        assert.isTrue(loadJsPluginStub.calledOnce);
-        assert.isTrue(
-            loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
-      });
-
-      test('Should honor original path if exists', () => {
-        Gerrit._loadPlugins([
-          'http://e.com/foo/bar.html',
-          'http://e.com/foo/bar.js',
-        ]);
-
-        assert.isTrue(importHtmlPluginStub.calledOnce);
-        assert.isTrue(
-            importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
-        );
-        assert.isTrue(loadJsPluginStub.calledOnce);
-        assert.isTrue(
-            loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`));
-      });
-
-      test('Should try replace current host with assetsPath', () => {
-        const host = window.location.origin;
-        Gerrit._loadPlugins([
-          `${host}/foo/bar.html`,
-          `${host}/foo/bar.js`,
-        ]);
-
-        assert.isTrue(importHtmlPluginStub.calledOnce);
-        assert.isTrue(
-            importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
-        );
-        assert.isTrue(loadJsPluginStub.calledOnce);
-        assert.isTrue(
-            loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
-      });
-    });
-
-    test('adds js plugins will call the body', () => {
+    test('Should try load plugins from assets path instead', () => {
       Gerrit._loadPlugins([
-        'http://e.com/foo/bar.js',
-        'http://e.com/bar/foo.js',
+        'foo/bar.js',
+        'foo/bar.html',
       ]);
-      assert.isTrue(document.body.appendChild.calledTwice);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
     });
 
-    test('can call awaitPluginsLoaded multiple times', done => {
-      const plugins = [
+    test('Should honor original path if exists', () => {
+      Gerrit._loadPlugins([
+        'http://e.com/foo/bar.html',
         'http://e.com/foo/bar.js',
-        'http://e.com/bar/foo.js',
-      ];
+      ]);
 
-      let installed = false;
-      function pluginCallback(url) {
-        if (url === plugins[1]) {
-          installed = true;
-        }
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`));
+    });
+
+    test('Should try replace current host with assetsPath', () => {
+      const host = window.location.origin;
+      Gerrit._loadPlugins([
+        `${host}/foo/bar.html`,
+        `${host}/foo/bar.js`,
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
+    });
+  });
+
+  test('adds js plugins will call the body', () => {
+    Gerrit._loadPlugins([
+      'http://e.com/foo/bar.js',
+      'http://e.com/bar/foo.js',
+    ]);
+    assert.isTrue(document.body.appendChild.calledTwice);
+  });
+
+  test('can call awaitPluginsLoaded multiple times', done => {
+    const plugins = [
+      'http://e.com/foo/bar.js',
+      'http://e.com/bar/foo.js',
+    ];
+
+    let installed = false;
+    function pluginCallback(url) {
+      if (url === plugins[1]) {
+        installed = true;
       }
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => pluginCallback(url), undefined, url);
-      });
+    }
+    sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+      Gerrit.install(() => pluginCallback(url), undefined, url);
+    });
 
-      Gerrit._loadPlugins(plugins);
+    Gerrit._loadPlugins(plugins);
+
+    Gerrit.awaitPluginsLoaded().then(() => {
+      assert.isTrue(installed);
 
       Gerrit.awaitPluginsLoaded().then(() => {
-        assert.isTrue(installed);
-
-        Gerrit.awaitPluginsLoaded().then(() => {
-          done();
-        });
-      });
-    });
-
-    suite('preloaded plugins', () => {
-      test('skips preloaded plugins when load plugins', () => {
-        const importHtmlPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_importHtmlPlugin', url => {
-          importHtmlPluginStub(url);
-        });
-        const loadJsPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-          loadJsPluginStub(url);
-        });
-
-        Gerrit._preloadedPlugins = {
-          foo: () => void 0,
-          bar: () => void 0,
-        };
-
-        Gerrit._loadPlugins([
-          'http://e.com/plugins/foo.js',
-          'plugins/bar.html',
-          'http://e.com/plugins/test/foo.js',
-        ]);
-
-        assert.isTrue(importHtmlPluginStub.notCalled);
-        assert.isTrue(loadJsPluginStub.calledOnce);
-      });
-
-      test('isPluginPreloaded', () => {
-        Gerrit._preloadedPlugins = {baz: ()=>{}};
-        assert.isFalse(Gerrit._pluginLoader.isPluginPreloaded('plugins/foo/bar'));
-        assert.isFalse(Gerrit._pluginLoader.isPluginPreloaded('http://a.com/42'));
-        assert.isTrue(
-            Gerrit._pluginLoader.isPluginPreloaded(PRELOADED_PROTOCOL + 'baz')
-        );
-        Gerrit._preloadedPlugins = null;
-      });
-
-      test('preloaded plugins are installed', () => {
-        const installStub = sandbox.stub();
-        Gerrit._preloadedPlugins = {foo: installStub};
-        Gerrit._pluginLoader.installPreloadedPlugins();
-        assert.isTrue(installStub.called);
-        const pluginApi = installStub.lastCall.args[0];
-        assert.strictEqual(pluginApi.getPluginName(), 'foo');
-      });
-
-      test('installing preloaded plugin', () => {
-        let plugin;
-        Gerrit.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
-        assert.strictEqual(plugin.getPluginName(), 'foo');
-        assert.strictEqual(plugin.url('/some/thing.html'),
-            `${window.location.origin}/plugins/foo/some/thing.html`);
+        done();
       });
     });
   });
+
+  suite('preloaded plugins', () => {
+    test('skips preloaded plugins when load plugins', () => {
+      const importHtmlPluginStub = sandbox.stub();
+      sandbox.stub(Gerrit._pluginLoader, '_importHtmlPlugin', url => {
+        importHtmlPluginStub(url);
+      });
+      const loadJsPluginStub = sandbox.stub();
+      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+        loadJsPluginStub(url);
+      });
+
+      Gerrit._preloadedPlugins = {
+        foo: () => void 0,
+        bar: () => void 0,
+      };
+
+      Gerrit._loadPlugins([
+        'http://e.com/plugins/foo.js',
+        'plugins/bar.html',
+        'http://e.com/plugins/test/foo.js',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.notCalled);
+      assert.isTrue(loadJsPluginStub.calledOnce);
+    });
+
+    test('isPluginPreloaded', () => {
+      Gerrit._preloadedPlugins = {baz: ()=>{}};
+      assert.isFalse(Gerrit._pluginLoader.isPluginPreloaded('plugins/foo/bar'));
+      assert.isFalse(Gerrit._pluginLoader.isPluginPreloaded('http://a.com/42'));
+      assert.isTrue(
+          Gerrit._pluginLoader.isPluginPreloaded(PRELOADED_PROTOCOL + 'baz')
+      );
+      Gerrit._preloadedPlugins = null;
+    });
+
+    test('preloaded plugins are installed', () => {
+      const installStub = sandbox.stub();
+      Gerrit._preloadedPlugins = {foo: installStub};
+      Gerrit._pluginLoader.installPreloadedPlugins();
+      assert.isTrue(installStub.called);
+      const pluginApi = installStub.lastCall.args[0];
+      assert.strictEqual(pluginApi.getPluginName(), 'foo');
+    });
+
+    test('installing preloaded plugin', () => {
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
+      assert.strictEqual(plugin.getPluginName(), 'foo');
+      assert.strictEqual(plugin.url('/some/thing.html'),
+          `${window.location.origin}/plugins/foo/some/thing.html`);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
index a486bf1..64c31d7 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
@@ -19,139 +19,141 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-rest-api</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-js-api-interface.js"></script>
 
-<script>
-  suite('gr-plugin-rest-api tests', async () => {
-    await readyToTest();
-    let instance;
-    let sandbox;
-    let getResponseObjectStub;
-    let sendStub;
-    let restApiStub;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+suite('gr-plugin-rest-api tests', () => {
+  let instance;
+  let sandbox;
+  let getResponseObjectStub;
+  let sendStub;
+  let restApiStub;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
-      sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-      restApiStub = {
-        getAccount: () => Promise.resolve({name: 'Judy Hopps'}),
-        getResponseObject: getResponseObjectStub,
-        send: sendStub,
-        getLoggedIn: sandbox.stub(),
-        getVersion: sandbox.stub(),
-        getConfig: sandbox.stub(),
-      };
-      stub('gr-rest-api-interface', Object.keys(restApiStub).reduce((a, k) => {
-        a[k] = (...args) => restApiStub[k](...args);
-        return a;
-      }, {}));
-      Gerrit.install(p => {}, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      instance = new GrPluginRestApi();
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
+    sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
+    restApiStub = {
+      getAccount: () => Promise.resolve({name: 'Judy Hopps'}),
+      getResponseObject: getResponseObjectStub,
+      send: sendStub,
+      getLoggedIn: sandbox.stub(),
+      getVersion: sandbox.stub(),
+      getConfig: sandbox.stub(),
+    };
+    stub('gr-rest-api-interface', Object.keys(restApiStub).reduce((a, k) => {
+      a[k] = (...args) => restApiStub[k](...args);
+      return a;
+    }, {}));
+    Gerrit.install(p => {}, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    instance = new GrPluginRestApi();
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('fetch', () => {
-      const payload = {foo: 'foo'};
-      return instance.fetch('HTTP_METHOD', '/url', payload).then(r => {
-        assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
-        assert.equal(r.status, 200);
-        assert.isFalse(getResponseObjectStub.called);
-      });
-    });
-
-    test('send', () => {
-      const payload = {foo: 'foo'};
-      const response = {bar: 'bar'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return instance.send('HTTP_METHOD', '/url', payload).then(r => {
-        assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
-        assert.strictEqual(r, response);
-      });
-    });
-
-    test('get', () => {
-      const response = {foo: 'foo'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return instance.get('/url').then(r => {
-        assert.isTrue(sendStub.calledWith('GET', '/url'));
-        assert.strictEqual(r, response);
-      });
-    });
-
-    test('post', () => {
-      const payload = {foo: 'foo'};
-      const response = {bar: 'bar'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return instance.post('/url', payload).then(r => {
-        assert.isTrue(sendStub.calledWith('POST', '/url', payload));
-        assert.strictEqual(r, response);
-      });
-    });
-
-    test('put', () => {
-      const payload = {foo: 'foo'};
-      const response = {bar: 'bar'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return instance.put('/url', payload).then(r => {
-        assert.isTrue(sendStub.calledWith('PUT', '/url', payload));
-        assert.strictEqual(r, response);
-      });
-    });
-
-    test('delete works', () => {
-      const response = {status: 204};
-      sendStub.returns(Promise.resolve(response));
-      return instance.delete('/url').then(r => {
-        assert.isTrue(sendStub.calledWith('DELETE', '/url'));
-        assert.strictEqual(r, response);
-      });
-    });
-
-    test('delete fails', () => {
-      sendStub.returns(Promise.resolve(
-          {status: 400, text() { return Promise.resolve('text'); }}));
-      return instance.delete('/url').then(r => {
-        throw new Error('Should not resolve');
-      })
-          .catch(err => {
-            assert.isTrue(sendStub.calledWith('DELETE', '/url'));
-            assert.equal('text', err.message);
-          });
-    });
-
-    test('getLoggedIn', () => {
-      restApiStub.getLoggedIn.returns(Promise.resolve(true));
-      return instance.getLoggedIn().then(result => {
-        assert.isTrue(restApiStub.getLoggedIn.calledOnce);
-        assert.isTrue(result);
-      });
-    });
-
-    test('getVersion', () => {
-      restApiStub.getVersion.returns(Promise.resolve('foo bar'));
-      return instance.getVersion().then(result => {
-        assert.isTrue(restApiStub.getVersion.calledOnce);
-        assert.equal(result, 'foo bar');
-      });
-    });
-
-    test('getConfig', () => {
-      restApiStub.getConfig.returns(Promise.resolve('foo bar'));
-      return instance.getConfig().then(result => {
-        assert.isTrue(restApiStub.getConfig.calledOnce);
-        assert.equal(result, 'foo bar');
-      });
+  test('fetch', () => {
+    const payload = {foo: 'foo'};
+    return instance.fetch('HTTP_METHOD', '/url', payload).then(r => {
+      assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
+      assert.equal(r.status, 200);
+      assert.isFalse(getResponseObjectStub.called);
     });
   });
+
+  test('send', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return instance.send('HTTP_METHOD', '/url', payload).then(r => {
+      assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('get', () => {
+    const response = {foo: 'foo'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return instance.get('/url').then(r => {
+      assert.isTrue(sendStub.calledWith('GET', '/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('post', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return instance.post('/url', payload).then(r => {
+      assert.isTrue(sendStub.calledWith('POST', '/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('put', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return instance.put('/url', payload).then(r => {
+      assert.isTrue(sendStub.calledWith('PUT', '/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('delete works', () => {
+    const response = {status: 204};
+    sendStub.returns(Promise.resolve(response));
+    return instance.delete('/url').then(r => {
+      assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('delete fails', () => {
+    sendStub.returns(Promise.resolve(
+        {status: 400, text() { return Promise.resolve('text'); }}));
+    return instance.delete('/url').then(r => {
+      throw new Error('Should not resolve');
+    })
+        .catch(err => {
+          assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+          assert.equal('text', err.message);
+        });
+  });
+
+  test('getLoggedIn', () => {
+    restApiStub.getLoggedIn.returns(Promise.resolve(true));
+    return instance.getLoggedIn().then(result => {
+      assert.isTrue(restApiStub.getLoggedIn.calledOnce);
+      assert.isTrue(result);
+    });
+  });
+
+  test('getVersion', () => {
+    restApiStub.getVersion.returns(Promise.resolve('foo bar'));
+    return instance.getVersion().then(result => {
+      assert.isTrue(restApiStub.getVersion.calledOnce);
+      assert.equal(result, 'foo bar');
+    });
+  });
+
+  test('getConfig', () => {
+    restApiStub.getConfig.returns(Promise.resolve('foo bar'));
+    return instance.getConfig().then(result => {
+      assert.isTrue(restApiStub.getConfig.calledOnce);
+      assert.equal(result, 'foo bar');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
index 6e99e01..ad9b852 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
@@ -14,156 +14,170 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrLabelInfo extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-label-info'; }
+import '../../../styles/gr-voting-styles.js';
+import '../../../styles/shared-styles.js';
+import '../gr-account-label/gr-account-label.js';
+import '../gr-account-chip/gr-account-chip.js';
+import '../gr-button/gr-button.js';
+import '../gr-icons/gr-icons.js';
+import '../gr-label/gr-label.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-label-info_html.js';
 
-    static get properties() {
-      return {
-        labelInfo: Object,
-        label: String,
-        /** @type {?} */
-        change: Object,
-        account: Object,
-        mutable: Boolean,
-      };
-    }
+/** @extends Polymer.Element */
+class GrLabelInfo extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * @param {!Object} labelInfo
-     * @param {!Object} account
-     * @param {Object} changeLabelsRecord not used, but added as a parameter in
-     *    order to trigger computation when a label is removed from the change.
-     */
-    _mapLabelInfo(labelInfo, account, changeLabelsRecord) {
-      const result = [];
-      if (!labelInfo || !account) { return result; }
-      if (!labelInfo.values) {
-        if (labelInfo.rejected || labelInfo.approved) {
-          const ok = labelInfo.approved || !labelInfo.rejected;
-          return [{
-            value: ok ? '👍️' : '👎️',
-            className: ok ? 'positive' : 'negative',
-            account: ok ? labelInfo.approved : labelInfo.rejected,
-          }];
-        }
-        return result;
-      }
-      // Sort votes by positivity.
-      const votes = (labelInfo.all || []).sort((a, b) => a.value - b.value);
-      const values = Object.keys(labelInfo.values);
-      for (const label of votes) {
-        if (label.value && label.value != labelInfo.default_value) {
-          let labelClassName;
-          let labelValPrefix = '';
-          if (label.value > 0) {
-            labelValPrefix = '+';
-            if (parseInt(label.value, 10) ===
-                parseInt(values[values.length - 1], 10)) {
-              labelClassName = 'max';
-            } else {
-              labelClassName = 'positive';
-            }
-          } else if (label.value < 0) {
-            if (parseInt(label.value, 10) === parseInt(values[0], 10)) {
-              labelClassName = 'min';
-            } else {
-              labelClassName = 'negative';
-            }
-          }
-          const formattedLabel = {
-            value: labelValPrefix + label.value,
-            className: labelClassName,
-            account: label,
-          };
-          if (label._account_id === account._account_id) {
-            // Put self-votes at the top.
-            result.unshift(formattedLabel);
-          } else {
-            result.push(formattedLabel);
-          }
-        }
+  static get is() { return 'gr-label-info'; }
+
+  static get properties() {
+    return {
+      labelInfo: Object,
+      label: String,
+      /** @type {?} */
+      change: Object,
+      account: Object,
+      mutable: Boolean,
+    };
+  }
+
+  /**
+   * @param {!Object} labelInfo
+   * @param {!Object} account
+   * @param {Object} changeLabelsRecord not used, but added as a parameter in
+   *    order to trigger computation when a label is removed from the change.
+   */
+  _mapLabelInfo(labelInfo, account, changeLabelsRecord) {
+    const result = [];
+    if (!labelInfo || !account) { return result; }
+    if (!labelInfo.values) {
+      if (labelInfo.rejected || labelInfo.approved) {
+        const ok = labelInfo.approved || !labelInfo.rejected;
+        return [{
+          value: ok ? '👍️' : '👎️',
+          className: ok ? 'positive' : 'negative',
+          account: ok ? labelInfo.approved : labelInfo.rejected,
+        }];
       }
       return result;
     }
-
-    /**
-     * A user is able to delete a vote iff the mutable property is true and the
-     * reviewer that left the vote exists in the list of removable_reviewers
-     * received from the backend.
-     *
-     * @param {!Object} reviewer An object describing the reviewer that left the
-     *     vote.
-     * @param {boolean} mutable
-     * @param {!Object} change
-     */
-    _computeDeleteClass(reviewer, mutable, change) {
-      if (!mutable || !change || !change.removable_reviewers) {
-        return 'hidden';
-      }
-      const removable = change.removable_reviewers;
-      if (removable.find(r => r._account_id === reviewer._account_id)) {
-        return '';
-      }
-      return 'hidden';
-    }
-
-    /**
-     * Closure annotation for Polymer.prototype.splice is off.
-     * For now, supressing annotations.
-     *
-     * @suppress {checkTypes} */
-    _onDeleteVote(e) {
-      e.preventDefault();
-      let target = Polymer.dom(e).rootTarget;
-      while (!target.classList.contains('deleteBtn')) {
-        if (!target.parentElement) { return; }
-        target = target.parentElement;
-      }
-
-      target.disabled = true;
-      const accountID = parseInt(target.getAttribute('data-account-id'), 10);
-      this._xhrPromise =
-          this.$.restAPI.deleteVote(this.change._number, accountID, this.label)
-              .then(response => {
-                target.disabled = false;
-                if (!response.ok) { return; }
-                Gerrit.Nav.navigateToChange(this.change);
-              })
-              .catch(err => {
-                target.disabled = false;
-                return;
-              });
-    }
-
-    _computeValueTooltip(labelInfo, score) {
-      if (!labelInfo || !labelInfo.values || !labelInfo.values[score]) {
-        return '';
-      }
-      return labelInfo.values[score];
-    }
-
-    /**
-     * @param {!Object} labelInfo
-     * @param {Object} changeLabelsRecord not used, but added as a parameter in
-     *    order to trigger computation when a label is removed from the change.
-     */
-    _computeShowPlaceholder(labelInfo, changeLabelsRecord) {
-      if (labelInfo && labelInfo.all) {
-        for (const label of labelInfo.all) {
-          if (label.value && label.value != labelInfo.default_value) {
-            return 'hidden';
+    // Sort votes by positivity.
+    const votes = (labelInfo.all || []).sort((a, b) => a.value - b.value);
+    const values = Object.keys(labelInfo.values);
+    for (const label of votes) {
+      if (label.value && label.value != labelInfo.default_value) {
+        let labelClassName;
+        let labelValPrefix = '';
+        if (label.value > 0) {
+          labelValPrefix = '+';
+          if (parseInt(label.value, 10) ===
+              parseInt(values[values.length - 1], 10)) {
+            labelClassName = 'max';
+          } else {
+            labelClassName = 'positive';
+          }
+        } else if (label.value < 0) {
+          if (parseInt(label.value, 10) === parseInt(values[0], 10)) {
+            labelClassName = 'min';
+          } else {
+            labelClassName = 'negative';
           }
         }
+        const formattedLabel = {
+          value: labelValPrefix + label.value,
+          className: labelClassName,
+          account: label,
+        };
+        if (label._account_id === account._account_id) {
+          // Put self-votes at the top.
+          result.unshift(formattedLabel);
+        } else {
+          result.push(formattedLabel);
+        }
       }
-      return '';
     }
+    return result;
   }
 
-  customElements.define(GrLabelInfo.is, GrLabelInfo);
-})();
+  /**
+   * A user is able to delete a vote iff the mutable property is true and the
+   * reviewer that left the vote exists in the list of removable_reviewers
+   * received from the backend.
+   *
+   * @param {!Object} reviewer An object describing the reviewer that left the
+   *     vote.
+   * @param {boolean} mutable
+   * @param {!Object} change
+   */
+  _computeDeleteClass(reviewer, mutable, change) {
+    if (!mutable || !change || !change.removable_reviewers) {
+      return 'hidden';
+    }
+    const removable = change.removable_reviewers;
+    if (removable.find(r => r._account_id === reviewer._account_id)) {
+      return '';
+    }
+    return 'hidden';
+  }
+
+  /**
+   * Closure annotation for Polymer.prototype.splice is off.
+   * For now, supressing annotations.
+   *
+   * @suppress {checkTypes} */
+  _onDeleteVote(e) {
+    e.preventDefault();
+    let target = dom(e).rootTarget;
+    while (!target.classList.contains('deleteBtn')) {
+      if (!target.parentElement) { return; }
+      target = target.parentElement;
+    }
+
+    target.disabled = true;
+    const accountID = parseInt(target.getAttribute('data-account-id'), 10);
+    this._xhrPromise =
+        this.$.restAPI.deleteVote(this.change._number, accountID, this.label)
+            .then(response => {
+              target.disabled = false;
+              if (!response.ok) { return; }
+              Gerrit.Nav.navigateToChange(this.change);
+            })
+            .catch(err => {
+              target.disabled = false;
+              return;
+            });
+  }
+
+  _computeValueTooltip(labelInfo, score) {
+    if (!labelInfo || !labelInfo.values || !labelInfo.values[score]) {
+      return '';
+    }
+    return labelInfo.values[score];
+  }
+
+  /**
+   * @param {!Object} labelInfo
+   * @param {Object} changeLabelsRecord not used, but added as a parameter in
+   *    order to trigger computation when a label is removed from the change.
+   */
+  _computeShowPlaceholder(labelInfo, changeLabelsRecord) {
+    if (labelInfo && labelInfo.all) {
+      for (const label of labelInfo.all) {
+        if (label.value && label.value != labelInfo.default_value) {
+          return 'hidden';
+        }
+      }
+    }
+    return '';
+  }
+}
+
+customElements.define(GrLabelInfo.is, GrLabelInfo);
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.js
index 27bd1c7..d19467b 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.js
@@ -1,33 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../styles/gr-voting-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-account-label/gr-account-label.html">
-<link rel="import" href="../gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../gr-icons/gr-icons.html">
-<link rel="import" href="../gr-label/gr-label.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-label-info">
-  <template>
+export const htmlTemplate = html`
     <style include="gr-voting-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -90,36 +79,22 @@
         padding-top: var(--spacing-s);
       }
     </style>
-    <table>
-      <p class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]">
+    <p class\$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]">
         No votes.
-      </p>
-      <template
-          is="dom-repeat"
-          items="[[_mapLabelInfo(labelInfo, account, change.labels.*)]]"
-          as="mappedLabel">
+      </p><table>
+      
+      <template is="dom-repeat" items="[[_mapLabelInfo(labelInfo, account, change.labels.*)]]" as="mappedLabel">
         <tr class="labelValueContainer">
           <td>
-            <gr-label
-                has-tooltip
-                title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]"
-                class$="[[mappedLabel.className]] voteChip">
+            <gr-label has-tooltip="" title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]" class\$="[[mappedLabel.className]] voteChip">
               [[mappedLabel.value]]
             </gr-label>
           </td>
           <td>
-            <gr-account-chip
-                account="[[mappedLabel.account]]"
-                transparent-background></gr-account-chip>
+            <gr-account-chip account="[[mappedLabel.account]]" transparent-background=""></gr-account-chip>
           </td>
           <td>
-            <gr-button
-                link
-                aria-label="Remove"
-                on-click="_onDeleteVote"
-                tooltip="Remove vote"
-                data-account-id$="[[mappedLabel.account._account_id]]"
-                class$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]">
+            <gr-button link="" aria-label="Remove" on-click="_onDeleteVote" tooltip="Remove vote" data-account-id\$="[[mappedLabel.account._account_id]]" class\$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]">
               <iron-icon icon="gr-icons:delete"></iron-icon>
             </gr-button>
           </td>
@@ -127,6 +102,4 @@
       </template>
     </table>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-label-info.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
index 013a6ee..44627ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
@@ -18,15 +18,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-label-info</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-label-info.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-label-info.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-label-info.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -34,205 +39,208 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-account-link tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-label-info.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-account-link tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    // Needed to trigger computed bindings.
+    element.account = {};
+    element.change = {labels: {}};
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('remove reviewer votes', () => {
     setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      // Needed to trigger computed bindings.
-      element.account = {};
-      element.change = {labels: {}};
+      sandbox.stub(element, '_computeValueTooltip').returns('');
+      element.account = {
+        _account_id: 1,
+        name: 'bojack',
+      };
+      const test = {
+        all: [{_account_id: 1, name: 'bojack', value: 1}],
+        default_value: 0,
+        values: [],
+      };
+      element.change = {
+        _number: 42,
+        change_id: 'the id',
+        actions: [],
+        topic: 'the topic',
+        status: 'NEW',
+        submit_type: 'CHERRY_PICK',
+        labels: {test},
+        removable_reviewers: [],
+      };
+      element.labelInfo = test;
+      element.label = 'test';
+
+      flushAsynchronousOperations();
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('_computeCanDeleteVote', () => {
+      element.mutable = false;
+      const button = element.shadowRoot
+          .querySelector('gr-button');
+      assert.isTrue(isHidden(button));
+      element.change.removable_reviewers = [element.account];
+      element.mutable = true;
+      assert.isFalse(isHidden(button));
     });
 
-    suite('remove reviewer votes', () => {
-      setup(() => {
-        sandbox.stub(element, '_computeValueTooltip').returns('');
-        element.account = {
-          _account_id: 1,
-          name: 'bojack',
-        };
-        const test = {
-          all: [{_account_id: 1, name: 'bojack', value: 1}],
-          default_value: 0,
-          values: [],
-        };
-        element.change = {
-          _number: 42,
-          change_id: 'the id',
-          actions: [],
-          topic: 'the topic',
-          status: 'NEW',
-          submit_type: 'CHERRY_PICK',
-          labels: {test},
-          removable_reviewers: [],
-        };
-        element.labelInfo = test;
-        element.label = 'test';
+    test('deletes votes', () => {
+      const deleteResponse = Promise.resolve({ok: true});
+      const deleteStub = sandbox.stub(
+          element.$.restAPI, 'deleteVote').returns(deleteResponse);
 
-        flushAsynchronousOperations();
+      element.change.removable_reviewers = [element.account];
+      element.change.labels.test.recommended = {_account_id: 1};
+      element.mutable = true;
+      const button = element.shadowRoot
+          .querySelector('gr-button');
+      MockInteractions.tap(button);
+      assert.isTrue(button.disabled);
+      return deleteResponse.then(() => {
+        assert.isFalse(button.disabled);
+        assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
       });
-
-      test('_computeCanDeleteVote', () => {
-        element.mutable = false;
-        const button = element.shadowRoot
-            .querySelector('gr-button');
-        assert.isTrue(isHidden(button));
-        element.change.removable_reviewers = [element.account];
-        element.mutable = true;
-        assert.isFalse(isHidden(button));
-      });
-
-      test('deletes votes', () => {
-        const deleteResponse = Promise.resolve({ok: true});
-        const deleteStub = sandbox.stub(
-            element.$.restAPI, 'deleteVote').returns(deleteResponse);
-
-        element.change.removable_reviewers = [element.account];
-        element.change.labels.test.recommended = {_account_id: 1};
-        element.mutable = true;
-        const button = element.shadowRoot
-            .querySelector('gr-button');
-        MockInteractions.tap(button);
-        assert.isTrue(button.disabled);
-        return deleteResponse.then(() => {
-          assert.isFalse(button.disabled);
-          assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
-        });
-      });
-    });
-
-    suite('label color and order', () => {
-      test('valueless label rejected', () => {
-        element.labelInfo = {rejected: {name: 'someone'}};
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('negative'));
-      });
-
-      test('valueless label approved', () => {
-        element.labelInfo = {approved: {name: 'someone'}};
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('positive'));
-      });
-
-      test('-2 to +2', () => {
-        element.labelInfo = {
-          all: [
-            {value: 2, name: 'user 2'},
-            {value: 1, name: 'user 1'},
-            {value: -1, name: 'user 3'},
-            {value: -2, name: 'user 4'},
-          ],
-          values: {
-            '-2': 'Awful',
-            '-1': 'Don\'t submit as-is',
-            ' 0': 'No score',
-            '+1': 'Looks good to me',
-            '+2': 'Ready to submit',
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('max'));
-        assert.isTrue(labels[1].classList.contains('positive'));
-        assert.isTrue(labels[2].classList.contains('negative'));
-        assert.isTrue(labels[3].classList.contains('min'));
-      });
-
-      test('-1 to +1', () => {
-        element.labelInfo = {
-          all: [
-            {value: 1, name: 'user 1'},
-            {value: -1, name: 'user 2'},
-          ],
-          values: {
-            '-1': 'Don\'t submit as-is',
-            ' 0': 'No score',
-            '+1': 'Looks good to me',
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('max'));
-        assert.isTrue(labels[1].classList.contains('min'));
-      });
-
-      test('0 to +2', () => {
-        element.labelInfo = {
-          all: [
-            {value: 1, name: 'user 2'},
-            {value: 2, name: 'user '},
-          ],
-          values: {
-            ' 0': 'Don\'t submit as-is',
-            '+1': 'No score',
-            '+2': 'Looks good to me',
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('max'));
-        assert.isTrue(labels[1].classList.contains('positive'));
-      });
-
-      test('self votes at top', () => {
-        element.account = {
-          _account_id: 1,
-          name: 'bojack',
-        };
-        element.labelInfo = {
-          all: [
-            {value: 1, name: 'user 1', _account_id: 2},
-            {value: -1, name: 'bojack', _account_id: 1},
-          ],
-          values: {
-            '-1': 'Don\'t submit as-is',
-            ' 0': 'No score',
-            '+1': 'Looks good to me',
-          },
-        };
-        flushAsynchronousOperations();
-        const chips =
-            Polymer.dom(element.root).querySelectorAll('gr-account-chip');
-        assert.equal(chips[0].account._account_id, element.account._account_id);
-      });
-    });
-
-    test('_computeValueTooltip', () => {
-      // Existing label.
-      let labelInfo = {values: {0: 'Baz'}};
-      let score = '0';
-      assert.equal(element._computeValueTooltip(labelInfo, score), 'Baz');
-
-      // Non-exsistent score.
-      score = '2';
-      assert.equal(element._computeValueTooltip(labelInfo, score), '');
-
-      // No values on label.
-      labelInfo = {values: {}};
-      score = '0';
-      assert.equal(element._computeValueTooltip(labelInfo, score), '');
-    });
-
-    test('placeholder', () => {
-      element.labelInfo = {};
-      assert.isFalse(isHidden(element.shadowRoot
-          .querySelector('.placeholder')));
-      element.labelInfo = {all: []};
-      assert.isFalse(isHidden(element.shadowRoot
-          .querySelector('.placeholder')));
-      element.labelInfo = {all: [{value: 1}]};
-      assert.isTrue(isHidden(element.shadowRoot
-          .querySelector('.placeholder')));
     });
   });
+
+  suite('label color and order', () => {
+    test('valueless label rejected', () => {
+      element.labelInfo = {rejected: {name: 'someone'}};
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('negative'));
+    });
+
+    test('valueless label approved', () => {
+      element.labelInfo = {approved: {name: 'someone'}};
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('positive'));
+    });
+
+    test('-2 to +2', () => {
+      element.labelInfo = {
+        all: [
+          {value: 2, name: 'user 2'},
+          {value: 1, name: 'user 1'},
+          {value: -1, name: 'user 3'},
+          {value: -2, name: 'user 4'},
+        ],
+        values: {
+          '-2': 'Awful',
+          '-1': 'Don\'t submit as-is',
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+          '+2': 'Ready to submit',
+        },
+      };
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('positive'));
+      assert.isTrue(labels[2].classList.contains('negative'));
+      assert.isTrue(labels[3].classList.contains('min'));
+    });
+
+    test('-1 to +1', () => {
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 1'},
+          {value: -1, name: 'user 2'},
+        ],
+        values: {
+          '-1': 'Don\'t submit as-is',
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+        },
+      };
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('min'));
+    });
+
+    test('0 to +2', () => {
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 2'},
+          {value: 2, name: 'user '},
+        ],
+        values: {
+          ' 0': 'Don\'t submit as-is',
+          '+1': 'No score',
+          '+2': 'Looks good to me',
+        },
+      };
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('positive'));
+    });
+
+    test('self votes at top', () => {
+      element.account = {
+        _account_id: 1,
+        name: 'bojack',
+      };
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 1', _account_id: 2},
+          {value: -1, name: 'bojack', _account_id: 1},
+        ],
+        values: {
+          '-1': 'Don\'t submit as-is',
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+        },
+      };
+      flushAsynchronousOperations();
+      const chips =
+          dom(element.root).querySelectorAll('gr-account-chip');
+      assert.equal(chips[0].account._account_id, element.account._account_id);
+    });
+  });
+
+  test('_computeValueTooltip', () => {
+    // Existing label.
+    let labelInfo = {values: {0: 'Baz'}};
+    let score = '0';
+    assert.equal(element._computeValueTooltip(labelInfo, score), 'Baz');
+
+    // Non-exsistent score.
+    score = '2';
+    assert.equal(element._computeValueTooltip(labelInfo, score), '');
+
+    // No values on label.
+    labelInfo = {values: {}};
+    score = '0';
+    assert.equal(element._computeValueTooltip(labelInfo, score), '');
+  });
+
+  test('placeholder', () => {
+    element.labelInfo = {};
+    assert.isFalse(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {all: []};
+    assert.isFalse(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {all: [{value: 1}]};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
index b594757..c797919 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
@@ -14,20 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.TooltipMixin
-   * @extends Polymer.Element
-   */
-  class GrLabel extends Polymer.mixinBehaviors( [
-    Gerrit.TooltipBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-label'; }
-  }
+import '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-label_html.js';
 
-  customElements.define(GrLabel.is, GrLabel);
-})();
+/**
+ * @appliesMixin Gerrit.TooltipMixin
+ * @extends Polymer.Element
+ */
+class GrLabel extends mixinBehaviors( [
+  Gerrit.TooltipBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-label'; }
+}
+
+customElements.define(GrLabel.is, GrLabel);
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.js b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.js
index c1c9b23..1644c07 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.js
@@ -1,24 +1,21 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<dom-module id="gr-label">
-  <template>
+export const htmlTemplate = html`
     <slot></slot>
-  </template>
-  <script src="gr-label.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
index cb5ad7c..f585347 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
@@ -14,69 +14,76 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrLabeledAutocomplete extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-labeled-autocomplete'; }
-    /**
-     * Fired when a value is chosen.
-     *
-     * @event commit
-     */
+import '../gr-autocomplete/gr-autocomplete.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-labeled-autocomplete_html.js';
 
-    static get properties() {
-      return {
+/** @extends Polymer.Element */
+class GrLabeledAutocomplete extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-        /**
-         * Used just like the query property of gr-autocomplete.
-         *
-         * @type {function(string): Promise<?>}
-         */
-        query: {
-          type: Function,
-          value() {
-            return function() {
-              return Promise.resolve([]);
-            };
-          },
+  static get is() { return 'gr-labeled-autocomplete'; }
+  /**
+   * Fired when a value is chosen.
+   *
+   * @event commit
+   */
+
+  static get properties() {
+    return {
+
+      /**
+       * Used just like the query property of gr-autocomplete.
+       *
+       * @type {function(string): Promise<?>}
+       */
+      query: {
+        type: Function,
+        value() {
+          return function() {
+            return Promise.resolve([]);
+          };
         },
+      },
 
-        text: {
-          type: String,
-          value: '',
-          notify: true,
-        },
-        label: String,
-        placeholder: String,
-        disabled: Boolean,
+      text: {
+        type: String,
+        value: '',
+        notify: true,
+      },
+      label: String,
+      placeholder: String,
+      disabled: Boolean,
 
-        _autocompleteThreshold: {
-          type: Number,
-          value: 0,
-          readOnly: true,
-        },
-      };
-    }
-
-    _handleTriggerClick(e) {
-      // Stop propagation here so we don't confuse gr-autocomplete, which
-      // listens for taps on body to try to determine when it's blurred.
-      e.stopPropagation();
-      this.$.autocomplete.focus();
-    }
-
-    setText(text) {
-      this.$.autocomplete.setText(text);
-    }
-
-    clear() {
-      this.setText('');
-    }
+      _autocompleteThreshold: {
+        type: Number,
+        value: 0,
+        readOnly: true,
+      },
+    };
   }
 
-  customElements.define(GrLabeledAutocomplete.is, GrLabeledAutocomplete);
-})();
+  _handleTriggerClick(e) {
+    // Stop propagation here so we don't confuse gr-autocomplete, which
+    // listens for taps on body to try to determine when it's blurred.
+    e.stopPropagation();
+    this.$.autocomplete.focus();
+  }
+
+  setText(text) {
+    this.$.autocomplete.setText(text);
+  }
+
+  clear() {
+    this.setText('');
+  }
+}
+
+customElements.define(GrLabeledAutocomplete.is, GrLabeledAutocomplete);
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.js
index 47be6f7..fe0b03c 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.js
@@ -1,25 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-labeled-autocomplete">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -50,16 +47,8 @@
     <div id="container">
       <div id="header">[[label]]</div>
       <div id="body">
-        <gr-autocomplete
-            id="autocomplete"
-            threshold="[[_autocompleteThreshold]]"
-            query="[[query]]"
-            disabled="[[disabled]]"
-            placeholder="[[placeholder]]"
-            borderless></gr-autocomplete>
+        <gr-autocomplete id="autocomplete" threshold="[[_autocompleteThreshold]]" query="[[query]]" disabled="[[disabled]]" placeholder="[[placeholder]]" borderless=""></gr-autocomplete>
         <div id="trigger" on-click="_handleTriggerClick">▼</div>
       </div>
     </div>
-  </template>
-  <script src="gr-labeled-autocomplete.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
index e79e741..cd58932 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
@@ -18,15 +18,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-labeled-autocomplete</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-labeled-autocomplete.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-labeled-autocomplete.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-labeled-autocomplete.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -34,32 +39,34 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-labeled-autocomplete tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-labeled-autocomplete.js';
+suite('gr-labeled-autocomplete tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('tapping trigger focuses autocomplete', () => {
-      const e = {stopPropagation: () => undefined};
-      sandbox.stub(e, 'stopPropagation');
-      sandbox.stub(element.$.autocomplete, 'focus');
-      element._handleTriggerClick(e);
-      assert.isTrue(e.stopPropagation.calledOnce);
-      assert.isTrue(element.$.autocomplete.focus.calledOnce);
-    });
-
-    test('setText', () => {
-      sandbox.stub(element.$.autocomplete, 'setText');
-      element.setText('foo-bar');
-      assert.isTrue(element.$.autocomplete.setText.calledWith('foo-bar'));
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('tapping trigger focuses autocomplete', () => {
+    const e = {stopPropagation: () => undefined};
+    sandbox.stub(e, 'stopPropagation');
+    sandbox.stub(element.$.autocomplete, 'focus');
+    element._handleTriggerClick(e);
+    assert.isTrue(e.stopPropagation.calledOnce);
+    assert.isTrue(element.$.autocomplete.focus.calledOnce);
+  });
+
+  test('setText', () => {
+    sandbox.stub(element.$.autocomplete, 'setText');
+    element.setText('foo-bar');
+    assert.isTrue(element.$.autocomplete.setText.calledWith('foo-bar'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
index 81126ec..9bd8a11 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
@@ -14,155 +14,163 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
-  const DARK_THEME_PATH = 'styles/themes/dark-theme.html';
+import '../gr-js-api-interface/gr-js-api-interface.js';
+import {importHref} from '../../../scripts/import-href.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-lib-loader_html.js';
 
-  /** @extends Polymer.Element */
-  class GrLibLoader extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-lib-loader'; }
+const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
+const DARK_THEME_PATH = 'styles/themes/dark-theme.html';
 
-    static get properties() {
-      return {
-        _hljsState: {
-          type: Object,
+/** @extends Polymer.Element */
+class GrLibLoader extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-          // NOTE: intended singleton.
-          value: {
-            configured: false,
-            loading: false,
-            callbacks: [],
-          },
+  static get is() { return 'gr-lib-loader'; }
+
+  static get properties() {
+    return {
+      _hljsState: {
+        type: Object,
+
+        // NOTE: intended singleton.
+        value: {
+          configured: false,
+          loading: false,
+          callbacks: [],
         },
-      };
-    }
-
-    /**
-     * Get the HLJS library. Returns a promise that resolves with a reference to
-     * the library after it's been loaded. The promise resolves immediately if
-     * it's already been loaded.
-     *
-     * @return {!Promise<Object>}
-     */
-    getHLJS() {
-      return new Promise((resolve, reject) => {
-        // If the lib is totally loaded, resolve immediately.
-        if (this._getHighlightLib()) {
-          resolve(this._getHighlightLib());
-          return;
-        }
-
-        // If the library is not currently being loaded, then start loading it.
-        if (!this._hljsState.loading) {
-          this._hljsState.loading = true;
-          this._loadScript(this._getHLJSUrl())
-              .then(this._onHLJSLibLoaded.bind(this))
-              .catch(reject);
-        }
-
-        this._hljsState.callbacks.push(resolve);
-      });
-    }
-
-    /**
-     * Loads the dark theme document. Returns a promise that resolves with a
-     * custom-style DOM element.
-     *
-     * @return {!Promise<Element>}
-     * @suppress {checkTypes}
-     */
-    getDarkTheme() {
-      return new Promise((resolve, reject) => {
-        Polymer.importHref(
-            this._getLibRoot() + DARK_THEME_PATH, () => {
-              const module = document.createElement('style');
-              module.setAttribute('include', 'dark-theme');
-              const cs = document.createElement('custom-style');
-              cs.appendChild(module);
-
-              resolve(cs);
-            },
-            reject);
-      });
-    }
-
-    /**
-     * Execute callbacks awaiting the HLJS lib load.
-     */
-    _onHLJSLibLoaded() {
-      const lib = this._getHighlightLib();
-      this._hljsState.loading = false;
-      this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.HIGHLIGHTJS_LOADED, {
-        hljs: lib,
-      });
-      for (const cb of this._hljsState.callbacks) {
-        cb(lib);
-      }
-      this._hljsState.callbacks = [];
-    }
-
-    /**
-     * Get the HLJS library, assuming it has been loaded. Configure the library
-     * if it hasn't already been configured.
-     *
-     * @return {!Object}
-     */
-    _getHighlightLib() {
-      const lib = window.hljs;
-      if (lib && !this._hljsState.configured) {
-        this._hljsState.configured = true;
-
-        lib.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
-      }
-      return lib;
-    }
-
-    /**
-     * Get the resource path used to load the application. If the application
-     * was loaded through a CDN, then this will be the path to CDN resources.
-     *
-     * @return {string}
-     */
-    _getLibRoot() {
-      if (window.STATIC_RESOURCE_PATH) {
-        return window.STATIC_RESOURCE_PATH + '/';
-      }
-      return '/';
-    }
-
-    /**
-     * Load and execute a JS file from the lib root.
-     *
-     * @param {string} src The path to the JS file without the lib root.
-     * @return {Promise} a promise that resolves when the script's onload
-     *     executes.
-     */
-    _loadScript(src) {
-      return new Promise((resolve, reject) => {
-        const script = document.createElement('script');
-
-        if (!src) {
-          reject(new Error('Unable to load blank script url.'));
-          return;
-        }
-
-        script.setAttribute('src', src);
-        script.onload = resolve;
-        script.onerror = reject;
-        Polymer.dom(document.head).appendChild(script);
-      });
-    }
-
-    _getHLJSUrl() {
-      const root = this._getLibRoot();
-      if (!root) { return null; }
-      return root + HLJS_PATH;
-    }
+      },
+    };
   }
 
-  customElements.define(GrLibLoader.is, GrLibLoader);
-})();
+  /**
+   * Get the HLJS library. Returns a promise that resolves with a reference to
+   * the library after it's been loaded. The promise resolves immediately if
+   * it's already been loaded.
+   *
+   * @return {!Promise<Object>}
+   */
+  getHLJS() {
+    return new Promise((resolve, reject) => {
+      // If the lib is totally loaded, resolve immediately.
+      if (this._getHighlightLib()) {
+        resolve(this._getHighlightLib());
+        return;
+      }
+
+      // If the library is not currently being loaded, then start loading it.
+      if (!this._hljsState.loading) {
+        this._hljsState.loading = true;
+        this._loadScript(this._getHLJSUrl())
+            .then(this._onHLJSLibLoaded.bind(this))
+            .catch(reject);
+      }
+
+      this._hljsState.callbacks.push(resolve);
+    });
+  }
+
+  /**
+   * Loads the dark theme document. Returns a promise that resolves with a
+   * custom-style DOM element.
+   *
+   * @return {!Promise<Element>}
+   * @suppress {checkTypes}
+   */
+  getDarkTheme() {
+    return new Promise((resolve, reject) => {
+      importHref(
+          this._getLibRoot() + DARK_THEME_PATH, () => {
+            const module = document.createElement('style');
+            module.setAttribute('include', 'dark-theme');
+            const cs = document.createElement('custom-style');
+            cs.appendChild(module);
+
+            resolve(cs);
+          },
+          reject);
+    });
+  }
+
+  /**
+   * Execute callbacks awaiting the HLJS lib load.
+   */
+  _onHLJSLibLoaded() {
+    const lib = this._getHighlightLib();
+    this._hljsState.loading = false;
+    this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.HIGHLIGHTJS_LOADED, {
+      hljs: lib,
+    });
+    for (const cb of this._hljsState.callbacks) {
+      cb(lib);
+    }
+    this._hljsState.callbacks = [];
+  }
+
+  /**
+   * Get the HLJS library, assuming it has been loaded. Configure the library
+   * if it hasn't already been configured.
+   *
+   * @return {!Object}
+   */
+  _getHighlightLib() {
+    const lib = window.hljs;
+    if (lib && !this._hljsState.configured) {
+      this._hljsState.configured = true;
+
+      lib.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+    }
+    return lib;
+  }
+
+  /**
+   * Get the resource path used to load the application. If the application
+   * was loaded through a CDN, then this will be the path to CDN resources.
+   *
+   * @return {string}
+   */
+  _getLibRoot() {
+    if (window.STATIC_RESOURCE_PATH) {
+      return window.STATIC_RESOURCE_PATH + '/';
+    }
+    return '/';
+  }
+
+  /**
+   * Load and execute a JS file from the lib root.
+   *
+   * @param {string} src The path to the JS file without the lib root.
+   * @return {Promise} a promise that resolves when the script's onload
+   *     executes.
+   */
+  _loadScript(src) {
+    return new Promise((resolve, reject) => {
+      const script = document.createElement('script');
+
+      if (!src) {
+        reject(new Error('Unable to load blank script url.'));
+        return;
+      }
+
+      script.setAttribute('src', src);
+      script.onload = resolve;
+      script.onerror = reject;
+      dom(document.head).appendChild(script);
+    });
+  }
+
+  _getHLJSUrl() {
+    const root = this._getLibRoot();
+    if (!root) { return null; }
+    return root + HLJS_PATH;
+  }
+}
+
+customElements.define(GrLibLoader.is, GrLibLoader);
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.js
index fb55c67..3bc0d72 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.js
@@ -1,25 +1,21 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-
-<dom-module id="gr-lib-loader">
-  <template>
+export const htmlTemplate = html`
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  </template>
-  <script src="gr-lib-loader.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
index a1d9c0f..1f726b3 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-lib-loader</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-lib-loader.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-lib-loader.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-lib-loader.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,117 +40,119 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-lib-loader tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let element;
-    let resolveLoad;
-    let loadStub;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-lib-loader.js';
+suite('gr-lib-loader tests', () => {
+  let sandbox;
+  let element;
+  let resolveLoad;
+  let loadStub;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+
+    loadStub = sandbox.stub(element, '_loadScript', () =>
+      new Promise(resolve => resolveLoad = resolve)
+    );
+
+    // Assert preconditions:
+    assert.isFalse(element._hljsState.loading);
+  });
+
+  teardown(() => {
+    if (window.hljs) {
+      delete window.hljs;
+    }
+    sandbox.restore();
+
+    // Because the element state is a singleton, clean it up.
+    element._hljsState.configured = false;
+    element._hljsState.loading = false;
+    element._hljsState.callbacks = [];
+  });
+
+  test('only load once', done => {
+    sandbox.stub(element, '_getHLJSUrl').returns('');
+    const firstCallHandler = sinon.stub();
+    element.getHLJS().then(firstCallHandler);
+
+    // It should now be in the loading state.
+    assert.isTrue(loadStub.called);
+    assert.isTrue(element._hljsState.loading);
+    assert.isFalse(firstCallHandler.called);
+
+    const secondCallHandler = sinon.stub();
+    element.getHLJS().then(secondCallHandler);
+
+    // No change in state.
+    assert.isTrue(element._hljsState.loading);
+    assert.isFalse(firstCallHandler.called);
+    assert.isFalse(secondCallHandler.called);
+
+    // Now load the library.
+    resolveLoad();
+    flush(() => {
+      // The state should be loaded and both handlers called.
+      assert.isFalse(element._hljsState.loading);
+      assert.isTrue(firstCallHandler.called);
+      assert.isTrue(secondCallHandler.called);
+      done();
+    });
+  });
+
+  suite('preloaded', () => {
+    let hljsStub;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-
-      loadStub = sandbox.stub(element, '_loadScript', () =>
-        new Promise(resolve => resolveLoad = resolve)
-      );
-
-      // Assert preconditions:
-      assert.isFalse(element._hljsState.loading);
+      hljsStub = {
+        configure: sinon.stub(),
+      };
+      window.hljs = hljsStub;
     });
 
     teardown(() => {
-      if (window.hljs) {
-        delete window.hljs;
-      }
-      sandbox.restore();
-
-      // Because the element state is a singleton, clean it up.
-      element._hljsState.configured = false;
-      element._hljsState.loading = false;
-      element._hljsState.callbacks = [];
+      delete window.hljs;
     });
 
-    test('only load once', done => {
-      sandbox.stub(element, '_getHLJSUrl').returns('');
+    test('returns hljs', done => {
       const firstCallHandler = sinon.stub();
       element.getHLJS().then(firstCallHandler);
-
-      // It should now be in the loading state.
-      assert.isTrue(loadStub.called);
-      assert.isTrue(element._hljsState.loading);
-      assert.isFalse(firstCallHandler.called);
-
-      const secondCallHandler = sinon.stub();
-      element.getHLJS().then(secondCallHandler);
-
-      // No change in state.
-      assert.isTrue(element._hljsState.loading);
-      assert.isFalse(firstCallHandler.called);
-      assert.isFalse(secondCallHandler.called);
-
-      // Now load the library.
-      resolveLoad();
       flush(() => {
-        // The state should be loaded and both handlers called.
-        assert.isFalse(element._hljsState.loading);
         assert.isTrue(firstCallHandler.called);
-        assert.isTrue(secondCallHandler.called);
+        assert.isTrue(firstCallHandler.calledWith(hljsStub));
         done();
       });
     });
 
-    suite('preloaded', () => {
-      let hljsStub;
-
-      setup(() => {
-        hljsStub = {
-          configure: sinon.stub(),
-        };
-        window.hljs = hljsStub;
-      });
-
-      teardown(() => {
-        delete window.hljs;
-      });
-
-      test('returns hljs', done => {
-        const firstCallHandler = sinon.stub();
-        element.getHLJS().then(firstCallHandler);
-        flush(() => {
-          assert.isTrue(firstCallHandler.called);
-          assert.isTrue(firstCallHandler.calledWith(hljsStub));
-          done();
-        });
-      });
-
-      test('configures hljs', done => {
-        element.getHLJS().then(() => {
-          assert.isTrue(window.hljs.configure.calledOnce);
-          done();
-        });
-      });
-    });
-
-    suite('_getHLJSUrl', () => {
-      suite('checking _getLibRoot', () => {
-        let root;
-
-        setup(() => {
-          sandbox.stub(element, '_getLibRoot', () => root);
-        });
-
-        test('with no root', () => {
-          assert.isNull(element._getHLJSUrl());
-        });
-
-        test('with root', () => {
-          root = 'test-root.com/';
-          assert.equal(element._getHLJSUrl(),
-              'test-root.com/bower_components/highlightjs/highlight.min.js');
-        });
+    test('configures hljs', done => {
+      element.getHLJS().then(() => {
+        assert.isTrue(window.hljs.configure.calledOnce);
+        done();
       });
     });
   });
+
+  suite('_getHLJSUrl', () => {
+    suite('checking _getLibRoot', () => {
+      let root;
+
+      setup(() => {
+        sandbox.stub(element, '_getLibRoot', () => root);
+      });
+
+      test('with no root', () => {
+        assert.isNull(element._getHLJSUrl());
+      });
+
+      test('with root', () => {
+        root = 'test-root.com/';
+        assert.equal(element._getHLJSUrl(),
+            'test-root.com/bower_components/highlightjs/highlight.min.js');
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
index ee032f6..d47dbbc 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
@@ -14,92 +14,99 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-limited-text_html.js';
+
+/**
+ * The gr-limited-text element is for displaying text with a maximum length
+ * (in number of characters) to display. If the length of the text exceeds the
+ * configured limit, then an ellipsis indicates that the text was truncated
+ * and a tooltip containing the full text is enabled.
+ *
+ * @appliesMixin Gerrit.TooltipMixin
+ * @extends Polymer.Element
+ */
+class GrLimitedText extends mixinBehaviors( [
+  Gerrit.TooltipBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-limited-text'; }
+
+  static get properties() {
+    return {
+    /** The un-truncated text to display. */
+      text: String,
+
+      /** The maximum length for the text to display before truncating. */
+      limit: {
+        type: Number,
+        value: null,
+      },
+
+      /** Boolean property used by Gerrit.TooltipBehavior. */
+      hasTooltip: {
+        type: Boolean,
+        value: false,
+      },
+
+      /**
+       * Disable the tooltip.
+       * When set to true, will not show tooltip even text is over limit
+       */
+      disableTooltip: {
+        type: Boolean,
+        value: false,
+      },
+
+      /**
+       * The maximum number of characters to display in the tooltop.
+       */
+      tooltipLimit: {
+        type: Number,
+        value: 1024,
+      },
+    };
+  }
+
+  static get observers() {
+    return [
+      '_updateTitle(text, limit, tooltipLimit)',
+    ];
+  }
 
   /**
-   * The gr-limited-text element is for displaying text with a maximum length
-   * (in number of characters) to display. If the length of the text exceeds the
-   * configured limit, then an ellipsis indicates that the text was truncated
-   * and a tooltip containing the full text is enabled.
-   *
-   * @appliesMixin Gerrit.TooltipMixin
-   * @extends Polymer.Element
+   * The text or limit have changed. Recompute whether a tooltip needs to be
+   * enabled.
    */
-  class GrLimitedText extends Polymer.mixinBehaviors( [
-    Gerrit.TooltipBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-limited-text'; }
-
-    static get properties() {
-      return {
-      /** The un-truncated text to display. */
-        text: String,
-
-        /** The maximum length for the text to display before truncating. */
-        limit: {
-          type: Number,
-          value: null,
-        },
-
-        /** Boolean property used by Gerrit.TooltipBehavior. */
-        hasTooltip: {
-          type: Boolean,
-          value: false,
-        },
-
-        /**
-         * Disable the tooltip.
-         * When set to true, will not show tooltip even text is over limit
-         */
-        disableTooltip: {
-          type: Boolean,
-          value: false,
-        },
-
-        /**
-         * The maximum number of characters to display in the tooltop.
-         */
-        tooltipLimit: {
-          type: Number,
-          value: 1024,
-        },
-      };
+  _updateTitle(text, limit, tooltipLimit) {
+    // Polymer 2: check for undefined
+    if ([text, limit, tooltipLimit].some(arg => arg === undefined)) {
+      return;
     }
 
-    static get observers() {
-      return [
-        '_updateTitle(text, limit, tooltipLimit)',
-      ];
-    }
-
-    /**
-     * The text or limit have changed. Recompute whether a tooltip needs to be
-     * enabled.
-     */
-    _updateTitle(text, limit, tooltipLimit) {
-      // Polymer 2: check for undefined
-      if ([text, limit, tooltipLimit].some(arg => arg === undefined)) {
-        return;
-      }
-
-      this.hasTooltip = !!limit && !!text && text.length > limit;
-      if (this.hasTooltip && !this.disableTooltip) {
-        this.setAttribute('title', text.substr(0, tooltipLimit));
-      } else {
-        this.removeAttribute('title');
-      }
-    }
-
-    _computeDisplayText(text, limit) {
-      if (!!limit && !!text && text.length > limit) {
-        return text.substr(0, limit - 1) + '…';
-      }
-      return text;
+    this.hasTooltip = !!limit && !!text && text.length > limit;
+    if (this.hasTooltip && !this.disableTooltip) {
+      this.setAttribute('title', text.substr(0, tooltipLimit));
+    } else {
+      this.removeAttribute('title');
     }
   }
 
-  customElements.define(GrLimitedText.is, GrLimitedText);
-})();
+  _computeDisplayText(text, limit) {
+    if (!!limit && !!text && text.length > limit) {
+      return text.substr(0, limit - 1) + '…';
+    }
+    return text;
+  }
+}
+
+customElements.define(GrLimitedText.is, GrLimitedText);
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.js
index d00416b..c14f9f9 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.js
@@ -1,24 +1,21 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-
-<dom-module id="gr-limited-text">
-  <template>[[_computeDisplayText(text, limit)]]</template>
-  <script src="gr-limited-text.js"></script>
-</dom-module>
+export const htmlTemplate = html`
+[[_computeDisplayText(text, limit)]]
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
index c34a348..8240cbf 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-limited-text</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel="import" href="gr-limited-text.html">
+<script type="module" src="./gr-limited-text.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-limited-text.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,72 +41,74 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-limited-text tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-limited-text.js';
+suite('gr-limited-text tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_updateTitle', () => {
-      const updateSpy = sandbox.spy(element, '_updateTitle');
-      element.text = 'abc 123';
-      flushAsynchronousOperations();
-      assert.isTrue(updateSpy.calledOnce);
-      assert.isNotOk(element.getAttribute('title'));
-      assert.isFalse(element.hasTooltip);
-
-      element.limit = 10;
-      flushAsynchronousOperations();
-      assert.isTrue(updateSpy.calledTwice);
-      assert.isNotOk(element.getAttribute('title'));
-      assert.isFalse(element.hasTooltip);
-
-      element.limit = 3;
-      flushAsynchronousOperations();
-      assert.isTrue(updateSpy.calledThrice);
-      assert.equal(element.getAttribute('title'), 'abc 123');
-      assert.isTrue(element.hasTooltip);
-
-      element.tooltipLimit = 3;
-      flushAsynchronousOperations();
-      assert.equal(element.getAttribute('title'), 'abc');
-
-      element.tooltipLimit = 1024;
-      element.limit = 100;
-      flushAsynchronousOperations();
-      assert.equal(updateSpy.callCount, 6);
-      assert.isNotOk(element.getAttribute('title'));
-      assert.isFalse(element.hasTooltip);
-
-      element.limit = null;
-      flushAsynchronousOperations();
-      assert.equal(updateSpy.callCount, 7);
-      assert.isNotOk(element.getAttribute('title'));
-      assert.isFalse(element.hasTooltip);
-    });
-
-    test('_computeDisplayText', () => {
-      assert.equal(element._computeDisplayText('foo bar', 100), 'foo bar');
-      assert.equal(element._computeDisplayText('foo bar', 4), 'foo…');
-      assert.equal(element._computeDisplayText('foo bar', null), 'foo bar');
-    });
-
-    test('when disable tooltip', () => {
-      sandbox.spy(element, '_updateTitle');
-      element.text = 'abcdefghijklmn';
-      element.disableTooltip = true;
-      element.limit = 10;
-      flushAsynchronousOperations();
-      assert.equal(element.getAttribute('title'), null);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_updateTitle', () => {
+    const updateSpy = sandbox.spy(element, '_updateTitle');
+    element.text = 'abc 123';
+    flushAsynchronousOperations();
+    assert.isTrue(updateSpy.calledOnce);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+
+    element.limit = 10;
+    flushAsynchronousOperations();
+    assert.isTrue(updateSpy.calledTwice);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+
+    element.limit = 3;
+    flushAsynchronousOperations();
+    assert.isTrue(updateSpy.calledThrice);
+    assert.equal(element.getAttribute('title'), 'abc 123');
+    assert.isTrue(element.hasTooltip);
+
+    element.tooltipLimit = 3;
+    flushAsynchronousOperations();
+    assert.equal(element.getAttribute('title'), 'abc');
+
+    element.tooltipLimit = 1024;
+    element.limit = 100;
+    flushAsynchronousOperations();
+    assert.equal(updateSpy.callCount, 6);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+
+    element.limit = null;
+    flushAsynchronousOperations();
+    assert.equal(updateSpy.callCount, 7);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+  });
+
+  test('_computeDisplayText', () => {
+    assert.equal(element._computeDisplayText('foo bar', 100), 'foo bar');
+    assert.equal(element._computeDisplayText('foo bar', 4), 'foo…');
+    assert.equal(element._computeDisplayText('foo bar', null), 'foo bar');
+  });
+
+  test('when disable tooltip', () => {
+    sandbox.spy(element, '_updateTitle');
+    element.text = 'abcdefghijklmn';
+    element.disableTooltip = true;
+    element.limit = 10;
+    flushAsynchronousOperations();
+    assert.equal(element.getAttribute('title'), null);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
index ccab685..2957c9f 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
@@ -14,52 +14,64 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
-   */
-  class GrLinkedChip extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-linked-chip'; }
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
+import '../gr-button/gr-button.js';
+import '../gr-icons/gr-icons.js';
+import '../gr-limited-text/gr-limited-text.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-linked-chip_html.js';
 
-    static get properties() {
-      return {
-        href: String,
-        disabled: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-        removable: {
-          type: Boolean,
-          value: false,
-        },
-        text: String,
-        transparentBackground: {
-          type: Boolean,
-          value: false,
-        },
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrLinkedChip extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-        /**  If provided, sets the maximum length of the content. */
-        limit: Number,
-      };
-    }
+  static get is() { return 'gr-linked-chip'; }
 
-    _getBackgroundClass(transparent) {
-      return transparent ? 'transparentBackground' : '';
-    }
+  static get properties() {
+    return {
+      href: String,
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      removable: {
+        type: Boolean,
+        value: false,
+      },
+      text: String,
+      transparentBackground: {
+        type: Boolean,
+        value: false,
+      },
 
-    _handleRemoveTap(e) {
-      e.preventDefault();
-      this.fire('remove');
-    }
+      /**  If provided, sets the maximum length of the content. */
+      limit: Number,
+    };
   }
 
-  customElements.define(GrLinkedChip.is, GrLinkedChip);
-})();
+  _getBackgroundClass(transparent) {
+    return transparent ? 'transparentBackground' : '';
+  }
+
+  _handleRemoveTap(e) {
+    e.preventDefault();
+    this.fire('remove');
+  }
+}
+
+customElements.define(GrLinkedChip.is, GrLinkedChip);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.js
index 844b8be..c028d02 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.js
@@ -1,30 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../gr-icons/gr-icons.html">
-<link rel="import" href="../gr-limited-text/gr-limited-text.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-linked-chip">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -78,22 +70,12 @@
         width: 1.2rem;
       }
     </style>
-    <div class$="container [[_getBackgroundClass(transparentBackground)]]">
-      <a href$="[[href]]">
-        <gr-limited-text
-            limit="[[limit]]"
-            text="[[text]]"></gr-limited-text>
+    <div class\$="container [[_getBackgroundClass(transparentBackground)]]">
+      <a href\$="[[href]]">
+        <gr-limited-text limit="[[limit]]" text="[[text]]"></gr-limited-text>
       </a>
-      <gr-button
-          id="remove"
-          link
-          hidden$="[[!removable]]"
-          hidden
-          class$="remove [[_getBackgroundClass(transparentBackground)]]"
-          on-click="_handleRemoveTap">
+      <gr-button id="remove" link="" hidden\$="[[!removable]]" hidden="" class\$="remove [[_getBackgroundClass(transparentBackground)]]" on-click="_handleRemoveTap">
         <iron-icon icon="gr-icons:close"></iron-icon>
       </gr-button>
     </div>
-  </template>
-  <script src="gr-linked-chip.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
index 2bc7cfa..af8d217 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
@@ -19,12 +19,12 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-linked-chip</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 <!-- Can't use absolute path below for mock-interaction.js.
 Web component tester(wct) has a built-in http server and it serves "/components" directory (which is
 actually /node_modules directory). Also, wct patches some files to load modules from /components.
@@ -33,9 +33,14 @@
 -->
 <script src="../../../node_modules/iron-test-helpers/mock-interactions.js"></script>
 
-<link rel="import" href="gr-linked-chip.html">
+<script type="module" src="./gr-linked-chip.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-linked-chip.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -43,27 +48,29 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-linked-chip tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-linked-chip.js';
+suite('gr-linked-chip tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('remove fired', () => {
-      const spy = sandbox.spy();
-      element.addEventListener('remove', spy);
-      flushAsynchronousOperations();
-      MockInteractions.tap(element.$.remove);
-      assert.isTrue(spy.called);
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('remove fired', () => {
+    const spy = sandbox.spy();
+    element.addEventListener('remove', spy);
+    flushAsynchronousOperations();
+    MockInteractions.tap(element.$.remove);
+    assert.isTrue(spy.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
index f970734..07ce424 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 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.
@@ -14,110 +14,121 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrLinkedText extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-linked-text'; }
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-navigation/gr-navigation.js';
+import './link-text-parser.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import 'ba-linkify/ba-linkify.js';
+import {htmlTemplate} from './gr-linked-text_html.js';
 
-    static get properties() {
-      return {
-        removeZeroWidthSpace: Boolean,
-        content: {
-          type: String,
-          observer: '_contentChanged',
-        },
-        pre: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-        disabled: {
-          type: Boolean,
-          value: false,
-          reflectToAttribute: true,
-        },
-        config: Object,
-      };
-    }
+/** @extends Polymer.Element */
+class GrLinkedText extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    static get observers() {
-      return [
-        '_contentOrConfigChanged(content, config)',
-      ];
-    }
+  static get is() { return 'gr-linked-text'; }
 
-    _contentChanged(content) {
-      // In the case where the config may not be set (perhaps due to the
-      // request for it still being in flight), set the content anyway to
-      // prevent waiting on the config to display the text.
-      if (this.config != null) { return; }
-      this.$.output.textContent = content;
-    }
-
-    /**
-     * Because either the source text or the linkification config has changed,
-     * the content should be re-parsed.
-     *
-     * @param {string|null|undefined} content The raw, un-linkified source
-     *     string to parse.
-     * @param {Object|null|undefined} config The server config specifying
-     *     commentLink patterns
-     */
-    _contentOrConfigChanged(content, config) {
-      if (!Gerrit.Nav || !Gerrit.Nav.mapCommentlinks) return;
-      config = Gerrit.Nav.mapCommentlinks(config);
-      const output = Polymer.dom(this.$.output);
-      output.textContent = '';
-      const parser = new GrLinkTextParser(config,
-          this._handleParseResult.bind(this), this.removeZeroWidthSpace);
-      parser.parse(content);
-
-      // Ensure that external links originating from HTML commentlink configs
-      // open in a new tab. @see Issue 5567
-      // Ensure links to the same host originating from commentlink configs
-      // open in the same tab. When target is not set - default is _self
-      // @see Issue 4616
-      output.querySelectorAll('a').forEach(anchor => {
-        if (anchor.hostname === window.location.hostname) {
-          anchor.removeAttribute('target');
-        } else {
-          anchor.setAttribute('target', '_blank');
-        }
-        anchor.setAttribute('rel', 'noopener');
-      });
-    }
-
-    /**
-     * This method is called when the GrLikTextParser emits a partial result
-     * (used as the "callback" parameter). It will be called in either of two
-     * ways:
-     * - To create a link: when called with `text` and `href` arguments, a link
-     *   element should be created and attached to the resulting DOM.
-     * - To attach an arbitrary fragment: when called with only the `fragment`
-     *   argument, the fragment should be attached to the resulting DOM as is.
-     *
-     * @param {string|null} text
-     * @param {string|null} href
-     * @param  {DocumentFragment|undefined} fragment
-     */
-    _handleParseResult(text, href, fragment) {
-      const output = Polymer.dom(this.$.output);
-      if (href) {
-        const a = document.createElement('a');
-        a.href = href;
-        a.textContent = text;
-        a.target = '_blank';
-        a.rel = 'noopener';
-        output.appendChild(a);
-      } else if (fragment) {
-        output.appendChild(fragment);
-      }
-    }
+  static get properties() {
+    return {
+      removeZeroWidthSpace: Boolean,
+      content: {
+        type: String,
+        observer: '_contentChanged',
+      },
+      pre: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      config: Object,
+    };
   }
 
-  customElements.define(GrLinkedText.is, GrLinkedText);
-})();
+  static get observers() {
+    return [
+      '_contentOrConfigChanged(content, config)',
+    ];
+  }
+
+  _contentChanged(content) {
+    // In the case where the config may not be set (perhaps due to the
+    // request for it still being in flight), set the content anyway to
+    // prevent waiting on the config to display the text.
+    if (this.config != null) { return; }
+    this.$.output.textContent = content;
+  }
+
+  /**
+   * Because either the source text or the linkification config has changed,
+   * the content should be re-parsed.
+   *
+   * @param {string|null|undefined} content The raw, un-linkified source
+   *     string to parse.
+   * @param {Object|null|undefined} config The server config specifying
+   *     commentLink patterns
+   */
+  _contentOrConfigChanged(content, config) {
+    if (!Gerrit.Nav || !Gerrit.Nav.mapCommentlinks) return;
+    config = Gerrit.Nav.mapCommentlinks(config);
+    const output = dom(this.$.output);
+    output.textContent = '';
+    const parser = new GrLinkTextParser(config,
+        this._handleParseResult.bind(this), this.removeZeroWidthSpace);
+    parser.parse(content);
+
+    // Ensure that external links originating from HTML commentlink configs
+    // open in a new tab. @see Issue 5567
+    // Ensure links to the same host originating from commentlink configs
+    // open in the same tab. When target is not set - default is _self
+    // @see Issue 4616
+    output.querySelectorAll('a').forEach(anchor => {
+      if (anchor.hostname === window.location.hostname) {
+        anchor.removeAttribute('target');
+      } else {
+        anchor.setAttribute('target', '_blank');
+      }
+      anchor.setAttribute('rel', 'noopener');
+    });
+  }
+
+  /**
+   * This method is called when the GrLikTextParser emits a partial result
+   * (used as the "callback" parameter). It will be called in either of two
+   * ways:
+   * - To create a link: when called with `text` and `href` arguments, a link
+   *   element should be created and attached to the resulting DOM.
+   * - To attach an arbitrary fragment: when called with only the `fragment`
+   *   argument, the fragment should be attached to the resulting DOM as is.
+   *
+   * @param {string|null} text
+   * @param {string|null} href
+   * @param  {DocumentFragment|undefined} fragment
+   */
+  _handleParseResult(text, href, fragment) {
+    const output = dom(this.$.output);
+    if (href) {
+      const a = document.createElement('a');
+      a.href = href;
+      a.textContent = text;
+      a.target = '_blank';
+      a.rel = 'noopener';
+      output.appendChild(a);
+    } else if (fragment) {
+      output.appendChild(fragment);
+    }
+  }
+}
+
+customElements.define(GrLinkedText.is, GrLinkedText);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.js
index 61facc0..43d7144 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-
-<script src="/bower_components/ba-linkify/ba-linkify.js"></script>
-<script src="link-text-parser.js"></script>
-<dom-module id="gr-linked-text">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -39,6 +32,4 @@
       }
     </style>
     <span id="output"></span>
-  </template>
-  <script src="gr-linked-text.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
index 9e373b7..b16acf4 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-linked-text</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-linked-text.html">
+<script type="module" src="./gr-linked-text.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-linked-text.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -39,340 +45,344 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-linked-text tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-linked-text.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-linked-text tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(Gerrit.Nav, 'mapCommentlinks', x => x);
-      element.config = {
-        ph: {
-          match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
-          link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
-        },
-        prefixsameinlinkandpattern: {
-          match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
-          link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
-        },
-        changeid: {
-          match: '(I[0-9a-f]{8,40})',
-          link: '#/q/$1',
-        },
-        changeid2: {
-          match: 'Change-Id: +(I[0-9a-f]{8,40})',
-          link: '#/q/$1',
-        },
-        googlesearch: {
-          match: 'google:(.+)',
-          link: 'https://bing.com/search?q=$1', // html should supercede link.
-          html: '<a href="https://google.com/search?q=$1">$1</a>',
-        },
-        hashedhtml: {
-          match: 'hash:(.+)',
-          html: '<a href="#/awesomesauce">$1</a>',
-        },
-        baseurl: {
-          match: 'test (.+)',
-          html: '<a href="/r/awesomesauce">$1</a>',
-        },
-        anotatstartwithbaseurl: {
-          match: 'a test (.+)',
-          html: '[Lookup: <a href="/r/awesomesauce">$1</a>]',
-        },
-        disabledconfig: {
-          match: 'foo:(.+)',
-          link: 'https://google.com/search?q=$1',
-          enabled: false,
-        },
-      };
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('URL pattern was parsed and linked.', () => {
-      // Regular inline link.
-      const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-      element.content = url;
-      const linkEl = element.$.output.childNodes[0];
-      assert.equal(linkEl.target, '_blank');
-      assert.equal(linkEl.rel, 'noopener');
-      assert.equal(linkEl.href, url);
-      assert.equal(linkEl.textContent, url);
-    });
-
-    test('Bug pattern was parsed and linked', () => {
-      // "Issue/Bug" pattern.
-      element.content = 'Issue 3650';
-
-      let linkEl = element.$.output.childNodes[0];
-      const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-      assert.equal(linkEl.target, '_blank');
-      assert.equal(linkEl.href, url);
-      assert.equal(linkEl.textContent, 'Issue 3650');
-
-      element.content = 'Bug 3650';
-      linkEl = element.$.output.childNodes[0];
-      assert.equal(linkEl.target, '_blank');
-      assert.equal(linkEl.rel, 'noopener');
-      assert.equal(linkEl.href, url);
-      assert.equal(linkEl.textContent, 'Bug 3650');
-    });
-
-    test('Pattern with same prefix as link was correctly parsed', () => {
-      // Pattern starts with the same prefix (`http`) as the url.
-      element.content = 'httpexample 3650';
-
-      assert.equal(element.$.output.childNodes.length, 1);
-      const linkEl = element.$.output.childNodes[0];
-      const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-      assert.equal(linkEl.target, '_blank');
-      assert.equal(linkEl.href, url);
-      assert.equal(linkEl.textContent, 'httpexample 3650');
-    });
-
-    test('Change-Id pattern was parsed and linked', () => {
-      // "Change-Id:" pattern.
-      const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-      const prefix = 'Change-Id: ';
-      element.content = prefix + changeID;
-
-      const textNode = element.$.output.childNodes[0];
-      const linkEl = element.$.output.childNodes[1];
-      assert.equal(textNode.textContent, prefix);
-      const url = '/q/' + changeID;
-      assert.isFalse(linkEl.hasAttribute('target'));
-      // Since url is a path, the host is added automatically.
-      assert.isTrue(linkEl.href.endsWith(url));
-      assert.equal(linkEl.textContent, changeID);
-    });
-
-    test('Change-Id pattern was parsed and linked with base url', () => {
-      window.CANONICAL_PATH = '/r';
-
-      // "Change-Id:" pattern.
-      const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-      const prefix = 'Change-Id: ';
-      element.content = prefix + changeID;
-
-      const textNode = element.$.output.childNodes[0];
-      const linkEl = element.$.output.childNodes[1];
-      assert.equal(textNode.textContent, prefix);
-      const url = '/r/q/' + changeID;
-      assert.isFalse(linkEl.hasAttribute('target'));
-      // Since url is a path, the host is added automatically.
-      assert.isTrue(linkEl.href.endsWith(url));
-      assert.equal(linkEl.textContent, changeID);
-    });
-
-    test('Multiple matches', () => {
-      element.content = 'Issue 3650\nIssue 3450';
-      const linkEl1 = element.$.output.childNodes[0];
-      const linkEl2 = element.$.output.childNodes[2];
-
-      assert.equal(linkEl1.target, '_blank');
-      assert.equal(linkEl1.href,
-          'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650');
-      assert.equal(linkEl1.textContent, 'Issue 3650');
-
-      assert.equal(linkEl2.target, '_blank');
-      assert.equal(linkEl2.href,
-          'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450');
-      assert.equal(linkEl2.textContent, 'Issue 3450');
-    });
-
-    test('Change-Id pattern parsed before bug pattern', () => {
-      // "Change-Id:" pattern.
-      const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-      const prefix = 'Change-Id: ';
-
-      // "Issue/Bug" pattern.
-      const bug = 'Issue 3650';
-
-      const changeUrl = '/q/' + changeID;
-      const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-
-      element.content = prefix + changeID + bug;
-
-      const textNode = element.$.output.childNodes[0];
-      const changeLinkEl = element.$.output.childNodes[1];
-      const bugLinkEl = element.$.output.childNodes[2];
-
-      assert.equal(textNode.textContent, prefix);
-
-      assert.isFalse(changeLinkEl.hasAttribute('target'));
-      assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
-      assert.equal(changeLinkEl.textContent, changeID);
-
-      assert.equal(bugLinkEl.target, '_blank');
-      assert.equal(bugLinkEl.href, bugUrl);
-      assert.equal(bugLinkEl.textContent, 'Issue 3650');
-    });
-
-    test('html field in link config', () => {
-      element.content = 'google:do a barrel roll';
-      const linkEl = element.$.output.childNodes[0];
-      assert.equal(linkEl.getAttribute('href'),
-          'https://google.com/search?q=do a barrel roll');
-      assert.equal(linkEl.textContent, 'do a barrel roll');
-    });
-
-    test('removing hash from links', () => {
-      element.content = 'hash:foo';
-      const linkEl = element.$.output.childNodes[0];
-      assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
-      assert.equal(linkEl.textContent, 'foo');
-    });
-
-    test('html with base url', () => {
-      window.CANONICAL_PATH = '/r';
-
-      element.content = 'test foo';
-      const linkEl = element.$.output.childNodes[0];
-      assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-      assert.equal(linkEl.textContent, 'foo');
-    });
-
-    test('a is not at start', () => {
-      window.CANONICAL_PATH = '/r';
-
-      element.content = 'a test foo';
-      const linkEl = element.$.output.childNodes[1];
-      assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-      assert.equal(linkEl.textContent, 'foo');
-    });
-
-    test('hash html with base url', () => {
-      window.CANONICAL_PATH = '/r';
-
-      element.content = 'hash:foo';
-      const linkEl = element.$.output.childNodes[0];
-      assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-      assert.equal(linkEl.textContent, 'foo');
-    });
-
-    test('disabled config', () => {
-      element.content = 'foo:baz';
-      assert.equal(element.$.output.innerHTML, 'foo:baz');
-    });
-
-    test('R=email labels link correctly', () => {
-      element.removeZeroWidthSpace = true;
-      element.content = 'R=\u200Btest@google.com';
-      assert.equal(element.$.output.textContent, 'R=test@google.com');
-      assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
-    });
-
-    test('CC=email labels link correctly', () => {
-      element.removeZeroWidthSpace = true;
-      element.content = 'CC=\u200Btest@google.com';
-      assert.equal(element.$.output.textContent, 'CC=test@google.com');
-      assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
-    });
-
-    test('only {http,https,mailto} protocols are linkified', () => {
-      element.content = 'xx mailto:test@google.com yy';
-      let links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
-      assert.equal(links[0].innerHTML, 'mailto:test@google.com');
-
-      element.content = 'xx http://google.com yy';
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'http://google.com');
-      assert.equal(links[0].innerHTML, 'http://google.com');
-
-      element.content = 'xx https://google.com yy';
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'https://google.com');
-      assert.equal(links[0].innerHTML, 'https://google.com');
-
-      element.content = 'xx ssh://google.com yy';
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 0);
-
-      element.content = 'xx ftp://google.com yy';
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 0);
-    });
-
-    test('links without leading whitespace are linkified', () => {
-      element.content = 'xx abcmailto:test@google.com yy';
-      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx abc');
-      let links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
-      assert.equal(links[0].innerHTML, 'mailto:test@google.com');
-
-      element.content = 'xx defhttp://google.com yy';
-      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx def');
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'http://google.com');
-      assert.equal(links[0].innerHTML, 'http://google.com');
-
-      element.content = 'xx qwehttps://google.com yy';
-      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx qwe');
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'https://google.com');
-      assert.equal(links[0].innerHTML, 'https://google.com');
-
-      // Non-latin character
-      element.content = 'xx абвhttps://google.com yy';
-      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx абв');
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'https://google.com');
-      assert.equal(links[0].innerHTML, 'https://google.com');
-
-      element.content = 'xx ssh://google.com yy';
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 0);
-
-      element.content = 'xx ftp://google.com yy';
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 0);
-    });
-
-    test('overlapping links', () => {
-      element.config = {
-        b1: {
-          match: '(B:\\s*)(\\d+)',
-          html: '$1<a href="ftp://foo/$2">$2</a>',
-        },
-        b2: {
-          match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
-          html: '$1<a href="ftp://foo/$2">$2</a>',
-        },
-      };
-      element.content = '- B: 123, 45';
-      const links = Polymer.dom(element.root).querySelectorAll('a');
-
-      assert.equal(links.length, 2);
-      assert.equal(element.shadowRoot
-          .querySelector('span').textContent, '- B: 123, 45');
-
-      assert.equal(links[0].href, 'ftp://foo/123');
-      assert.equal(links[0].textContent, '123');
-
-      assert.equal(links[1].href, 'ftp://foo/45');
-      assert.equal(links[1].textContent, '45');
-    });
-
-    test('_contentOrConfigChanged called with config', () => {
-      const contentStub = sandbox.stub(element, '_contentChanged');
-      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
-      element.content = 'some text';
-      assert.isTrue(contentStub.called);
-      assert.isTrue(contentConfigStub.called);
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(Gerrit.Nav, 'mapCommentlinks', x => x);
+    element.config = {
+      ph: {
+        match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
+        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
+      },
+      prefixsameinlinkandpattern: {
+        match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
+        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
+      },
+      changeid: {
+        match: '(I[0-9a-f]{8,40})',
+        link: '#/q/$1',
+      },
+      changeid2: {
+        match: 'Change-Id: +(I[0-9a-f]{8,40})',
+        link: '#/q/$1',
+      },
+      googlesearch: {
+        match: 'google:(.+)',
+        link: 'https://bing.com/search?q=$1', // html should supercede link.
+        html: '<a href="https://google.com/search?q=$1">$1</a>',
+      },
+      hashedhtml: {
+        match: 'hash:(.+)',
+        html: '<a href="#/awesomesauce">$1</a>',
+      },
+      baseurl: {
+        match: 'test (.+)',
+        html: '<a href="/r/awesomesauce">$1</a>',
+      },
+      anotatstartwithbaseurl: {
+        match: 'a test (.+)',
+        html: '[Lookup: <a href="/r/awesomesauce">$1</a>]',
+      },
+      disabledconfig: {
+        match: 'foo:(.+)',
+        link: 'https://google.com/search?q=$1',
+        enabled: false,
+      },
+    };
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('URL pattern was parsed and linked.', () => {
+    // Regular inline link.
+    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    element.content = url;
+    const linkEl = element.$.output.childNodes[0];
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.rel, 'noopener');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, url);
+  });
+
+  test('Bug pattern was parsed and linked', () => {
+    // "Issue/Bug" pattern.
+    element.content = 'Issue 3650';
+
+    let linkEl = element.$.output.childNodes[0];
+    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, 'Issue 3650');
+
+    element.content = 'Bug 3650';
+    linkEl = element.$.output.childNodes[0];
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.rel, 'noopener');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, 'Bug 3650');
+  });
+
+  test('Pattern with same prefix as link was correctly parsed', () => {
+    // Pattern starts with the same prefix (`http`) as the url.
+    element.content = 'httpexample 3650';
+
+    assert.equal(element.$.output.childNodes.length, 1);
+    const linkEl = element.$.output.childNodes[0];
+    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, 'httpexample 3650');
+  });
+
+  test('Change-Id pattern was parsed and linked', () => {
+    // "Change-Id:" pattern.
+    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+    const prefix = 'Change-Id: ';
+    element.content = prefix + changeID;
+
+    const textNode = element.$.output.childNodes[0];
+    const linkEl = element.$.output.childNodes[1];
+    assert.equal(textNode.textContent, prefix);
+    const url = '/q/' + changeID;
+    assert.isFalse(linkEl.hasAttribute('target'));
+    // Since url is a path, the host is added automatically.
+    assert.isTrue(linkEl.href.endsWith(url));
+    assert.equal(linkEl.textContent, changeID);
+  });
+
+  test('Change-Id pattern was parsed and linked with base url', () => {
+    window.CANONICAL_PATH = '/r';
+
+    // "Change-Id:" pattern.
+    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+    const prefix = 'Change-Id: ';
+    element.content = prefix + changeID;
+
+    const textNode = element.$.output.childNodes[0];
+    const linkEl = element.$.output.childNodes[1];
+    assert.equal(textNode.textContent, prefix);
+    const url = '/r/q/' + changeID;
+    assert.isFalse(linkEl.hasAttribute('target'));
+    // Since url is a path, the host is added automatically.
+    assert.isTrue(linkEl.href.endsWith(url));
+    assert.equal(linkEl.textContent, changeID);
+  });
+
+  test('Multiple matches', () => {
+    element.content = 'Issue 3650\nIssue 3450';
+    const linkEl1 = element.$.output.childNodes[0];
+    const linkEl2 = element.$.output.childNodes[2];
+
+    assert.equal(linkEl1.target, '_blank');
+    assert.equal(linkEl1.href,
+        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650');
+    assert.equal(linkEl1.textContent, 'Issue 3650');
+
+    assert.equal(linkEl2.target, '_blank');
+    assert.equal(linkEl2.href,
+        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450');
+    assert.equal(linkEl2.textContent, 'Issue 3450');
+  });
+
+  test('Change-Id pattern parsed before bug pattern', () => {
+    // "Change-Id:" pattern.
+    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+    const prefix = 'Change-Id: ';
+
+    // "Issue/Bug" pattern.
+    const bug = 'Issue 3650';
+
+    const changeUrl = '/q/' + changeID;
+    const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+
+    element.content = prefix + changeID + bug;
+
+    const textNode = element.$.output.childNodes[0];
+    const changeLinkEl = element.$.output.childNodes[1];
+    const bugLinkEl = element.$.output.childNodes[2];
+
+    assert.equal(textNode.textContent, prefix);
+
+    assert.isFalse(changeLinkEl.hasAttribute('target'));
+    assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
+    assert.equal(changeLinkEl.textContent, changeID);
+
+    assert.equal(bugLinkEl.target, '_blank');
+    assert.equal(bugLinkEl.href, bugUrl);
+    assert.equal(bugLinkEl.textContent, 'Issue 3650');
+  });
+
+  test('html field in link config', () => {
+    element.content = 'google:do a barrel roll';
+    const linkEl = element.$.output.childNodes[0];
+    assert.equal(linkEl.getAttribute('href'),
+        'https://google.com/search?q=do a barrel roll');
+    assert.equal(linkEl.textContent, 'do a barrel roll');
+  });
+
+  test('removing hash from links', () => {
+    element.content = 'hash:foo';
+    const linkEl = element.$.output.childNodes[0];
+    assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('html with base url', () => {
+    window.CANONICAL_PATH = '/r';
+
+    element.content = 'test foo';
+    const linkEl = element.$.output.childNodes[0];
+    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('a is not at start', () => {
+    window.CANONICAL_PATH = '/r';
+
+    element.content = 'a test foo';
+    const linkEl = element.$.output.childNodes[1];
+    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('hash html with base url', () => {
+    window.CANONICAL_PATH = '/r';
+
+    element.content = 'hash:foo';
+    const linkEl = element.$.output.childNodes[0];
+    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('disabled config', () => {
+    element.content = 'foo:baz';
+    assert.equal(element.$.output.innerHTML, 'foo:baz');
+  });
+
+  test('R=email labels link correctly', () => {
+    element.removeZeroWidthSpace = true;
+    element.content = 'R=\u200Btest@google.com';
+    assert.equal(element.$.output.textContent, 'R=test@google.com');
+    assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
+  });
+
+  test('CC=email labels link correctly', () => {
+    element.removeZeroWidthSpace = true;
+    element.content = 'CC=\u200Btest@google.com';
+    assert.equal(element.$.output.textContent, 'CC=test@google.com');
+    assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
+  });
+
+  test('only {http,https,mailto} protocols are linkified', () => {
+    element.content = 'xx mailto:test@google.com yy';
+    let links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
+
+    element.content = 'xx http://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'http://google.com');
+    assert.equal(links[0].innerHTML, 'http://google.com');
+
+    element.content = 'xx https://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'https://google.com');
+    assert.equal(links[0].innerHTML, 'https://google.com');
+
+    element.content = 'xx ssh://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 0);
+
+    element.content = 'xx ftp://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 0);
+  });
+
+  test('links without leading whitespace are linkified', () => {
+    element.content = 'xx abcmailto:test@google.com yy';
+    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx abc');
+    let links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
+
+    element.content = 'xx defhttp://google.com yy';
+    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx def');
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'http://google.com');
+    assert.equal(links[0].innerHTML, 'http://google.com');
+
+    element.content = 'xx qwehttps://google.com yy';
+    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx qwe');
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'https://google.com');
+    assert.equal(links[0].innerHTML, 'https://google.com');
+
+    // Non-latin character
+    element.content = 'xx абвhttps://google.com yy';
+    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx абв');
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'https://google.com');
+    assert.equal(links[0].innerHTML, 'https://google.com');
+
+    element.content = 'xx ssh://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 0);
+
+    element.content = 'xx ftp://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 0);
+  });
+
+  test('overlapping links', () => {
+    element.config = {
+      b1: {
+        match: '(B:\\s*)(\\d+)',
+        html: '$1<a href="ftp://foo/$2">$2</a>',
+      },
+      b2: {
+        match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
+        html: '$1<a href="ftp://foo/$2">$2</a>',
+      },
+    };
+    element.content = '- B: 123, 45';
+    const links = dom(element.root).querySelectorAll('a');
+
+    assert.equal(links.length, 2);
+    assert.equal(element.shadowRoot
+        .querySelector('span').textContent, '- B: 123, 45');
+
+    assert.equal(links[0].href, 'ftp://foo/123');
+    assert.equal(links[0].textContent, '123');
+
+    assert.equal(links[1].href, 'ftp://foo/45');
+    assert.equal(links[1].textContent, '45');
+  });
+
+  test('_contentOrConfigChanged called with config', () => {
+    const contentStub = sandbox.stub(element, '_contentChanged');
+    const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    element.content = 'some text';
+    assert.isTrue(contentStub.called);
+    assert.isTrue(contentConfigStub.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
index 8913cd8..d52a912 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
@@ -14,106 +14,119 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
+import '@polymer/iron-input/iron-input.js';
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '../../../styles/shared-styles.js';
+import '../gr-button/gr-button.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-list-view_html.js';
 
-  /**
-   * @appliesMixin Gerrit.BaseUrlMixin
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
-   */
-  class GrListView extends Polymer.mixinBehaviors( [
-    Gerrit.BaseUrlBehavior,
-    Gerrit.FireBehavior,
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-list-view'; }
+const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
-    static get properties() {
-      return {
-        createNew: Boolean,
-        items: Array,
-        itemsPerPage: Number,
-        filter: {
-          type: String,
-          observer: '_filterChanged',
-        },
-        offset: Number,
-        loading: Boolean,
-        path: String,
-      };
-    }
+/**
+ * @appliesMixin Gerrit.BaseUrlMixin
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrListView extends mixinBehaviors( [
+  Gerrit.BaseUrlBehavior,
+  Gerrit.FireBehavior,
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /** @override */
-    detached() {
-      super.detached();
-      this.cancelDebouncer('reload');
-    }
+  static get is() { return 'gr-list-view'; }
 
-    _filterChanged(newFilter, oldFilter) {
-      if (!newFilter && !oldFilter) {
-        return;
-      }
-
-      this._debounceReload(newFilter);
-    }
-
-    _debounceReload(filter) {
-      this.debounce('reload', () => {
-        if (filter) {
-          return page.show(`${this.path}/q/filter:` +
-              this.encodeURL(filter, false));
-        }
-        page.show(this.path);
-      }, REQUEST_DEBOUNCE_INTERVAL_MS);
-    }
-
-    _createNewItem() {
-      this.fire('create-clicked');
-    }
-
-    _computeNavLink(offset, direction, itemsPerPage, filter, path) {
-      // Offset could be a string when passed from the router.
-      offset = +(offset || 0);
-      const newOffset = Math.max(0, offset + (itemsPerPage * direction));
-      let href = this.getBaseUrl() + path;
-      if (filter) {
-        href += '/q/filter:' + this.encodeURL(filter, false);
-      }
-      if (newOffset > 0) {
-        href += ',' + newOffset;
-      }
-      return href;
-    }
-
-    _computeCreateClass(createNew) {
-      return createNew ? 'show' : '';
-    }
-
-    _hidePrevArrow(loading, offset) {
-      return loading || offset === 0;
-    }
-
-    _hideNextArrow(loading, items) {
-      if (loading || !items || !items.length) {
-        return true;
-      }
-      const lastPage = items.length < this.itemsPerPage + 1;
-      return lastPage;
-    }
-
-    // TODO: fix offset (including itemsPerPage)
-    // to either support a decimal or make it go to the nearest
-    // whole number (e.g 3).
-    _computePage(offset, itemsPerPage) {
-      return offset / itemsPerPage + 1;
-    }
+  static get properties() {
+    return {
+      createNew: Boolean,
+      items: Array,
+      itemsPerPage: Number,
+      filter: {
+        type: String,
+        observer: '_filterChanged',
+      },
+      offset: Number,
+      loading: Boolean,
+      path: String,
+    };
   }
 
-  customElements.define(GrListView.is, GrListView);
-})();
+  /** @override */
+  detached() {
+    super.detached();
+    this.cancelDebouncer('reload');
+  }
+
+  _filterChanged(newFilter, oldFilter) {
+    if (!newFilter && !oldFilter) {
+      return;
+    }
+
+    this._debounceReload(newFilter);
+  }
+
+  _debounceReload(filter) {
+    this.debounce('reload', () => {
+      if (filter) {
+        return page.show(`${this.path}/q/filter:` +
+            this.encodeURL(filter, false));
+      }
+      page.show(this.path);
+    }, REQUEST_DEBOUNCE_INTERVAL_MS);
+  }
+
+  _createNewItem() {
+    this.fire('create-clicked');
+  }
+
+  _computeNavLink(offset, direction, itemsPerPage, filter, path) {
+    // Offset could be a string when passed from the router.
+    offset = +(offset || 0);
+    const newOffset = Math.max(0, offset + (itemsPerPage * direction));
+    let href = this.getBaseUrl() + path;
+    if (filter) {
+      href += '/q/filter:' + this.encodeURL(filter, false);
+    }
+    if (newOffset > 0) {
+      href += ',' + newOffset;
+    }
+    return href;
+  }
+
+  _computeCreateClass(createNew) {
+    return createNew ? 'show' : '';
+  }
+
+  _hidePrevArrow(loading, offset) {
+    return loading || offset === 0;
+  }
+
+  _hideNextArrow(loading, items) {
+    if (loading || !items || !items.length) {
+      return true;
+    }
+    const lastPage = items.length < this.itemsPerPage + 1;
+    return lastPage;
+  }
+
+  // TODO: fix offset (including itemsPerPage)
+  // to either support a decimal or make it go to the nearest
+  // whole number (e.g 3).
+  _computePage(offset, itemsPerPage) {
+    return offset / itemsPerPage + 1;
+  }
+}
+
+customElements.define(GrListView.is, GrListView);
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.js
index 3d41a7c..0d33dd0 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.js
@@ -1,31 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-
-<dom-module id="gr-list-view">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       #filter {
         max-width: 25em;
@@ -70,19 +61,12 @@
     <div id="topContainer">
       <div class="filterContainer">
         <label>Filter:</label>
-        <iron-input
-            type="text"
-            bind-value="{{filter}}">
-          <input
-              is="iron-input"
-              type="text"
-              id="filter"
-              bind-value="{{filter}}">
+        <iron-input type="text" bind-value="{{filter}}">
+          <input is="iron-input" type="text" id="filter" bind-value="{{filter}}">
         </iron-input>
       </div>
-      <div id="createNewContainer"
-          class$="[[_computeCreateClass(createNew)]]">
-        <gr-button primary link id="createNew" on-click="_createNewItem">
+      <div id="createNewContainer" class\$="[[_computeCreateClass(createNew)]]">
+        <gr-button primary="" link="" id="createNew" on-click="_createNewItem">
           Create New
         </gr-button>
       </div>
@@ -90,17 +74,11 @@
     <slot></slot>
     <nav>
       Page [[_computePage(offset, itemsPerPage)]]
-      <a id="prevArrow"
-          href$="[[_computeNavLink(offset, -1, itemsPerPage, filter, path)]]"
-          hidden$="[[_hidePrevArrow(loading, offset)]]" hidden>
+      <a id="prevArrow" href\$="[[_computeNavLink(offset, -1, itemsPerPage, filter, path)]]" hidden\$="[[_hidePrevArrow(loading, offset)]]" hidden="">
         <iron-icon icon="gr-icons:chevron-left"></iron-icon>
       </a>
-      <a id="nextArrow"
-          href$="[[_computeNavLink(offset, 1, itemsPerPage, filter, path)]]"
-          hidden$="[[_hideNextArrow(loading, items)]]" hidden>
+      <a id="nextArrow" href\$="[[_computeNavLink(offset, 1, itemsPerPage, filter, path)]]" hidden\$="[[_hideNextArrow(loading, items)]]" hidden="">
         <iron-icon icon="gr-icons:chevron-right"></iron-icon>
       </a>
     </nav>
-  </template>
-  <script src="gr-list-view.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
index 70605a1..cd45650 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
@@ -19,16 +19,21 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-list-view</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-list-view.html">
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-list-view.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-list-view.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -36,133 +41,135 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-list-view tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-list-view.js';
+suite('gr-list-view tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_computeNavLink', () => {
+    const offset = 25;
+    const projectsPerPage = 25;
+    let filter = 'test';
+    const path = '/admin/projects';
+
+    sandbox.stub(element, 'getBaseUrl', () => '');
+
+    assert.equal(
+        element._computeNavLink(offset, 1, projectsPerPage, filter, path),
+        '/admin/projects/q/filter:test,50');
+
+    assert.equal(
+        element._computeNavLink(offset, -1, projectsPerPage, filter, path),
+        '/admin/projects/q/filter:test');
+
+    assert.equal(
+        element._computeNavLink(offset, 1, projectsPerPage, null, path),
+        '/admin/projects,50');
+
+    assert.equal(
+        element._computeNavLink(offset, -1, projectsPerPage, null, path),
+        '/admin/projects');
+
+    filter = 'plugins/';
+    assert.equal(
+        element._computeNavLink(offset, 1, projectsPerPage, filter, path),
+        '/admin/projects/q/filter:plugins%252F,50');
+  });
+
+  test('_onValueChange', done => {
+    element.path = '/admin/projects';
+    sandbox.stub(page, 'show', url => {
+      assert.equal(url, '/admin/projects/q/filter:test');
+      done();
     });
+    element.filter = 'test';
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  test('_filterChanged not reload when swap between falsy values', () => {
+    sandbox.stub(element, '_debounceReload');
+    element.filter = null;
+    element.filter = undefined;
+    element.filter = '';
+    assert.isFalse(element._debounceReload.called);
+  });
 
-    test('_computeNavLink', () => {
-      const offset = 25;
-      const projectsPerPage = 25;
-      let filter = 'test';
-      const path = '/admin/projects';
+  test('next button', done => {
+    element.itemsPerPage = 25;
+    let projects = new Array(26);
 
-      sandbox.stub(element, 'getBaseUrl', () => '');
-
-      assert.equal(
-          element._computeNavLink(offset, 1, projectsPerPage, filter, path),
-          '/admin/projects/q/filter:test,50');
-
-      assert.equal(
-          element._computeNavLink(offset, -1, projectsPerPage, filter, path),
-          '/admin/projects/q/filter:test');
-
-      assert.equal(
-          element._computeNavLink(offset, 1, projectsPerPage, null, path),
-          '/admin/projects,50');
-
-      assert.equal(
-          element._computeNavLink(offset, -1, projectsPerPage, null, path),
-          '/admin/projects');
-
-      filter = 'plugins/';
-      assert.equal(
-          element._computeNavLink(offset, 1, projectsPerPage, filter, path),
-          '/admin/projects/q/filter:plugins%252F,50');
-    });
-
-    test('_onValueChange', done => {
-      element.path = '/admin/projects';
-      sandbox.stub(page, 'show', url => {
-        assert.equal(url, '/admin/projects/q/filter:test');
-        done();
-      });
-      element.filter = 'test';
-    });
-
-    test('_filterChanged not reload when swap between falsy values', () => {
-      sandbox.stub(element, '_debounceReload');
-      element.filter = null;
-      element.filter = undefined;
-      element.filter = '';
-      assert.isFalse(element._debounceReload.called);
-    });
-
-    test('next button', done => {
-      element.itemsPerPage = 25;
-      let projects = new Array(26);
-
-      flush(() => {
-        let loading;
-        assert.isFalse(element._hideNextArrow(loading, projects));
-        loading = true;
-        assert.isTrue(element._hideNextArrow(loading, projects));
-        loading = false;
-        assert.isFalse(element._hideNextArrow(loading, projects));
-        element._projects = [];
-        assert.isTrue(element._hideNextArrow(loading, element._projects));
-        projects = new Array(4);
-        assert.isTrue(element._hideNextArrow(loading, projects));
-        done();
-      });
-    });
-
-    test('prev button', () => {
-      assert.isTrue(element._hidePrevArrow(true, 0));
-      flush(() => {
-        let offset = 0;
-        assert.isTrue(element._hidePrevArrow(false, offset));
-        offset = 5;
-        assert.isFalse(element._hidePrevArrow(false, offset));
-      });
-    });
-
-    test('createNew link appears correctly', () => {
-      assert.isFalse(element.shadowRoot
-          .querySelector('#createNewContainer').classList
-          .contains('show'));
-      element.createNew = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.shadowRoot
-          .querySelector('#createNewContainer').classList
-          .contains('show'));
-    });
-
-    test('fires create clicked event when button tapped', () => {
-      const clickHandler = sandbox.stub();
-      element.addEventListener('create-clicked', clickHandler);
-      element.createNew = true;
-      flushAsynchronousOperations();
-      MockInteractions.tap(element.shadowRoot.querySelector('#createNew'));
-      assert.isTrue(clickHandler.called);
-    });
-
-    test('next/prev links change when path changes', () => {
-      const BRANCHES_PATH = '/path/to/branches';
-      const TAGS_PATH = '/path/to/tags';
-      sandbox.stub(element, '_computeNavLink');
-      element.offset = 0;
-      element.itemsPerPage = 25;
-      element.filter = '';
-      element.path = BRANCHES_PATH;
-      assert.equal(element._computeNavLink.lastCall.args[4], BRANCHES_PATH);
-      element.path = TAGS_PATH;
-      assert.equal(element._computeNavLink.lastCall.args[4], TAGS_PATH);
-    });
-
-    test('_computePage', () => {
-      assert.equal(element._computePage(0, 25), 1);
-      assert.equal(element._computePage(50, 25), 3);
+    flush(() => {
+      let loading;
+      assert.isFalse(element._hideNextArrow(loading, projects));
+      loading = true;
+      assert.isTrue(element._hideNextArrow(loading, projects));
+      loading = false;
+      assert.isFalse(element._hideNextArrow(loading, projects));
+      element._projects = [];
+      assert.isTrue(element._hideNextArrow(loading, element._projects));
+      projects = new Array(4);
+      assert.isTrue(element._hideNextArrow(loading, projects));
+      done();
     });
   });
+
+  test('prev button', () => {
+    assert.isTrue(element._hidePrevArrow(true, 0));
+    flush(() => {
+      let offset = 0;
+      assert.isTrue(element._hidePrevArrow(false, offset));
+      offset = 5;
+      assert.isFalse(element._hidePrevArrow(false, offset));
+    });
+  });
+
+  test('createNew link appears correctly', () => {
+    assert.isFalse(element.shadowRoot
+        .querySelector('#createNewContainer').classList
+        .contains('show'));
+    element.createNew = true;
+    flushAsynchronousOperations();
+    assert.isTrue(element.shadowRoot
+        .querySelector('#createNewContainer').classList
+        .contains('show'));
+  });
+
+  test('fires create clicked event when button tapped', () => {
+    const clickHandler = sandbox.stub();
+    element.addEventListener('create-clicked', clickHandler);
+    element.createNew = true;
+    flushAsynchronousOperations();
+    MockInteractions.tap(element.shadowRoot.querySelector('#createNew'));
+    assert.isTrue(clickHandler.called);
+  });
+
+  test('next/prev links change when path changes', () => {
+    const BRANCHES_PATH = '/path/to/branches';
+    const TAGS_PATH = '/path/to/tags';
+    sandbox.stub(element, '_computeNavLink');
+    element.offset = 0;
+    element.itemsPerPage = 25;
+    element.filter = '';
+    element.path = BRANCHES_PATH;
+    assert.equal(element._computeNavLink.lastCall.args[4], BRANCHES_PATH);
+    element.path = TAGS_PATH;
+    assert.equal(element._computeNavLink.lastCall.args[4], TAGS_PATH);
+  });
+
+  test('_computePage', () => {
+    assert.equal(element._computePage(0, 25), 1);
+    assert.equal(element._computePage(50, 25), 3);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
index 3a957ef..fd68971 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -14,108 +14,117 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const AWAIT_MAX_ITERS = 10;
-  const AWAIT_STEP = 5;
-  const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
+import {IronOverlayBehaviorImpl, IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-overlay_html.js';
+
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
+const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrOverlay extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  IronOverlayBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-overlay'; }
+  /**
+   * Fired when a fullscreen overlay is closed
+   *
+   * @event fullscreen-overlay-closed
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
+   * Fired when an overlay is opened in full screen mode
+   *
+   * @event fullscreen-overlay-opened
    */
-  class GrOverlay extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Polymer.IronOverlayBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-overlay'; }
-    /**
-     * Fired when a fullscreen overlay is closed
-     *
-     * @event fullscreen-overlay-closed
-     */
 
-    /**
-     * Fired when an overlay is opened in full screen mode
-     *
-     * @event fullscreen-overlay-opened
-     */
+  static get properties() {
+    return {
+      _fullScreenOpen: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-    static get properties() {
-      return {
-        _fullScreenOpen: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('iron-overlay-closed',
+        () => this._close());
+    this.addEventListener('iron-overlay-cancelled',
+        () => this._close());
+  }
 
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('iron-overlay-closed',
-          () => this._close());
-      this.addEventListener('iron-overlay-cancelled',
-          () => this._close());
-    }
-
-    open(...args) {
-      return new Promise((resolve, reject) => {
-        Polymer.IronOverlayBehaviorImpl.open.apply(this, args);
-        if (this._isMobile()) {
-          this.fire('fullscreen-overlay-opened');
-          this._fullScreenOpen = true;
-        }
-        this._awaitOpen(resolve, reject);
-      });
-    }
-
-    _isMobile() {
-      return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
-    }
-
-    _close() {
-      if (this._fullScreenOpen) {
-        this.fire('fullscreen-overlay-closed');
-        this._fullScreenOpen = false;
+  open(...args) {
+    return new Promise((resolve, reject) => {
+      IronOverlayBehaviorImpl.open.apply(this, args);
+      if (this._isMobile()) {
+        this.fire('fullscreen-overlay-opened');
+        this._fullScreenOpen = true;
       }
-    }
+      this._awaitOpen(resolve, reject);
+    });
+  }
 
-    /**
-     * Override the focus stops that iron-overlay-behavior tries to find.
-     */
-    setFocusStops(stops) {
-      this.__firstFocusableNode = stops.start;
-      this.__lastFocusableNode = stops.end;
-    }
+  _isMobile() {
+    return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
+  }
 
-    /**
-     * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
-     * opening. Eventually replace with a direct way to listen to the overlay.
-     */
-    _awaitOpen(fn, reject) {
-      let iters = 0;
-      const step = () => {
-        this.async(() => {
-          if (this.style.display !== 'none') {
-            fn.call(this);
-          } else if (iters++ < AWAIT_MAX_ITERS) {
-            step.call(this);
-          } else {
-            reject(new Error('gr-overlay _awaitOpen failed to resolve'));
-          }
-        }, AWAIT_STEP);
-      };
-      step.call(this);
-    }
-
-    _id() {
-      return this.getAttribute('id') || 'global';
+  _close() {
+    if (this._fullScreenOpen) {
+      this.fire('fullscreen-overlay-closed');
+      this._fullScreenOpen = false;
     }
   }
 
-  customElements.define(GrOverlay.is, GrOverlay);
-})();
+  /**
+   * Override the focus stops that iron-overlay-behavior tries to find.
+   */
+  setFocusStops(stops) {
+    this.__firstFocusableNode = stops.start;
+    this.__lastFocusableNode = stops.end;
+  }
+
+  /**
+   * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
+   * opening. Eventually replace with a direct way to listen to the overlay.
+   */
+  _awaitOpen(fn, reject) {
+    let iters = 0;
+    const step = () => {
+      this.async(() => {
+        if (this.style.display !== 'none') {
+          fn.call(this);
+        } else if (iters++ < AWAIT_MAX_ITERS) {
+          step.call(this);
+        } else {
+          reject(new Error('gr-overlay _awaitOpen failed to resolve'));
+        }
+      }, AWAIT_STEP);
+    };
+    step.call(this);
+  }
+
+  _id() {
+    return this.getAttribute('id') || 'global';
+  }
+}
+
+customElements.define(GrOverlay.is, GrOverlay);
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.js
index 1afd1c9..5fe000a 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.js
@@ -1,27 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-overlay-behavior/iron-overlay-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-overlay">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         background: var(--dialog-background-color);
@@ -42,6 +37,4 @@
       }
     </style>
     <slot></slot>
-  </template>
-  <script src="gr-overlay.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
index e1218af3..72f01fb 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
@@ -19,18 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-overlay</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel="import" href="gr-overlay.html">
+<script type="module" src="./gr-overlay.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-overlay.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -40,57 +45,59 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-overlay tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-overlay.js';
+suite('gr-overlay tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('events are fired on fullscreen view', done => {
-      sandbox.stub(element, '_isMobile').returns(true);
-      const openHandler = sandbox.stub();
-      const closeHandler = sandbox.stub();
-      element.addEventListener('fullscreen-overlay-opened', openHandler);
-      element.addEventListener('fullscreen-overlay-closed', closeHandler);
+  test('events are fired on fullscreen view', done => {
+    sandbox.stub(element, '_isMobile').returns(true);
+    const openHandler = sandbox.stub();
+    const closeHandler = sandbox.stub();
+    element.addEventListener('fullscreen-overlay-opened', openHandler);
+    element.addEventListener('fullscreen-overlay-closed', closeHandler);
 
-      element.open().then(() => {
-        assert.isTrue(element._isMobile.called);
-        assert.isTrue(element._fullScreenOpen);
-        assert.isTrue(openHandler.called);
+    element.open().then(() => {
+      assert.isTrue(element._isMobile.called);
+      assert.isTrue(element._fullScreenOpen);
+      assert.isTrue(openHandler.called);
 
-        element._close();
-        assert.isFalse(element._fullScreenOpen);
-        assert.isTrue(closeHandler.called);
-        done();
-      });
-    });
-
-    test('events are not fired on desktop view', done => {
-      sandbox.stub(element, '_isMobile').returns(false);
-      const openHandler = sandbox.stub();
-      const closeHandler = sandbox.stub();
-      element.addEventListener('fullscreen-overlay-opened', openHandler);
-      element.addEventListener('fullscreen-overlay-closed', closeHandler);
-
-      element.open().then(() => {
-        assert.isTrue(element._isMobile.called);
-        assert.isFalse(element._fullScreenOpen);
-        assert.isFalse(openHandler.called);
-
-        element._close();
-        assert.isFalse(element._fullScreenOpen);
-        assert.isFalse(closeHandler.called);
-        done();
-      });
+      element._close();
+      assert.isFalse(element._fullScreenOpen);
+      assert.isTrue(closeHandler.called);
+      done();
     });
   });
+
+  test('events are not fired on desktop view', done => {
+    sandbox.stub(element, '_isMobile').returns(false);
+    const openHandler = sandbox.stub();
+    const closeHandler = sandbox.stub();
+    element.addEventListener('fullscreen-overlay-opened', openHandler);
+    element.addEventListener('fullscreen-overlay-closed', closeHandler);
+
+    element.open().then(() => {
+      assert.isTrue(element._isMobile.called);
+      assert.isFalse(element._fullScreenOpen);
+      assert.isFalse(openHandler.called);
+
+      element._close();
+      assert.isFalse(element._fullScreenOpen);
+      assert.isFalse(closeHandler.called);
+      done();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
index ac876c4..23b284c 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
@@ -14,62 +14,69 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
 
-  /** @extends Polymer.Element */
-  class GrPageNav extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-page-nav'; }
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-page-nav_html.js';
 
-    static get properties() {
-      return {
-        _headerHeight: Number,
-      };
-    }
+/** @extends Polymer.Element */
+class GrPageNav extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /** @override */
-    attached() {
-      super.attached();
-      this.listen(window, 'scroll', '_handleBodyScroll');
-    }
+  static get is() { return 'gr-page-nav'; }
 
-    /** @override */
-    detached() {
-      super.detached();
-      this.unlisten(window, 'scroll', '_handleBodyScroll');
-    }
-
-    _handleBodyScroll() {
-      if (this._headerHeight === undefined) {
-        let top = this._getOffsetTop(this);
-        for (let offsetParent = this.offsetParent;
-          offsetParent;
-          offsetParent = this._getOffsetParent(offsetParent)) {
-          top += this._getOffsetTop(offsetParent);
-        }
-        this._headerHeight = top;
-      }
-
-      this.$.nav.classList.toggle('pinned',
-          this._getScrollY() >= this._headerHeight);
-    }
-
-    /* Functions used for test purposes */
-    _getOffsetParent(element) {
-      if (!element || !element.offsetParent) { return ''; }
-      return element.offsetParent;
-    }
-
-    _getOffsetTop(element) {
-      return element.offsetTop;
-    }
-
-    _getScrollY() {
-      return window.scrollY;
-    }
+  static get properties() {
+    return {
+      _headerHeight: Number,
+    };
   }
 
-  customElements.define(GrPageNav.is, GrPageNav);
-})();
+  /** @override */
+  attached() {
+    super.attached();
+    this.listen(window, 'scroll', '_handleBodyScroll');
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'scroll', '_handleBodyScroll');
+  }
+
+  _handleBodyScroll() {
+    if (this._headerHeight === undefined) {
+      let top = this._getOffsetTop(this);
+      for (let offsetParent = this.offsetParent;
+        offsetParent;
+        offsetParent = this._getOffsetParent(offsetParent)) {
+        top += this._getOffsetTop(offsetParent);
+      }
+      this._headerHeight = top;
+    }
+
+    this.$.nav.classList.toggle('pinned',
+        this._getScrollY() >= this._headerHeight);
+  }
+
+  /* Functions used for test purposes */
+  _getOffsetParent(element) {
+    if (!element || !element.offsetParent) { return ''; }
+    return element.offsetParent;
+  }
+
+  _getOffsetTop(element) {
+    return element.offsetTop;
+  }
+
+  _getScrollY() {
+    return window.scrollY;
+  }
+}
+
+customElements.define(GrPageNav.is, GrPageNav);
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.js
index f1c3a6f..fe17a1b 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.js
@@ -1,25 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-page-nav">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       #nav {
         background-color: var(--table-header-background-color);
@@ -42,6 +39,4 @@
     <nav id="nav">
       <slot></slot>
     </nav>
-  </template>
-  <script src="gr-page-nav.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
index fdfe46c..4f47b95 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
@@ -19,18 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-page-nav</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/page/page.js"></script>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel="import" href="gr-page-nav.html">
+<script type="module" src="./gr-page-nav.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-page-nav.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -42,53 +47,55 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-page-nav tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-page-nav.js';
+suite('gr-page-nav tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      flushAsynchronousOperations();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('header is not pinned just below top', () => {
-      sandbox.stub(element, '_getOffsetParent', () => 0);
-      sandbox.stub(element, '_getOffsetTop', () => 10);
-      sandbox.stub(element, '_getScrollY', () => 5);
-      element._handleBodyScroll();
-      assert.isFalse(element.$.nav.classList.contains('pinned'));
-    });
-
-    test('header is pinned when scroll down the page', () => {
-      sandbox.stub(element, '_getOffsetParent', () => 0);
-      sandbox.stub(element, '_getOffsetTop', () => 10);
-      sandbox.stub(element, '_getScrollY', () => 25);
-      window.scrollY = 100;
-      element._handleBodyScroll();
-      assert.isTrue(element.$.nav.classList.contains('pinned'));
-    });
-
-    test('header is not pinned just below top with header set', () => {
-      element._headerHeight = 20;
-      sandbox.stub(element, '_getScrollY', () => 15);
-      window.scrollY = 100;
-      element._handleBodyScroll();
-      assert.isFalse(element.$.nav.classList.contains('pinned'));
-    });
-
-    test('header is pinned when scroll down the page with header set', () => {
-      element._headerHeight = 20;
-      sandbox.stub(element, '_getScrollY', () => 25);
-      window.scrollY = 100;
-      element._handleBodyScroll();
-      assert.isTrue(element.$.nav.classList.contains('pinned'));
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    flushAsynchronousOperations();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('header is not pinned just below top', () => {
+    sandbox.stub(element, '_getOffsetParent', () => 0);
+    sandbox.stub(element, '_getOffsetTop', () => 10);
+    sandbox.stub(element, '_getScrollY', () => 5);
+    element._handleBodyScroll();
+    assert.isFalse(element.$.nav.classList.contains('pinned'));
+  });
+
+  test('header is pinned when scroll down the page', () => {
+    sandbox.stub(element, '_getOffsetParent', () => 0);
+    sandbox.stub(element, '_getOffsetTop', () => 10);
+    sandbox.stub(element, '_getScrollY', () => 25);
+    window.scrollY = 100;
+    element._handleBodyScroll();
+    assert.isTrue(element.$.nav.classList.contains('pinned'));
+  });
+
+  test('header is not pinned just below top with header set', () => {
+    element._headerHeight = 20;
+    sandbox.stub(element, '_getScrollY', () => 15);
+    window.scrollY = 100;
+    element._handleBodyScroll();
+    assert.isFalse(element.$.nav.classList.contains('pinned'));
+  });
+
+  test('header is pinned when scroll down the page with header set', () => {
+    element._headerHeight = 20;
+    sandbox.stub(element, '_getScrollY', () => 25);
+    window.scrollY = 100;
+    element._handleBodyScroll();
+    assert.isTrue(element.$.nav.classList.contains('pinned'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
index 18e2596..15d5f76 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
@@ -14,110 +14,122 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const SUGGESTIONS_LIMIT = 15;
-  const REF_PREFIX = 'refs/heads/';
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../styles/shared-styles.js';
+import '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import '../gr-icons/gr-icons.js';
+import '../gr-labeled-autocomplete/gr-labeled-autocomplete.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-branch-picker_html.js';
 
-  /**
-   * @appliesMixin Gerrit.URLEncodingMixin
-   * @extends Polymer.Element
-   */
-  class GrRepoBranchPicker extends Polymer.mixinBehaviors( [
-    Gerrit.URLEncodingBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-repo-branch-picker'; }
+const SUGGESTIONS_LIMIT = 15;
+const REF_PREFIX = 'refs/heads/';
 
-    static get properties() {
-      return {
-        repo: {
-          type: String,
-          notify: true,
-          observer: '_repoChanged',
+/**
+ * @appliesMixin Gerrit.URLEncodingMixin
+ * @extends Polymer.Element
+ */
+class GrRepoBranchPicker extends mixinBehaviors( [
+  Gerrit.URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo-branch-picker'; }
+
+  static get properties() {
+    return {
+      repo: {
+        type: String,
+        notify: true,
+        observer: '_repoChanged',
+      },
+      branch: {
+        type: String,
+        notify: true,
+      },
+      _branchDisabled: Boolean,
+      _query: {
+        type: Function,
+        value() {
+          return this._getRepoBranchesSuggestions.bind(this);
         },
-        branch: {
-          type: String,
-          notify: true,
+      },
+      _repoQuery: {
+        type: Function,
+        value() {
+          return this._getRepoSuggestions.bind(this);
         },
-        _branchDisabled: Boolean,
-        _query: {
-          type: Function,
-          value() {
-            return this._getRepoBranchesSuggestions.bind(this);
-          },
-        },
-        _repoQuery: {
-          type: Function,
-          value() {
-            return this._getRepoSuggestions.bind(this);
-          },
-        },
-      };
-    }
+      },
+    };
+  }
 
-    /** @override */
-    attached() {
-      super.attached();
-      if (this.repo) {
-        this.$.repoInput.setText(this.repo);
-      }
-    }
-
-    /** @override */
-    ready() {
-      super.ready();
-      this._branchDisabled = !this.repo;
-    }
-
-    _getRepoBranchesSuggestions(input) {
-      if (!this.repo) { return Promise.resolve([]); }
-      if (input.startsWith(REF_PREFIX)) {
-        input = input.substring(REF_PREFIX.length);
-      }
-      return this.$.restAPI.getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
-          .then(this._branchResponseToSuggestions.bind(this));
-    }
-
-    _getRepoSuggestions(input) {
-      return this.$.restAPI.getRepos(input, SUGGESTIONS_LIMIT)
-          .then(this._repoResponseToSuggestions.bind(this));
-    }
-
-    _repoResponseToSuggestions(res) {
-      return res.map(repo => {
-        return {
-          name: repo.name,
-          value: this.singleDecodeURL(repo.id),
-        };
-      });
-    }
-
-    _branchResponseToSuggestions(res) {
-      return Object.keys(res).map(key => {
-        let branch = res[key].ref;
-        if (branch.startsWith(REF_PREFIX)) {
-          branch = branch.substring(REF_PREFIX.length);
-        }
-        return {name: branch, value: branch};
-      });
-    }
-
-    _repoCommitted(e) {
-      this.repo = e.detail.value;
-    }
-
-    _branchCommitted(e) {
-      this.branch = e.detail.value;
-    }
-
-    _repoChanged() {
-      this.$.branchInput.clear();
-      this._branchDisabled = !this.repo;
+  /** @override */
+  attached() {
+    super.attached();
+    if (this.repo) {
+      this.$.repoInput.setText(this.repo);
     }
   }
 
-  customElements.define(GrRepoBranchPicker.is, GrRepoBranchPicker);
-})();
+  /** @override */
+  ready() {
+    super.ready();
+    this._branchDisabled = !this.repo;
+  }
+
+  _getRepoBranchesSuggestions(input) {
+    if (!this.repo) { return Promise.resolve([]); }
+    if (input.startsWith(REF_PREFIX)) {
+      input = input.substring(REF_PREFIX.length);
+    }
+    return this.$.restAPI.getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
+        .then(this._branchResponseToSuggestions.bind(this));
+  }
+
+  _getRepoSuggestions(input) {
+    return this.$.restAPI.getRepos(input, SUGGESTIONS_LIMIT)
+        .then(this._repoResponseToSuggestions.bind(this));
+  }
+
+  _repoResponseToSuggestions(res) {
+    return res.map(repo => {
+      return {
+        name: repo.name,
+        value: this.singleDecodeURL(repo.id),
+      };
+    });
+  }
+
+  _branchResponseToSuggestions(res) {
+    return Object.keys(res).map(key => {
+      let branch = res[key].ref;
+      if (branch.startsWith(REF_PREFIX)) {
+        branch = branch.substring(REF_PREFIX.length);
+      }
+      return {name: branch, value: branch};
+    });
+  }
+
+  _repoCommitted(e) {
+    this.repo = e.detail.value;
+  }
+
+  _branchCommitted(e) {
+    this.branch = e.detail.value;
+  }
+
+  _repoChanged() {
+    this.$.branchInput.clear();
+    this._branchDisabled = !this.repo;
+  }
+}
+
+customElements.define(GrRepoBranchPicker.is, GrRepoBranchPicker);
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.js
index ce596f8..fe6b522 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-repo-branch-picker">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: block;
@@ -37,24 +30,11 @@
       }
     </style>
     <div>
-      <gr-labeled-autocomplete
-          id="repoInput"
-          label="Repository"
-          placeholder="Select repo"
-          on-commit="_repoCommitted"
-          query="[[_repoQuery]]">
+      <gr-labeled-autocomplete id="repoInput" label="Repository" placeholder="Select repo" on-commit="_repoCommitted" query="[[_repoQuery]]">
       </gr-labeled-autocomplete>
       <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-      <gr-labeled-autocomplete
-          id="branchInput"
-          label="Branch"
-          placeholder="Select branch"
-          disabled="[[_branchDisabled]]"
-          on-commit="_branchCommitted"
-          query="[[_query]]">
+      <gr-labeled-autocomplete id="branchInput" label="Branch" placeholder="Select branch" disabled="[[_branchDisabled]]" on-commit="_branchCommitted" query="[[_query]]">
       </gr-labeled-autocomplete>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-branch-picker.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
index b068b25..191b5d5 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
@@ -18,15 +18,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-branch-picker</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-branch-picker.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-repo-branch-picker.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-branch-picker.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -34,113 +39,115 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-branch-picker tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-repo-branch-picker.js';
+suite('gr-repo-branch-picker tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  suite('_getRepoSuggestions', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+      sandbox.stub(element.$.restAPI, 'getRepos')
+          .returns(Promise.resolve([
+            {
+              id: 'plugins%2Favatars-external',
+              name: 'plugins/avatars-external',
+            }, {
+              id: 'plugins%2Favatars-gravatar',
+              name: 'plugins/avatars-gravatar',
+            }, {
+              id: 'plugins%2Favatars%2Fexternal',
+              name: 'plugins/avatars/external',
+            }, {
+              id: 'plugins%2Favatars%2Fgravatar',
+              name: 'plugins/avatars/gravatar',
+            },
+          ]));
     });
 
-    teardown(() => { sandbox.restore(); });
-
-    suite('_getRepoSuggestions', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getRepos')
-            .returns(Promise.resolve([
-              {
-                id: 'plugins%2Favatars-external',
-                name: 'plugins/avatars-external',
-              }, {
-                id: 'plugins%2Favatars-gravatar',
-                name: 'plugins/avatars-gravatar',
-              }, {
-                id: 'plugins%2Favatars%2Fexternal',
-                name: 'plugins/avatars/external',
-              }, {
-                id: 'plugins%2Favatars%2Fgravatar',
-                name: 'plugins/avatars/gravatar',
-              },
-            ]));
-      });
-
-      test('converts to suggestion objects', () => {
-        const input = 'plugins/avatars';
-        return element._getRepoSuggestions(input).then(suggestions => {
-          assert.isTrue(element.$.restAPI.getRepos.calledWith(input));
-          const unencodedNames = [
-            'plugins/avatars-external',
-            'plugins/avatars-gravatar',
-            'plugins/avatars/external',
-            'plugins/avatars/gravatar',
-          ];
-          assert.deepEqual(suggestions.map(s => s.name), unencodedNames);
-          assert.deepEqual(suggestions.map(s => s.value), unencodedNames);
-        });
-      });
-    });
-
-    suite('_getRepoBranchesSuggestions', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getRepoBranches')
-            .returns(Promise.resolve([
-              {ref: 'refs/heads/stable-2.10'},
-              {ref: 'refs/heads/stable-2.11'},
-              {ref: 'refs/heads/stable-2.12'},
-              {ref: 'refs/heads/stable-2.13'},
-              {ref: 'refs/heads/stable-2.14'},
-              {ref: 'refs/heads/stable-2.15'},
-            ]));
-      });
-
-      test('converts to suggestion objects', () => {
-        const repo = 'gerrit';
-        const branchInput = 'stable-2.1';
-        element.repo = repo;
-        return element._getRepoBranchesSuggestions(branchInput)
-            .then(suggestions => {
-              assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
-                  branchInput, repo, 15));
-              const refNames = [
-                'stable-2.10',
-                'stable-2.11',
-                'stable-2.12',
-                'stable-2.13',
-                'stable-2.14',
-                'stable-2.15',
-              ];
-              assert.deepEqual(suggestions.map(s => s.name), refNames);
-              assert.deepEqual(suggestions.map(s => s.value), refNames);
-            });
-      });
-
-      test('filters out ref prefix', () => {
-        const repo = 'gerrit';
-        const branchInput = 'refs/heads/stable-2.1';
-        element.repo = repo;
-        return element._getRepoBranchesSuggestions(branchInput)
-            .then(suggestions => {
-              assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
-                  'stable-2.1', repo, 15));
-            });
-      });
-
-      test('does not query when repo is unset', done => {
-        element
-            ._getRepoBranchesSuggestions('')
-            .then(() => {
-              assert.isFalse(element.$.restAPI.getRepoBranches.called);
-              element.repo = 'gerrit';
-              return element._getRepoBranchesSuggestions('');
-            })
-            .then(() => {
-              assert.isTrue(element.$.restAPI.getRepoBranches.called);
-              done();
-            });
+    test('converts to suggestion objects', () => {
+      const input = 'plugins/avatars';
+      return element._getRepoSuggestions(input).then(suggestions => {
+        assert.isTrue(element.$.restAPI.getRepos.calledWith(input));
+        const unencodedNames = [
+          'plugins/avatars-external',
+          'plugins/avatars-gravatar',
+          'plugins/avatars/external',
+          'plugins/avatars/gravatar',
+        ];
+        assert.deepEqual(suggestions.map(s => s.name), unencodedNames);
+        assert.deepEqual(suggestions.map(s => s.value), unencodedNames);
       });
     });
   });
+
+  suite('_getRepoBranchesSuggestions', () => {
+    setup(() => {
+      sandbox.stub(element.$.restAPI, 'getRepoBranches')
+          .returns(Promise.resolve([
+            {ref: 'refs/heads/stable-2.10'},
+            {ref: 'refs/heads/stable-2.11'},
+            {ref: 'refs/heads/stable-2.12'},
+            {ref: 'refs/heads/stable-2.13'},
+            {ref: 'refs/heads/stable-2.14'},
+            {ref: 'refs/heads/stable-2.15'},
+          ]));
+    });
+
+    test('converts to suggestion objects', () => {
+      const repo = 'gerrit';
+      const branchInput = 'stable-2.1';
+      element.repo = repo;
+      return element._getRepoBranchesSuggestions(branchInput)
+          .then(suggestions => {
+            assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
+                branchInput, repo, 15));
+            const refNames = [
+              'stable-2.10',
+              'stable-2.11',
+              'stable-2.12',
+              'stable-2.13',
+              'stable-2.14',
+              'stable-2.15',
+            ];
+            assert.deepEqual(suggestions.map(s => s.name), refNames);
+            assert.deepEqual(suggestions.map(s => s.value), refNames);
+          });
+    });
+
+    test('filters out ref prefix', () => {
+      const repo = 'gerrit';
+      const branchInput = 'refs/heads/stable-2.1';
+      element.repo = repo;
+      return element._getRepoBranchesSuggestions(branchInput)
+          .then(suggestions => {
+            assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
+                'stable-2.1', repo, 15));
+          });
+    });
+
+    test('does not query when repo is unset', done => {
+      element
+          ._getRepoBranchesSuggestions('')
+          .then(() => {
+            assert.isFalse(element.$.restAPI.getRepoBranches.called);
+            element.repo = 'gerrit';
+            return element._getRepoBranchesSuggestions('');
+          })
+          .then(() => {
+            assert.isTrue(element.$.restAPI.getRepoBranches.called);
+            done();
+          });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
index 091c88e..01baa43 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
@@ -19,377 +19,380 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-auth</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../behaviors/base-url-behavior/base-url-behavior.js"></script>
 
-<script src="gr-auth.js"></script>
+<script type="module" src="./gr-auth.js"></script>
 
-<script>
-  suite('gr-auth', async () => {
-    await readyToTest();
-    let auth;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import './gr-auth.js';
+suite('gr-auth', () => {
+  let auth;
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    auth = Gerrit.Auth;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('Auth class methods', () => {
+    let fakeFetch;
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      auth = Gerrit.Auth;
+      auth = new Auth();
+      fakeFetch = sandbox.stub(window, 'fetch');
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('Auth class methods', () => {
-      let fakeFetch;
-      setup(() => {
-        auth = new Auth();
-        fakeFetch = sandbox.stub(window, 'fetch');
+    test('auth-check returns 403', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        done();
       });
+    });
 
-      test('auth-check returns 403', done => {
-        fakeFetch.returns(Promise.resolve({status: 403}));
-        auth.authCheck().then(authed => {
+    test('auth-check returns 204', done => {
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      auth.authCheck().then(authed => {
+        assert.isTrue(authed);
+        assert.equal(auth.status, Auth.STATUS.AUTHED);
+        done();
+      });
+    });
+
+    test('auth-check returns 502', done => {
+      fakeFetch.returns(Promise.resolve({status: 502}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        done();
+      });
+    });
+
+    test('auth-check failed', done => {
+      fakeFetch.returns(Promise.reject(new Error('random error')));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.ERROR);
+        done();
+      });
+    });
+  });
+
+  suite('cache and events behaivor', () => {
+    let fakeFetch;
+    let clock;
+    setup(() => {
+      auth = new Auth();
+      clock = sinon.useFakeTimers();
+      fakeFetch = sandbox.stub(window, 'fetch');
+    });
+
+    test('cache auth-check result', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        auth.authCheck().then(authed2 => {
           assert.isFalse(authed);
           assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
           done();
         });
       });
+    });
 
-      test('auth-check returns 204', done => {
+    test('clearCache should refetch auth-check result', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
         fakeFetch.returns(Promise.resolve({status: 204}));
-        auth.authCheck().then(authed => {
-          assert.isTrue(authed);
+        auth.clearCache();
+        auth.authCheck().then(authed2 => {
+          assert.isTrue(authed2);
           assert.equal(auth.status, Auth.STATUS.AUTHED);
           done();
         });
       });
+    });
 
-      test('auth-check returns 502', done => {
-        fakeFetch.returns(Promise.resolve({status: 502}));
-        auth.authCheck().then(authed => {
-          assert.isFalse(authed);
-          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+    test('cache expired on auth-check after certain time', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        auth.authCheck().then(authed2 => {
+          assert.isTrue(authed2);
+          assert.equal(auth.status, Auth.STATUS.AUTHED);
           done();
         });
       });
+    });
 
-      test('auth-check failed', done => {
+    test('no cache if auth-check failed', done => {
+      fakeFetch.returns(Promise.reject(new Error('random error')));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.ERROR);
+        assert.equal(fakeFetch.callCount, 1);
+        auth.authCheck().then(() => {
+          assert.equal(fakeFetch.callCount, 2);
+          done();
+        });
+      });
+    });
+
+    test('fire event when switch from authed to unauthed', done => {
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      auth.authCheck().then(authed => {
+        assert.isTrue(authed);
+        assert.equal(auth.status, Auth.STATUS.AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.resolve({status: 403}));
+        const emitStub = sinon.stub();
+        Gerrit.emit = emitStub;
+        auth.authCheck().then(authed2 => {
+          assert.isFalse(authed2);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          assert.isTrue(emitStub.called);
+          done();
+        });
+      });
+    });
+
+    test('fire event when switch from authed to error', done => {
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      auth.authCheck().then(authed => {
+        assert.isTrue(authed);
+        assert.equal(auth.status, Auth.STATUS.AUTHED);
+        clock.tick(1000 * 10000);
         fakeFetch.returns(Promise.reject(new Error('random error')));
-        auth.authCheck().then(authed => {
-          assert.isFalse(authed);
+        const emitStub = sinon.stub();
+        Gerrit.emit = emitStub;
+        auth.authCheck().then(authed2 => {
+          assert.isFalse(authed2);
+          assert.isTrue(emitStub.called);
           assert.equal(auth.status, Auth.STATUS.ERROR);
           done();
         });
       });
     });
 
-    suite('cache and events behaivor', () => {
-      let fakeFetch;
-      let clock;
-      setup(() => {
-        auth = new Auth();
-        clock = sinon.useFakeTimers();
-        fakeFetch = sandbox.stub(window, 'fetch');
-      });
-
-      test('cache auth-check result', done => {
-        fakeFetch.returns(Promise.resolve({status: 403}));
-        auth.authCheck().then(authed => {
-          assert.isFalse(authed);
-          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-          fakeFetch.returns(Promise.resolve({status: 204}));
-          auth.authCheck().then(authed2 => {
-            assert.isFalse(authed);
-            assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-            done();
-          });
+    test('no event from non-authed to other status', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        const emitStub = sinon.stub();
+        Gerrit.emit = emitStub;
+        auth.authCheck().then(authed2 => {
+          assert.isTrue(authed2);
+          assert.isFalse(emitStub.called);
+          assert.equal(auth.status, Auth.STATUS.AUTHED);
+          done();
         });
       });
+    });
 
-      test('clearCache should refetch auth-check result', done => {
-        fakeFetch.returns(Promise.resolve({status: 403}));
-        auth.authCheck().then(authed => {
-          assert.isFalse(authed);
-          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-          fakeFetch.returns(Promise.resolve({status: 204}));
-          auth.clearCache();
-          auth.authCheck().then(authed2 => {
-            assert.isTrue(authed2);
-            assert.equal(auth.status, Auth.STATUS.AUTHED);
-            done();
-          });
-        });
-      });
-
-      test('cache expired on auth-check after certain time', done => {
-        fakeFetch.returns(Promise.resolve({status: 403}));
-        auth.authCheck().then(authed => {
-          assert.isFalse(authed);
-          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-          clock.tick(1000 * 10000);
-          fakeFetch.returns(Promise.resolve({status: 204}));
-          auth.authCheck().then(authed2 => {
-            assert.isTrue(authed2);
-            assert.equal(auth.status, Auth.STATUS.AUTHED);
-            done();
-          });
-        });
-      });
-
-      test('no cache if auth-check failed', done => {
+    test('no event from non-authed to other status', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        clock.tick(1000 * 10000);
         fakeFetch.returns(Promise.reject(new Error('random error')));
-        auth.authCheck().then(authed => {
-          assert.isFalse(authed);
+        const emitStub = sinon.stub();
+        Gerrit.emit = emitStub;
+        auth.authCheck().then(authed2 => {
+          assert.isFalse(authed2);
+          assert.isFalse(emitStub.called);
           assert.equal(auth.status, Auth.STATUS.ERROR);
-          assert.equal(fakeFetch.callCount, 1);
-          auth.authCheck().then(() => {
-            assert.equal(fakeFetch.callCount, 2);
-            done();
-          });
-        });
-      });
-
-      test('fire event when switch from authed to unauthed', done => {
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        auth.authCheck().then(authed => {
-          assert.isTrue(authed);
-          assert.equal(auth.status, Auth.STATUS.AUTHED);
-          clock.tick(1000 * 10000);
-          fakeFetch.returns(Promise.resolve({status: 403}));
-          const emitStub = sinon.stub();
-          Gerrit.emit = emitStub;
-          auth.authCheck().then(authed2 => {
-            assert.isFalse(authed2);
-            assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-            assert.isTrue(emitStub.called);
-            done();
-          });
-        });
-      });
-
-      test('fire event when switch from authed to error', done => {
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        auth.authCheck().then(authed => {
-          assert.isTrue(authed);
-          assert.equal(auth.status, Auth.STATUS.AUTHED);
-          clock.tick(1000 * 10000);
-          fakeFetch.returns(Promise.reject(new Error('random error')));
-          const emitStub = sinon.stub();
-          Gerrit.emit = emitStub;
-          auth.authCheck().then(authed2 => {
-            assert.isFalse(authed2);
-            assert.isTrue(emitStub.called);
-            assert.equal(auth.status, Auth.STATUS.ERROR);
-            done();
-          });
-        });
-      });
-
-      test('no event from non-authed to other status', done => {
-        fakeFetch.returns(Promise.resolve({status: 403}));
-        auth.authCheck().then(authed => {
-          assert.isFalse(authed);
-          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-          clock.tick(1000 * 10000);
-          fakeFetch.returns(Promise.resolve({status: 204}));
-          const emitStub = sinon.stub();
-          Gerrit.emit = emitStub;
-          auth.authCheck().then(authed2 => {
-            assert.isTrue(authed2);
-            assert.isFalse(emitStub.called);
-            assert.equal(auth.status, Auth.STATUS.AUTHED);
-            done();
-          });
-        });
-      });
-
-      test('no event from non-authed to other status', done => {
-        fakeFetch.returns(Promise.resolve({status: 403}));
-        auth.authCheck().then(authed => {
-          assert.isFalse(authed);
-          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-          clock.tick(1000 * 10000);
-          fakeFetch.returns(Promise.reject(new Error('random error')));
-          const emitStub = sinon.stub();
-          Gerrit.emit = emitStub;
-          auth.authCheck().then(authed2 => {
-            assert.isFalse(authed2);
-            assert.isFalse(emitStub.called);
-            assert.equal(auth.status, Auth.STATUS.ERROR);
-            done();
-          });
-        });
-      });
-    });
-
-    suite('default (xsrf token header)', () => {
-      setup(() => {
-        sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
-      });
-
-      test('GET', done => {
-        auth.fetch('/url', {bar: 'bar'}).then(() => {
-          const [url, options] = fetch.lastCall.args;
-          assert.equal(url, '/url');
-          assert.equal(options.credentials, 'same-origin');
-          done();
-        });
-      });
-
-      test('POST', done => {
-        sandbox.stub(auth, '_getCookie')
-            .withArgs('XSRF_TOKEN')
-            .returns('foobar');
-        auth.fetch('/url', {method: 'POST'}).then(() => {
-          const [url, options] = fetch.lastCall.args;
-          assert.equal(url, '/url');
-          assert.equal(options.credentials, 'same-origin');
-          assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
-          done();
-        });
-      });
-    });
-
-    suite('cors (access token)', () => {
-      setup(() => {
-        sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
-      });
-
-      let getToken;
-
-      const makeToken = opt_accessToken => {
-        return {
-          access_token: opt_accessToken || 'zbaz',
-          expires_at: new Date(Date.now() + 10e8).getTime(),
-        };
-      };
-
-      setup(() => {
-        getToken = sandbox.stub();
-        getToken.returns(Promise.resolve(makeToken()));
-        auth.setup(getToken);
-      });
-
-      test('base url support', done => {
-        const baseUrl = 'http://foo';
-        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns(baseUrl);
-        auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
-          const [url] = fetch.lastCall.args;
-          assert.equal(url, 'http://foo/a/url?access_token=zbaz');
-          done();
-        });
-      });
-
-      test('fetch not signed in', done => {
-        getToken.returns(Promise.resolve());
-        auth.fetch('/url', {bar: 'bar'}).then(() => {
-          const [url, options] = fetch.lastCall.args;
-          assert.equal(url, '/url');
-          assert.equal(options.bar, 'bar');
-          assert.equal(Object.keys(options.headers).length, 0);
-          done();
-        });
-      });
-
-      test('fetch signed in', done => {
-        auth.fetch('/url', {bar: 'bar'}).then(() => {
-          const [url, options] = fetch.lastCall.args;
-          assert.equal(url, '/a/url?access_token=zbaz');
-          assert.equal(options.bar, 'bar');
-          done();
-        });
-      });
-
-      test('getToken calls are cached', done => {
-        Promise.all([
-          auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
-          assert.equal(getToken.callCount, 1);
-          done();
-        });
-      });
-
-      test('getToken refreshes token', done => {
-        sandbox.stub(auth, '_isTokenValid');
-        auth._isTokenValid
-            .onFirstCall().returns(true)
-            .onSecondCall()
-            .returns(false)
-            .onThirdCall()
-            .returns(true);
-        auth.fetch('/url-one')
-            .then(() => {
-              getToken.returns(Promise.resolve(makeToken('bzzbb')));
-              return auth.fetch('/url-two');
-            })
-            .then(() => {
-              const [[firstUrl], [secondUrl]] = fetch.args;
-              assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
-              assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
-              done();
-            });
-      });
-
-      test('signed in token error falls back to anonymous', done => {
-        getToken.returns(Promise.resolve('rubbish'));
-        auth.fetch('/url', {bar: 'bar'}).then(() => {
-          const [url, options] = fetch.lastCall.args;
-          assert.equal(url, '/url');
-          assert.equal(options.bar, 'bar');
-          done();
-        });
-      });
-
-      test('_isTokenValid', () => {
-        assert.isFalse(auth._isTokenValid());
-        assert.isFalse(auth._isTokenValid({}));
-        assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
-        assert.isFalse(auth._isTokenValid({
-          access_token: 'foo',
-          expires_at: Date.now()/1000 - 1,
-        }));
-        assert.isTrue(auth._isTokenValid({
-          access_token: 'foo',
-          expires_at: Date.now()/1000 + 1,
-        }));
-      });
-
-      test('HTTP PUT with content type', done => {
-        const originalOptions = {
-          method: 'PUT',
-          headers: new Headers({'Content-Type': 'mail/pigeon'}),
-        };
-        auth.fetch('/url', originalOptions).then(() => {
-          assert.isTrue(getToken.called);
-          const [url, options] = fetch.lastCall.args;
-          assert.include(url, '$ct=mail%2Fpigeon');
-          assert.include(url, '$m=PUT');
-          assert.include(url, 'access_token=zbaz');
-          assert.equal(options.method, 'POST');
-          assert.equal(options.headers.get('Content-Type'), 'text/plain');
-          done();
-        });
-      });
-
-      test('HTTP PUT without content type', done => {
-        const originalOptions = {
-          method: 'PUT',
-        };
-        auth.fetch('/url', originalOptions).then(() => {
-          assert.isTrue(getToken.called);
-          const [url, options] = fetch.lastCall.args;
-          assert.include(url, '$ct=text%2Fplain');
-          assert.include(url, '$m=PUT');
-          assert.include(url, 'access_token=zbaz');
-          assert.equal(options.method, 'POST');
-          assert.equal(options.headers.get('Content-Type'), 'text/plain');
           done();
         });
       });
     });
   });
+
+  suite('default (xsrf token header)', () => {
+    setup(() => {
+      sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
+    });
+
+    test('GET', done => {
+      auth.fetch('/url', {bar: 'bar'}).then(() => {
+        const [url, options] = fetch.lastCall.args;
+        assert.equal(url, '/url');
+        assert.equal(options.credentials, 'same-origin');
+        done();
+      });
+    });
+
+    test('POST', done => {
+      sandbox.stub(auth, '_getCookie')
+          .withArgs('XSRF_TOKEN')
+          .returns('foobar');
+      auth.fetch('/url', {method: 'POST'}).then(() => {
+        const [url, options] = fetch.lastCall.args;
+        assert.equal(url, '/url');
+        assert.equal(options.credentials, 'same-origin');
+        assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
+        done();
+      });
+    });
+  });
+
+  suite('cors (access token)', () => {
+    setup(() => {
+      sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
+    });
+
+    let getToken;
+
+    const makeToken = opt_accessToken => {
+      return {
+        access_token: opt_accessToken || 'zbaz',
+        expires_at: new Date(Date.now() + 10e8).getTime(),
+      };
+    };
+
+    setup(() => {
+      getToken = sandbox.stub();
+      getToken.returns(Promise.resolve(makeToken()));
+      auth.setup(getToken);
+    });
+
+    test('base url support', done => {
+      const baseUrl = 'http://foo';
+      sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns(baseUrl);
+      auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
+        const [url] = fetch.lastCall.args;
+        assert.equal(url, 'http://foo/a/url?access_token=zbaz');
+        done();
+      });
+    });
+
+    test('fetch not signed in', done => {
+      getToken.returns(Promise.resolve());
+      auth.fetch('/url', {bar: 'bar'}).then(() => {
+        const [url, options] = fetch.lastCall.args;
+        assert.equal(url, '/url');
+        assert.equal(options.bar, 'bar');
+        assert.equal(Object.keys(options.headers).length, 0);
+        done();
+      });
+    });
+
+    test('fetch signed in', done => {
+      auth.fetch('/url', {bar: 'bar'}).then(() => {
+        const [url, options] = fetch.lastCall.args;
+        assert.equal(url, '/a/url?access_token=zbaz');
+        assert.equal(options.bar, 'bar');
+        done();
+      });
+    });
+
+    test('getToken calls are cached', done => {
+      Promise.all([
+        auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
+        assert.equal(getToken.callCount, 1);
+        done();
+      });
+    });
+
+    test('getToken refreshes token', done => {
+      sandbox.stub(auth, '_isTokenValid');
+      auth._isTokenValid
+          .onFirstCall().returns(true)
+          .onSecondCall()
+          .returns(false)
+          .onThirdCall()
+          .returns(true);
+      auth.fetch('/url-one')
+          .then(() => {
+            getToken.returns(Promise.resolve(makeToken('bzzbb')));
+            return auth.fetch('/url-two');
+          })
+          .then(() => {
+            const [[firstUrl], [secondUrl]] = fetch.args;
+            assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
+            assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
+            done();
+          });
+    });
+
+    test('signed in token error falls back to anonymous', done => {
+      getToken.returns(Promise.resolve('rubbish'));
+      auth.fetch('/url', {bar: 'bar'}).then(() => {
+        const [url, options] = fetch.lastCall.args;
+        assert.equal(url, '/url');
+        assert.equal(options.bar, 'bar');
+        done();
+      });
+    });
+
+    test('_isTokenValid', () => {
+      assert.isFalse(auth._isTokenValid());
+      assert.isFalse(auth._isTokenValid({}));
+      assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
+      assert.isFalse(auth._isTokenValid({
+        access_token: 'foo',
+        expires_at: Date.now()/1000 - 1,
+      }));
+      assert.isTrue(auth._isTokenValid({
+        access_token: 'foo',
+        expires_at: Date.now()/1000 + 1,
+      }));
+    });
+
+    test('HTTP PUT with content type', done => {
+      const originalOptions = {
+        method: 'PUT',
+        headers: new Headers({'Content-Type': 'mail/pigeon'}),
+      };
+      auth.fetch('/url', originalOptions).then(() => {
+        assert.isTrue(getToken.called);
+        const [url, options] = fetch.lastCall.args;
+        assert.include(url, '$ct=mail%2Fpigeon');
+        assert.include(url, '$m=PUT');
+        assert.include(url, 'access_token=zbaz');
+        assert.equal(options.method, 'POST');
+        assert.equal(options.headers.get('Content-Type'), 'text/plain');
+        done();
+      });
+    });
+
+    test('HTTP PUT without content type', done => {
+      const originalOptions = {
+        method: 'PUT',
+      };
+      auth.fetch('/url', originalOptions).then(() => {
+        assert.isTrue(getToken.called);
+        const [url, options] = fetch.lastCall.args;
+        assert.include(url, '$ct=text%2Fplain');
+        assert.include(url, '$m=PUT');
+        assert.include(url, 'access_token=zbaz');
+        assert.equal(options.method, 'POST');
+        assert.equal(options.headers.get('Content-Type'), 'text/plain');
+        done();
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html
deleted file mode 100644
index d3500d8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html
+++ /dev/null
@@ -1,22 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-etag-decorator">
-  <script src="gr-etag-decorator.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
index 7022d23..33c8d8e 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
@@ -14,6 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../scripts/bundled-polymer.js';
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-etag-decorator">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
 (function(window) {
   'use strict';
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
index 21cbe89e..3eae300 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
@@ -19,83 +19,85 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-etag-decorator</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
 
-<link rel="import" href="./gr-etag-decorator.html">
+<script type="module" src="./gr-etag-decorator.js"></script>
 
-<script>
-  suite('gr-etag-decorator', async () => {
-    await readyToTest();
-    let etag;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-etag-decorator.js';
+suite('gr-etag-decorator', () => {
+  let etag;
+  let sandbox;
 
-    const fakeRequest = (opt_etag, opt_status) => {
-      const headers = new Headers();
-      if (opt_etag) {
-        headers.set('etag', opt_etag);
-      }
-      const status = opt_status || 200;
-      return {ok: true, status, headers};
-    };
+  const fakeRequest = (opt_etag, opt_status) => {
+    const headers = new Headers();
+    if (opt_etag) {
+      headers.set('etag', opt_etag);
+    }
+    const status = opt_status || 200;
+    return {ok: true, status, headers};
+  };
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      etag = new GrEtagDecorator();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('exists', () => {
-      assert.isOk(etag);
-    });
-
-    test('works', () => {
-      etag.collect('/foo', fakeRequest('bar'));
-      const options = etag.getOptions('/foo');
-      assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
-    });
-
-    test('updates etags', () => {
-      etag.collect('/foo', fakeRequest('bar'));
-      etag.collect('/foo', fakeRequest('baz'));
-      const options = etag.getOptions('/foo');
-      assert.strictEqual(options.headers.get('If-None-Match'), 'baz');
-    });
-
-    test('discards empty etags', () => {
-      etag.collect('/foo', fakeRequest('bar'));
-      etag.collect('/foo', fakeRequest());
-      const options = etag.getOptions('/foo', {headers: new Headers()});
-      assert.isNull(options.headers.get('If-None-Match'));
-    });
-
-    test('discards etags in order used', () => {
-      etag.collect('/foo', fakeRequest('bar'));
-      _.times(29, i => {
-        etag.collect('/qaz/' + i, fakeRequest('qaz'));
-      });
-      let options = etag.getOptions('/foo');
-      assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
-      etag.collect('/zaq', fakeRequest('zaq'));
-      options = etag.getOptions('/foo', {headers: new Headers()});
-      assert.isNull(options.headers.get('If-None-Match'));
-    });
-
-    test('getCachedPayload', () => {
-      const payload = 'payload';
-      etag.collect('/foo', fakeRequest('bar'), payload);
-      assert.strictEqual(etag.getCachedPayload('/foo'), payload);
-      etag.collect('/foo', fakeRequest('bar', 304), 'garbage');
-      assert.strictEqual(etag.getCachedPayload('/foo'), payload);
-      etag.collect('/foo', fakeRequest('bar', 200), 'new payload');
-      assert.strictEqual(etag.getCachedPayload('/foo'), 'new payload');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    etag = new GrEtagDecorator();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('exists', () => {
+    assert.isOk(etag);
+  });
+
+  test('works', () => {
+    etag.collect('/foo', fakeRequest('bar'));
+    const options = etag.getOptions('/foo');
+    assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
+  });
+
+  test('updates etags', () => {
+    etag.collect('/foo', fakeRequest('bar'));
+    etag.collect('/foo', fakeRequest('baz'));
+    const options = etag.getOptions('/foo');
+    assert.strictEqual(options.headers.get('If-None-Match'), 'baz');
+  });
+
+  test('discards empty etags', () => {
+    etag.collect('/foo', fakeRequest('bar'));
+    etag.collect('/foo', fakeRequest());
+    const options = etag.getOptions('/foo', {headers: new Headers()});
+    assert.isNull(options.headers.get('If-None-Match'));
+  });
+
+  test('discards etags in order used', () => {
+    etag.collect('/foo', fakeRequest('bar'));
+    _.times(29, i => {
+      etag.collect('/qaz/' + i, fakeRequest('qaz'));
+    });
+    let options = etag.getOptions('/foo');
+    assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
+    etag.collect('/zaq', fakeRequest('zaq'));
+    options = etag.getOptions('/foo', {headers: new Headers()});
+    assert.isNull(options.headers.get('If-None-Match'));
+  });
+
+  test('getCachedPayload', () => {
+    const payload = 'payload';
+    etag.collect('/foo', fakeRequest('bar'), payload);
+    assert.strictEqual(etag.getCachedPayload('/foo'), payload);
+    etag.collect('/foo', fakeRequest('bar', 304), 'garbage');
+    assert.strictEqual(etag.getCachedPayload('/foo'), payload);
+    etag.collect('/foo', fakeRequest('bar', 200), 'new payload');
+    assert.strictEqual(etag.getCachedPayload('/foo'), 'new payload');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
deleted file mode 100644
index 2047f91..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
+++ /dev/null
@@ -1,37 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="gr-etag-decorator.html">
-
-<!-- NB: es6-promise Needed for IE11 and fetch polyfill support, see Issue 4308 -->
-<script src="/bower_components/es6-promise/dist/es6-promise.min.js"></script>
-<script src="/bower_components/fetch/fetch.js"></script>
-
-<!-- NB: Order is important, because of namespaced classes. -->
-<script src="gr-rest-apis/gr-rest-api-helper.js"></script>
-<script src="gr-auth.js"></script>
-<script src="gr-reviewer-updates-parser.js"></script>
-
-<dom-module id="gr-rest-api-interface">
-  <script src="gr-rest-api-interface.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 5b88d34..3e78bd3 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -14,2756 +14,2777 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+/* NB: es6-promise Needed for IE11 and fetch polyfill support, see Issue 4308 */
+/* NB: Order is important, because of namespaced classes. */
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+import '../../../scripts/bundled-polymer.js';
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
-  const JSON_PREFIX = ')]}\'';
-  const MAX_PROJECT_RESULTS = 25;
-  // This value is somewhat arbitrary and not based on research or calculations.
-  const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
-  const PARENT_PATCH_NUM = 'PARENT';
+import '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import './gr-etag-decorator.js';
+import './gr-rest-apis/gr-rest-api-helper.js';
+import './gr-auth.js';
+import './gr-reviewer-updates-parser.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import 'es6-promise/lib/es6-promise.js';
+import 'whatwg-fetch/fetch.js';
 
-  const Requests = {
-    SEND_DIFF_DRAFT: 'sendDiffDraft',
-  };
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
+const JSON_PREFIX = ')]}\'';
+const MAX_PROJECT_RESULTS = 25;
+// This value is somewhat arbitrary and not based on research or calculations.
+const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
+const PARENT_PATCH_NUM = 'PARENT';
 
-  const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
-      'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
-  const HEADER_REPORTING_BLACKLIST = /^set-cookie$/i;
+const Requests = {
+  SEND_DIFF_DRAFT: 'sendDiffDraft',
+};
 
-  const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
-  const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
-      '/revisions/*';
+const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
+    'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
+const HEADER_REPORTING_BLACKLIST = /^set-cookie$/i;
+
+const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
+const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
+    '/revisions/*';
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.PathListMixin
+ * @appliesMixin Gerrit.PatchSetMixin
+ * @appliesMixin Gerrit.RESTClientMixin
+ * @extends Polymer.Element
+ */
+class GrRestApiInterface extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.PathListBehavior,
+  Gerrit.PatchSetBehavior,
+  Gerrit.RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get is() { return 'gr-rest-api-interface'; }
+  /**
+   * Fired when an server error occurs.
+   *
+   * @event server-error
+   */
 
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.PathListMixin
-   * @appliesMixin Gerrit.PatchSetMixin
-   * @appliesMixin Gerrit.RESTClientMixin
-   * @extends Polymer.Element
+   * Fired when a network error occurs.
+   *
+   * @event network-error
    */
-  class GrRestApiInterface extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.PathListBehavior,
-    Gerrit.PatchSetBehavior,
-    Gerrit.RESTClientBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-rest-api-interface'; }
-    /**
-     * Fired when an server error occurs.
-     *
-     * @event server-error
-     */
 
-    /**
-     * Fired when a network error occurs.
-     *
-     * @event network-error
-     */
+  /**
+   * Fired after an RPC completes.
+   *
+   * @event rpc-log
+   */
 
-    /**
-     * Fired after an RPC completes.
-     *
-     * @event rpc-log
-     */
+  constructor() {
+    super();
+    this.JSON_PREFIX = JSON_PREFIX;
+  }
 
-    constructor() {
-      super();
-      this.JSON_PREFIX = JSON_PREFIX;
+  static get properties() {
+    return {
+      _cache: {
+        type: Object,
+        value: new SiteBasedCache(), // Shared across instances.
+      },
+      _sharedFetchPromises: {
+        type: Object,
+        value: new FetchPromisesCache(), // Shared across instances.
+      },
+      _pendingRequests: {
+        type: Object,
+        value: {}, // Intentional to share the object across instances.
+      },
+      _etags: {
+        type: Object,
+        value: new GrEtagDecorator(), // Share across instances.
+      },
+      /**
+       * Used to maintain a mapping of changeNums to project names.
+       */
+      _projectLookup: {
+        type: Object,
+        value: {}, // Intentional to share the object across instances.
+      },
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this._auth = Gerrit.Auth;
+    this._initRestApiHelper();
+  }
+
+  _initRestApiHelper() {
+    if (this._restApiHelper) {
+      return;
     }
-
-    static get properties() {
-      return {
-        _cache: {
-          type: Object,
-          value: new SiteBasedCache(), // Shared across instances.
-        },
-        _sharedFetchPromises: {
-          type: Object,
-          value: new FetchPromisesCache(), // Shared across instances.
-        },
-        _pendingRequests: {
-          type: Object,
-          value: {}, // Intentional to share the object across instances.
-        },
-        _etags: {
-          type: Object,
-          value: new GrEtagDecorator(), // Share across instances.
-        },
-        /**
-         * Used to maintain a mapping of changeNums to project names.
-         */
-        _projectLookup: {
-          type: Object,
-          value: {}, // Intentional to share the object across instances.
-        },
-      };
+    if (this._cache && this._auth && this._sharedFetchPromises) {
+      this._restApiHelper = new GrRestApiHelper(this._cache, this._auth,
+          this._sharedFetchPromises, this);
     }
+  }
 
-    /** @override */
-    created() {
-      super.created();
-      this._auth = Gerrit.Auth;
-      this._initRestApiHelper();
-    }
+  _fetchSharedCacheURL(req) {
+    // Cache is shared across instances
+    return this._restApiHelper.fetchCacheURL(req);
+  }
 
-    _initRestApiHelper() {
-      if (this._restApiHelper) {
-        return;
-      }
-      if (this._cache && this._auth && this._sharedFetchPromises) {
-        this._restApiHelper = new GrRestApiHelper(this._cache, this._auth,
-            this._sharedFetchPromises, this);
-      }
-    }
+  /**
+   * @param {!Object} response
+   * @return {?}
+   */
+  getResponseObject(response) {
+    return this._restApiHelper.getResponseObject(response);
+  }
 
-    _fetchSharedCacheURL(req) {
-      // Cache is shared across instances
-      return this._restApiHelper.fetchCacheURL(req);
-    }
-
-    /**
-     * @param {!Object} response
-     * @return {?}
-     */
-    getResponseObject(response) {
-      return this._restApiHelper.getResponseObject(response);
-    }
-
-    getConfig(noCache) {
-      if (!noCache) {
-        return this._fetchSharedCacheURL({
-          url: '/config/server/info',
-          reportUrlAsIs: true,
-        });
-      }
-
-      return this._restApiHelper.fetchJSON({
+  getConfig(noCache) {
+    if (!noCache) {
+      return this._fetchSharedCacheURL({
         url: '/config/server/info',
         reportUrlAsIs: true,
       });
     }
 
-    getRepo(repo, opt_errFn) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._fetchSharedCacheURL({
-        url: '/projects/' + encodeURIComponent(repo),
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*',
-      });
-    }
+    return this._restApiHelper.fetchJSON({
+      url: '/config/server/info',
+      reportUrlAsIs: true,
+    });
+  }
 
-    getProjectConfig(repo, opt_errFn) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._fetchSharedCacheURL({
-        url: '/projects/' + encodeURIComponent(repo) + '/config',
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/config',
-      });
-    }
+  getRepo(repo, opt_errFn) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: '/projects/' + encodeURIComponent(repo),
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*',
+    });
+  }
 
-    getRepoAccess(repo) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._fetchSharedCacheURL({
-        url: '/access/?project=' + encodeURIComponent(repo),
-        anonymizedUrl: '/access/?project=*',
-      });
-    }
+  getProjectConfig(repo, opt_errFn) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: '/projects/' + encodeURIComponent(repo) + '/config',
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/config',
+    });
+  }
 
-    getRepoDashboards(repo, opt_errFn) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._fetchSharedCacheURL({
-        url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/dashboards?inherited',
-      });
-    }
+  getRepoAccess(repo) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: '/access/?project=' + encodeURIComponent(repo),
+      anonymizedUrl: '/access/?project=*',
+    });
+  }
 
-    saveRepoConfig(repo, config, opt_errFn) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const url = `/projects/${encodeURIComponent(repo)}/config`;
-      this._cache.delete(url);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url,
-        body: config,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/config',
-      });
-    }
+  getRepoDashboards(repo, opt_errFn) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/dashboards?inherited',
+    });
+  }
 
-    runRepoGC(repo, opt_errFn) {
-      if (!repo) { return ''; }
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const encodeName = encodeURIComponent(repo);
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: `/projects/${encodeName}/gc`,
-        body: '',
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/gc',
-      });
-    }
+  saveRepoConfig(repo, config, opt_errFn) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const url = `/projects/${encodeURIComponent(repo)}/config`;
+    this._cache.delete(url);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url,
+      body: config,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/config',
+    });
+  }
 
-    /**
-     * @param {?Object} config
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    createRepo(config, opt_errFn) {
-      if (!config.name) { return ''; }
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const encodeName = encodeURIComponent(config.name);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/projects/${encodeName}`,
-        body: config,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*',
-      });
-    }
+  runRepoGC(repo, opt_errFn) {
+    if (!repo) { return ''; }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(repo);
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: `/projects/${encodeName}/gc`,
+      body: '',
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/gc',
+    });
+  }
 
-    /**
-     * @param {?Object} config
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    createGroup(config, opt_errFn) {
-      if (!config.name) { return ''; }
-      const encodeName = encodeURIComponent(config.name);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/groups/${encodeName}`,
-        body: config,
-        errFn: opt_errFn,
-        anonymizedUrl: '/groups/*',
-      });
-    }
+  /**
+   * @param {?Object} config
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  createRepo(config, opt_errFn) {
+    if (!config.name) { return ''; }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(config.name);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/projects/${encodeName}`,
+      body: config,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*',
+    });
+  }
 
-    getGroupConfig(group, opt_errFn) {
-      return this._restApiHelper.fetchJSON({
-        url: `/groups/${encodeURIComponent(group)}/detail`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/groups/*/detail',
-      });
-    }
+  /**
+   * @param {?Object} config
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  createGroup(config, opt_errFn) {
+    if (!config.name) { return ''; }
+    const encodeName = encodeURIComponent(config.name);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/groups/${encodeName}`,
+      body: config,
+      errFn: opt_errFn,
+      anonymizedUrl: '/groups/*',
+    });
+  }
 
-    /**
-     * @param {string} repo
-     * @param {string} ref
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    deleteRepoBranches(repo, ref, opt_errFn) {
-      if (!repo || !ref) { return ''; }
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const encodeName = encodeURIComponent(repo);
-      const encodeRef = encodeURIComponent(ref);
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: `/projects/${encodeName}/branches/${encodeRef}`,
-        body: '',
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/branches/*',
-      });
-    }
+  getGroupConfig(group, opt_errFn) {
+    return this._restApiHelper.fetchJSON({
+      url: `/groups/${encodeURIComponent(group)}/detail`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/groups/*/detail',
+    });
+  }
 
-    /**
-     * @param {string} repo
-     * @param {string} ref
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    deleteRepoTags(repo, ref, opt_errFn) {
-      if (!repo || !ref) { return ''; }
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const encodeName = encodeURIComponent(repo);
-      const encodeRef = encodeURIComponent(ref);
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: `/projects/${encodeName}/tags/${encodeRef}`,
-        body: '',
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/tags/*',
-      });
-    }
+  /**
+   * @param {string} repo
+   * @param {string} ref
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  deleteRepoBranches(repo, ref, opt_errFn) {
+    if (!repo || !ref) { return ''; }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(repo);
+    const encodeRef = encodeURIComponent(ref);
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: `/projects/${encodeName}/branches/${encodeRef}`,
+      body: '',
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/branches/*',
+    });
+  }
 
-    /**
-     * @param {string} name
-     * @param {string} branch
-     * @param {string} revision
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    createRepoBranch(name, branch, revision, opt_errFn) {
-      if (!name || !branch || !revision) { return ''; }
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const encodeName = encodeURIComponent(name);
-      const encodeBranch = encodeURIComponent(branch);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/projects/${encodeName}/branches/${encodeBranch}`,
-        body: revision,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/branches/*',
-      });
-    }
+  /**
+   * @param {string} repo
+   * @param {string} ref
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  deleteRepoTags(repo, ref, opt_errFn) {
+    if (!repo || !ref) { return ''; }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(repo);
+    const encodeRef = encodeURIComponent(ref);
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: `/projects/${encodeName}/tags/${encodeRef}`,
+      body: '',
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/tags/*',
+    });
+  }
 
-    /**
-     * @param {string} name
-     * @param {string} tag
-     * @param {string} revision
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    createRepoTag(name, tag, revision, opt_errFn) {
-      if (!name || !tag || !revision) { return ''; }
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const encodeName = encodeURIComponent(name);
-      const encodeTag = encodeURIComponent(tag);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/projects/${encodeName}/tags/${encodeTag}`,
-        body: revision,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/tags/*',
-      });
-    }
+  /**
+   * @param {string} name
+   * @param {string} branch
+   * @param {string} revision
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  createRepoBranch(name, branch, revision, opt_errFn) {
+    if (!name || !branch || !revision) { return ''; }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(name);
+    const encodeBranch = encodeURIComponent(branch);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/projects/${encodeName}/branches/${encodeBranch}`,
+      body: revision,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/branches/*',
+    });
+  }
 
-    /**
-     * @param {!string} groupName
-     * @returns {!Promise<boolean>}
-     */
-    getIsGroupOwner(groupName) {
-      const encodeName = encodeURIComponent(groupName);
-      const req = {
-        url: `/groups/?owned&g=${encodeName}`,
-        anonymizedUrl: '/groups/owned&g=*',
-      };
-      return this._fetchSharedCacheURL(req)
-          .then(configs => configs.hasOwnProperty(groupName));
-    }
+  /**
+   * @param {string} name
+   * @param {string} tag
+   * @param {string} revision
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  createRepoTag(name, tag, revision, opt_errFn) {
+    if (!name || !tag || !revision) { return ''; }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(name);
+    const encodeTag = encodeURIComponent(tag);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/projects/${encodeName}/tags/${encodeTag}`,
+      body: revision,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/tags/*',
+    });
+  }
 
-    getGroupMembers(groupName, opt_errFn) {
-      const encodeName = encodeURIComponent(groupName);
-      return this._restApiHelper.fetchJSON({
-        url: `/groups/${encodeName}/members/`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/groups/*/members',
-      });
-    }
+  /**
+   * @param {!string} groupName
+   * @returns {!Promise<boolean>}
+   */
+  getIsGroupOwner(groupName) {
+    const encodeName = encodeURIComponent(groupName);
+    const req = {
+      url: `/groups/?owned&g=${encodeName}`,
+      anonymizedUrl: '/groups/owned&g=*',
+    };
+    return this._fetchSharedCacheURL(req)
+        .then(configs => configs.hasOwnProperty(groupName));
+  }
 
-    getIncludedGroup(groupName) {
-      return this._restApiHelper.fetchJSON({
-        url: `/groups/${encodeURIComponent(groupName)}/groups/`,
-        anonymizedUrl: '/groups/*/groups',
-      });
-    }
+  getGroupMembers(groupName, opt_errFn) {
+    const encodeName = encodeURIComponent(groupName);
+    return this._restApiHelper.fetchJSON({
+      url: `/groups/${encodeName}/members/`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/groups/*/members',
+    });
+  }
 
-    saveGroupName(groupId, name) {
-      const encodeId = encodeURIComponent(groupId);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/groups/${encodeId}/name`,
-        body: {name},
-        anonymizedUrl: '/groups/*/name',
-      });
-    }
+  getIncludedGroup(groupName) {
+    return this._restApiHelper.fetchJSON({
+      url: `/groups/${encodeURIComponent(groupName)}/groups/`,
+      anonymizedUrl: '/groups/*/groups',
+    });
+  }
 
-    saveGroupOwner(groupId, ownerId) {
-      const encodeId = encodeURIComponent(groupId);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/groups/${encodeId}/owner`,
-        body: {owner: ownerId},
-        anonymizedUrl: '/groups/*/owner',
-      });
-    }
+  saveGroupName(groupId, name) {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/groups/${encodeId}/name`,
+      body: {name},
+      anonymizedUrl: '/groups/*/name',
+    });
+  }
 
-    saveGroupDescription(groupId, description) {
-      const encodeId = encodeURIComponent(groupId);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/groups/${encodeId}/description`,
-        body: {description},
-        anonymizedUrl: '/groups/*/description',
-      });
-    }
+  saveGroupOwner(groupId, ownerId) {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/groups/${encodeId}/owner`,
+      body: {owner: ownerId},
+      anonymizedUrl: '/groups/*/owner',
+    });
+  }
 
-    saveGroupOptions(groupId, options) {
-      const encodeId = encodeURIComponent(groupId);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/groups/${encodeId}/options`,
-        body: options,
-        anonymizedUrl: '/groups/*/options',
-      });
-    }
+  saveGroupDescription(groupId, description) {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/groups/${encodeId}/description`,
+      body: {description},
+      anonymizedUrl: '/groups/*/description',
+    });
+  }
 
-    getGroupAuditLog(group, opt_errFn) {
-      return this._fetchSharedCacheURL({
-        url: '/groups/' + group + '/log.audit',
-        errFn: opt_errFn,
-        anonymizedUrl: '/groups/*/log.audit',
-      });
-    }
+  saveGroupOptions(groupId, options) {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/groups/${encodeId}/options`,
+      body: options,
+      anonymizedUrl: '/groups/*/options',
+    });
+  }
 
-    saveGroupMembers(groupName, groupMembers) {
-      const encodeName = encodeURIComponent(groupName);
-      const encodeMember = encodeURIComponent(groupMembers);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/groups/${encodeName}/members/${encodeMember}`,
-        parseResponse: true,
-        anonymizedUrl: '/groups/*/members/*',
-      });
-    }
+  getGroupAuditLog(group, opt_errFn) {
+    return this._fetchSharedCacheURL({
+      url: '/groups/' + group + '/log.audit',
+      errFn: opt_errFn,
+      anonymizedUrl: '/groups/*/log.audit',
+    });
+  }
 
-    saveIncludedGroup(groupName, includedGroup, opt_errFn) {
-      const encodeName = encodeURIComponent(groupName);
-      const encodeIncludedGroup = encodeURIComponent(includedGroup);
-      const req = {
-        method: 'PUT',
-        url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/groups/*/groups/*',
-      };
-      return this._restApiHelper.send(req).then(response => {
-        if (response.ok) {
-          return this.getResponseObject(response);
-        }
-      });
-    }
+  saveGroupMembers(groupName, groupMembers) {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeMember = encodeURIComponent(groupMembers);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/groups/${encodeName}/members/${encodeMember}`,
+      parseResponse: true,
+      anonymizedUrl: '/groups/*/members/*',
+    });
+  }
 
-    deleteGroupMembers(groupName, groupMembers) {
-      const encodeName = encodeURIComponent(groupName);
-      const encodeMember = encodeURIComponent(groupMembers);
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: `/groups/${encodeName}/members/${encodeMember}`,
-        anonymizedUrl: '/groups/*/members/*',
-      });
-    }
-
-    deleteIncludedGroup(groupName, includedGroup) {
-      const encodeName = encodeURIComponent(groupName);
-      const encodeIncludedGroup = encodeURIComponent(includedGroup);
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
-        anonymizedUrl: '/groups/*/groups/*',
-      });
-    }
-
-    getVersion() {
-      return this._fetchSharedCacheURL({
-        url: '/config/server/version',
-        reportUrlAsIs: true,
-      });
-    }
-
-    getDiffPreferences() {
-      return this.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          return this._fetchSharedCacheURL({
-            url: '/accounts/self/preferences.diff',
-            reportUrlAsIs: true,
-          });
-        }
-        // These defaults should match the defaults in
-        // java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
-        // NOTE: There are some settings that don't apply to PolyGerrit
-        // (Render mode being at least one of them).
-        return Promise.resolve({
-          auto_hide_diff_table_header: true,
-          context: 10,
-          cursor_blink_rate: 0,
-          font_size: 12,
-          ignore_whitespace: 'IGNORE_NONE',
-          intraline_difference: true,
-          line_length: 100,
-          line_wrapping: false,
-          show_line_endings: true,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          tab_size: 8,
-          theme: 'DEFAULT',
-        });
-      });
-    }
-
-    getEditPreferences() {
-      return this.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          return this._fetchSharedCacheURL({
-            url: '/accounts/self/preferences.edit',
-            reportUrlAsIs: true,
-          });
-        }
-        // These defaults should match the defaults in
-        // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
-        return Promise.resolve({
-          auto_close_brackets: false,
-          cursor_blink_rate: 0,
-          hide_line_numbers: false,
-          hide_top_menu: false,
-          indent_unit: 2,
-          indent_with_tabs: false,
-          key_map_type: 'DEFAULT',
-          line_length: 100,
-          line_wrapping: false,
-          match_brackets: true,
-          show_base: false,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          tab_size: 8,
-          theme: 'DEFAULT',
-        });
-      });
-    }
-
-    /**
-     * @param {?Object} prefs
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    savePreferences(prefs, opt_errFn) {
-      // Note (Issue 5142): normalize the download scheme with lower case before
-      // saving.
-      if (prefs.download_scheme) {
-        prefs.download_scheme = prefs.download_scheme.toLowerCase();
+  saveIncludedGroup(groupName, includedGroup, opt_errFn) {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeIncludedGroup = encodeURIComponent(includedGroup);
+    const req = {
+      method: 'PUT',
+      url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/groups/*/groups/*',
+    };
+    return this._restApiHelper.send(req).then(response => {
+      if (response.ok) {
+        return this.getResponseObject(response);
       }
+    });
+  }
 
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: '/accounts/self/preferences',
-        body: prefs,
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
+  deleteGroupMembers(groupName, groupMembers) {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeMember = encodeURIComponent(groupMembers);
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: `/groups/${encodeName}/members/${encodeMember}`,
+      anonymizedUrl: '/groups/*/members/*',
+    });
+  }
+
+  deleteIncludedGroup(groupName, includedGroup) {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeIncludedGroup = encodeURIComponent(includedGroup);
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+      anonymizedUrl: '/groups/*/groups/*',
+    });
+  }
+
+  getVersion() {
+    return this._fetchSharedCacheURL({
+      url: '/config/server/version',
+      reportUrlAsIs: true,
+    });
+  }
+
+  getDiffPreferences() {
+    return this.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        return this._fetchSharedCacheURL({
+          url: '/accounts/self/preferences.diff',
+          reportUrlAsIs: true,
+        });
+      }
+      // These defaults should match the defaults in
+      // java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+      // NOTE: There are some settings that don't apply to PolyGerrit
+      // (Render mode being at least one of them).
+      return Promise.resolve({
+        auto_hide_diff_table_header: true,
+        context: 10,
+        cursor_blink_rate: 0,
+        font_size: 12,
+        ignore_whitespace: 'IGNORE_NONE',
+        intraline_difference: true,
+        line_length: 100,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        tab_size: 8,
+        theme: 'DEFAULT',
       });
+    });
+  }
+
+  getEditPreferences() {
+    return this.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        return this._fetchSharedCacheURL({
+          url: '/accounts/self/preferences.edit',
+          reportUrlAsIs: true,
+        });
+      }
+      // These defaults should match the defaults in
+      // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+      return Promise.resolve({
+        auto_close_brackets: false,
+        cursor_blink_rate: 0,
+        hide_line_numbers: false,
+        hide_top_menu: false,
+        indent_unit: 2,
+        indent_with_tabs: false,
+        key_map_type: 'DEFAULT',
+        line_length: 100,
+        line_wrapping: false,
+        match_brackets: true,
+        show_base: false,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        tab_size: 8,
+        theme: 'DEFAULT',
+      });
+    });
+  }
+
+  /**
+   * @param {?Object} prefs
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  savePreferences(prefs, opt_errFn) {
+    // Note (Issue 5142): normalize the download scheme with lower case before
+    // saving.
+    if (prefs.download_scheme) {
+      prefs.download_scheme = prefs.download_scheme.toLowerCase();
     }
 
-    /**
-     * @param {?Object} prefs
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    saveDiffPreferences(prefs, opt_errFn) {
-      // Invalidate the cache.
-      this._cache.delete('/accounts/self/preferences.diff');
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: '/accounts/self/preferences.diff',
-        body: prefs,
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
-      });
-    }
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: '/accounts/self/preferences',
+      body: prefs,
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
 
-    /**
-     * @param {?Object} prefs
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    saveEditPreferences(prefs, opt_errFn) {
-      // Invalidate the cache.
-      this._cache.delete('/accounts/self/preferences.edit');
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: '/accounts/self/preferences.edit',
-        body: prefs,
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
-      });
-    }
+  /**
+   * @param {?Object} prefs
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  saveDiffPreferences(prefs, opt_errFn) {
+    // Invalidate the cache.
+    this._cache.delete('/accounts/self/preferences.diff');
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: '/accounts/self/preferences.diff',
+      body: prefs,
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
 
-    getAccount() {
-      return this._fetchSharedCacheURL({
-        url: '/accounts/self/detail',
-        reportUrlAsIs: true,
-        errFn: resp => {
-          if (!resp || resp.status === 403) {
-            this._cache.delete('/accounts/self/detail');
-          }
-        },
-      });
-    }
+  /**
+   * @param {?Object} prefs
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  saveEditPreferences(prefs, opt_errFn) {
+    // Invalidate the cache.
+    this._cache.delete('/accounts/self/preferences.edit');
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: '/accounts/self/preferences.edit',
+      body: prefs,
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
 
-    getAvatarChangeUrl() {
-      return this._fetchSharedCacheURL({
-        url: '/accounts/self/avatar.change.url',
-        reportUrlAsIs: true,
-        errFn: resp => {
-          if (!resp || resp.status === 403) {
-            this._cache.delete('/accounts/self/avatar.change.url');
-          }
-        },
-      });
-    }
-
-    getExternalIds() {
-      return this._restApiHelper.fetchJSON({
-        url: '/accounts/self/external.ids',
-        reportUrlAsIs: true,
-      });
-    }
-
-    deleteAccountIdentity(id) {
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: '/accounts/self/external.ids:delete',
-        body: id,
-        parseResponse: true,
-        reportUrlAsIs: true,
-      });
-    }
-
-    /**
-     * @param {string} userId the ID of the user usch as an email address.
-     * @return {!Promise<!Object>}
-     */
-    getAccountDetails(userId) {
-      return this._restApiHelper.fetchJSON({
-        url: `/accounts/${encodeURIComponent(userId)}/detail`,
-        anonymizedUrl: '/accounts/*/detail',
-      });
-    }
-
-    getAccountEmails() {
-      return this._fetchSharedCacheURL({
-        url: '/accounts/self/emails',
-        reportUrlAsIs: true,
-      });
-    }
-
-    /**
-     * @param {string} email
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    addAccountEmail(email, opt_errFn) {
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: '/accounts/self/emails/' + encodeURIComponent(email),
-        errFn: opt_errFn,
-        anonymizedUrl: '/account/self/emails/*',
-      });
-    }
-
-    /**
-     * @param {string} email
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    deleteAccountEmail(email, opt_errFn) {
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: '/accounts/self/emails/' + encodeURIComponent(email),
-        errFn: opt_errFn,
-        anonymizedUrl: '/accounts/self/email/*',
-      });
-    }
-
-    /**
-     * @param {string} email
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    setPreferredAccountEmail(email, opt_errFn) {
-      const encodedEmail = encodeURIComponent(email);
-      const req = {
-        method: 'PUT',
-        url: `/accounts/self/emails/${encodedEmail}/preferred`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/accounts/self/emails/*/preferred',
-      };
-      return this._restApiHelper.send(req).then(() => {
-        // If result of getAccountEmails is in cache, update it in the cache
-        // so we don't have to invalidate it.
-        const cachedEmails = this._cache.get('/accounts/self/emails');
-        if (cachedEmails) {
-          const emails = cachedEmails.map(entry => {
-            if (entry.email === email) {
-              return {email, preferred: true};
-            } else {
-              return {email};
-            }
-          });
-          this._cache.set('/accounts/self/emails', emails);
+  getAccount() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/detail',
+      reportUrlAsIs: true,
+      errFn: resp => {
+        if (!resp || resp.status === 403) {
+          this._cache.delete('/accounts/self/detail');
         }
-      });
-    }
+      },
+    });
+  }
 
-    /**
-     * @param {?Object} obj
-     */
-    _updateCachedAccount(obj) {
-      // If result of getAccount is in cache, update it in the cache
+  getAvatarChangeUrl() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/avatar.change.url',
+      reportUrlAsIs: true,
+      errFn: resp => {
+        if (!resp || resp.status === 403) {
+          this._cache.delete('/accounts/self/avatar.change.url');
+        }
+      },
+    });
+  }
+
+  getExternalIds() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/external.ids',
+      reportUrlAsIs: true,
+    });
+  }
+
+  deleteAccountIdentity(id) {
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: '/accounts/self/external.ids:delete',
+      body: id,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string} userId the ID of the user usch as an email address.
+   * @return {!Promise<!Object>}
+   */
+  getAccountDetails(userId) {
+    return this._restApiHelper.fetchJSON({
+      url: `/accounts/${encodeURIComponent(userId)}/detail`,
+      anonymizedUrl: '/accounts/*/detail',
+    });
+  }
+
+  getAccountEmails() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/emails',
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string} email
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  addAccountEmail(email, opt_errFn) {
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: '/accounts/self/emails/' + encodeURIComponent(email),
+      errFn: opt_errFn,
+      anonymizedUrl: '/account/self/emails/*',
+    });
+  }
+
+  /**
+   * @param {string} email
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  deleteAccountEmail(email, opt_errFn) {
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: '/accounts/self/emails/' + encodeURIComponent(email),
+      errFn: opt_errFn,
+      anonymizedUrl: '/accounts/self/email/*',
+    });
+  }
+
+  /**
+   * @param {string} email
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  setPreferredAccountEmail(email, opt_errFn) {
+    const encodedEmail = encodeURIComponent(email);
+    const req = {
+      method: 'PUT',
+      url: `/accounts/self/emails/${encodedEmail}/preferred`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/accounts/self/emails/*/preferred',
+    };
+    return this._restApiHelper.send(req).then(() => {
+      // If result of getAccountEmails is in cache, update it in the cache
       // so we don't have to invalidate it.
-      const cachedAccount = this._cache.get('/accounts/self/detail');
-      if (cachedAccount) {
-        // Replace object in cache with new object to force UI updates.
-        this._cache.set('/accounts/self/detail',
-            Object.assign({}, cachedAccount, obj));
-      }
-    }
-
-    /**
-     * @param {string} name
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    setAccountName(name, opt_errFn) {
-      const req = {
-        method: 'PUT',
-        url: '/accounts/self/name',
-        body: {name},
-        errFn: opt_errFn,
-        parseResponse: true,
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.send(req)
-          .then(newName => this._updateCachedAccount({name: newName}));
-    }
-
-    /**
-     * @param {string} username
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    setAccountUsername(username, opt_errFn) {
-      const req = {
-        method: 'PUT',
-        url: '/accounts/self/username',
-        body: {username},
-        errFn: opt_errFn,
-        parseResponse: true,
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.send(req)
-          .then(newName => this._updateCachedAccount({username: newName}));
-    }
-
-    /**
-     * @param {string} status
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    setAccountStatus(status, opt_errFn) {
-      const req = {
-        method: 'PUT',
-        url: '/accounts/self/status',
-        body: {status},
-        errFn: opt_errFn,
-        parseResponse: true,
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.send(req)
-          .then(newStatus => this._updateCachedAccount({status: newStatus}));
-    }
-
-    getAccountStatus(userId) {
-      return this._restApiHelper.fetchJSON({
-        url: `/accounts/${encodeURIComponent(userId)}/status`,
-        anonymizedUrl: '/accounts/*/status',
-      });
-    }
-
-    getAccountGroups() {
-      return this._restApiHelper.fetchJSON({
-        url: '/accounts/self/groups',
-        reportUrlAsIs: true,
-      });
-    }
-
-    getAccountAgreements() {
-      return this._restApiHelper.fetchJSON({
-        url: '/accounts/self/agreements',
-        reportUrlAsIs: true,
-      });
-    }
-
-    saveAccountAgreement(name) {
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: '/accounts/self/agreements',
-        body: name,
-        reportUrlAsIs: true,
-      });
-    }
-
-    /**
-     * @param {string=} opt_params
-     */
-    getAccountCapabilities(opt_params) {
-      let queryString = '';
-      if (opt_params) {
-        queryString = '?q=' + opt_params
-            .map(param => encodeURIComponent(param))
-            .join('&q=');
-      }
-      return this._fetchSharedCacheURL({
-        url: '/accounts/self/capabilities' + queryString,
-        anonymizedUrl: '/accounts/self/capabilities?q=*',
-      });
-    }
-
-    getLoggedIn() {
-      return this._auth.authCheck();
-    }
-
-    getIsAdmin() {
-      return this.getLoggedIn()
-          .then(isLoggedIn => {
-            if (isLoggedIn) {
-              return this.getAccountCapabilities();
-            } else {
-              return Promise.resolve();
-            }
-          })
-          .then(
-              capabilities => capabilities && capabilities.administrateServer
-          );
-    }
-
-    getDefaultPreferences() {
-      return this._fetchSharedCacheURL({
-        url: '/config/server/preferences',
-        reportUrlAsIs: true,
-      });
-    }
-
-    getPreferences() {
-      return this.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
-          return this._fetchSharedCacheURL(req).then(res => {
-            if (this._isNarrowScreen()) {
-              // Note that this can be problematic, because the diff will stay
-              // unified even after increasing the window width.
-              res.default_diff_view = DiffViewMode.UNIFIED;
-            } else {
-              res.default_diff_view = res.diff_view;
-            }
-            return Promise.resolve(res);
-          });
-        }
-
-        return Promise.resolve({
-          changes_per_page: 25,
-          default_diff_view: this._isNarrowScreen() ?
-            DiffViewMode.UNIFIED : DiffViewMode.SIDE_BY_SIDE,
-          diff_view: 'SIDE_BY_SIDE',
-          size_bar_in_change_table: true,
-        });
-      });
-    }
-
-    getWatchedProjects() {
-      return this._fetchSharedCacheURL({
-        url: '/accounts/self/watched.projects',
-        reportUrlAsIs: true,
-      });
-    }
-
-    /**
-     * @param {string} projects
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    saveWatchedProjects(projects, opt_errFn) {
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: '/accounts/self/watched.projects',
-        body: projects,
-        errFn: opt_errFn,
-        parseResponse: true,
-        reportUrlAsIs: true,
-      });
-    }
-
-    /**
-     * @param {string} projects
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    deleteWatchedProjects(projects, opt_errFn) {
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: '/accounts/self/watched.projects:delete',
-        body: projects,
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
-      });
-    }
-
-    _isNarrowScreen() {
-      return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
-    }
-
-    /**
-     * @param {number=} opt_changesPerPage
-     * @param {string|!Array<string>=} opt_query A query or an array of queries.
-     * @param {number|string=} opt_offset
-     * @param {!Object=} opt_options
-     * @return {?Array<!Object>|?Array<!Array<!Object>>} If opt_query is an
-     *     array, _fetchJSON will return an array of arrays of changeInfos. If it
-     *     is unspecified or a string, _fetchJSON will return an array of
-     *     changeInfos.
-     */
-    getChanges(opt_changesPerPage, opt_query, opt_offset, opt_options) {
-      const options = opt_options || this.listChangesOptionsToHex(
-          this.ListChangesOption.LABELS,
-          this.ListChangesOption.DETAILED_ACCOUNTS
-      );
-      // Issue 4524: respect legacy token with max sortkey.
-      if (opt_offset === 'n,z') {
-        opt_offset = 0;
-      }
-      const params = {
-        O: options,
-        S: opt_offset || 0,
-      };
-      if (opt_changesPerPage) { params.n = opt_changesPerPage; }
-      if (opt_query && opt_query.length > 0) {
-        params.q = opt_query;
-      }
-      const iterateOverChanges = arr => {
-        for (const change of (arr || [])) {
-          this._maybeInsertInLookup(change);
-        }
-      };
-      const req = {
-        url: '/changes/',
-        params,
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.fetchJSON(req).then(response => {
-        // Response may be an array of changes OR an array of arrays of
-        // changes.
-        if (opt_query instanceof Array) {
-          // Normalize the response to look like a multi-query response
-          // when there is only one query.
-          if (opt_query.length === 1) {
-            response = [response];
-          }
-          for (const arr of response) {
-            iterateOverChanges(arr);
-          }
-        } else {
-          iterateOverChanges(response);
-        }
-        return response;
-      });
-    }
-
-    /**
-     * Inserts a change into _projectLookup iff it has a valid structure.
-     *
-     * @param {?{ _number: (number|string) }} change
-     */
-    _maybeInsertInLookup(change) {
-      if (change && change.project && change._number) {
-        this.setInProjectLookup(change._number, change.project);
-      }
-    }
-
-    /**
-     * TODO (beckysiegel) this needs to be rewritten with the optional param
-     * at the end.
-     *
-     * @param {number|string} changeNum
-     * @param {?number|string=} opt_patchNum passed as null sometimes.
-     * @param {?=} endpoint
-     * @return {!Promise<string>}
-     */
-    getChangeActionURL(changeNum, opt_patchNum, endpoint) {
-      return this._changeBaseURL(changeNum, opt_patchNum)
-          .then(url => url + endpoint);
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {function(?Response, string=)=} opt_errFn
-     * @param {function()=} opt_cancelCondition
-     */
-    getChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
-      return this.getConfig(false).then(config => {
-        const optionsHex = this._getChangeOptionsHex(config);
-        return this._getChangeDetail(
-            changeNum, optionsHex, opt_errFn, opt_cancelCondition)
-            .then(GrReviewerUpdatesParser.parse);
-      });
-    }
-
-    _getChangeOptionsHex(config) {
-      if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.changePage
-          && !(config.receive && config.receive.enable_signed_push)) {
-        return window.DEFAULT_DETAIL_HEXES.changePage;
-      }
-
-      // This list MUST be kept in sync with
-      // ChangeIT#changeDetailsDoesNotRequireIndex
-      const options = [
-        this.ListChangesOption.ALL_COMMITS,
-        this.ListChangesOption.ALL_REVISIONS,
-        this.ListChangesOption.CHANGE_ACTIONS,
-        this.ListChangesOption.DETAILED_LABELS,
-        this.ListChangesOption.DOWNLOAD_COMMANDS,
-        this.ListChangesOption.MESSAGES,
-        this.ListChangesOption.SUBMITTABLE,
-        this.ListChangesOption.WEB_LINKS,
-        this.ListChangesOption.SKIP_DIFFSTAT,
-      ];
-      if (config.receive && config.receive.enable_signed_push) {
-        options.push(this.ListChangesOption.PUSH_CERTIFICATES);
-      }
-      return this.listChangesOptionsToHex(...options);
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {function(?Response, string=)=} opt_errFn
-     * @param {function()=} opt_cancelCondition
-     */
-    getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
-      let optionsHex = '';
-      if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.diffPage) {
-        optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
-      } else {
-        optionsHex = this.listChangesOptionsToHex(
-            this.ListChangesOption.ALL_COMMITS,
-            this.ListChangesOption.ALL_REVISIONS,
-            this.ListChangesOption.SKIP_DIFFSTAT
-        );
-      }
-      return this._getChangeDetail(changeNum, optionsHex, opt_errFn,
-          opt_cancelCondition);
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {string|undefined} optionsHex list changes options in hex
-     * @param {function(?Response, string=)=} opt_errFn
-     * @param {function()=} opt_cancelCondition
-     */
-    _getChangeDetail(changeNum, optionsHex, opt_errFn, opt_cancelCondition) {
-      return this.getChangeActionURL(changeNum, null, '/detail').then(url => {
-        const urlWithParams = this._restApiHelper
-            .urlWithParams(url, optionsHex);
-        const params = {O: optionsHex};
-        const req = {
-          url,
-          errFn: opt_errFn,
-          cancelCondition: opt_cancelCondition,
-          params,
-          fetchOptions: this._etags.getOptions(urlWithParams),
-          anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
-        };
-        return this._restApiHelper.fetchRawJSON(req).then(response => {
-          if (response && response.status === 304) {
-            return Promise.resolve(this._restApiHelper.parsePrefixedJSON(
-                this._etags.getCachedPayload(urlWithParams)));
-          }
-
-          if (response && !response.ok) {
-            if (opt_errFn) {
-              opt_errFn.call(null, response);
-            } else {
-              this.fire('server-error', {request: req, response});
-            }
-            return;
-          }
-
-          const payloadPromise = response ?
-            this._restApiHelper.readResponsePayload(response) :
-            Promise.resolve(null);
-
-          return payloadPromise.then(payload => {
-            if (!payload) { return null; }
-            this._etags.collect(urlWithParams, response, payload.raw);
-            this._maybeInsertInLookup(payload.parsed);
-
-            return payload.parsed;
-          });
-        });
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string} patchNum
-     */
-    getChangeCommitInfo(changeNum, patchNum) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/commit?links',
-        patchNum,
-        reportEndpointAsIs: true,
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {Gerrit.PatchRange} patchRange
-     * @param {number=} opt_parentIndex
-     */
-    getChangeFiles(changeNum, patchRange, opt_parentIndex) {
-      let params = undefined;
-      if (this.isMergeParent(patchRange.basePatchNum)) {
-        params = {parent: this.getParentIndex(patchRange.basePatchNum)};
-      } else if (!this.patchNumEquals(patchRange.basePatchNum, 'PARENT')) {
-        params = {base: patchRange.basePatchNum};
-      }
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/files',
-        patchNum: patchRange.patchNum,
-        params,
-        reportEndpointAsIs: true,
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {Gerrit.PatchRange} patchRange
-     */
-    getChangeEditFiles(changeNum, patchRange) {
-      let endpoint = '/edit?list';
-      let anonymizedEndpoint = endpoint;
-      if (patchRange.basePatchNum !== 'PARENT') {
-        endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + '');
-        anonymizedEndpoint += '&base=*';
-      }
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint,
-        anonymizedEndpoint,
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string} patchNum
-     * @param {string} query
-     * @return {!Promise<!Object>}
-     */
-    queryChangeFiles(changeNum, patchNum, query) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: `/files?q=${encodeURIComponent(query)}`,
-        patchNum,
-        anonymizedEndpoint: '/files?q=*',
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {Gerrit.PatchRange} patchRange
-     * @return {!Promise<!Array<!Object>>}
-     */
-    getChangeOrEditFiles(changeNum, patchRange) {
-      if (this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME)) {
-        return this.getChangeEditFiles(changeNum, patchRange).then(res =>
-          res.files);
-      }
-      return this.getChangeFiles(changeNum, patchRange);
-    }
-
-    getChangeRevisionActions(changeNum, patchNum) {
-      const req = {
-        changeNum,
-        endpoint: '/actions',
-        patchNum,
-        reportEndpointAsIs: true,
-      };
-      return this._getChangeURLAndFetch(req).then(revisionActions => {
-        // The rebase button on change screen is always enabled.
-        if (revisionActions.rebase) {
-          revisionActions.rebase.rebaseOnCurrent =
-              !!revisionActions.rebase.enabled;
-          revisionActions.rebase.enabled = true;
-        }
-        return revisionActions;
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {string} inputVal
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) {
-      return this._getChangeSuggestedGroup('REVIEWER', changeNum, inputVal,
-          opt_errFn);
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {string} inputVal
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    getChangeSuggestedCCs(changeNum, inputVal, opt_errFn) {
-      return this._getChangeSuggestedGroup('CC', changeNum, inputVal,
-          opt_errFn);
-    }
-
-    _getChangeSuggestedGroup(reviewerState, changeNum, inputVal, opt_errFn) {
-      // More suggestions may obscure content underneath in the reply dialog,
-      // see issue 10793.
-      const params = {'n': 6, 'reviewer-state': reviewerState};
-      if (inputVal) { params.q = inputVal; }
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/suggest_reviewers',
-        errFn: opt_errFn,
-        params,
-        reportEndpointAsIs: true,
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     */
-    getChangeIncludedIn(changeNum) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/in',
-        reportEndpointAsIs: true,
-      });
-    }
-
-    _computeFilter(filter) {
-      if (filter && filter.startsWith('^')) {
-        filter = '&r=' + encodeURIComponent(filter);
-      } else if (filter) {
-        filter = '&m=' + encodeURIComponent(filter);
-      } else {
-        filter = '';
-      }
-      return filter;
-    }
-
-    /**
-     * @param {string} filter
-     * @param {number} groupsPerPage
-     * @param {number=} opt_offset
-     */
-    _getGroupsUrl(filter, groupsPerPage, opt_offset) {
-      const offset = opt_offset || 0;
-
-      return `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
-        this._computeFilter(filter);
-    }
-
-    /**
-     * @param {string} filter
-     * @param {number} reposPerPage
-     * @param {number=} opt_offset
-     */
-    _getReposUrl(filter, reposPerPage, opt_offset) {
-      const defaultFilter = 'state:active OR state:read-only';
-      const namePartDelimiters = /[@.\-\s\/_]/g;
-      const offset = opt_offset || 0;
-
-      if (filter && !filter.includes(':') && filter.match(namePartDelimiters)) {
-        // The query language specifies hyphens as operators. Split the string
-        // by hyphens and 'AND' the parts together as 'inname:' queries.
-        // If the filter includes a semicolon, the user is using a more complex
-        // query so we trust them and don't do any magic under the hood.
-        const originalFilter = filter;
-        filter = '';
-        originalFilter.split(namePartDelimiters).forEach(part => {
-          if (part) {
-            filter += (filter === '' ? 'inname:' : ' AND inname:') + part;
+      const cachedEmails = this._cache.get('/accounts/self/emails');
+      if (cachedEmails) {
+        const emails = cachedEmails.map(entry => {
+          if (entry.email === email) {
+            return {email, preferred: true};
+          } else {
+            return {email};
           }
         });
+        this._cache.set('/accounts/self/emails', emails);
       }
-      // Check if filter is now empty which could be either because the user did
-      // not provide it or because the user provided only a split character.
-      if (!filter) {
-        filter = defaultFilter;
-      }
+    });
+  }
 
-      filter = filter.trim();
-      const encodedFilter = encodeURIComponent(filter);
-
-      return `/projects/?n=${reposPerPage + 1}&S=${offset}` +
-        `&query=${encodedFilter}`;
-    }
-
-    invalidateGroupsCache() {
-      this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
-    }
-
-    invalidateReposCache() {
-      this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
-    }
-
-    invalidateAccountsCache() {
-      this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
-    }
-
-    /**
-     * @param {string} filter
-     * @param {number} groupsPerPage
-     * @param {number=} opt_offset
-     * @return {!Promise<?Object>}
-     */
-    getGroups(filter, groupsPerPage, opt_offset) {
-      const url = this._getGroupsUrl(filter, groupsPerPage, opt_offset);
-
-      return this._fetchSharedCacheURL({
-        url,
-        anonymizedUrl: '/groups/?*',
-      });
-    }
-
-    /**
-     * @param {string} filter
-     * @param {number} reposPerPage
-     * @param {number=} opt_offset
-     * @return {!Promise<?Object>}
-     */
-    getRepos(filter, reposPerPage, opt_offset) {
-      const url = this._getReposUrl(filter, reposPerPage, opt_offset);
-
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._fetchSharedCacheURL({
-        url,
-        anonymizedUrl: '/projects/?*',
-      });
-    }
-
-    setRepoHead(repo, ref) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/projects/${encodeURIComponent(repo)}/HEAD`,
-        body: {ref},
-        anonymizedUrl: '/projects/*/HEAD',
-      });
-    }
-
-    /**
-     * @param {string} filter
-     * @param {string} repo
-     * @param {number} reposBranchesPerPage
-     * @param {number=} opt_offset
-     * @param {?function(?Response, string=)=} opt_errFn
-     * @return {!Promise<?Object>}
-     */
-    getRepoBranches(filter, repo, reposBranchesPerPage, opt_offset, opt_errFn) {
-      const offset = opt_offset || 0;
-      const count = reposBranchesPerPage + 1;
-      filter = this._computeFilter(filter);
-      repo = encodeURIComponent(repo);
-      const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._restApiHelper.fetchJSON({
-        url,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/branches?*',
-      });
-    }
-
-    /**
-     * @param {string} filter
-     * @param {string} repo
-     * @param {number} reposTagsPerPage
-     * @param {number=} opt_offset
-     * @param {?function(?Response, string=)=} opt_errFn
-     * @return {!Promise<?Object>}
-     */
-    getRepoTags(filter, repo, reposTagsPerPage, opt_offset, opt_errFn) {
-      const offset = opt_offset || 0;
-      const encodedRepo = encodeURIComponent(repo);
-      const n = reposTagsPerPage + 1;
-      const encodedFilter = this._computeFilter(filter);
-      const url = `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` +
-          encodedFilter;
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._restApiHelper.fetchJSON({
-        url,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/tags',
-      });
-    }
-
-    /**
-     * @param {string} filter
-     * @param {number} pluginsPerPage
-     * @param {number=} opt_offset
-     * @param {?function(?Response, string=)=} opt_errFn
-     * @return {!Promise<?Object>}
-     */
-    getPlugins(filter, pluginsPerPage, opt_offset, opt_errFn) {
-      const offset = opt_offset || 0;
-      const encodedFilter = this._computeFilter(filter);
-      const n = pluginsPerPage + 1;
-      const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
-      return this._restApiHelper.fetchJSON({
-        url,
-        errFn: opt_errFn,
-        anonymizedUrl: '/plugins/?all',
-      });
-    }
-
-    getRepoAccessRights(repoName, opt_errFn) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._restApiHelper.fetchJSON({
-        url: `/projects/${encodeURIComponent(repoName)}/access`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/access',
-      });
-    }
-
-    setRepoAccessRights(repoName, repoInfo) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: `/projects/${encodeURIComponent(repoName)}/access`,
-        body: repoInfo,
-        anonymizedUrl: '/projects/*/access',
-      });
-    }
-
-    setRepoAccessRightsForReview(projectName, projectInfo) {
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/projects/${encodeURIComponent(projectName)}/access:review`,
-        body: projectInfo,
-        parseResponse: true,
-        anonymizedUrl: '/projects/*/access:review',
-      });
-    }
-
-    /**
-     * @param {string} inputVal
-     * @param {number} opt_n
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    getSuggestedGroups(inputVal, opt_n, opt_errFn) {
-      const params = {s: inputVal};
-      if (opt_n) { params.n = opt_n; }
-      return this._restApiHelper.fetchJSON({
-        url: '/groups/',
-        errFn: opt_errFn,
-        params,
-        reportUrlAsIs: true,
-      });
-    }
-
-    /**
-     * @param {string} inputVal
-     * @param {number} opt_n
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    getSuggestedProjects(inputVal, opt_n, opt_errFn) {
-      const params = {
-        m: inputVal,
-        n: MAX_PROJECT_RESULTS,
-        type: 'ALL',
-      };
-      if (opt_n) { params.n = opt_n; }
-      return this._restApiHelper.fetchJSON({
-        url: '/projects/',
-        errFn: opt_errFn,
-        params,
-        reportUrlAsIs: true,
-      });
-    }
-
-    /**
-     * @param {string} inputVal
-     * @param {number} opt_n
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    getSuggestedAccounts(inputVal, opt_n, opt_errFn) {
-      if (!inputVal) {
-        return Promise.resolve([]);
-      }
-      const params = {suggest: null, q: inputVal};
-      if (opt_n) { params.n = opt_n; }
-      return this._restApiHelper.fetchJSON({
-        url: '/accounts/',
-        errFn: opt_errFn,
-        params,
-        anonymizedUrl: '/accounts/?n=*',
-      });
-    }
-
-    addChangeReviewer(changeNum, reviewerID) {
-      return this._sendChangeReviewerRequest('POST', changeNum, reviewerID);
-    }
-
-    removeChangeReviewer(changeNum, reviewerID) {
-      return this._sendChangeReviewerRequest('DELETE', changeNum, reviewerID);
-    }
-
-    _sendChangeReviewerRequest(method, changeNum, reviewerID) {
-      return this.getChangeActionURL(changeNum, null, '/reviewers')
-          .then(url => {
-            let body;
-            switch (method) {
-              case 'POST':
-                body = {reviewer: reviewerID};
-                break;
-              case 'DELETE':
-                url += '/' + encodeURIComponent(reviewerID);
-                break;
-              default:
-                throw Error('Unsupported HTTP method: ' + method);
-            }
-
-            return this._restApiHelper.send({method, url, body});
-          });
-    }
-
-    getRelatedChanges(changeNum, patchNum) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/related',
-        patchNum,
-        reportEndpointAsIs: true,
-      });
-    }
-
-    getChangesSubmittedTogether(changeNum) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
-        reportEndpointAsIs: true,
-      });
-    }
-
-    getChangeConflicts(changeNum) {
-      const options = this.listChangesOptionsToHex(
-          this.ListChangesOption.CURRENT_REVISION,
-          this.ListChangesOption.CURRENT_COMMIT
-      );
-      const params = {
-        O: options,
-        q: 'status:open conflicts:' + changeNum,
-      };
-      return this._restApiHelper.fetchJSON({
-        url: '/changes/',
-        params,
-        anonymizedUrl: '/changes/conflicts:*',
-      });
-    }
-
-    getChangeCherryPicks(project, changeID, changeNum) {
-      const options = this.listChangesOptionsToHex(
-          this.ListChangesOption.CURRENT_REVISION,
-          this.ListChangesOption.CURRENT_COMMIT
-      );
-      const query = [
-        'project:' + project,
-        'change:' + changeID,
-        '-change:' + changeNum,
-        '-is:abandoned',
-      ].join(' ');
-      const params = {
-        O: options,
-        q: query,
-      };
-      return this._restApiHelper.fetchJSON({
-        url: '/changes/',
-        params,
-        anonymizedUrl: '/changes/change:*',
-      });
-    }
-
-    getChangesWithSameTopic(topic, changeNum) {
-      const options = this.listChangesOptionsToHex(
-          this.ListChangesOption.LABELS,
-          this.ListChangesOption.CURRENT_REVISION,
-          this.ListChangesOption.CURRENT_COMMIT,
-          this.ListChangesOption.DETAILED_LABELS
-      );
-      const query = [
-        'status:open',
-        '-change:' + changeNum,
-        `topic:"${topic}"`,
-      ].join(' ');
-      const params = {
-        O: options,
-        q: query,
-      };
-      return this._restApiHelper.fetchJSON({
-        url: '/changes/',
-        params,
-        anonymizedUrl: '/changes/topic:*',
-      });
-    }
-
-    getReviewedFiles(changeNum, patchNum) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/files?reviewed',
-        patchNum,
-        reportEndpointAsIs: true,
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string} patchNum
-     * @param {string} path
-     * @param {boolean} reviewed
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    saveFileReviewed(changeNum, patchNum, path, reviewed, opt_errFn) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: reviewed ? 'PUT' : 'DELETE',
-        patchNum,
-        endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
-        errFn: opt_errFn,
-        anonymizedEndpoint: '/files/*/reviewed',
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string} patchNum
-     * @param {!Object} review
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    saveChangeReview(changeNum, patchNum, review, opt_errFn) {
-      const promises = [
-        this.awaitPendingDiffDrafts(),
-        this.getChangeActionURL(changeNum, patchNum, '/review'),
-      ];
-      return Promise.all(promises).then(([, url]) => this._restApiHelper.send({
-        method: 'POST',
-        url,
-        body: review,
-        errFn: opt_errFn,
-      }));
-    }
-
-    getChangeEdit(changeNum, opt_download_commands) {
-      const params = opt_download_commands ? {'download-commands': true} : null;
-      return this.getLoggedIn().then(loggedIn => {
-        if (!loggedIn) { return false; }
-        return this._getChangeURLAndFetch({
-          changeNum,
-          endpoint: '/edit/',
-          params,
-          reportEndpointAsIs: true,
-        }, true);
-      });
-    }
-
-    /**
-     * @param {string} project
-     * @param {string} branch
-     * @param {string} subject
-     * @param {string=} opt_topic
-     * @param {boolean=} opt_isPrivate
-     * @param {boolean=} opt_workInProgress
-     * @param {string=} opt_baseChange
-     * @param {string=} opt_baseCommit
-     */
-    createChange(project, branch, subject, opt_topic, opt_isPrivate,
-        opt_workInProgress, opt_baseChange, opt_baseCommit) {
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: '/changes/',
-        body: {
-          project,
-          branch,
-          subject,
-          topic: opt_topic,
-          is_private: opt_isPrivate,
-          work_in_progress: opt_workInProgress,
-          base_change: opt_baseChange,
-          base_commit: opt_baseCommit,
-        },
-        parseResponse: true,
-        reportUrlAsIs: true,
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {string} path
-     * @param {number|string} patchNum
-     */
-    getFileContent(changeNum, path, patchNum) {
-      // 404s indicate the file does not exist yet in the revision, so suppress
-      // them.
-      const suppress404s = res => {
-        if (res && res.status !== 404) { this.fire('server-error', {res}); }
-        return res;
-      };
-      const promise = this.patchNumEquals(patchNum, this.EDIT_NAME) ?
-        this._getFileInChangeEdit(changeNum, path) :
-        this._getFileInRevision(changeNum, path, patchNum, suppress404s);
-
-      return promise.then(res => {
-        if (!res.ok) { return res; }
-
-        // The file type (used for syntax highlighting) is identified in the
-        // X-FYI-Content-Type header of the response.
-        const type = res.headers.get('X-FYI-Content-Type');
-        return this.getResponseObject(res).then(content => {
-          return {content, type, ok: true};
-        });
-      });
-    }
-
-    /**
-     * Gets a file in a specific change and revision.
-     *
-     * @param {number|string} changeNum
-     * @param {string} path
-     * @param {number|string} patchNum
-     * @param {?function(?Response, string=)=} opt_errFn
-     */
-    _getFileInRevision(changeNum, path, patchNum, opt_errFn) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'GET',
-        patchNum,
-        endpoint: `/files/${encodeURIComponent(path)}/content`,
-        errFn: opt_errFn,
-        headers: {Accept: 'application/json'},
-        anonymizedEndpoint: '/files/*/content',
-      });
-    }
-
-    /**
-     * Gets a file in a change edit.
-     *
-     * @param {number|string} changeNum
-     * @param {string} path
-     */
-    _getFileInChangeEdit(changeNum, path) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'GET',
-        endpoint: '/edit/' + encodeURIComponent(path),
-        headers: {Accept: 'application/json'},
-        anonymizedEndpoint: '/edit/*',
-      });
-    }
-
-    rebaseChangeEdit(changeNum) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        endpoint: '/edit:rebase',
-        reportEndpointAsIs: true,
-      });
-    }
-
-    deleteChangeEdit(changeNum) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'DELETE',
-        endpoint: '/edit',
-        reportEndpointAsIs: true,
-      });
-    }
-
-    restoreFileInChangeEdit(changeNum, restore_path) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        endpoint: '/edit',
-        body: {restore_path},
-        reportEndpointAsIs: true,
-      });
-    }
-
-    renameFileInChangeEdit(changeNum, old_path, new_path) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        endpoint: '/edit',
-        body: {old_path, new_path},
-        reportEndpointAsIs: true,
-      });
-    }
-
-    deleteFileInChangeEdit(changeNum, path) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'DELETE',
-        endpoint: '/edit/' + encodeURIComponent(path),
-        anonymizedEndpoint: '/edit/*',
-      });
-    }
-
-    saveChangeEdit(changeNum, path, contents) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT',
-        endpoint: '/edit/' + encodeURIComponent(path),
-        body: contents,
-        contentType: 'text/plain',
-        anonymizedEndpoint: '/edit/*',
-      });
-    }
-
-    getRobotCommentFixPreview(changeNum, patchNum, fixId) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        patchNum,
-        endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
-        reportEndpointAsId: true,
-      });
-    }
-
-    applyFixSuggestion(changeNum, patchNum, fixId) {
-      return this._getChangeURLAndSend({
-        method: 'POST',
-        changeNum,
-        patchNum,
-        endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`,
-        reportEndpointAsId: true,
-      });
-    }
-
-    // Deprecated, prefer to use putChangeCommitMessage instead.
-    saveChangeCommitMessageEdit(changeNum, message) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT',
-        endpoint: '/edit:message',
-        body: {message},
-        reportEndpointAsIs: true,
-      });
-    }
-
-    publishChangeEdit(changeNum) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        endpoint: '/edit:publish',
-        reportEndpointAsIs: true,
-      });
-    }
-
-    putChangeCommitMessage(changeNum, message) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT',
-        endpoint: '/message',
-        body: {message},
-        reportEndpointAsIs: true,
-      });
-    }
-
-    deleteChangeCommitMessage(changeNum, messageId) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'DELETE',
-        endpoint: '/messages/' + messageId,
-        reportEndpointAsIs: true,
-      });
-    }
-
-    saveChangeStarred(changeNum, starred) {
-      // Some servers may require the project name to be provided
-      // alongside the change number, so resolve the project name
-      // first.
-      return this.getFromProjectLookup(changeNum).then(project => {
-        const url = '/accounts/self/starred.changes/' +
-            (project ? encodeURIComponent(project) + '~' : '') + changeNum;
-        return this._restApiHelper.send({
-          method: starred ? 'PUT' : 'DELETE',
-          url,
-          anonymizedUrl: '/accounts/self/starred.changes/*',
-        });
-      });
-    }
-
-    saveChangeReviewed(changeNum, reviewed) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT',
-        endpoint: reviewed ? '/reviewed' : '/unreviewed',
-      });
-    }
-
-    /**
-     * Public version of the _restApiHelper.send method preserved for plugins.
-     *
-     * @param {string} method
-     * @param {string} url
-     * @param {?string|number|Object=} opt_body passed as null sometimes
-     *    and also apparently a number. TODO (beckysiegel) remove need for
-     *    number at least.
-     * @param {?function(?Response, string=)=} opt_errFn
-     *    passed as null sometimes.
-     * @param {?string=} opt_contentType
-     * @param {Object=} opt_headers
-     */
-    send(method, url, opt_body, opt_errFn, opt_contentType,
-        opt_headers) {
-      return this._restApiHelper.send({
-        method,
-        url,
-        body: opt_body,
-        errFn: opt_errFn,
-        contentType: opt_contentType,
-        headers: opt_headers,
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string} basePatchNum Negative values specify merge parent
-     *     index.
-     * @param {number|string} patchNum
-     * @param {string} path
-     * @param {string=} opt_whitespace the ignore-whitespace level for the diff
-     *     algorithm.
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    getDiff(changeNum, basePatchNum, patchNum, path, opt_whitespace,
-        opt_errFn) {
-      const params = {
-        context: 'ALL',
-        intraline: null,
-        whitespace: opt_whitespace || 'IGNORE_NONE',
-      };
-      if (this.isMergeParent(basePatchNum)) {
-        params.parent = this.getParentIndex(basePatchNum);
-      } else if (!this.patchNumEquals(basePatchNum, PARENT_PATCH_NUM)) {
-        params.base = basePatchNum;
-      }
-      const endpoint = `/files/${encodeURIComponent(path)}/diff`;
-      const req = {
-        changeNum,
-        endpoint,
-        patchNum,
-        errFn: opt_errFn,
-        params,
-        anonymizedEndpoint: '/files/*/diff',
-      };
-
-      // Invalidate the cache if its edit patch to make sure we always get latest.
-      if (patchNum === this.EDIT_NAME) {
-        if (!req.fetchOptions) req.fetchOptions = {};
-        if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
-        req.fetchOptions.headers.append('Cache-Control', 'no-cache');
-      }
-
-      return this._getChangeURLAndFetch(req);
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string=} opt_basePatchNum
-     * @param {number|string=} opt_patchNum
-     * @param {string=} opt_path
-     * @return {!Promise<!Object>}
-     */
-    getDiffComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
-      return this._getDiffComments(changeNum, '/comments', opt_basePatchNum,
-          opt_patchNum, opt_path);
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string=} opt_basePatchNum
-     * @param {number|string=} opt_patchNum
-     * @param {string=} opt_path
-     * @return {!Promise<!Object>}
-     */
-    getDiffRobotComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
-      return this._getDiffComments(changeNum, '/robotcomments',
-          opt_basePatchNum, opt_patchNum, opt_path);
-    }
-
-    /**
-     * If the user is logged in, fetch the user's draft diff comments. If there
-     * is no logged in user, the request is not made and the promise yields an
-     * empty object.
-     *
-     * @param {number|string} changeNum
-     * @param {number|string=} opt_basePatchNum
-     * @param {number|string=} opt_patchNum
-     * @param {string=} opt_path
-     * @return {!Promise<!Object>}
-     */
-    getDiffDrafts(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
-      return this.getLoggedIn().then(loggedIn => {
-        if (!loggedIn) { return Promise.resolve({}); }
-        return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum,
-            opt_patchNum, opt_path);
-      });
-    }
-
-    _setRange(comments, comment) {
-      if (comment.in_reply_to && !comment.range) {
-        for (let i = 0; i < comments.length; i++) {
-          if (comments[i].id === comment.in_reply_to) {
-            comment.range = comments[i].range;
-            break;
-          }
-        }
-      }
-      return comment;
-    }
-
-    _setRanges(comments) {
-      comments = comments || [];
-      comments.sort(
-          (a, b) => util.parseDate(a.updated) - util.parseDate(b.updated)
-      );
-      for (const comment of comments) {
-        this._setRange(comments, comment);
-      }
-      return comments;
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {string} endpoint
-     * @param {number|string=} opt_basePatchNum
-     * @param {number|string=} opt_patchNum
-     * @param {string=} opt_path
-     * @return {!Promise<!Object>}
-     */
-    _getDiffComments(changeNum, endpoint, opt_basePatchNum,
-        opt_patchNum, opt_path) {
-      /**
-       * Fetches the comments for a given patchNum.
-       * Helper function to make promises more legible.
-       *
-       * @param {string|number=} opt_patchNum
-       * @return {!Promise<!Object>} Diff comments response.
-       */
-      // We don't want to add accept header, since preloading of comments is
-      // working only without accept header.
-      const noAcceptHeader = true;
-      const fetchComments = opt_patchNum => this._getChangeURLAndFetch({
-        changeNum,
-        endpoint,
-        patchNum: opt_patchNum,
-        reportEndpointAsIs: true,
-      }, noAcceptHeader);
-
-      if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
-        return fetchComments();
-      }
-      function onlyParent(c) { return c.side == PARENT_PATCH_NUM; }
-      function withoutParent(c) { return c.side != PARENT_PATCH_NUM; }
-      function setPath(c) { c.path = opt_path; }
-
-      const promises = [];
-      let comments;
-      let baseComments;
-      let fetchPromise;
-      fetchPromise = fetchComments(opt_patchNum).then(response => {
-        comments = response[opt_path] || [];
-        // TODO(kaspern): Implement this on in the backend so this can
-        // be removed.
-        // Sort comments by date so that parent ranges can be propagated
-        // in a single pass.
-        comments = this._setRanges(comments);
-
-        if (opt_basePatchNum == PARENT_PATCH_NUM) {
-          baseComments = comments.filter(onlyParent);
-          baseComments.forEach(setPath);
-        }
-        comments = comments.filter(withoutParent);
-
-        comments.forEach(setPath);
-      });
-      promises.push(fetchPromise);
-
-      if (opt_basePatchNum != PARENT_PATCH_NUM) {
-        fetchPromise = fetchComments(opt_basePatchNum).then(response => {
-          baseComments = (response[opt_path] || [])
-              .filter(withoutParent);
-          baseComments = this._setRanges(baseComments);
-          baseComments.forEach(setPath);
-        });
-        promises.push(fetchPromise);
-      }
-
-      return Promise.all(promises).then(() => Promise.resolve({
-        baseComments,
-        comments,
-      }));
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {string} endpoint
-     * @param {number|string=} opt_patchNum
-     */
-    _getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum) {
-      return this._changeBaseURL(changeNum, opt_patchNum)
-          .then(url => url + endpoint);
-    }
-
-    saveDiffDraft(changeNum, patchNum, draft) {
-      return this._sendDiffDraftRequest('PUT', changeNum, patchNum, draft);
-    }
-
-    deleteDiffDraft(changeNum, patchNum, draft) {
-      return this._sendDiffDraftRequest('DELETE', changeNum, patchNum, draft);
-    }
-
-    /**
-     * @returns {boolean} Whether there are pending diff draft sends.
-     */
-    hasPendingDiffDrafts() {
-      const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
-      return promises && promises.length;
-    }
-
-    /**
-     * @returns {!Promise<undefined>} A promise that resolves when all pending
-     *    diff draft sends have resolved.
-     */
-    awaitPendingDiffDrafts() {
-      return Promise.all(this._pendingRequests[Requests.SEND_DIFF_DRAFT] || [])
-          .then(() => {
-            this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
-          });
-    }
-
-    _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
-      const isCreate = !draft.id && method === 'PUT';
-      let endpoint = '/drafts';
-      let anonymizedEndpoint = endpoint;
-      if (draft.id) {
-        endpoint += '/' + draft.id;
-        anonymizedEndpoint += '/*';
-      }
-      let body;
-      if (method === 'PUT') {
-        body = draft;
-      }
-
-      if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
-        this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
-      }
-
-      const req = {
-        changeNum,
-        method,
-        patchNum,
-        endpoint,
-        body,
-        anonymizedEndpoint,
-      };
-
-      const promise = this._getChangeURLAndSend(req);
-      this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
-
-      if (isCreate) {
-        return this._failForCreate200(promise);
-      }
-
-      return promise;
-    }
-
-    getCommitInfo(project, commit) {
-      return this._restApiHelper.fetchJSON({
-        url: '/projects/' + encodeURIComponent(project) +
-            '/commits/' + encodeURIComponent(commit),
-        anonymizedUrl: '/projects/*/comments/*',
-      });
-    }
-
-    _fetchB64File(url) {
-      return this._restApiHelper.fetch({url: this.getBaseUrl() + url})
-          .then(response => {
-            if (!response.ok) {
-              return Promise.reject(new Error(response.statusText));
-            }
-            const type = response.headers.get('X-FYI-Content-Type');
-            return response.text()
-                .then(text => {
-                  return {body: text, type};
-                });
-          });
-    }
-
-    /**
-     * @param {string} changeId
-     * @param {string|number} patchNum
-     * @param {string} path
-     * @param {number=} opt_parentIndex
-     */
-    getB64FileContents(changeId, patchNum, path, opt_parentIndex) {
-      const parent = typeof opt_parentIndex === 'number' ?
-        '?parent=' + opt_parentIndex : '';
-      return this._changeBaseURL(changeId, patchNum).then(url => {
-        url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
-        return this._fetchB64File(url);
-      });
-    }
-
-    getImagesForDiff(changeNum, diff, patchRange) {
-      let promiseA;
-      let promiseB;
-
-      if (diff.meta_a && diff.meta_a.content_type.startsWith('image/')) {
-        if (patchRange.basePatchNum === 'PARENT') {
-          // Note: we only attempt to get the image from the first parent.
-          promiseA = this.getB64FileContents(changeNum, patchRange.patchNum,
-              diff.meta_a.name, 1);
-        } else {
-          promiseA = this.getB64FileContents(changeNum,
-              patchRange.basePatchNum, diff.meta_a.name);
-        }
-      } else {
-        promiseA = Promise.resolve(null);
-      }
-
-      if (diff.meta_b && diff.meta_b.content_type.startsWith('image/')) {
-        promiseB = this.getB64FileContents(changeNum, patchRange.patchNum,
-            diff.meta_b.name);
-      } else {
-        promiseB = Promise.resolve(null);
-      }
-
-      return Promise.all([promiseA, promiseB]).then(results => {
-        const baseImage = results[0];
-        const revisionImage = results[1];
-
-        // Sometimes the server doesn't send back the content type.
-        if (baseImage) {
-          baseImage._expectedType = diff.meta_a.content_type;
-          baseImage._name = diff.meta_a.name;
-        }
-        if (revisionImage) {
-          revisionImage._expectedType = diff.meta_b.content_type;
-          revisionImage._name = diff.meta_b.name;
-        }
-
-        return {baseImage, revisionImage};
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {?number|string=} opt_patchNum passed as null sometimes.
-     * @param {string=} opt_project
-     * @return {!Promise<string>}
-     */
-    _changeBaseURL(changeNum, opt_patchNum, opt_project) {
-      // TODO(kaspern): For full slicer migration, app should warn with a call
-      // stack every time _changeBaseURL is called without a project.
-      const projectPromise = opt_project ?
-        Promise.resolve(opt_project) :
-        this.getFromProjectLookup(changeNum);
-      return projectPromise.then(project => {
-        let url = `/changes/${encodeURIComponent(project)}~${changeNum}`;
-        if (opt_patchNum) {
-          url += `/revisions/${opt_patchNum}`;
-        }
-        return url;
-      });
-    }
-
-    /**
-     * @suppress {checkTypes}
-     * Resulted in error: Promise.prototype.then does not match formal
-     * parameter.
-     */
-    setChangeTopic(changeNum, topic) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT',
-        endpoint: '/topic',
-        body: {topic},
-        parseResponse: true,
-        reportUrlAsIs: true,
-      });
-    }
-
-    /**
-     * @suppress {checkTypes}
-     * Resulted in error: Promise.prototype.then does not match formal
-     * parameter.
-     */
-    setChangeHashtag(changeNum, hashtag) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        endpoint: '/hashtags',
-        body: hashtag,
-        parseResponse: true,
-        reportUrlAsIs: true,
-      });
-    }
-
-    deleteAccountHttpPassword() {
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: '/accounts/self/password.http',
-        reportUrlAsIs: true,
-      });
-    }
-
-    /**
-     * @suppress {checkTypes}
-     * Resulted in error: Promise.prototype.then does not match formal
-     * parameter.
-     */
-    generateAccountHttpPassword() {
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: '/accounts/self/password.http',
-        body: {generate: true},
-        parseResponse: true,
-        reportUrlAsIs: true,
-      });
-    }
-
-    getAccountSSHKeys() {
-      return this._fetchSharedCacheURL({
-        url: '/accounts/self/sshkeys',
-        reportUrlAsIs: true,
-      });
-    }
-
-    addAccountSSHKey(key) {
-      const req = {
-        method: 'POST',
-        url: '/accounts/self/sshkeys',
-        body: key,
-        contentType: 'text/plain',
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.send(req)
-          .then(response => {
-            if (response.status < 200 && response.status >= 300) {
-              return Promise.reject(new Error('error'));
-            }
-            return this.getResponseObject(response);
-          })
-          .then(obj => {
-            if (!obj.valid) { return Promise.reject(new Error('error')); }
-            return obj;
-          });
-    }
-
-    deleteAccountSSHKey(id) {
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: '/accounts/self/sshkeys/' + id,
-        anonymizedUrl: '/accounts/self/sshkeys/*',
-      });
-    }
-
-    getAccountGPGKeys() {
-      return this._restApiHelper.fetchJSON({
-        url: '/accounts/self/gpgkeys',
-        reportUrlAsIs: true,
-      });
-    }
-
-    addAccountGPGKey(key) {
-      const req = {
-        method: 'POST',
-        url: '/accounts/self/gpgkeys',
-        body: key,
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.send(req)
-          .then(response => {
-            if (response.status < 200 && response.status >= 300) {
-              return Promise.reject(new Error('error'));
-            }
-            return this.getResponseObject(response);
-          })
-          .then(obj => {
-            if (!obj) { return Promise.reject(new Error('error')); }
-            return obj;
-          });
-    }
-
-    deleteAccountGPGKey(id) {
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: '/accounts/self/gpgkeys/' + id,
-        anonymizedUrl: '/accounts/self/gpgkeys/*',
-      });
-    }
-
-    deleteVote(changeNum, account, label) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'DELETE',
-        endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
-        anonymizedEndpoint: '/reviewers/*/votes/*',
-      });
-    }
-
-    setDescription(changeNum, patchNum, desc) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT', patchNum,
-        endpoint: '/description',
-        body: {description: desc},
-        reportUrlAsIs: true,
-      });
-    }
-
-    confirmEmail(token) {
-      const req = {
-        method: 'PUT',
-        url: '/config/server/email.confirm',
-        body: {token},
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.send(req).then(response => {
-        if (response.status === 204) {
-          return 'Email confirmed successfully.';
-        }
-        return null;
-      });
-    }
-
-    getCapabilities(opt_errFn) {
-      return this._restApiHelper.fetchJSON({
-        url: '/config/server/capabilities',
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
-      });
-    }
-
-    getTopMenus(opt_errFn) {
-      return this._fetchSharedCacheURL({
-        url: '/config/server/top-menus',
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
-      });
-    }
-
-    setAssignee(changeNum, assignee) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT',
-        endpoint: '/assignee',
-        body: {assignee},
-        reportUrlAsIs: true,
-      });
-    }
-
-    deleteAssignee(changeNum) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'DELETE',
-        endpoint: '/assignee',
-        reportUrlAsIs: true,
-      });
-    }
-
-    probePath(path) {
-      return fetch(new Request(path, {method: 'HEAD'}))
-          .then(response => response.ok);
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string=} opt_message
-     */
-    startWorkInProgress(changeNum, opt_message) {
-      const body = {};
-      if (opt_message) {
-        body.message = opt_message;
-      }
-      const req = {
-        changeNum,
-        method: 'POST',
-        endpoint: '/wip',
-        body,
-        reportUrlAsIs: true,
-      };
-      return this._getChangeURLAndSend(req).then(response => {
-        if (response.status === 204) {
-          return 'Change marked as Work In Progress.';
-        }
-      });
-    }
-
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string=} opt_body
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    startReview(changeNum, opt_body, opt_errFn) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        endpoint: '/ready',
-        body: opt_body,
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
-      });
-    }
-
-    /**
-     * @suppress {checkTypes}
-     * Resulted in error: Promise.prototype.then does not match formal
-     * parameter.
-     */
-    deleteComment(changeNum, patchNum, commentID, reason) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        patchNum,
-        endpoint: `/comments/${commentID}/delete`,
-        body: {reason},
-        parseResponse: true,
-        anonymizedEndpoint: '/comments/*/delete',
-      });
-    }
-
-    /**
-     * Given a changeNum, gets the change.
-     *
-     * @param {number|string} changeNum
-     * @param {function(?Response, string=)=} opt_errFn
-     * @return {!Promise<?Object>} The change
-     */
-    getChange(changeNum, opt_errFn) {
-      // Cannot use _changeBaseURL, as this function is used by _projectLookup.
-      return this._restApiHelper.fetchJSON({
-        url: `/changes/?q=change:${changeNum}`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/changes/?q=change:*',
-      }).then(res => {
-        if (!res || !res.length) { return null; }
-        return res[0];
-      });
-    }
-
-    /**
-     * @param {string|number} changeNum
-     * @param {string=} project
-     */
-    setInProjectLookup(changeNum, project) {
-      if (this._projectLookup[changeNum] &&
-          this._projectLookup[changeNum] !== project) {
-        console.warn('Change set with multiple project nums.' +
-            'One of them must be invalid.');
-      }
-      this._projectLookup[changeNum] = project;
-    }
-
-    /**
-     * Checks in _projectLookup for the changeNum. If it exists, returns the
-     * project. If not, calls the restAPI to get the change, populates
-     * _projectLookup with the project for that change, and returns the project.
-     *
-     * @param {string|number} changeNum
-     * @return {!Promise<string|undefined>}
-     */
-    getFromProjectLookup(changeNum) {
-      const project = this._projectLookup[changeNum];
-      if (project) { return Promise.resolve(project); }
-
-      const onError = response => {
-        // Fire a page error so that the visual 404 is displayed.
-        this.fire('page-error', {response});
-      };
-
-      return this.getChange(changeNum, onError).then(change => {
-        if (!change || !change.project) { return; }
-        this.setInProjectLookup(changeNum, change.project);
-        return change.project;
-      });
-    }
-
-    /**
-     * Alias for _changeBaseURL.then(send).
-     *
-     * @todo(beckysiegel) clean up comments
-     * @param {Gerrit.ChangeSendRequest} req
-     * @return {!Promise<!Object>}
-     */
-    _getChangeURLAndSend(req) {
-      const anonymizedBaseUrl = req.patchNum ?
-        ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
-      const anonymizedEndpoint = req.reportEndpointAsIs ?
-        req.endpoint : req.anonymizedEndpoint;
-
-      return this._changeBaseURL(req.changeNum, req.patchNum)
-          .then(url => this._restApiHelper.send({
-            method: req.method,
-            url: url + req.endpoint,
-            body: req.body,
-            errFn: req.errFn,
-            contentType: req.contentType,
-            headers: req.headers,
-            parseResponse: req.parseResponse,
-            anonymizedUrl: anonymizedEndpoint ?
-              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
-          }));
-    }
-
-    /**
-     * Alias for _changeBaseURL.then(_fetchJSON).
-     *
-     * @param {Gerrit.ChangeFetchRequest} req
-     * @return {!Promise<!Object>}
-     */
-    _getChangeURLAndFetch(req, noAcceptHeader) {
-      const anonymizedEndpoint = req.reportEndpointAsIs ?
-        req.endpoint : req.anonymizedEndpoint;
-      const anonymizedBaseUrl = req.patchNum ?
-        ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
-      return this._changeBaseURL(req.changeNum, req.patchNum)
-          .then(url => this._restApiHelper.fetchJSON({
-            url: url + req.endpoint,
-            errFn: req.errFn,
-            params: req.params,
-            fetchOptions: req.fetchOptions,
-            anonymizedUrl: anonymizedEndpoint ?
-              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
-          }, noAcceptHeader));
-    }
-
-    /**
-     * Execute a change action or revision action on a change.
-     *
-     * @param {number} changeNum
-     * @param {string} method
-     * @param {string} endpoint
-     * @param {string|number|undefined} opt_patchNum
-     * @param {Object=} opt_payload
-     * @param {?function(?Response, string=)=} opt_errFn
-     * @return {Promise}
-     */
-    executeChangeAction(changeNum, method, endpoint, opt_patchNum, opt_payload,
-        opt_errFn) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method,
-        patchNum: opt_patchNum,
-        endpoint,
-        body: opt_payload,
-        errFn: opt_errFn,
-      });
-    }
-
-    /**
-     * Get blame information for the given diff.
-     *
-     * @param {string|number} changeNum
-     * @param {string|number} patchNum
-     * @param {string} path
-     * @param {boolean=} opt_base If true, requests blame for the base of the
-     *     diff, rather than the revision.
-     * @return {!Promise<!Object>}
-     */
-    getBlame(changeNum, patchNum, path, opt_base) {
-      const encodedPath = encodeURIComponent(path);
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: `/files/${encodedPath}/blame`,
-        patchNum,
-        params: opt_base ? {base: 't'} : undefined,
-        anonymizedEndpoint: '/files/*/blame',
-      });
-    }
-
-    /**
-     * Modify the given create draft request promise so that it fails and throws
-     * an error if the response bears HTTP status 200 instead of HTTP 201.
-     *
-     * @see Issue 7763
-     * @param {Promise} promise The original promise.
-     * @return {Promise} The modified promise.
-     */
-    _failForCreate200(promise) {
-      return promise.then(result => {
-        if (result.status === 200) {
-          // Read the response headers into an object representation.
-          const headers = Array.from(result.headers.entries())
-              .reduce((obj, [key, val]) => {
-                if (!HEADER_REPORTING_BLACKLIST.test(key)) {
-                  obj[key] = val;
-                }
-                return obj;
-              }, {});
-          const err = new Error([
-            CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE,
-            JSON.stringify(headers),
-          ].join('\n'));
-          // Throw the error so that it is caught by gr-reporting.
-          throw err;
-        }
-        return result;
-      });
-    }
-
-    /**
-     * Fetch a project dashboard definition.
-     * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
-     *
-     * @param {string} project
-     * @param {string} dashboard
-     * @param {function(?Response, string=)=} opt_errFn
-     *    passed as null sometimes.
-     * @return {!Promise<!Object>}
-     */
-    getDashboard(project, dashboard, opt_errFn) {
-      const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' +
-          encodeURIComponent(dashboard);
-      return this._fetchSharedCacheURL({
-        url,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/dashboards/*',
-      });
-    }
-
-    /**
-     * @param {string} filter
-     * @return {!Promise<?Object>}
-     */
-    getDocumentationSearches(filter) {
-      filter = filter.trim();
-      const encodedFilter = encodeURIComponent(filter);
-
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._fetchSharedCacheURL({
-        url: `/Documentation/?q=${encodedFilter}`,
-        anonymizedUrl: '/Documentation/?*',
-      });
-    }
-
-    getMergeable(changeNum) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/revisions/current/mergeable',
-        parseResponse: true,
-        reportEndpointAsIs: true,
-      });
-    }
-
-    deleteDraftComments(query) {
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: '/accounts/self/drafts:delete',
-        body: {query},
-      });
+  /**
+   * @param {?Object} obj
+   */
+  _updateCachedAccount(obj) {
+    // If result of getAccount is in cache, update it in the cache
+    // so we don't have to invalidate it.
+    const cachedAccount = this._cache.get('/accounts/self/detail');
+    if (cachedAccount) {
+      // Replace object in cache with new object to force UI updates.
+      this._cache.set('/accounts/self/detail',
+          Object.assign({}, cachedAccount, obj));
     }
   }
 
-  customElements.define(GrRestApiInterface.is, GrRestApiInterface);
-})();
+  /**
+   * @param {string} name
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  setAccountName(name, opt_errFn) {
+    const req = {
+      method: 'PUT',
+      url: '/accounts/self/name',
+      body: {name},
+      errFn: opt_errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req)
+        .then(newName => this._updateCachedAccount({name: newName}));
+  }
+
+  /**
+   * @param {string} username
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  setAccountUsername(username, opt_errFn) {
+    const req = {
+      method: 'PUT',
+      url: '/accounts/self/username',
+      body: {username},
+      errFn: opt_errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req)
+        .then(newName => this._updateCachedAccount({username: newName}));
+  }
+
+  /**
+   * @param {string} status
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  setAccountStatus(status, opt_errFn) {
+    const req = {
+      method: 'PUT',
+      url: '/accounts/self/status',
+      body: {status},
+      errFn: opt_errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req)
+        .then(newStatus => this._updateCachedAccount({status: newStatus}));
+  }
+
+  getAccountStatus(userId) {
+    return this._restApiHelper.fetchJSON({
+      url: `/accounts/${encodeURIComponent(userId)}/status`,
+      anonymizedUrl: '/accounts/*/status',
+    });
+  }
+
+  getAccountGroups() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/groups',
+      reportUrlAsIs: true,
+    });
+  }
+
+  getAccountAgreements() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/agreements',
+      reportUrlAsIs: true,
+    });
+  }
+
+  saveAccountAgreement(name) {
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: '/accounts/self/agreements',
+      body: name,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string=} opt_params
+   */
+  getAccountCapabilities(opt_params) {
+    let queryString = '';
+    if (opt_params) {
+      queryString = '?q=' + opt_params
+          .map(param => encodeURIComponent(param))
+          .join('&q=');
+    }
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/capabilities' + queryString,
+      anonymizedUrl: '/accounts/self/capabilities?q=*',
+    });
+  }
+
+  getLoggedIn() {
+    return this._auth.authCheck();
+  }
+
+  getIsAdmin() {
+    return this.getLoggedIn()
+        .then(isLoggedIn => {
+          if (isLoggedIn) {
+            return this.getAccountCapabilities();
+          } else {
+            return Promise.resolve();
+          }
+        })
+        .then(
+            capabilities => capabilities && capabilities.administrateServer
+        );
+  }
+
+  getDefaultPreferences() {
+    return this._fetchSharedCacheURL({
+      url: '/config/server/preferences',
+      reportUrlAsIs: true,
+    });
+  }
+
+  getPreferences() {
+    return this.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
+        return this._fetchSharedCacheURL(req).then(res => {
+          if (this._isNarrowScreen()) {
+            // Note that this can be problematic, because the diff will stay
+            // unified even after increasing the window width.
+            res.default_diff_view = DiffViewMode.UNIFIED;
+          } else {
+            res.default_diff_view = res.diff_view;
+          }
+          return Promise.resolve(res);
+        });
+      }
+
+      return Promise.resolve({
+        changes_per_page: 25,
+        default_diff_view: this._isNarrowScreen() ?
+          DiffViewMode.UNIFIED : DiffViewMode.SIDE_BY_SIDE,
+        diff_view: 'SIDE_BY_SIDE',
+        size_bar_in_change_table: true,
+      });
+    });
+  }
+
+  getWatchedProjects() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/watched.projects',
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string} projects
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  saveWatchedProjects(projects, opt_errFn) {
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: '/accounts/self/watched.projects',
+      body: projects,
+      errFn: opt_errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string} projects
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  deleteWatchedProjects(projects, opt_errFn) {
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: '/accounts/self/watched.projects:delete',
+      body: projects,
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
+
+  _isNarrowScreen() {
+    return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
+  }
+
+  /**
+   * @param {number=} opt_changesPerPage
+   * @param {string|!Array<string>=} opt_query A query or an array of queries.
+   * @param {number|string=} opt_offset
+   * @param {!Object=} opt_options
+   * @return {?Array<!Object>|?Array<!Array<!Object>>} If opt_query is an
+   *     array, _fetchJSON will return an array of arrays of changeInfos. If it
+   *     is unspecified or a string, _fetchJSON will return an array of
+   *     changeInfos.
+   */
+  getChanges(opt_changesPerPage, opt_query, opt_offset, opt_options) {
+    const options = opt_options || this.listChangesOptionsToHex(
+        this.ListChangesOption.LABELS,
+        this.ListChangesOption.DETAILED_ACCOUNTS
+    );
+    // Issue 4524: respect legacy token with max sortkey.
+    if (opt_offset === 'n,z') {
+      opt_offset = 0;
+    }
+    const params = {
+      O: options,
+      S: opt_offset || 0,
+    };
+    if (opt_changesPerPage) { params.n = opt_changesPerPage; }
+    if (opt_query && opt_query.length > 0) {
+      params.q = opt_query;
+    }
+    const iterateOverChanges = arr => {
+      for (const change of (arr || [])) {
+        this._maybeInsertInLookup(change);
+      }
+    };
+    const req = {
+      url: '/changes/',
+      params,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.fetchJSON(req).then(response => {
+      // Response may be an array of changes OR an array of arrays of
+      // changes.
+      if (opt_query instanceof Array) {
+        // Normalize the response to look like a multi-query response
+        // when there is only one query.
+        if (opt_query.length === 1) {
+          response = [response];
+        }
+        for (const arr of response) {
+          iterateOverChanges(arr);
+        }
+      } else {
+        iterateOverChanges(response);
+      }
+      return response;
+    });
+  }
+
+  /**
+   * Inserts a change into _projectLookup iff it has a valid structure.
+   *
+   * @param {?{ _number: (number|string) }} change
+   */
+  _maybeInsertInLookup(change) {
+    if (change && change.project && change._number) {
+      this.setInProjectLookup(change._number, change.project);
+    }
+  }
+
+  /**
+   * TODO (beckysiegel) this needs to be rewritten with the optional param
+   * at the end.
+   *
+   * @param {number|string} changeNum
+   * @param {?number|string=} opt_patchNum passed as null sometimes.
+   * @param {?=} endpoint
+   * @return {!Promise<string>}
+   */
+  getChangeActionURL(changeNum, opt_patchNum, endpoint) {
+    return this._changeBaseURL(changeNum, opt_patchNum)
+        .then(url => url + endpoint);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {function(?Response, string=)=} opt_errFn
+   * @param {function()=} opt_cancelCondition
+   */
+  getChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
+    return this.getConfig(false).then(config => {
+      const optionsHex = this._getChangeOptionsHex(config);
+      return this._getChangeDetail(
+          changeNum, optionsHex, opt_errFn, opt_cancelCondition)
+          .then(GrReviewerUpdatesParser.parse);
+    });
+  }
+
+  _getChangeOptionsHex(config) {
+    if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.changePage
+        && !(config.receive && config.receive.enable_signed_push)) {
+      return window.DEFAULT_DETAIL_HEXES.changePage;
+    }
+
+    // This list MUST be kept in sync with
+    // ChangeIT#changeDetailsDoesNotRequireIndex
+    const options = [
+      this.ListChangesOption.ALL_COMMITS,
+      this.ListChangesOption.ALL_REVISIONS,
+      this.ListChangesOption.CHANGE_ACTIONS,
+      this.ListChangesOption.DETAILED_LABELS,
+      this.ListChangesOption.DOWNLOAD_COMMANDS,
+      this.ListChangesOption.MESSAGES,
+      this.ListChangesOption.SUBMITTABLE,
+      this.ListChangesOption.WEB_LINKS,
+      this.ListChangesOption.SKIP_DIFFSTAT,
+    ];
+    if (config.receive && config.receive.enable_signed_push) {
+      options.push(this.ListChangesOption.PUSH_CERTIFICATES);
+    }
+    return this.listChangesOptionsToHex(...options);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {function(?Response, string=)=} opt_errFn
+   * @param {function()=} opt_cancelCondition
+   */
+  getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
+    let optionsHex = '';
+    if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.diffPage) {
+      optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
+    } else {
+      optionsHex = this.listChangesOptionsToHex(
+          this.ListChangesOption.ALL_COMMITS,
+          this.ListChangesOption.ALL_REVISIONS,
+          this.ListChangesOption.SKIP_DIFFSTAT
+      );
+    }
+    return this._getChangeDetail(changeNum, optionsHex, opt_errFn,
+        opt_cancelCondition);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {string|undefined} optionsHex list changes options in hex
+   * @param {function(?Response, string=)=} opt_errFn
+   * @param {function()=} opt_cancelCondition
+   */
+  _getChangeDetail(changeNum, optionsHex, opt_errFn, opt_cancelCondition) {
+    return this.getChangeActionURL(changeNum, null, '/detail').then(url => {
+      const urlWithParams = this._restApiHelper
+          .urlWithParams(url, optionsHex);
+      const params = {O: optionsHex};
+      const req = {
+        url,
+        errFn: opt_errFn,
+        cancelCondition: opt_cancelCondition,
+        params,
+        fetchOptions: this._etags.getOptions(urlWithParams),
+        anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
+      };
+      return this._restApiHelper.fetchRawJSON(req).then(response => {
+        if (response && response.status === 304) {
+          return Promise.resolve(this._restApiHelper.parsePrefixedJSON(
+              this._etags.getCachedPayload(urlWithParams)));
+        }
+
+        if (response && !response.ok) {
+          if (opt_errFn) {
+            opt_errFn.call(null, response);
+          } else {
+            this.fire('server-error', {request: req, response});
+          }
+          return;
+        }
+
+        const payloadPromise = response ?
+          this._restApiHelper.readResponsePayload(response) :
+          Promise.resolve(null);
+
+        return payloadPromise.then(payload => {
+          if (!payload) { return null; }
+          this._etags.collect(urlWithParams, response, payload.raw);
+          this._maybeInsertInLookup(payload.parsed);
+
+          return payload.parsed;
+        });
+      });
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string} patchNum
+   */
+  getChangeCommitInfo(changeNum, patchNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/commit?links',
+      patchNum,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {Gerrit.PatchRange} patchRange
+   * @param {number=} opt_parentIndex
+   */
+  getChangeFiles(changeNum, patchRange, opt_parentIndex) {
+    let params = undefined;
+    if (this.isMergeParent(patchRange.basePatchNum)) {
+      params = {parent: this.getParentIndex(patchRange.basePatchNum)};
+    } else if (!this.patchNumEquals(patchRange.basePatchNum, 'PARENT')) {
+      params = {base: patchRange.basePatchNum};
+    }
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/files',
+      patchNum: patchRange.patchNum,
+      params,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {Gerrit.PatchRange} patchRange
+   */
+  getChangeEditFiles(changeNum, patchRange) {
+    let endpoint = '/edit?list';
+    let anonymizedEndpoint = endpoint;
+    if (patchRange.basePatchNum !== 'PARENT') {
+      endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + '');
+      anonymizedEndpoint += '&base=*';
+    }
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint,
+      anonymizedEndpoint,
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string} patchNum
+   * @param {string} query
+   * @return {!Promise<!Object>}
+   */
+  queryChangeFiles(changeNum, patchNum, query) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: `/files?q=${encodeURIComponent(query)}`,
+      patchNum,
+      anonymizedEndpoint: '/files?q=*',
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {Gerrit.PatchRange} patchRange
+   * @return {!Promise<!Array<!Object>>}
+   */
+  getChangeOrEditFiles(changeNum, patchRange) {
+    if (this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME)) {
+      return this.getChangeEditFiles(changeNum, patchRange).then(res =>
+        res.files);
+    }
+    return this.getChangeFiles(changeNum, patchRange);
+  }
+
+  getChangeRevisionActions(changeNum, patchNum) {
+    const req = {
+      changeNum,
+      endpoint: '/actions',
+      patchNum,
+      reportEndpointAsIs: true,
+    };
+    return this._getChangeURLAndFetch(req).then(revisionActions => {
+      // The rebase button on change screen is always enabled.
+      if (revisionActions.rebase) {
+        revisionActions.rebase.rebaseOnCurrent =
+            !!revisionActions.rebase.enabled;
+        revisionActions.rebase.enabled = true;
+      }
+      return revisionActions;
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {string} inputVal
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) {
+    return this._getChangeSuggestedGroup('REVIEWER', changeNum, inputVal,
+        opt_errFn);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {string} inputVal
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  getChangeSuggestedCCs(changeNum, inputVal, opt_errFn) {
+    return this._getChangeSuggestedGroup('CC', changeNum, inputVal,
+        opt_errFn);
+  }
+
+  _getChangeSuggestedGroup(reviewerState, changeNum, inputVal, opt_errFn) {
+    // More suggestions may obscure content underneath in the reply dialog,
+    // see issue 10793.
+    const params = {'n': 6, 'reviewer-state': reviewerState};
+    if (inputVal) { params.q = inputVal; }
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/suggest_reviewers',
+      errFn: opt_errFn,
+      params,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   */
+  getChangeIncludedIn(changeNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/in',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  _computeFilter(filter) {
+    if (filter && filter.startsWith('^')) {
+      filter = '&r=' + encodeURIComponent(filter);
+    } else if (filter) {
+      filter = '&m=' + encodeURIComponent(filter);
+    } else {
+      filter = '';
+    }
+    return filter;
+  }
+
+  /**
+   * @param {string} filter
+   * @param {number} groupsPerPage
+   * @param {number=} opt_offset
+   */
+  _getGroupsUrl(filter, groupsPerPage, opt_offset) {
+    const offset = opt_offset || 0;
+
+    return `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
+      this._computeFilter(filter);
+  }
+
+  /**
+   * @param {string} filter
+   * @param {number} reposPerPage
+   * @param {number=} opt_offset
+   */
+  _getReposUrl(filter, reposPerPage, opt_offset) {
+    const defaultFilter = 'state:active OR state:read-only';
+    const namePartDelimiters = /[@.\-\s\/_]/g;
+    const offset = opt_offset || 0;
+
+    if (filter && !filter.includes(':') && filter.match(namePartDelimiters)) {
+      // The query language specifies hyphens as operators. Split the string
+      // by hyphens and 'AND' the parts together as 'inname:' queries.
+      // If the filter includes a semicolon, the user is using a more complex
+      // query so we trust them and don't do any magic under the hood.
+      const originalFilter = filter;
+      filter = '';
+      originalFilter.split(namePartDelimiters).forEach(part => {
+        if (part) {
+          filter += (filter === '' ? 'inname:' : ' AND inname:') + part;
+        }
+      });
+    }
+    // Check if filter is now empty which could be either because the user did
+    // not provide it or because the user provided only a split character.
+    if (!filter) {
+      filter = defaultFilter;
+    }
+
+    filter = filter.trim();
+    const encodedFilter = encodeURIComponent(filter);
+
+    return `/projects/?n=${reposPerPage + 1}&S=${offset}` +
+      `&query=${encodedFilter}`;
+  }
+
+  invalidateGroupsCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
+  }
+
+  invalidateReposCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
+  }
+
+  invalidateAccountsCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
+  }
+
+  /**
+   * @param {string} filter
+   * @param {number} groupsPerPage
+   * @param {number=} opt_offset
+   * @return {!Promise<?Object>}
+   */
+  getGroups(filter, groupsPerPage, opt_offset) {
+    const url = this._getGroupsUrl(filter, groupsPerPage, opt_offset);
+
+    return this._fetchSharedCacheURL({
+      url,
+      anonymizedUrl: '/groups/?*',
+    });
+  }
+
+  /**
+   * @param {string} filter
+   * @param {number} reposPerPage
+   * @param {number=} opt_offset
+   * @return {!Promise<?Object>}
+   */
+  getRepos(filter, reposPerPage, opt_offset) {
+    const url = this._getReposUrl(filter, reposPerPage, opt_offset);
+
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url,
+      anonymizedUrl: '/projects/?*',
+    });
+  }
+
+  setRepoHead(repo, ref) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/projects/${encodeURIComponent(repo)}/HEAD`,
+      body: {ref},
+      anonymizedUrl: '/projects/*/HEAD',
+    });
+  }
+
+  /**
+   * @param {string} filter
+   * @param {string} repo
+   * @param {number} reposBranchesPerPage
+   * @param {number=} opt_offset
+   * @param {?function(?Response, string=)=} opt_errFn
+   * @return {!Promise<?Object>}
+   */
+  getRepoBranches(filter, repo, reposBranchesPerPage, opt_offset, opt_errFn) {
+    const offset = opt_offset || 0;
+    const count = reposBranchesPerPage + 1;
+    filter = this._computeFilter(filter);
+    repo = encodeURIComponent(repo);
+    const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.fetchJSON({
+      url,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/branches?*',
+    });
+  }
+
+  /**
+   * @param {string} filter
+   * @param {string} repo
+   * @param {number} reposTagsPerPage
+   * @param {number=} opt_offset
+   * @param {?function(?Response, string=)=} opt_errFn
+   * @return {!Promise<?Object>}
+   */
+  getRepoTags(filter, repo, reposTagsPerPage, opt_offset, opt_errFn) {
+    const offset = opt_offset || 0;
+    const encodedRepo = encodeURIComponent(repo);
+    const n = reposTagsPerPage + 1;
+    const encodedFilter = this._computeFilter(filter);
+    const url = `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` +
+        encodedFilter;
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.fetchJSON({
+      url,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/tags',
+    });
+  }
+
+  /**
+   * @param {string} filter
+   * @param {number} pluginsPerPage
+   * @param {number=} opt_offset
+   * @param {?function(?Response, string=)=} opt_errFn
+   * @return {!Promise<?Object>}
+   */
+  getPlugins(filter, pluginsPerPage, opt_offset, opt_errFn) {
+    const offset = opt_offset || 0;
+    const encodedFilter = this._computeFilter(filter);
+    const n = pluginsPerPage + 1;
+    const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
+    return this._restApiHelper.fetchJSON({
+      url,
+      errFn: opt_errFn,
+      anonymizedUrl: '/plugins/?all',
+    });
+  }
+
+  getRepoAccessRights(repoName, opt_errFn) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.fetchJSON({
+      url: `/projects/${encodeURIComponent(repoName)}/access`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/access',
+    });
+  }
+
+  setRepoAccessRights(repoName, repoInfo) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: `/projects/${encodeURIComponent(repoName)}/access`,
+      body: repoInfo,
+      anonymizedUrl: '/projects/*/access',
+    });
+  }
+
+  setRepoAccessRightsForReview(projectName, projectInfo) {
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/projects/${encodeURIComponent(projectName)}/access:review`,
+      body: projectInfo,
+      parseResponse: true,
+      anonymizedUrl: '/projects/*/access:review',
+    });
+  }
+
+  /**
+   * @param {string} inputVal
+   * @param {number} opt_n
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  getSuggestedGroups(inputVal, opt_n, opt_errFn) {
+    const params = {s: inputVal};
+    if (opt_n) { params.n = opt_n; }
+    return this._restApiHelper.fetchJSON({
+      url: '/groups/',
+      errFn: opt_errFn,
+      params,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string} inputVal
+   * @param {number} opt_n
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  getSuggestedProjects(inputVal, opt_n, opt_errFn) {
+    const params = {
+      m: inputVal,
+      n: MAX_PROJECT_RESULTS,
+      type: 'ALL',
+    };
+    if (opt_n) { params.n = opt_n; }
+    return this._restApiHelper.fetchJSON({
+      url: '/projects/',
+      errFn: opt_errFn,
+      params,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string} inputVal
+   * @param {number} opt_n
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  getSuggestedAccounts(inputVal, opt_n, opt_errFn) {
+    if (!inputVal) {
+      return Promise.resolve([]);
+    }
+    const params = {suggest: null, q: inputVal};
+    if (opt_n) { params.n = opt_n; }
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/',
+      errFn: opt_errFn,
+      params,
+      anonymizedUrl: '/accounts/?n=*',
+    });
+  }
+
+  addChangeReviewer(changeNum, reviewerID) {
+    return this._sendChangeReviewerRequest('POST', changeNum, reviewerID);
+  }
+
+  removeChangeReviewer(changeNum, reviewerID) {
+    return this._sendChangeReviewerRequest('DELETE', changeNum, reviewerID);
+  }
+
+  _sendChangeReviewerRequest(method, changeNum, reviewerID) {
+    return this.getChangeActionURL(changeNum, null, '/reviewers')
+        .then(url => {
+          let body;
+          switch (method) {
+            case 'POST':
+              body = {reviewer: reviewerID};
+              break;
+            case 'DELETE':
+              url += '/' + encodeURIComponent(reviewerID);
+              break;
+            default:
+              throw Error('Unsupported HTTP method: ' + method);
+          }
+
+          return this._restApiHelper.send({method, url, body});
+        });
+  }
+
+  getRelatedChanges(changeNum, patchNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/related',
+      patchNum,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  getChangesSubmittedTogether(changeNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  getChangeConflicts(changeNum) {
+    const options = this.listChangesOptionsToHex(
+        this.ListChangesOption.CURRENT_REVISION,
+        this.ListChangesOption.CURRENT_COMMIT
+    );
+    const params = {
+      O: options,
+      q: 'status:open conflicts:' + changeNum,
+    };
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params,
+      anonymizedUrl: '/changes/conflicts:*',
+    });
+  }
+
+  getChangeCherryPicks(project, changeID, changeNum) {
+    const options = this.listChangesOptionsToHex(
+        this.ListChangesOption.CURRENT_REVISION,
+        this.ListChangesOption.CURRENT_COMMIT
+    );
+    const query = [
+      'project:' + project,
+      'change:' + changeID,
+      '-change:' + changeNum,
+      '-is:abandoned',
+    ].join(' ');
+    const params = {
+      O: options,
+      q: query,
+    };
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params,
+      anonymizedUrl: '/changes/change:*',
+    });
+  }
+
+  getChangesWithSameTopic(topic, changeNum) {
+    const options = this.listChangesOptionsToHex(
+        this.ListChangesOption.LABELS,
+        this.ListChangesOption.CURRENT_REVISION,
+        this.ListChangesOption.CURRENT_COMMIT,
+        this.ListChangesOption.DETAILED_LABELS
+    );
+    const query = [
+      'status:open',
+      '-change:' + changeNum,
+      `topic:"${topic}"`,
+    ].join(' ');
+    const params = {
+      O: options,
+      q: query,
+    };
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params,
+      anonymizedUrl: '/changes/topic:*',
+    });
+  }
+
+  getReviewedFiles(changeNum, patchNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/files?reviewed',
+      patchNum,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string} patchNum
+   * @param {string} path
+   * @param {boolean} reviewed
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  saveFileReviewed(changeNum, patchNum, path, reviewed, opt_errFn) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: reviewed ? 'PUT' : 'DELETE',
+      patchNum,
+      endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
+      errFn: opt_errFn,
+      anonymizedEndpoint: '/files/*/reviewed',
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string} patchNum
+   * @param {!Object} review
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  saveChangeReview(changeNum, patchNum, review, opt_errFn) {
+    const promises = [
+      this.awaitPendingDiffDrafts(),
+      this.getChangeActionURL(changeNum, patchNum, '/review'),
+    ];
+    return Promise.all(promises).then(([, url]) => this._restApiHelper.send({
+      method: 'POST',
+      url,
+      body: review,
+      errFn: opt_errFn,
+    }));
+  }
+
+  getChangeEdit(changeNum, opt_download_commands) {
+    const params = opt_download_commands ? {'download-commands': true} : null;
+    return this.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) { return false; }
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/edit/',
+        params,
+        reportEndpointAsIs: true,
+      }, true);
+    });
+  }
+
+  /**
+   * @param {string} project
+   * @param {string} branch
+   * @param {string} subject
+   * @param {string=} opt_topic
+   * @param {boolean=} opt_isPrivate
+   * @param {boolean=} opt_workInProgress
+   * @param {string=} opt_baseChange
+   * @param {string=} opt_baseCommit
+   */
+  createChange(project, branch, subject, opt_topic, opt_isPrivate,
+      opt_workInProgress, opt_baseChange, opt_baseCommit) {
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: '/changes/',
+      body: {
+        project,
+        branch,
+        subject,
+        topic: opt_topic,
+        is_private: opt_isPrivate,
+        work_in_progress: opt_workInProgress,
+        base_change: opt_baseChange,
+        base_commit: opt_baseCommit,
+      },
+      parseResponse: true,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {string} path
+   * @param {number|string} patchNum
+   */
+  getFileContent(changeNum, path, patchNum) {
+    // 404s indicate the file does not exist yet in the revision, so suppress
+    // them.
+    const suppress404s = res => {
+      if (res && res.status !== 404) { this.fire('server-error', {res}); }
+      return res;
+    };
+    const promise = this.patchNumEquals(patchNum, this.EDIT_NAME) ?
+      this._getFileInChangeEdit(changeNum, path) :
+      this._getFileInRevision(changeNum, path, patchNum, suppress404s);
+
+    return promise.then(res => {
+      if (!res.ok) { return res; }
+
+      // The file type (used for syntax highlighting) is identified in the
+      // X-FYI-Content-Type header of the response.
+      const type = res.headers.get('X-FYI-Content-Type');
+      return this.getResponseObject(res).then(content => {
+        return {content, type, ok: true};
+      });
+    });
+  }
+
+  /**
+   * Gets a file in a specific change and revision.
+   *
+   * @param {number|string} changeNum
+   * @param {string} path
+   * @param {number|string} patchNum
+   * @param {?function(?Response, string=)=} opt_errFn
+   */
+  _getFileInRevision(changeNum, path, patchNum, opt_errFn) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'GET',
+      patchNum,
+      endpoint: `/files/${encodeURIComponent(path)}/content`,
+      errFn: opt_errFn,
+      headers: {Accept: 'application/json'},
+      anonymizedEndpoint: '/files/*/content',
+    });
+  }
+
+  /**
+   * Gets a file in a change edit.
+   *
+   * @param {number|string} changeNum
+   * @param {string} path
+   */
+  _getFileInChangeEdit(changeNum, path) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'GET',
+      endpoint: '/edit/' + encodeURIComponent(path),
+      headers: {Accept: 'application/json'},
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  rebaseChangeEdit(changeNum) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/edit:rebase',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteChangeEdit(changeNum) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'DELETE',
+      endpoint: '/edit',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  restoreFileInChangeEdit(changeNum, restore_path) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/edit',
+      body: {restore_path},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  renameFileInChangeEdit(changeNum, old_path, new_path) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/edit',
+      body: {old_path, new_path},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteFileInChangeEdit(changeNum, path) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'DELETE',
+      endpoint: '/edit/' + encodeURIComponent(path),
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  saveChangeEdit(changeNum, path, contents) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: '/edit/' + encodeURIComponent(path),
+      body: contents,
+      contentType: 'text/plain',
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  getRobotCommentFixPreview(changeNum, patchNum, fixId) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      patchNum,
+      endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
+      reportEndpointAsId: true,
+    });
+  }
+
+  applyFixSuggestion(changeNum, patchNum, fixId) {
+    return this._getChangeURLAndSend({
+      method: 'POST',
+      changeNum,
+      patchNum,
+      endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`,
+      reportEndpointAsId: true,
+    });
+  }
+
+  // Deprecated, prefer to use putChangeCommitMessage instead.
+  saveChangeCommitMessageEdit(changeNum, message) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: '/edit:message',
+      body: {message},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  publishChangeEdit(changeNum) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/edit:publish',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  putChangeCommitMessage(changeNum, message) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: '/message',
+      body: {message},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteChangeCommitMessage(changeNum, messageId) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'DELETE',
+      endpoint: '/messages/' + messageId,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  saveChangeStarred(changeNum, starred) {
+    // Some servers may require the project name to be provided
+    // alongside the change number, so resolve the project name
+    // first.
+    return this.getFromProjectLookup(changeNum).then(project => {
+      const url = '/accounts/self/starred.changes/' +
+          (project ? encodeURIComponent(project) + '~' : '') + changeNum;
+      return this._restApiHelper.send({
+        method: starred ? 'PUT' : 'DELETE',
+        url,
+        anonymizedUrl: '/accounts/self/starred.changes/*',
+      });
+    });
+  }
+
+  saveChangeReviewed(changeNum, reviewed) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: reviewed ? '/reviewed' : '/unreviewed',
+    });
+  }
+
+  /**
+   * Public version of the _restApiHelper.send method preserved for plugins.
+   *
+   * @param {string} method
+   * @param {string} url
+   * @param {?string|number|Object=} opt_body passed as null sometimes
+   *    and also apparently a number. TODO (beckysiegel) remove need for
+   *    number at least.
+   * @param {?function(?Response, string=)=} opt_errFn
+   *    passed as null sometimes.
+   * @param {?string=} opt_contentType
+   * @param {Object=} opt_headers
+   */
+  send(method, url, opt_body, opt_errFn, opt_contentType,
+      opt_headers) {
+    return this._restApiHelper.send({
+      method,
+      url,
+      body: opt_body,
+      errFn: opt_errFn,
+      contentType: opt_contentType,
+      headers: opt_headers,
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string} basePatchNum Negative values specify merge parent
+   *     index.
+   * @param {number|string} patchNum
+   * @param {string} path
+   * @param {string=} opt_whitespace the ignore-whitespace level for the diff
+   *     algorithm.
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  getDiff(changeNum, basePatchNum, patchNum, path, opt_whitespace,
+      opt_errFn) {
+    const params = {
+      context: 'ALL',
+      intraline: null,
+      whitespace: opt_whitespace || 'IGNORE_NONE',
+    };
+    if (this.isMergeParent(basePatchNum)) {
+      params.parent = this.getParentIndex(basePatchNum);
+    } else if (!this.patchNumEquals(basePatchNum, PARENT_PATCH_NUM)) {
+      params.base = basePatchNum;
+    }
+    const endpoint = `/files/${encodeURIComponent(path)}/diff`;
+    const req = {
+      changeNum,
+      endpoint,
+      patchNum,
+      errFn: opt_errFn,
+      params,
+      anonymizedEndpoint: '/files/*/diff',
+    };
+
+    // Invalidate the cache if its edit patch to make sure we always get latest.
+    if (patchNum === this.EDIT_NAME) {
+      if (!req.fetchOptions) req.fetchOptions = {};
+      if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
+      req.fetchOptions.headers.append('Cache-Control', 'no-cache');
+    }
+
+    return this._getChangeURLAndFetch(req);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string=} opt_basePatchNum
+   * @param {number|string=} opt_patchNum
+   * @param {string=} opt_path
+   * @return {!Promise<!Object>}
+   */
+  getDiffComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
+    return this._getDiffComments(changeNum, '/comments', opt_basePatchNum,
+        opt_patchNum, opt_path);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string=} opt_basePatchNum
+   * @param {number|string=} opt_patchNum
+   * @param {string=} opt_path
+   * @return {!Promise<!Object>}
+   */
+  getDiffRobotComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
+    return this._getDiffComments(changeNum, '/robotcomments',
+        opt_basePatchNum, opt_patchNum, opt_path);
+  }
+
+  /**
+   * If the user is logged in, fetch the user's draft diff comments. If there
+   * is no logged in user, the request is not made and the promise yields an
+   * empty object.
+   *
+   * @param {number|string} changeNum
+   * @param {number|string=} opt_basePatchNum
+   * @param {number|string=} opt_patchNum
+   * @param {string=} opt_path
+   * @return {!Promise<!Object>}
+   */
+  getDiffDrafts(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
+    return this.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) { return Promise.resolve({}); }
+      return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum,
+          opt_patchNum, opt_path);
+    });
+  }
+
+  _setRange(comments, comment) {
+    if (comment.in_reply_to && !comment.range) {
+      for (let i = 0; i < comments.length; i++) {
+        if (comments[i].id === comment.in_reply_to) {
+          comment.range = comments[i].range;
+          break;
+        }
+      }
+    }
+    return comment;
+  }
+
+  _setRanges(comments) {
+    comments = comments || [];
+    comments.sort(
+        (a, b) => util.parseDate(a.updated) - util.parseDate(b.updated)
+    );
+    for (const comment of comments) {
+      this._setRange(comments, comment);
+    }
+    return comments;
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {string} endpoint
+   * @param {number|string=} opt_basePatchNum
+   * @param {number|string=} opt_patchNum
+   * @param {string=} opt_path
+   * @return {!Promise<!Object>}
+   */
+  _getDiffComments(changeNum, endpoint, opt_basePatchNum,
+      opt_patchNum, opt_path) {
+    /**
+     * Fetches the comments for a given patchNum.
+     * Helper function to make promises more legible.
+     *
+     * @param {string|number=} opt_patchNum
+     * @return {!Promise<!Object>} Diff comments response.
+     */
+    // We don't want to add accept header, since preloading of comments is
+    // working only without accept header.
+    const noAcceptHeader = true;
+    const fetchComments = opt_patchNum => this._getChangeURLAndFetch({
+      changeNum,
+      endpoint,
+      patchNum: opt_patchNum,
+      reportEndpointAsIs: true,
+    }, noAcceptHeader);
+
+    if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
+      return fetchComments();
+    }
+    function onlyParent(c) { return c.side == PARENT_PATCH_NUM; }
+    function withoutParent(c) { return c.side != PARENT_PATCH_NUM; }
+    function setPath(c) { c.path = opt_path; }
+
+    const promises = [];
+    let comments;
+    let baseComments;
+    let fetchPromise;
+    fetchPromise = fetchComments(opt_patchNum).then(response => {
+      comments = response[opt_path] || [];
+      // TODO(kaspern): Implement this on in the backend so this can
+      // be removed.
+      // Sort comments by date so that parent ranges can be propagated
+      // in a single pass.
+      comments = this._setRanges(comments);
+
+      if (opt_basePatchNum == PARENT_PATCH_NUM) {
+        baseComments = comments.filter(onlyParent);
+        baseComments.forEach(setPath);
+      }
+      comments = comments.filter(withoutParent);
+
+      comments.forEach(setPath);
+    });
+    promises.push(fetchPromise);
+
+    if (opt_basePatchNum != PARENT_PATCH_NUM) {
+      fetchPromise = fetchComments(opt_basePatchNum).then(response => {
+        baseComments = (response[opt_path] || [])
+            .filter(withoutParent);
+        baseComments = this._setRanges(baseComments);
+        baseComments.forEach(setPath);
+      });
+      promises.push(fetchPromise);
+    }
+
+    return Promise.all(promises).then(() => Promise.resolve({
+      baseComments,
+      comments,
+    }));
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {string} endpoint
+   * @param {number|string=} opt_patchNum
+   */
+  _getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum) {
+    return this._changeBaseURL(changeNum, opt_patchNum)
+        .then(url => url + endpoint);
+  }
+
+  saveDiffDraft(changeNum, patchNum, draft) {
+    return this._sendDiffDraftRequest('PUT', changeNum, patchNum, draft);
+  }
+
+  deleteDiffDraft(changeNum, patchNum, draft) {
+    return this._sendDiffDraftRequest('DELETE', changeNum, patchNum, draft);
+  }
+
+  /**
+   * @returns {boolean} Whether there are pending diff draft sends.
+   */
+  hasPendingDiffDrafts() {
+    const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
+    return promises && promises.length;
+  }
+
+  /**
+   * @returns {!Promise<undefined>} A promise that resolves when all pending
+   *    diff draft sends have resolved.
+   */
+  awaitPendingDiffDrafts() {
+    return Promise.all(this._pendingRequests[Requests.SEND_DIFF_DRAFT] || [])
+        .then(() => {
+          this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
+        });
+  }
+
+  _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
+    const isCreate = !draft.id && method === 'PUT';
+    let endpoint = '/drafts';
+    let anonymizedEndpoint = endpoint;
+    if (draft.id) {
+      endpoint += '/' + draft.id;
+      anonymizedEndpoint += '/*';
+    }
+    let body;
+    if (method === 'PUT') {
+      body = draft;
+    }
+
+    if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
+      this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
+    }
+
+    const req = {
+      changeNum,
+      method,
+      patchNum,
+      endpoint,
+      body,
+      anonymizedEndpoint,
+    };
+
+    const promise = this._getChangeURLAndSend(req);
+    this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
+
+    if (isCreate) {
+      return this._failForCreate200(promise);
+    }
+
+    return promise;
+  }
+
+  getCommitInfo(project, commit) {
+    return this._restApiHelper.fetchJSON({
+      url: '/projects/' + encodeURIComponent(project) +
+          '/commits/' + encodeURIComponent(commit),
+      anonymizedUrl: '/projects/*/comments/*',
+    });
+  }
+
+  _fetchB64File(url) {
+    return this._restApiHelper.fetch({url: this.getBaseUrl() + url})
+        .then(response => {
+          if (!response.ok) {
+            return Promise.reject(new Error(response.statusText));
+          }
+          const type = response.headers.get('X-FYI-Content-Type');
+          return response.text()
+              .then(text => {
+                return {body: text, type};
+              });
+        });
+  }
+
+  /**
+   * @param {string} changeId
+   * @param {string|number} patchNum
+   * @param {string} path
+   * @param {number=} opt_parentIndex
+   */
+  getB64FileContents(changeId, patchNum, path, opt_parentIndex) {
+    const parent = typeof opt_parentIndex === 'number' ?
+      '?parent=' + opt_parentIndex : '';
+    return this._changeBaseURL(changeId, patchNum).then(url => {
+      url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
+      return this._fetchB64File(url);
+    });
+  }
+
+  getImagesForDiff(changeNum, diff, patchRange) {
+    let promiseA;
+    let promiseB;
+
+    if (diff.meta_a && diff.meta_a.content_type.startsWith('image/')) {
+      if (patchRange.basePatchNum === 'PARENT') {
+        // Note: we only attempt to get the image from the first parent.
+        promiseA = this.getB64FileContents(changeNum, patchRange.patchNum,
+            diff.meta_a.name, 1);
+      } else {
+        promiseA = this.getB64FileContents(changeNum,
+            patchRange.basePatchNum, diff.meta_a.name);
+      }
+    } else {
+      promiseA = Promise.resolve(null);
+    }
+
+    if (diff.meta_b && diff.meta_b.content_type.startsWith('image/')) {
+      promiseB = this.getB64FileContents(changeNum, patchRange.patchNum,
+          diff.meta_b.name);
+    } else {
+      promiseB = Promise.resolve(null);
+    }
+
+    return Promise.all([promiseA, promiseB]).then(results => {
+      const baseImage = results[0];
+      const revisionImage = results[1];
+
+      // Sometimes the server doesn't send back the content type.
+      if (baseImage) {
+        baseImage._expectedType = diff.meta_a.content_type;
+        baseImage._name = diff.meta_a.name;
+      }
+      if (revisionImage) {
+        revisionImage._expectedType = diff.meta_b.content_type;
+        revisionImage._name = diff.meta_b.name;
+      }
+
+      return {baseImage, revisionImage};
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {?number|string=} opt_patchNum passed as null sometimes.
+   * @param {string=} opt_project
+   * @return {!Promise<string>}
+   */
+  _changeBaseURL(changeNum, opt_patchNum, opt_project) {
+    // TODO(kaspern): For full slicer migration, app should warn with a call
+    // stack every time _changeBaseURL is called without a project.
+    const projectPromise = opt_project ?
+      Promise.resolve(opt_project) :
+      this.getFromProjectLookup(changeNum);
+    return projectPromise.then(project => {
+      let url = `/changes/${encodeURIComponent(project)}~${changeNum}`;
+      if (opt_patchNum) {
+        url += `/revisions/${opt_patchNum}`;
+      }
+      return url;
+    });
+  }
+
+  /**
+   * @suppress {checkTypes}
+   * Resulted in error: Promise.prototype.then does not match formal
+   * parameter.
+   */
+  setChangeTopic(changeNum, topic) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: '/topic',
+      body: {topic},
+      parseResponse: true,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @suppress {checkTypes}
+   * Resulted in error: Promise.prototype.then does not match formal
+   * parameter.
+   */
+  setChangeHashtag(changeNum, hashtag) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/hashtags',
+      body: hashtag,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    });
+  }
+
+  deleteAccountHttpPassword() {
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: '/accounts/self/password.http',
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @suppress {checkTypes}
+   * Resulted in error: Promise.prototype.then does not match formal
+   * parameter.
+   */
+  generateAccountHttpPassword() {
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: '/accounts/self/password.http',
+      body: {generate: true},
+      parseResponse: true,
+      reportUrlAsIs: true,
+    });
+  }
+
+  getAccountSSHKeys() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/sshkeys',
+      reportUrlAsIs: true,
+    });
+  }
+
+  addAccountSSHKey(key) {
+    const req = {
+      method: 'POST',
+      url: '/accounts/self/sshkeys',
+      body: key,
+      contentType: 'text/plain',
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req)
+        .then(response => {
+          if (response.status < 200 && response.status >= 300) {
+            return Promise.reject(new Error('error'));
+          }
+          return this.getResponseObject(response);
+        })
+        .then(obj => {
+          if (!obj.valid) { return Promise.reject(new Error('error')); }
+          return obj;
+        });
+  }
+
+  deleteAccountSSHKey(id) {
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: '/accounts/self/sshkeys/' + id,
+      anonymizedUrl: '/accounts/self/sshkeys/*',
+    });
+  }
+
+  getAccountGPGKeys() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/gpgkeys',
+      reportUrlAsIs: true,
+    });
+  }
+
+  addAccountGPGKey(key) {
+    const req = {
+      method: 'POST',
+      url: '/accounts/self/gpgkeys',
+      body: key,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req)
+        .then(response => {
+          if (response.status < 200 && response.status >= 300) {
+            return Promise.reject(new Error('error'));
+          }
+          return this.getResponseObject(response);
+        })
+        .then(obj => {
+          if (!obj) { return Promise.reject(new Error('error')); }
+          return obj;
+        });
+  }
+
+  deleteAccountGPGKey(id) {
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: '/accounts/self/gpgkeys/' + id,
+      anonymizedUrl: '/accounts/self/gpgkeys/*',
+    });
+  }
+
+  deleteVote(changeNum, account, label) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'DELETE',
+      endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
+      anonymizedEndpoint: '/reviewers/*/votes/*',
+    });
+  }
+
+  setDescription(changeNum, patchNum, desc) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT', patchNum,
+      endpoint: '/description',
+      body: {description: desc},
+      reportUrlAsIs: true,
+    });
+  }
+
+  confirmEmail(token) {
+    const req = {
+      method: 'PUT',
+      url: '/config/server/email.confirm',
+      body: {token},
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req).then(response => {
+      if (response.status === 204) {
+        return 'Email confirmed successfully.';
+      }
+      return null;
+    });
+  }
+
+  getCapabilities(opt_errFn) {
+    return this._restApiHelper.fetchJSON({
+      url: '/config/server/capabilities',
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
+
+  getTopMenus(opt_errFn) {
+    return this._fetchSharedCacheURL({
+      url: '/config/server/top-menus',
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
+
+  setAssignee(changeNum, assignee) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: '/assignee',
+      body: {assignee},
+      reportUrlAsIs: true,
+    });
+  }
+
+  deleteAssignee(changeNum) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'DELETE',
+      endpoint: '/assignee',
+      reportUrlAsIs: true,
+    });
+  }
+
+  probePath(path) {
+    return fetch(new Request(path, {method: 'HEAD'}))
+        .then(response => response.ok);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string=} opt_message
+   */
+  startWorkInProgress(changeNum, opt_message) {
+    const body = {};
+    if (opt_message) {
+      body.message = opt_message;
+    }
+    const req = {
+      changeNum,
+      method: 'POST',
+      endpoint: '/wip',
+      body,
+      reportUrlAsIs: true,
+    };
+    return this._getChangeURLAndSend(req).then(response => {
+      if (response.status === 204) {
+        return 'Change marked as Work In Progress.';
+      }
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string=} opt_body
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  startReview(changeNum, opt_body, opt_errFn) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/ready',
+      body: opt_body,
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @suppress {checkTypes}
+   * Resulted in error: Promise.prototype.then does not match formal
+   * parameter.
+   */
+  deleteComment(changeNum, patchNum, commentID, reason) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      patchNum,
+      endpoint: `/comments/${commentID}/delete`,
+      body: {reason},
+      parseResponse: true,
+      anonymizedEndpoint: '/comments/*/delete',
+    });
+  }
+
+  /**
+   * Given a changeNum, gets the change.
+   *
+   * @param {number|string} changeNum
+   * @param {function(?Response, string=)=} opt_errFn
+   * @return {!Promise<?Object>} The change
+   */
+  getChange(changeNum, opt_errFn) {
+    // Cannot use _changeBaseURL, as this function is used by _projectLookup.
+    return this._restApiHelper.fetchJSON({
+      url: `/changes/?q=change:${changeNum}`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/changes/?q=change:*',
+    }).then(res => {
+      if (!res || !res.length) { return null; }
+      return res[0];
+    });
+  }
+
+  /**
+   * @param {string|number} changeNum
+   * @param {string=} project
+   */
+  setInProjectLookup(changeNum, project) {
+    if (this._projectLookup[changeNum] &&
+        this._projectLookup[changeNum] !== project) {
+      console.warn('Change set with multiple project nums.' +
+          'One of them must be invalid.');
+    }
+    this._projectLookup[changeNum] = project;
+  }
+
+  /**
+   * Checks in _projectLookup for the changeNum. If it exists, returns the
+   * project. If not, calls the restAPI to get the change, populates
+   * _projectLookup with the project for that change, and returns the project.
+   *
+   * @param {string|number} changeNum
+   * @return {!Promise<string|undefined>}
+   */
+  getFromProjectLookup(changeNum) {
+    const project = this._projectLookup[changeNum];
+    if (project) { return Promise.resolve(project); }
+
+    const onError = response => {
+      // Fire a page error so that the visual 404 is displayed.
+      this.fire('page-error', {response});
+    };
+
+    return this.getChange(changeNum, onError).then(change => {
+      if (!change || !change.project) { return; }
+      this.setInProjectLookup(changeNum, change.project);
+      return change.project;
+    });
+  }
+
+  /**
+   * Alias for _changeBaseURL.then(send).
+   *
+   * @todo(beckysiegel) clean up comments
+   * @param {Gerrit.ChangeSendRequest} req
+   * @return {!Promise<!Object>}
+   */
+  _getChangeURLAndSend(req) {
+    const anonymizedBaseUrl = req.patchNum ?
+      ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
+    const anonymizedEndpoint = req.reportEndpointAsIs ?
+      req.endpoint : req.anonymizedEndpoint;
+
+    return this._changeBaseURL(req.changeNum, req.patchNum)
+        .then(url => this._restApiHelper.send({
+          method: req.method,
+          url: url + req.endpoint,
+          body: req.body,
+          errFn: req.errFn,
+          contentType: req.contentType,
+          headers: req.headers,
+          parseResponse: req.parseResponse,
+          anonymizedUrl: anonymizedEndpoint ?
+            (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
+        }));
+  }
+
+  /**
+   * Alias for _changeBaseURL.then(_fetchJSON).
+   *
+   * @param {Gerrit.ChangeFetchRequest} req
+   * @return {!Promise<!Object>}
+   */
+  _getChangeURLAndFetch(req, noAcceptHeader) {
+    const anonymizedEndpoint = req.reportEndpointAsIs ?
+      req.endpoint : req.anonymizedEndpoint;
+    const anonymizedBaseUrl = req.patchNum ?
+      ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
+    return this._changeBaseURL(req.changeNum, req.patchNum)
+        .then(url => this._restApiHelper.fetchJSON({
+          url: url + req.endpoint,
+          errFn: req.errFn,
+          params: req.params,
+          fetchOptions: req.fetchOptions,
+          anonymizedUrl: anonymizedEndpoint ?
+            (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
+        }, noAcceptHeader));
+  }
+
+  /**
+   * Execute a change action or revision action on a change.
+   *
+   * @param {number} changeNum
+   * @param {string} method
+   * @param {string} endpoint
+   * @param {string|number|undefined} opt_patchNum
+   * @param {Object=} opt_payload
+   * @param {?function(?Response, string=)=} opt_errFn
+   * @return {Promise}
+   */
+  executeChangeAction(changeNum, method, endpoint, opt_patchNum, opt_payload,
+      opt_errFn) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method,
+      patchNum: opt_patchNum,
+      endpoint,
+      body: opt_payload,
+      errFn: opt_errFn,
+    });
+  }
+
+  /**
+   * Get blame information for the given diff.
+   *
+   * @param {string|number} changeNum
+   * @param {string|number} patchNum
+   * @param {string} path
+   * @param {boolean=} opt_base If true, requests blame for the base of the
+   *     diff, rather than the revision.
+   * @return {!Promise<!Object>}
+   */
+  getBlame(changeNum, patchNum, path, opt_base) {
+    const encodedPath = encodeURIComponent(path);
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: `/files/${encodedPath}/blame`,
+      patchNum,
+      params: opt_base ? {base: 't'} : undefined,
+      anonymizedEndpoint: '/files/*/blame',
+    });
+  }
+
+  /**
+   * Modify the given create draft request promise so that it fails and throws
+   * an error if the response bears HTTP status 200 instead of HTTP 201.
+   *
+   * @see Issue 7763
+   * @param {Promise} promise The original promise.
+   * @return {Promise} The modified promise.
+   */
+  _failForCreate200(promise) {
+    return promise.then(result => {
+      if (result.status === 200) {
+        // Read the response headers into an object representation.
+        const headers = Array.from(result.headers.entries())
+            .reduce((obj, [key, val]) => {
+              if (!HEADER_REPORTING_BLACKLIST.test(key)) {
+                obj[key] = val;
+              }
+              return obj;
+            }, {});
+        const err = new Error([
+          CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE,
+          JSON.stringify(headers),
+        ].join('\n'));
+        // Throw the error so that it is caught by gr-reporting.
+        throw err;
+      }
+      return result;
+    });
+  }
+
+  /**
+   * Fetch a project dashboard definition.
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
+   *
+   * @param {string} project
+   * @param {string} dashboard
+   * @param {function(?Response, string=)=} opt_errFn
+   *    passed as null sometimes.
+   * @return {!Promise<!Object>}
+   */
+  getDashboard(project, dashboard, opt_errFn) {
+    const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' +
+        encodeURIComponent(dashboard);
+    return this._fetchSharedCacheURL({
+      url,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/dashboards/*',
+    });
+  }
+
+  /**
+   * @param {string} filter
+   * @return {!Promise<?Object>}
+   */
+  getDocumentationSearches(filter) {
+    filter = filter.trim();
+    const encodedFilter = encodeURIComponent(filter);
+
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: `/Documentation/?q=${encodedFilter}`,
+      anonymizedUrl: '/Documentation/?*',
+    });
+  }
+
+  getMergeable(changeNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/revisions/current/mergeable',
+      parseResponse: true,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteDraftComments(query) {
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: '/accounts/self/drafts:delete',
+      body: {query},
+    });
+  }
+}
+
+customElements.define(GrRestApiInterface.is, GrRestApiInterface);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 1088f7e..13ed562 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -19,17 +19,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-rest-api-interface</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-rest-api-interface.html">
+<script type="module" src="./gr-rest-api-interface.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-rest-api-interface.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,92 +43,151 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-rest-api-interface tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
-    let ctr = 0;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-rest-api-interface.js';
+suite('gr-rest-api-interface tests', () => {
+  let element;
+  let sandbox;
+  let ctr = 0;
 
-    setup(() => {
-      // Modify CANONICAL_PATH to effectively reset cache.
-      ctr += 1;
-      window.CANONICAL_PATH = `test${ctr}`;
+  setup(() => {
+    // Modify CANONICAL_PATH to effectively reset cache.
+    ctr += 1;
+    window.CANONICAL_PATH = `test${ctr}`;
 
-      sandbox = sinon.sandbox.create();
-      const testJSON = ')]}\'\n{"hello": "bonjour"}';
-      sandbox.stub(window, 'fetch').returns(Promise.resolve({
-        ok: true,
-        text() {
-          return Promise.resolve(testJSON);
-        },
-      }));
-      // fake auth
-      sandbox.stub(Gerrit.Auth, 'authCheck').returns(Promise.resolve(true));
-      element = fixture('basic');
-      element._projectLookup = {};
-    });
+    sandbox = sinon.sandbox.create();
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    sandbox.stub(window, 'fetch').returns(Promise.resolve({
+      ok: true,
+      text() {
+        return Promise.resolve(testJSON);
+      },
+    }));
+    // fake auth
+    sandbox.stub(Gerrit.Auth, 'authCheck').returns(Promise.resolve(true));
+    element = fixture('basic');
+    element._projectLookup = {};
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('parent diff comments are properly grouped', done => {
-      sandbox.stub(element._restApiHelper, 'fetchJSON', () => Promise.resolve({
-        '/COMMIT_MSG': [],
-        'sieve.go': [
-          {
-            updated: '2017-02-03 22:32:28.000000000',
-            message: 'this isn’t quite right',
-          },
-          {
-            side: 'PARENT',
-            message: 'how did this work in the first place?',
-            updated: '2017-02-03 22:33:28.000000000',
-          },
-        ],
-      }));
-      element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
-          obj => {
-            assert.equal(obj.baseComments.length, 1);
-            assert.deepEqual(obj.baseComments[0], {
-              side: 'PARENT',
-              message: 'how did this work in the first place?',
-              path: 'sieve.go',
-              updated: '2017-02-03 22:33:28.000000000',
-            });
-            assert.equal(obj.comments.length, 1);
-            assert.deepEqual(obj.comments[0], {
-              message: 'this isn’t quite right',
-              path: 'sieve.go',
-              updated: '2017-02-03 22:32:28.000000000',
-            });
-            done();
-          });
-    });
-
-    test('_setRange', () => {
-      const comments = [
+  test('parent diff comments are properly grouped', done => {
+    sandbox.stub(element._restApiHelper, 'fetchJSON', () => Promise.resolve({
+      '/COMMIT_MSG': [],
+      'sieve.go': [
         {
-          id: 1,
+          updated: '2017-02-03 22:32:28.000000000',
+          message: 'this isn’t quite right',
+        },
+        {
           side: 'PARENT',
           message: 'how did this work in the first place?',
-          updated: '2017-02-03 22:32:28.000000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 2,
-            end_character: 1,
-          },
-        },
-        {
-          id: 2,
-          in_reply_to: 1,
-          message: 'this isn’t quite right',
           updated: '2017-02-03 22:33:28.000000000',
         },
-      ];
-      const expectedResult = {
+      ],
+    }));
+    element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
+        obj => {
+          assert.equal(obj.baseComments.length, 1);
+          assert.deepEqual(obj.baseComments[0], {
+            side: 'PARENT',
+            message: 'how did this work in the first place?',
+            path: 'sieve.go',
+            updated: '2017-02-03 22:33:28.000000000',
+          });
+          assert.equal(obj.comments.length, 1);
+          assert.deepEqual(obj.comments[0], {
+            message: 'this isn’t quite right',
+            path: 'sieve.go',
+            updated: '2017-02-03 22:32:28.000000000',
+          });
+          done();
+        });
+  });
+
+  test('_setRange', () => {
+    const comments = [
+      {
+        id: 1,
+        side: 'PARENT',
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: 2,
+        in_reply_to: 1,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000',
+      },
+    ];
+    const expectedResult = {
+      id: 2,
+      in_reply_to: 1,
+      message: 'this isn’t quite right',
+      updated: '2017-02-03 22:33:28.000000000',
+      range: {
+        start_line: 1,
+        start_character: 1,
+        end_line: 2,
+        end_character: 1,
+      },
+    };
+    const comment = comments[1];
+    assert.deepEqual(element._setRange(comments, comment), expectedResult);
+  });
+
+  test('_setRanges', () => {
+    const comments = [
+      {
+        id: 3,
+        in_reply_to: 2,
+        message: 'this isn’t quite right either',
+        updated: '2017-02-03 22:34:28.000000000',
+      },
+      {
+        id: 2,
+        in_reply_to: 1,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000',
+      },
+      {
+        id: 1,
+        side: 'PARENT',
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+    ];
+    const expectedResult = [
+      {
+        id: 1,
+        side: 'PARENT',
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
         id: 2,
         in_reply_to: 1,
         message: 'this isn’t quite right',
@@ -133,1347 +198,1291 @@
           end_line: 2,
           end_character: 1,
         },
-      };
-      const comment = comments[1];
-      assert.deepEqual(element._setRange(comments, comment), expectedResult);
-    });
+      },
+      {
+        id: 3,
+        in_reply_to: 2,
+        message: 'this isn’t quite right either',
+        updated: '2017-02-03 22:34:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+    ];
+    assert.deepEqual(element._setRanges(comments), expectedResult);
+  });
 
-    test('_setRanges', () => {
-      const comments = [
-        {
-          id: 3,
-          in_reply_to: 2,
-          message: 'this isn’t quite right either',
-          updated: '2017-02-03 22:34:28.000000000',
-        },
-        {
-          id: 2,
-          in_reply_to: 1,
-          message: 'this isn’t quite right',
-          updated: '2017-02-03 22:33:28.000000000',
-        },
-        {
-          id: 1,
-          side: 'PARENT',
-          message: 'how did this work in the first place?',
-          updated: '2017-02-03 22:32:28.000000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 2,
-            end_character: 1,
-          },
-        },
-      ];
-      const expectedResult = [
-        {
-          id: 1,
-          side: 'PARENT',
-          message: 'how did this work in the first place?',
-          updated: '2017-02-03 22:32:28.000000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 2,
-            end_character: 1,
-          },
-        },
-        {
-          id: 2,
-          in_reply_to: 1,
-          message: 'this isn’t quite right',
-          updated: '2017-02-03 22:33:28.000000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 2,
-            end_character: 1,
-          },
-        },
-        {
-          id: 3,
-          in_reply_to: 2,
-          message: 'this isn’t quite right either',
-          updated: '2017-02-03 22:34:28.000000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 2,
-            end_character: 1,
-          },
-        },
-      ];
-      assert.deepEqual(element._setRanges(comments), expectedResult);
-    });
-
-    test('differing patch diff comments are properly grouped', done => {
-      sandbox.stub(element, 'getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      sandbox.stub(element._restApiHelper, 'fetchJSON', request => {
-        const url = request.url;
-        if (url === '/changes/test~42/revisions/1') {
-          return Promise.resolve({
-            '/COMMIT_MSG': [],
-            'sieve.go': [
-              {
-                message: 'this isn’t quite right',
-                updated: '2017-02-03 22:32:28.000000000',
-              },
-              {
-                side: 'PARENT',
-                message: 'how did this work in the first place?',
-                updated: '2017-02-03 22:33:28.000000000',
-              },
-            ],
-          });
-        } else if (url === '/changes/test~42/revisions/2') {
-          return Promise.resolve({
-            '/COMMIT_MSG': [],
-            'sieve.go': [
-              {
-                message: 'What on earth are you thinking, here?',
-                updated: '2017-02-03 22:32:28.000000000',
-              },
-              {
-                side: 'PARENT',
-                message: 'Yeah not sure how this worked either?',
-                updated: '2017-02-03 22:33:28.000000000',
-              },
-              {
-                message: '¯\\_(ツ)_/¯',
-                updated: '2017-02-04 22:33:28.000000000',
-              },
-            ],
-          });
-        }
-      });
-      element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
-          obj => {
-            assert.equal(obj.baseComments.length, 1);
-            assert.deepEqual(obj.baseComments[0], {
+  test('differing patch diff comments are properly grouped', done => {
+    sandbox.stub(element, 'getFromProjectLookup')
+        .returns(Promise.resolve('test'));
+    sandbox.stub(element._restApiHelper, 'fetchJSON', request => {
+      const url = request.url;
+      if (url === '/changes/test~42/revisions/1') {
+        return Promise.resolve({
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
               message: 'this isn’t quite right',
-              path: 'sieve.go',
               updated: '2017-02-03 22:32:28.000000000',
-            });
-            assert.equal(obj.comments.length, 2);
-            assert.deepEqual(obj.comments[0], {
+            },
+            {
+              side: 'PARENT',
+              message: 'how did this work in the first place?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+          ],
+        });
+      } else if (url === '/changes/test~42/revisions/2') {
+        return Promise.resolve({
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
               message: 'What on earth are you thinking, here?',
-              path: 'sieve.go',
               updated: '2017-02-03 22:32:28.000000000',
-            });
-            assert.deepEqual(obj.comments[1], {
+            },
+            {
+              side: 'PARENT',
+              message: 'Yeah not sure how this worked either?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+            {
               message: '¯\\_(ツ)_/¯',
-              path: 'sieve.go',
               updated: '2017-02-04 22:33:28.000000000',
-            });
+            },
+          ],
+        });
+      }
+    });
+    element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
+        obj => {
+          assert.equal(obj.baseComments.length, 1);
+          assert.deepEqual(obj.baseComments[0], {
+            message: 'this isn’t quite right',
+            path: 'sieve.go',
+            updated: '2017-02-03 22:32:28.000000000',
+          });
+          assert.equal(obj.comments.length, 2);
+          assert.deepEqual(obj.comments[0], {
+            message: 'What on earth are you thinking, here?',
+            path: 'sieve.go',
+            updated: '2017-02-03 22:32:28.000000000',
+          });
+          assert.deepEqual(obj.comments[1], {
+            message: '¯\\_(ツ)_/¯',
+            path: 'sieve.go',
+            updated: '2017-02-04 22:33:28.000000000',
+          });
+          done();
+        });
+  });
+
+  test('special file path sorting', () => {
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
+            element.specialFilePathCompare),
+        ['/COMMIT_MSG', '.a', '.b', 'file']);
+
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
+            element.specialFilePathCompare),
+        ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']);
+
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
+            element.specialFilePathCompare),
+        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']);
+
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
+            element.specialFilePathCompare),
+        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']);
+
+    assert.deepEqual(
+        ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(
+            element.specialFilePathCompare),
+        ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
+
+    // Regression test for Issue 4448.
+    assert.deepEqual(
+        [
+          'minidump/minidump_memory_writer.cc',
+          'minidump/minidump_memory_writer.h',
+          'minidump/minidump_thread_writer.cc',
+          'minidump/minidump_thread_writer.h',
+        ].sort(element.specialFilePathCompare),
+        [
+          'minidump/minidump_memory_writer.h',
+          'minidump/minidump_memory_writer.cc',
+          'minidump/minidump_thread_writer.h',
+          'minidump/minidump_thread_writer.cc',
+        ]);
+
+    // Regression test for Issue 4545.
+    assert.deepEqual(
+        [
+          'task_test.go',
+          'task.go',
+        ].sort(element.specialFilePathCompare),
+        [
+          'task.go',
+          'task_test.go',
+        ]);
+  });
+
+  suite('rebase action', () => {
+    let resolve_fetchJSON;
+    setup(() => {
+      sandbox.stub(element._restApiHelper, 'fetchJSON').returns(
+          new Promise(resolve => {
+            resolve_fetchJSON = resolve;
+          }));
+    });
+
+    test('no rebase on current', done => {
+      element.getChangeRevisionActions('42', '1337').then(
+          response => {
+            assert.isTrue(response.rebase.enabled);
+            assert.isFalse(response.rebase.rebaseOnCurrent);
             done();
           });
+      resolve_fetchJSON({rebase: {}});
     });
 
-    test('special file path sorting', () => {
-      assert.deepEqual(
-          ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
-              element.specialFilePathCompare),
-          ['/COMMIT_MSG', '.a', '.b', 'file']);
-
-      assert.deepEqual(
-          ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
-              element.specialFilePathCompare),
-          ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']);
-
-      assert.deepEqual(
-          ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
-              element.specialFilePathCompare),
-          ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']);
-
-      assert.deepEqual(
-          ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
-              element.specialFilePathCompare),
-          ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']);
-
-      assert.deepEqual(
-          ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(
-              element.specialFilePathCompare),
-          ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
-
-      // Regression test for Issue 4448.
-      assert.deepEqual(
-          [
-            'minidump/minidump_memory_writer.cc',
-            'minidump/minidump_memory_writer.h',
-            'minidump/minidump_thread_writer.cc',
-            'minidump/minidump_thread_writer.h',
-          ].sort(element.specialFilePathCompare),
-          [
-            'minidump/minidump_memory_writer.h',
-            'minidump/minidump_memory_writer.cc',
-            'minidump/minidump_thread_writer.h',
-            'minidump/minidump_thread_writer.cc',
-          ]);
-
-      // Regression test for Issue 4545.
-      assert.deepEqual(
-          [
-            'task_test.go',
-            'task.go',
-          ].sort(element.specialFilePathCompare),
-          [
-            'task.go',
-            'task_test.go',
-          ]);
-    });
-
-    suite('rebase action', () => {
-      let resolve_fetchJSON;
-      setup(() => {
-        sandbox.stub(element._restApiHelper, 'fetchJSON').returns(
-            new Promise(resolve => {
-              resolve_fetchJSON = resolve;
-            }));
-      });
-
-      test('no rebase on current', done => {
-        element.getChangeRevisionActions('42', '1337').then(
-            response => {
-              assert.isTrue(response.rebase.enabled);
-              assert.isFalse(response.rebase.rebaseOnCurrent);
-              done();
-            });
-        resolve_fetchJSON({rebase: {}});
-      });
-
-      test('rebase on current', done => {
-        element.getChangeRevisionActions('42', '1337').then(
-            response => {
-              assert.isTrue(response.rebase.enabled);
-              assert.isTrue(response.rebase.rebaseOnCurrent);
-              done();
-            });
-        resolve_fetchJSON({rebase: {enabled: true}});
-      });
-    });
-
-    test('server error', done => {
-      const getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
-      window.fetch.returns(Promise.resolve({ok: false}));
-      const serverErrorEventPromise = new Promise(resolve => {
-        element.addEventListener('server-error', resolve);
-      });
-
-      element._restApiHelper.fetchJSON({}).then(response => {
-        assert.isUndefined(response);
-        assert.isTrue(getResponseObjectStub.notCalled);
-        serverErrorEventPromise.then(() => done());
-      });
-    });
-
-    test('legacy n,z key in change url is replaced', () => {
-      const stub = sandbox.stub(element._restApiHelper, 'fetchJSON')
-          .returns(Promise.resolve([]));
-      element.getChanges(1, null, 'n,z');
-      assert.equal(stub.lastCall.args[0].params.S, 0);
-    });
-
-    test('saveDiffPreferences invalidates cache line', () => {
-      const cacheKey = '/accounts/self/preferences.diff';
-      const sendStub = sandbox.stub(element._restApiHelper, 'send');
-      element._cache.set(cacheKey, {tab_size: 4});
-      element.saveDiffPreferences({tab_size: 8});
-      assert.isTrue(sendStub.called);
-      assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-    });
-
-    test('getAccount when resp is null does not add anything to the cache',
-        done => {
-          const cacheKey = '/accounts/self/detail';
-          const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
-              () => Promise.resolve());
-
-          element.getAccount().then(() => {
-            assert.isTrue(stub.called);
-            assert.isFalse(element._restApiHelper._cache.has(cacheKey));
+    test('rebase on current', done => {
+      element.getChangeRevisionActions('42', '1337').then(
+          response => {
+            assert.isTrue(response.rebase.enabled);
+            assert.isTrue(response.rebase.rebaseOnCurrent);
             done();
           });
+      resolve_fetchJSON({rebase: {enabled: true}});
+    });
+  });
 
-          element._restApiHelper._cache.set(cacheKey, 'fake cache');
-          stub.lastCall.args[0].errFn();
+  test('server error', done => {
+    const getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
+    window.fetch.returns(Promise.resolve({ok: false}));
+    const serverErrorEventPromise = new Promise(resolve => {
+      element.addEventListener('server-error', resolve);
+    });
+
+    element._restApiHelper.fetchJSON({}).then(response => {
+      assert.isUndefined(response);
+      assert.isTrue(getResponseObjectStub.notCalled);
+      serverErrorEventPromise.then(() => done());
+    });
+  });
+
+  test('legacy n,z key in change url is replaced', () => {
+    const stub = sandbox.stub(element._restApiHelper, 'fetchJSON')
+        .returns(Promise.resolve([]));
+    element.getChanges(1, null, 'n,z');
+    assert.equal(stub.lastCall.args[0].params.S, 0);
+  });
+
+  test('saveDiffPreferences invalidates cache line', () => {
+    const cacheKey = '/accounts/self/preferences.diff';
+    const sendStub = sandbox.stub(element._restApiHelper, 'send');
+    element._cache.set(cacheKey, {tab_size: 4});
+    element.saveDiffPreferences({tab_size: 8});
+    assert.isTrue(sendStub.called);
+    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
+  });
+
+  test('getAccount when resp is null does not add anything to the cache',
+      done => {
+        const cacheKey = '/accounts/self/detail';
+        const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
+            () => Promise.resolve());
+
+        element.getAccount().then(() => {
+          assert.isTrue(stub.called);
+          assert.isFalse(element._restApiHelper._cache.has(cacheKey));
+          done();
         });
 
-    test('getAccount does not add to the cache when resp.status is 403',
-        done => {
-          const cacheKey = '/accounts/self/detail';
-          const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
-              () => Promise.resolve());
-
-          element.getAccount().then(() => {
-            assert.isTrue(stub.called);
-            assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-            done();
-          });
-          element._cache.set(cacheKey, 'fake cache');
-          stub.lastCall.args[0].errFn({status: 403});
-        });
-
-    test('getAccount when resp is successful', done => {
-      const cacheKey = '/accounts/self/detail';
-      const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
-          () => Promise.resolve());
-
-      element.getAccount().then(response => {
-        assert.isTrue(stub.called);
-        assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
-        done();
+        element._restApiHelper._cache.set(cacheKey, 'fake cache');
+        stub.lastCall.args[0].errFn();
       });
-      element._restApiHelper._cache.set(cacheKey, 'fake cache');
 
-      stub.lastCall.args[0].errFn({});
-    });
+  test('getAccount does not add to the cache when resp.status is 403',
+      done => {
+        const cacheKey = '/accounts/self/detail';
+        const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
+            () => Promise.resolve());
 
-    const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
-      sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(loggedIn));
-      sandbox.stub(element, '_isNarrowScreen', () => smallScreen);
-      sandbox.stub(
-          element._restApiHelper,
-          'fetchCacheURL',
-          () => Promise.resolve(testJSON));
-    };
-
-    test('getPreferences returns correctly on small screens logged in',
-        done => {
-          const testJSON = {diff_view: 'SIDE_BY_SIDE'};
-          const loggedIn = true;
-          const smallScreen = true;
-
-          preferenceSetup(testJSON, loggedIn, smallScreen);
-
-          element.getPreferences().then(obj => {
-            assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-            assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-            done();
-          });
+        element.getAccount().then(() => {
+          assert.isTrue(stub.called);
+          assert.isFalse(element._restApiHelper._cache.has(cacheKey));
+          done();
         });
-
-    test('getPreferences returns correctly on small screens not logged in',
-        done => {
-          const testJSON = {diff_view: 'SIDE_BY_SIDE'};
-          const loggedIn = false;
-          const smallScreen = true;
-
-          preferenceSetup(testJSON, loggedIn, smallScreen);
-          element.getPreferences().then(obj => {
-            assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-            assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-            done();
-          });
-        });
-
-    test('getPreferences returns correctly on larger screens logged in',
-        done => {
-          const testJSON = {diff_view: 'UNIFIED_DIFF'};
-          const loggedIn = true;
-          const smallScreen = false;
-
-          preferenceSetup(testJSON, loggedIn, smallScreen);
-
-          element.getPreferences().then(obj => {
-            assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-            assert.equal(obj.diff_view, 'UNIFIED_DIFF');
-            done();
-          });
-        });
-
-    test('getPreferences returns correctly on larger screens not logged in',
-        done => {
-          const testJSON = {diff_view: 'UNIFIED_DIFF'};
-          const loggedIn = false;
-          const smallScreen = false;
-
-          preferenceSetup(testJSON, loggedIn, smallScreen);
-
-          element.getPreferences().then(obj => {
-            assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
-            assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-            done();
-          });
-        });
-
-    test('savPreferences normalizes download scheme', () => {
-      const sendStub = sandbox.stub(element._restApiHelper, 'send');
-      element.savePreferences({download_scheme: 'HTTP'});
-      assert.isTrue(sendStub.called);
-      assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
-    });
-
-    test('getDiffPreferences returns correct defaults', done => {
-      sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(false));
-
-      element.getDiffPreferences().then(obj => {
-        assert.equal(obj.auto_hide_diff_table_header, true);
-        assert.equal(obj.context, 10);
-        assert.equal(obj.cursor_blink_rate, 0);
-        assert.equal(obj.font_size, 12);
-        assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
-        assert.equal(obj.intraline_difference, true);
-        assert.equal(obj.line_length, 100);
-        assert.equal(obj.line_wrapping, false);
-        assert.equal(obj.show_line_endings, true);
-        assert.equal(obj.show_tabs, true);
-        assert.equal(obj.show_whitespace_errors, true);
-        assert.equal(obj.syntax_highlighting, true);
-        assert.equal(obj.tab_size, 8);
-        assert.equal(obj.theme, 'DEFAULT');
-        done();
+        element._cache.set(cacheKey, 'fake cache');
+        stub.lastCall.args[0].errFn({status: 403});
       });
+
+  test('getAccount when resp is successful', done => {
+    const cacheKey = '/accounts/self/detail';
+    const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
+        () => Promise.resolve());
+
+    element.getAccount().then(response => {
+      assert.isTrue(stub.called);
+      assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
+      done();
     });
+    element._restApiHelper._cache.set(cacheKey, 'fake cache');
 
-    test('saveDiffPreferences set show_tabs to false', () => {
-      const sendStub = sandbox.stub(element._restApiHelper, 'send');
-      element.saveDiffPreferences({show_tabs: false});
-      assert.isTrue(sendStub.called);
-      assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
-    });
+    stub.lastCall.args[0].errFn({});
+  });
 
-    test('getEditPreferences returns correct defaults', done => {
-      sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(false));
+  const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
+    sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(loggedIn));
+    sandbox.stub(element, '_isNarrowScreen', () => smallScreen);
+    sandbox.stub(
+        element._restApiHelper,
+        'fetchCacheURL',
+        () => Promise.resolve(testJSON));
+  };
 
-      element.getEditPreferences().then(obj => {
-        assert.equal(obj.auto_close_brackets, false);
-        assert.equal(obj.cursor_blink_rate, 0);
-        assert.equal(obj.hide_line_numbers, false);
-        assert.equal(obj.hide_top_menu, false);
-        assert.equal(obj.indent_unit, 2);
-        assert.equal(obj.indent_with_tabs, false);
-        assert.equal(obj.key_map_type, 'DEFAULT');
-        assert.equal(obj.line_length, 100);
-        assert.equal(obj.line_wrapping, false);
-        assert.equal(obj.match_brackets, true);
-        assert.equal(obj.show_base, false);
-        assert.equal(obj.show_tabs, true);
-        assert.equal(obj.show_whitespace_errors, true);
-        assert.equal(obj.syntax_highlighting, true);
-        assert.equal(obj.tab_size, 8);
-        assert.equal(obj.theme, 'DEFAULT');
-        done();
+  test('getPreferences returns correctly on small screens logged in',
+      done => {
+        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+        const loggedIn = true;
+        const smallScreen = true;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+
+        element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+          done();
+        });
       });
-    });
 
-    test('saveEditPreferences set show_tabs to false', () => {
-      const sendStub = sandbox.stub(element._restApiHelper, 'send');
-      element.saveEditPreferences({show_tabs: false});
-      assert.isTrue(sendStub.called);
-      assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
-    });
+  test('getPreferences returns correctly on small screens not logged in',
+      done => {
+        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+        const loggedIn = false;
+        const smallScreen = true;
 
-    test('confirmEmail', () => {
-      const sendStub = sandbox.spy(element._restApiHelper, 'send');
-      element.confirmEmail('foo');
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+        element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+          done();
+        });
+      });
+
+  test('getPreferences returns correctly on larger screens logged in',
+      done => {
+        const testJSON = {diff_view: 'UNIFIED_DIFF'};
+        const loggedIn = true;
+        const smallScreen = false;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+
+        element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+          assert.equal(obj.diff_view, 'UNIFIED_DIFF');
+          done();
+        });
+      });
+
+  test('getPreferences returns correctly on larger screens not logged in',
+      done => {
+        const testJSON = {diff_view: 'UNIFIED_DIFF'};
+        const loggedIn = false;
+        const smallScreen = false;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+
+        element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
+          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+          done();
+        });
+      });
+
+  test('savPreferences normalizes download scheme', () => {
+    const sendStub = sandbox.stub(element._restApiHelper, 'send');
+    element.savePreferences({download_scheme: 'HTTP'});
+    assert.isTrue(sendStub.called);
+    assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
+  });
+
+  test('getDiffPreferences returns correct defaults', done => {
+    sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(false));
+
+    element.getDiffPreferences().then(obj => {
+      assert.equal(obj.auto_hide_diff_table_header, true);
+      assert.equal(obj.context, 10);
+      assert.equal(obj.cursor_blink_rate, 0);
+      assert.equal(obj.font_size, 12);
+      assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
+      assert.equal(obj.intraline_difference, true);
+      assert.equal(obj.line_length, 100);
+      assert.equal(obj.line_wrapping, false);
+      assert.equal(obj.show_line_endings, true);
+      assert.equal(obj.show_tabs, true);
+      assert.equal(obj.show_whitespace_errors, true);
+      assert.equal(obj.syntax_highlighting, true);
+      assert.equal(obj.tab_size, 8);
+      assert.equal(obj.theme, 'DEFAULT');
+      done();
+    });
+  });
+
+  test('saveDiffPreferences set show_tabs to false', () => {
+    const sendStub = sandbox.stub(element._restApiHelper, 'send');
+    element.saveDiffPreferences({show_tabs: false});
+    assert.isTrue(sendStub.called);
+    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
+  });
+
+  test('getEditPreferences returns correct defaults', done => {
+    sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(false));
+
+    element.getEditPreferences().then(obj => {
+      assert.equal(obj.auto_close_brackets, false);
+      assert.equal(obj.cursor_blink_rate, 0);
+      assert.equal(obj.hide_line_numbers, false);
+      assert.equal(obj.hide_top_menu, false);
+      assert.equal(obj.indent_unit, 2);
+      assert.equal(obj.indent_with_tabs, false);
+      assert.equal(obj.key_map_type, 'DEFAULT');
+      assert.equal(obj.line_length, 100);
+      assert.equal(obj.line_wrapping, false);
+      assert.equal(obj.match_brackets, true);
+      assert.equal(obj.show_base, false);
+      assert.equal(obj.show_tabs, true);
+      assert.equal(obj.show_whitespace_errors, true);
+      assert.equal(obj.syntax_highlighting, true);
+      assert.equal(obj.tab_size, 8);
+      assert.equal(obj.theme, 'DEFAULT');
+      done();
+    });
+  });
+
+  test('saveEditPreferences set show_tabs to false', () => {
+    const sendStub = sandbox.stub(element._restApiHelper, 'send');
+    element.saveEditPreferences({show_tabs: false});
+    assert.isTrue(sendStub.called);
+    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
+  });
+
+  test('confirmEmail', () => {
+    const sendStub = sandbox.spy(element._restApiHelper, 'send');
+    element.confirmEmail('foo');
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, 'PUT');
+    assert.equal(sendStub.lastCall.args[0].url,
+        '/config/server/email.confirm');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
+  });
+
+  test('setAccountStatus', () => {
+    const sendStub = sandbox.stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve('OOO'));
+    element._cache.set('/accounts/self/detail', {});
+    return element.setAccountStatus('OOO').then(() => {
       assert.isTrue(sendStub.calledOnce);
       assert.equal(sendStub.lastCall.args[0].method, 'PUT');
       assert.equal(sendStub.lastCall.args[0].url,
-          '/config/server/email.confirm');
-      assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
-    });
-
-    test('setAccountStatus', () => {
-      const sendStub = sandbox.stub(element._restApiHelper, 'send')
-          .returns(Promise.resolve('OOO'));
-      element._cache.set('/accounts/self/detail', {});
-      return element.setAccountStatus('OOO').then(() => {
-        assert.isTrue(sendStub.calledOnce);
-        assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-        assert.equal(sendStub.lastCall.args[0].url,
-            '/accounts/self/status');
-        assert.deepEqual(sendStub.lastCall.args[0].body,
-            {status: 'OOO'});
-        assert.deepEqual(element._restApiHelper
-            ._cache.get('/accounts/self/detail'),
-        {status: 'OOO'});
-      });
-    });
-
-    suite('draft comments', () => {
-      test('_sendDiffDraftRequest pending requests tracked', () => {
-        const obj = element._pendingRequests;
-        sandbox.stub(element, '_getChangeURLAndSend', () => mockPromise());
-        assert.notOk(element.hasPendingDiffDrafts());
-
-        element._sendDiffDraftRequest(null, null, null, {});
-        assert.equal(obj.sendDiffDraft.length, 1);
-        assert.isTrue(!!element.hasPendingDiffDrafts());
-
-        element._sendDiffDraftRequest(null, null, null, {});
-        assert.equal(obj.sendDiffDraft.length, 2);
-        assert.isTrue(!!element.hasPendingDiffDrafts());
-
-        for (const promise of obj.sendDiffDraft) { promise.resolve(); }
-
-        return element.awaitPendingDiffDrafts().then(() => {
-          assert.equal(obj.sendDiffDraft.length, 0);
-          assert.isFalse(!!element.hasPendingDiffDrafts());
-        });
-      });
-
-      suite('_failForCreate200', () => {
-        test('_sendDiffDraftRequest checks for 200 on create', () => {
-          const sendPromise = Promise.resolve();
-          sandbox.stub(element, '_getChangeURLAndSend').returns(sendPromise);
-          const failStub = sandbox.stub(element, '_failForCreate200')
-              .returns(Promise.resolve());
-          return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
-            assert.isTrue(failStub.calledOnce);
-            assert.isTrue(failStub.calledWithExactly(sendPromise));
-          });
-        });
-
-        test('_sendDiffDraftRequest no checks for 200 on non create', () => {
-          sandbox.stub(element, '_getChangeURLAndSend')
-              .returns(Promise.resolve());
-          const failStub = sandbox.stub(element, '_failForCreate200')
-              .returns(Promise.resolve());
-          return element._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
-              .then(() => {
-                assert.isFalse(failStub.called);
-              });
-        });
-
-        test('_failForCreate200 fails on 200', done => {
-          const result = {
-            ok: true,
-            status: 200,
-            headers: {entries: () => [
-              ['Set-CoOkiE', 'secret'],
-              ['Innocuous', 'hello'],
-            ]},
-          };
-          element._failForCreate200(Promise.resolve(result))
-              .then(() => {
-                assert.isTrue(false, 'Promise should not resolve');
-              })
-              .catch(e => {
-                assert.isOk(e);
-                assert.include(e.message, 'Saving draft resulted in HTTP 200');
-                assert.include(e.message, 'hello');
-                assert.notInclude(e.message, 'secret');
-                done();
-              });
-        });
-
-        test('_failForCreate200 does not fail on 201', done => {
-          const result = {
-            ok: true,
-            status: 201,
-            headers: {entries: () => []},
-          };
-          element._failForCreate200(Promise.resolve(result))
-              .then(() => {
-                done();
-              })
-              .catch(e => {
-                assert.isTrue(false, 'Promise should not fail');
-              });
-        });
-      });
-    });
-
-    test('saveChangeEdit', () => {
-      element._projectLookup = {1: 'test'};
-      const change_num = '1';
-      const file_name = 'index.php';
-      const file_contents = '<?php';
-      sandbox.stub(element._restApiHelper, 'send').returns(
-          Promise.resolve([change_num, file_name, file_contents]));
-      sandbox.stub(element, 'getResponseObject')
-          .returns(Promise.resolve([change_num, file_name, file_contents]));
-      element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
-      return element.saveChangeEdit(change_num, file_name, file_contents)
-          .then(() => {
-            assert.isTrue(element._restApiHelper.send.calledOnce);
-            assert.equal(element._restApiHelper.send.lastCall.args[0].method,
-                'PUT');
-            assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-                '/changes/test~1/edit/' + file_name);
-            assert.equal(element._restApiHelper.send.lastCall.args[0].body,
-                file_contents);
-          });
-    });
-
-    test('putChangeCommitMessage', () => {
-      element._projectLookup = {1: 'test'};
-      const change_num = '1';
-      const message = 'this is a commit message';
-      sandbox.stub(element._restApiHelper, 'send').returns(
-          Promise.resolve([change_num, message]));
-      sandbox.stub(element, 'getResponseObject')
-          .returns(Promise.resolve([change_num, message]));
-      element._cache.set('/changes/' + change_num + '/message', {});
-      return element.putChangeCommitMessage(change_num, message).then(() => {
-        assert.isTrue(element._restApiHelper.send.calledOnce);
-        assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
-        assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-            '/changes/test~1/message');
-        assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body,
-            {message});
-      });
-    });
-
-    test('deleteChangeCommitMessage', () => {
-      element._projectLookup = {1: 'test'};
-      const change_num = '1';
-      const messageId = 'abc';
-      sandbox.stub(element._restApiHelper, 'send').returns(
-          Promise.resolve([change_num, messageId]));
-      sandbox.stub(element, 'getResponseObject')
-          .returns(Promise.resolve([change_num, messageId]));
-      return element.deleteChangeCommitMessage(change_num, messageId).then(() => {
-        assert.isTrue(element._restApiHelper.send.calledOnce);
-        assert.equal(
-            element._restApiHelper.send.lastCall.args[0].method,
-            'DELETE'
-        );
-        assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-            '/changes/test~1/messages/abc');
-      });
-    });
-
-    test('startWorkInProgress', () => {
-      const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
-          .returns(Promise.resolve('ok'));
-      element.startWorkInProgress('42');
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-      assert.equal(sendStub.lastCall.args[0].method, 'POST');
-      assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-      assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
-      assert.deepEqual(sendStub.lastCall.args[0].body, {});
-
-      element.startWorkInProgress('42', 'revising...');
-      assert.isTrue(sendStub.calledTwice);
-      assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-      assert.equal(sendStub.lastCall.args[0].method, 'POST');
-      assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-      assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+          '/accounts/self/status');
       assert.deepEqual(sendStub.lastCall.args[0].body,
-          {message: 'revising...'});
+          {status: 'OOO'});
+      assert.deepEqual(element._restApiHelper
+          ._cache.get('/accounts/self/detail'),
+      {status: 'OOO'});
     });
+  });
 
-    test('startReview', () => {
-      const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
-          .returns(Promise.resolve({}));
-      element.startReview('42', {message: 'Please review.'});
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-      assert.equal(sendStub.lastCall.args[0].method, 'POST');
-      assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-      assert.equal(sendStub.lastCall.args[0].endpoint, '/ready');
-      assert.deepEqual(sendStub.lastCall.args[0].body,
-          {message: 'Please review.'});
-    });
+  suite('draft comments', () => {
+    test('_sendDiffDraftRequest pending requests tracked', () => {
+      const obj = element._pendingRequests;
+      sandbox.stub(element, '_getChangeURLAndSend', () => mockPromise());
+      assert.notOk(element.hasPendingDiffDrafts());
 
-    test('deleteComment', () => {
-      const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
-          .returns(Promise.resolve('some response'));
-      return element.deleteComment('foo', 'bar', '01234', 'removal reason')
-          .then(response => {
-            assert.equal(response, 'some response');
-            assert.isTrue(sendStub.calledOnce);
-            assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
-            assert.equal(sendStub.lastCall.args[0].method, 'POST');
-            assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
-            assert.equal(sendStub.lastCall.args[0].endpoint,
-                '/comments/01234/delete');
-            assert.deepEqual(sendStub.lastCall.args[0].body,
-                {reason: 'removal reason'});
-          });
-    });
+      element._sendDiffDraftRequest(null, null, null, {});
+      assert.equal(obj.sendDiffDraft.length, 1);
+      assert.isTrue(!!element.hasPendingDiffDrafts());
 
-    test('createRepo encodes name', () => {
-      const sendStub = sandbox.stub(element._restApiHelper, 'send')
-          .returns(Promise.resolve());
-      return element.createRepo({name: 'x/y'}).then(() => {
-        assert.isTrue(sendStub.calledOnce);
-        assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
+      element._sendDiffDraftRequest(null, null, null, {});
+      assert.equal(obj.sendDiffDraft.length, 2);
+      assert.isTrue(!!element.hasPendingDiffDrafts());
+
+      for (const promise of obj.sendDiffDraft) { promise.resolve(); }
+
+      return element.awaitPendingDiffDrafts().then(() => {
+        assert.equal(obj.sendDiffDraft.length, 0);
+        assert.isFalse(!!element.hasPendingDiffDrafts());
       });
     });
 
-    test('queryChangeFiles', () => {
-      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
-        assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
-        assert.equal(fetchStub.lastCall.args[0].endpoint,
-            '/files?q=test%2Fpath.js');
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 'edit');
-      });
-    });
-
-    test('normal use', () => {
-      const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
-
-      assert.equal(element._getReposUrl('test', 25),
-          '/projects/?n=26&S=0&query=test');
-
-      assert.equal(element._getReposUrl(null, 25),
-          `/projects/?n=26&S=0&query=${defaultQuery}`);
-
-      assert.equal(element._getReposUrl('test', 25, 25),
-          '/projects/?n=26&S=25&query=test');
-    });
-
-    test('invalidateReposCache', () => {
-      const url = '/projects/?n=26&S=0&query=test';
-
-      element._cache.set(url, {});
-
-      element.invalidateReposCache();
-
-      assert.isUndefined(element._sharedFetchPromises[url]);
-
-      assert.isFalse(element._cache.has(url));
-    });
-
-    test('invalidateAccountsCache', () => {
-      const url = '/accounts/self/detail';
-
-      element._cache.set(url, {});
-
-      element.invalidateAccountsCache();
-
-      assert.isUndefined(element._sharedFetchPromises[url]);
-
-      assert.isFalse(element._cache.has(url));
-    });
-
-    suite('getRepos', () => {
-      const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
-      let fetchCacheURLStub;
-      setup(() => {
-        fetchCacheURLStub =
-            sandbox.stub(element._restApiHelper, 'fetchCacheURL');
-      });
-
-      test('normal use', () => {
-        element.getRepos('test', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=test');
-
-        element.getRepos(null, 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            `/projects/?n=26&S=0&query=${defaultQuery}`);
-
-        element.getRepos('test', 25, 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=25&query=test');
-      });
-
-      test('with blank', () => {
-        element.getRepos('test/test', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=inname%3Atest%20AND%20inname%3Atest');
-      });
-
-      test('with hyphen', () => {
-        element.getRepos('foo-bar', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-      });
-
-      test('with leading hyphen', () => {
-        element.getRepos('-bar', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=inname%3Abar');
-      });
-
-      test('with trailing hyphen', () => {
-        element.getRepos('foo-bar-', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-      });
-
-      test('with underscore', () => {
-        element.getRepos('foo_bar', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-      });
-
-      test('with underscore', () => {
-        element.getRepos('foo_bar', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-      });
-
-      test('hyphen only', () => {
-        element.getRepos('-', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            `/projects/?n=26&S=0&query=${defaultQuery}`);
-      });
-    });
-
-    test('_getGroupsUrl normal use', () => {
-      assert.equal(element._getGroupsUrl('test', 25),
-          '/groups/?n=26&S=0&m=test');
-
-      assert.equal(element._getGroupsUrl(null, 25),
-          '/groups/?n=26&S=0');
-
-      assert.equal(element._getGroupsUrl('test', 25, 25),
-          '/groups/?n=26&S=25&m=test');
-    });
-
-    test('invalidateGroupsCache', () => {
-      const url = '/groups/?n=26&S=0&m=test';
-
-      element._cache.set(url, {});
-
-      element.invalidateGroupsCache();
-
-      assert.isUndefined(element._sharedFetchPromises[url]);
-
-      assert.isFalse(element._cache.has(url));
-    });
-
-    suite('getGroups', () => {
-      let fetchCacheURLStub;
-      setup(() => {
-        fetchCacheURLStub =
-            sandbox.stub(element._restApiHelper, 'fetchCacheURL');
-      });
-
-      test('normal use', () => {
-        element.getGroups('test', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/groups/?n=26&S=0&m=test');
-
-        element.getGroups(null, 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/groups/?n=26&S=0');
-
-        element.getGroups('test', 25, 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/groups/?n=26&S=25&m=test');
-      });
-
-      test('regex', () => {
-        element.getGroups('^test.*', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/groups/?n=26&S=0&r=%5Etest.*');
-
-        element.getGroups('^test.*', 25, 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/groups/?n=26&S=25&r=%5Etest.*');
-      });
-    });
-
-    test('gerrit auth is used', () => {
-      sandbox.stub(Gerrit.Auth, 'fetch').returns(Promise.resolve());
-      element._restApiHelper.fetchJSON({url: 'foo'});
-      assert(Gerrit.Auth.fetch.called);
-    });
-
-    test('getSuggestedAccounts does not return _fetchJSON', () => {
-      const _fetchJSONSpy = sandbox.spy(element._restApiHelper, 'fetchJSON');
-      return element.getSuggestedAccounts().then(accts => {
-        assert.isFalse(_fetchJSONSpy.called);
-        assert.equal(accts.length, 0);
-      });
-    });
-
-    test('_fetchJSON gets called by getSuggestedAccounts', () => {
-      const _fetchJSONStub = sandbox.stub(element._restApiHelper, 'fetchJSON',
-          () => Promise.resolve());
-      return element.getSuggestedAccounts('own').then(() => {
-        assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
-          q: 'own',
-          suggest: null,
-        });
-      });
-    });
-
-    suite('getChangeDetail', () => {
-      suite('change detail options', () => {
-        let toHexStub;
-
-        setup(() => {
-          toHexStub = sandbox.stub(element, 'listChangesOptionsToHex',
-              options => 'deadbeef');
-          sandbox.stub(element, '_getChangeDetail',
-              async (changeNum, options) => { return {changeNum, options}; });
-        });
-
-        test('signed pushes disabled', async () => {
-          const {PUSH_CERTIFICATES} = element.ListChangesOption;
-          sandbox.stub(element, 'getConfig', async () => { return {}; });
-          const {changeNum, options} = await element.getChangeDetail(123);
-          assert.strictEqual(123, changeNum);
-          assert.strictEqual('deadbeef', options);
-          assert.isTrue(toHexStub.calledOnce);
-          assert.isFalse(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
-        });
-
-        test('signed pushes enabled', async () => {
-          const {PUSH_CERTIFICATES} = element.ListChangesOption;
-          sandbox.stub(element, 'getConfig', async () => {
-            return {receive: {enable_signed_push: true}};
-          });
-          const {changeNum, options} = await element.getChangeDetail(123);
-          assert.strictEqual(123, changeNum);
-          assert.strictEqual('deadbeef', options);
-          assert.isTrue(toHexStub.calledOnce);
-          assert.isTrue(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
+    suite('_failForCreate200', () => {
+      test('_sendDiffDraftRequest checks for 200 on create', () => {
+        const sendPromise = Promise.resolve();
+        sandbox.stub(element, '_getChangeURLAndSend').returns(sendPromise);
+        const failStub = sandbox.stub(element, '_failForCreate200')
+            .returns(Promise.resolve());
+        return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
+          assert.isTrue(failStub.calledOnce);
+          assert.isTrue(failStub.calledWithExactly(sendPromise));
         });
       });
 
-      test('GrReviewerUpdatesParser.parse is used', () => {
-        sandbox.stub(GrReviewerUpdatesParser, 'parse').returns(
-            Promise.resolve('foo'));
-        return element.getChangeDetail(42).then(result => {
-          assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
-          assert.equal(result, 'foo');
-        });
-      });
-
-      test('_getChangeDetail passes params to ETags decorator', () => {
-        const changeNum = 4321;
-        element._projectLookup[changeNum] = 'test';
-        const expectedUrl =
-            window.CANONICAL_PATH + '/changes/test~4321/detail?'+
-            '0=5&1=1&2=6&3=7&4=1&5=4';
-        sandbox.stub(element._etags, 'getOptions');
-        sandbox.stub(element._etags, 'collect');
-        return element._getChangeDetail(changeNum, '516714').then(() => {
-          assert.isTrue(element._etags.getOptions.calledWithExactly(
-              expectedUrl));
-          assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
-        });
-      });
-
-      test('_getChangeDetail calls errFn on 500', () => {
-        const errFn = sinon.stub();
-        sandbox.stub(element, 'getChangeActionURL')
-            .returns(Promise.resolve(''));
-        sandbox.stub(element._restApiHelper, 'fetchRawJSON')
-            .returns(Promise.resolve({ok: false, status: 500}));
-        return element._getChangeDetail(123, '516714', errFn).then(() => {
-          assert.isTrue(errFn.called);
-        });
-      });
-
-      test('_getChangeDetail populates _projectLookup', () => {
-        sandbox.stub(element, 'getChangeActionURL')
-            .returns(Promise.resolve(''));
-        sandbox.stub(element._restApiHelper, 'fetchRawJSON')
-            .returns(Promise.resolve({ok: true}));
-
-        const mockResponse = {_number: 1, project: 'test'};
-        sandbox.stub(element._restApiHelper, 'readResponsePayload')
-            .returns(Promise.resolve({
-              parsed: mockResponse,
-              raw: JSON.stringify(mockResponse),
-            }));
-        return element._getChangeDetail(1, '516714').then(() => {
-          assert.equal(Object.keys(element._projectLookup).length, 1);
-          assert.equal(element._projectLookup[1], 'test');
-        });
-      });
-
-      suite('_getChangeDetail ETag cache', () => {
-        let requestUrl;
-        let mockResponseSerial;
-        let collectSpy;
-        let getPayloadSpy;
-
-        setup(() => {
-          requestUrl = '/foo/bar';
-          const mockResponse = {foo: 'bar', baz: 42};
-          mockResponseSerial = element.JSON_PREFIX +
-              JSON.stringify(mockResponse);
-          sandbox.stub(element._restApiHelper, 'urlWithParams')
-              .returns(requestUrl);
-          sandbox.stub(element, 'getChangeActionURL')
-              .returns(Promise.resolve(requestUrl));
-          collectSpy = sandbox.spy(element._etags, 'collect');
-          getPayloadSpy = sandbox.spy(element._etags, 'getCachedPayload');
-        });
-
-        test('contributes to cache', () => {
-          sandbox.stub(element._restApiHelper, 'fetchRawJSON')
-              .returns(Promise.resolve({
-                text: () => Promise.resolve(mockResponseSerial),
-                status: 200,
-                ok: true,
-              }));
-
-          return element._getChangeDetail(123, '516714').then(detail => {
-            assert.isFalse(getPayloadSpy.called);
-            assert.isTrue(collectSpy.calledOnce);
-            const cachedResponse = element._etags.getCachedPayload(requestUrl);
-            assert.equal(cachedResponse, mockResponseSerial);
-          });
-        });
-
-        test('uses cache on HTTP 304', () => {
-          sandbox.stub(element._restApiHelper, 'fetchRawJSON')
-              .returns(Promise.resolve({
-                text: () => Promise.resolve(mockResponseSerial),
-                status: 304,
-                ok: true,
-              }));
-
-          return element._getChangeDetail(123, {}).then(detail => {
-            assert.isFalse(collectSpy.called);
-            assert.isTrue(getPayloadSpy.calledOnce);
-          });
-        });
-      });
-    });
-
-    test('setInProjectLookup', () => {
-      element.setInProjectLookup('test', 'project');
-      assert.deepEqual(element._projectLookup, {test: 'project'});
-    });
-
-    suite('getFromProjectLookup', () => {
-      test('getChange fails', () => {
-        sandbox.stub(element, 'getChange')
-            .returns(Promise.resolve(null));
-        return element.getFromProjectLookup().then(val => {
-          assert.strictEqual(val, undefined);
-          assert.deepEqual(element._projectLookup, {});
-        });
-      });
-
-      test('getChange succeeds, no project', () => {
-        sandbox.stub(element, 'getChange').returns(Promise.resolve(null));
-        return element.getFromProjectLookup().then(val => {
-          assert.strictEqual(val, undefined);
-          assert.deepEqual(element._projectLookup, {});
-        });
-      });
-
-      test('getChange succeeds with project', () => {
-        sandbox.stub(element, 'getChange')
-            .returns(Promise.resolve({project: 'project'}));
-        return element.getFromProjectLookup('test').then(val => {
-          assert.equal(val, 'project');
-          assert.deepEqual(element._projectLookup, {test: 'project'});
-        });
-      });
-    });
-
-    suite('getChanges populates _projectLookup', () => {
-      test('multiple queries', () => {
-        sandbox.stub(element._restApiHelper, 'fetchJSON')
-            .returns(Promise.resolve([
-              [
-                {_number: 1, project: 'test'},
-                {_number: 2, project: 'test'},
-              ], [
-                {_number: 3, project: 'test/test'},
-              ],
-            ]));
-        // When opt_query instanceof Array, _fetchJSON returns
-        // Array<Array<Object>>.
-        return element.getChanges(null, []).then(() => {
-          assert.equal(Object.keys(element._projectLookup).length, 3);
-          assert.equal(element._projectLookup[1], 'test');
-          assert.equal(element._projectLookup[2], 'test');
-          assert.equal(element._projectLookup[3], 'test/test');
-        });
-      });
-
-      test('no query', () => {
-        sandbox.stub(element._restApiHelper, 'fetchJSON')
-            .returns(Promise.resolve([
-              {_number: 1, project: 'test'},
-              {_number: 2, project: 'test'},
-              {_number: 3, project: 'test/test'},
-            ]));
-
-        // When opt_query !instanceof Array, _fetchJSON returns
-        // Array<Object>.
-        return element.getChanges().then(() => {
-          assert.equal(Object.keys(element._projectLookup).length, 3);
-          assert.equal(element._projectLookup[1], 'test');
-          assert.equal(element._projectLookup[2], 'test');
-          assert.equal(element._projectLookup[3], 'test/test');
-        });
-      });
-    });
-
-    test('_getChangeURLAndFetch', () => {
-      element._projectLookup = {1: 'test'};
-      const fetchStub = sandbox.stub(element._restApiHelper, 'fetchJSON')
-          .returns(Promise.resolve());
-      const req = {changeNum: 1, endpoint: '/test', patchNum: 1};
-      return element._getChangeURLAndFetch(req).then(() => {
-        assert.equal(fetchStub.lastCall.args[0].url,
-            '/changes/test~1/revisions/1/test');
-      });
-    });
-
-    test('_getChangeURLAndSend', () => {
-      element._projectLookup = {1: 'test'};
-      const sendStub = sandbox.stub(element._restApiHelper, 'send')
-          .returns(Promise.resolve());
-
-      const req = {
-        changeNum: 1,
-        method: 'POST',
-        patchNum: 1,
-        endpoint: '/test',
-      };
-      return element._getChangeURLAndSend(req).then(() => {
-        assert.isTrue(sendStub.calledOnce);
-        assert.equal(sendStub.lastCall.args[0].method, 'POST');
-        assert.equal(sendStub.lastCall.args[0].url,
-            '/changes/test~1/revisions/1/test');
-      });
-    });
-
-    suite('reading responses', () => {
-      test('_readResponsePayload', () => {
-        const mockObject = {foo: 'bar', baz: 'foo'};
-        const serial = element.JSON_PREFIX + JSON.stringify(mockObject);
-        const mockResponse = {text: () => Promise.resolve(serial)};
-        return element._restApiHelper.readResponsePayload(mockResponse)
-            .then(payload => {
-              assert.deepEqual(payload.parsed, mockObject);
-              assert.equal(payload.raw, serial);
+      test('_sendDiffDraftRequest no checks for 200 on non create', () => {
+        sandbox.stub(element, '_getChangeURLAndSend')
+            .returns(Promise.resolve());
+        const failStub = sandbox.stub(element, '_failForCreate200')
+            .returns(Promise.resolve());
+        return element._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
+            .then(() => {
+              assert.isFalse(failStub.called);
             });
       });
 
-      test('_parsePrefixedJSON', () => {
-        const obj = {x: 3, y: {z: 4}, w: 23};
-        const serial = element.JSON_PREFIX + JSON.stringify(obj);
-        const result = element._restApiHelper.parsePrefixedJSON(serial);
-        assert.deepEqual(result, obj);
-      });
-    });
-
-    test('setChangeTopic', () => {
-      const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
-      return element.setChangeTopic(123, 'foo-bar').then(() => {
-        assert.isTrue(sendSpy.calledOnce);
-        assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
-      });
-    });
-
-    test('setChangeHashtag', () => {
-      const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
-      return element.setChangeHashtag(123, 'foo-bar').then(() => {
-        assert.isTrue(sendSpy.calledOnce);
-        assert.equal(sendSpy.lastCall.args[0].body, 'foo-bar');
-      });
-    });
-
-    test('generateAccountHttpPassword', () => {
-      const sendSpy = sandbox.spy(element._restApiHelper, 'send');
-      return element.generateAccountHttpPassword().then(() => {
-        assert.isTrue(sendSpy.calledOnce);
-        assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
-      });
-    });
-
-    suite('getChangeFiles', () => {
-      test('patch only', () => {
-        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-            .returns(Promise.resolve());
-        const range = {basePatchNum: 'PARENT', patchNum: 2};
-        return element.getChangeFiles(123, range).then(() => {
-          assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
-          assert.isNotOk(fetchStub.lastCall.args[0].params);
-        });
+      test('_failForCreate200 fails on 200', done => {
+        const result = {
+          ok: true,
+          status: 200,
+          headers: {entries: () => [
+            ['Set-CoOkiE', 'secret'],
+            ['Innocuous', 'hello'],
+          ]},
+        };
+        element._failForCreate200(Promise.resolve(result))
+            .then(() => {
+              assert.isTrue(false, 'Promise should not resolve');
+            })
+            .catch(e => {
+              assert.isOk(e);
+              assert.include(e.message, 'Saving draft resulted in HTTP 200');
+              assert.include(e.message, 'hello');
+              assert.notInclude(e.message, 'secret');
+              done();
+            });
       });
 
-      test('simple range', () => {
-        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-            .returns(Promise.resolve());
-        const range = {basePatchNum: 4, patchNum: 5};
-        return element.getChangeFiles(123, range).then(() => {
-          assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
-          assert.isOk(fetchStub.lastCall.args[0].params);
-          assert.equal(fetchStub.lastCall.args[0].params.base, 4);
-          assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-        });
-      });
-
-      test('parent index', () => {
-        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-            .returns(Promise.resolve());
-        const range = {basePatchNum: -3, patchNum: 5};
-        return element.getChangeFiles(123, range).then(() => {
-          assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
-          assert.isOk(fetchStub.lastCall.args[0].params);
-          assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-          assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
-        });
-      });
-    });
-
-    suite('getDiff', () => {
-      test('patchOnly', () => {
-        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-            .returns(Promise.resolve());
-        return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
-          assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
-          assert.isOk(fetchStub.lastCall.args[0].params);
-          assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-          assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-        });
-      });
-
-      test('simple range', () => {
-        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-            .returns(Promise.resolve());
-        return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
-          assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
-          assert.isOk(fetchStub.lastCall.args[0].params);
-          assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-          assert.equal(fetchStub.lastCall.args[0].params.base, 4);
-        });
-      });
-
-      test('parent index', () => {
-        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-            .returns(Promise.resolve());
-        return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
-          assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
-          assert.isOk(fetchStub.lastCall.args[0].params);
-          assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-          assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
-        });
-      });
-    });
-
-    test('getDashboard', () => {
-      const fetchCacheURLStub = sandbox.stub(element._restApiHelper,
-          'fetchCacheURL');
-      element.getDashboard('gerrit/project', 'default:main');
-      assert.isTrue(fetchCacheURLStub.calledOnce);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/gerrit%2Fproject/dashboards/default%3Amain');
-    });
-
-    test('getFileContent', () => {
-      sandbox.stub(element, '_getChangeURLAndSend')
-          .returns(Promise.resolve({
-            ok: 'true',
-            headers: {
-              get(header) {
-                if (header === 'X-FYI-Content-Type') {
-                  return 'text/java';
-                }
-              },
-            },
-          }));
-
-      sandbox.stub(element, 'getResponseObject')
-          .returns(Promise.resolve('new content'));
-
-      const edit = element.getFileContent('1', 'tst/path', 'EDIT').then(res => {
-        assert.deepEqual(res,
-            {content: 'new content', type: 'text/java', ok: true});
-      });
-
-      const normal = element.getFileContent('1', 'tst/path', '3').then(res => {
-        assert.deepEqual(res,
-            {content: 'new content', type: 'text/java', ok: true});
-      });
-
-      return Promise.all([edit, normal]);
-    });
-
-    test('getFileContent suppresses 404s', done => {
-      const res = {status: 404};
-      const handler = e => {
-        assert.isFalse(e.detail.res.status === 404);
-        done();
-      };
-      element.addEventListener('server-error', handler);
-      sandbox.stub(Gerrit.Auth, 'fetch').returns(Promise.resolve(res));
-      sandbox.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
-      element.getFileContent('1', 'tst/path', '1').then(() => {
-        flushAsynchronousOperations();
-
-        res.status = 500;
-        element.getFileContent('1', 'tst/path', '1');
-      });
-    });
-
-    test('getChangeFilesOrEditFiles is edit-sensitive', () => {
-      const fn = element.getChangeOrEditFiles.bind(element);
-      const getChangeFilesStub = sandbox.stub(element, 'getChangeFiles')
-          .returns(Promise.resolve({}));
-      const getChangeEditFilesStub = sandbox.stub(element, 'getChangeEditFiles')
-          .returns(Promise.resolve({}));
-
-      return fn('1', {patchNum: 'edit'}).then(() => {
-        assert.isTrue(getChangeEditFilesStub.calledOnce);
-        assert.isFalse(getChangeFilesStub.called);
-        return fn('1', {patchNum: '1'}).then(() => {
-          assert.isTrue(getChangeEditFilesStub.calledOnce);
-          assert.isTrue(getChangeFilesStub.calledOnce);
-        });
-      });
-    });
-
-    test('_fetch forwards request and logs', () => {
-      const logStub = sandbox.stub(element._restApiHelper, '_logCall');
-      const response = {status: 404, text: sinon.stub()};
-      const url = 'my url';
-      const fetchOptions = {method: 'DELETE'};
-      sandbox.stub(element._auth, 'fetch').returns(Promise.resolve(response));
-      const startTime = 123;
-      sandbox.stub(Date, 'now').returns(startTime);
-      const req = {url, fetchOptions};
-      return element._restApiHelper.fetch(req).then(() => {
-        assert.isTrue(logStub.calledOnce);
-        assert.isTrue(logStub.calledWith(req, startTime, response.status));
-        assert.isFalse(response.text.called);
-      });
-    });
-
-    test('_logCall only reports requests with anonymized URLss', () => {
-      sandbox.stub(Date, 'now').returns(200);
-      const handler = sinon.stub();
-      element.addEventListener('rpc-log', handler);
-
-      element._restApiHelper._logCall({url: 'url'}, 100, 200);
-      assert.isFalse(handler.called);
-
-      element._restApiHelper
-          ._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
-      flushAsynchronousOperations();
-      assert.isTrue(handler.calledOnce);
-    });
-
-    test('saveChangeStarred', async () => {
-      sandbox.stub(element, 'getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      const sendStub =
-          sandbox.stub(element._restApiHelper, 'send').returns(Promise.resolve());
-
-      await element.saveChangeStarred(123, true);
-      assert.isTrue(sendStub.calledOnce);
-      assert.deepEqual(sendStub.lastCall.args[0], {
-        method: 'PUT',
-        url: '/accounts/self/starred.changes/test~123',
-        anonymizedUrl: '/accounts/self/starred.changes/*',
-      });
-
-      await element.saveChangeStarred(456, false);
-      assert.isTrue(sendStub.calledTwice);
-      assert.deepEqual(sendStub.lastCall.args[0], {
-        method: 'DELETE',
-        url: '/accounts/self/starred.changes/test~456',
-        anonymizedUrl: '/accounts/self/starred.changes/*',
+      test('_failForCreate200 does not fail on 201', done => {
+        const result = {
+          ok: true,
+          status: 201,
+          headers: {entries: () => []},
+        };
+        element._failForCreate200(Promise.resolve(result))
+            .then(() => {
+              done();
+            })
+            .catch(e => {
+              assert.isTrue(false, 'Promise should not fail');
+            });
       });
     });
   });
+
+  test('saveChangeEdit', () => {
+    element._projectLookup = {1: 'test'};
+    const change_num = '1';
+    const file_name = 'index.php';
+    const file_contents = '<?php';
+    sandbox.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve([change_num, file_name, file_contents]));
+    sandbox.stub(element, 'getResponseObject')
+        .returns(Promise.resolve([change_num, file_name, file_contents]));
+    element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
+    return element.saveChangeEdit(change_num, file_name, file_contents)
+        .then(() => {
+          assert.isTrue(element._restApiHelper.send.calledOnce);
+          assert.equal(element._restApiHelper.send.lastCall.args[0].method,
+              'PUT');
+          assert.equal(element._restApiHelper.send.lastCall.args[0].url,
+              '/changes/test~1/edit/' + file_name);
+          assert.equal(element._restApiHelper.send.lastCall.args[0].body,
+              file_contents);
+        });
+  });
+
+  test('putChangeCommitMessage', () => {
+    element._projectLookup = {1: 'test'};
+    const change_num = '1';
+    const message = 'this is a commit message';
+    sandbox.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve([change_num, message]));
+    sandbox.stub(element, 'getResponseObject')
+        .returns(Promise.resolve([change_num, message]));
+    element._cache.set('/changes/' + change_num + '/message', {});
+    return element.putChangeCommitMessage(change_num, message).then(() => {
+      assert.isTrue(element._restApiHelper.send.calledOnce);
+      assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
+      assert.equal(element._restApiHelper.send.lastCall.args[0].url,
+          '/changes/test~1/message');
+      assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body,
+          {message});
+    });
+  });
+
+  test('deleteChangeCommitMessage', () => {
+    element._projectLookup = {1: 'test'};
+    const change_num = '1';
+    const messageId = 'abc';
+    sandbox.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve([change_num, messageId]));
+    sandbox.stub(element, 'getResponseObject')
+        .returns(Promise.resolve([change_num, messageId]));
+    return element.deleteChangeCommitMessage(change_num, messageId).then(() => {
+      assert.isTrue(element._restApiHelper.send.calledOnce);
+      assert.equal(
+          element._restApiHelper.send.lastCall.args[0].method,
+          'DELETE'
+      );
+      assert.equal(element._restApiHelper.send.lastCall.args[0].url,
+          '/changes/test~1/messages/abc');
+    });
+  });
+
+  test('startWorkInProgress', () => {
+    const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve('ok'));
+    element.startWorkInProgress('42');
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+    assert.equal(sendStub.lastCall.args[0].method, 'POST');
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {});
+
+    element.startWorkInProgress('42', 'revising...');
+    assert.isTrue(sendStub.calledTwice);
+    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+    assert.equal(sendStub.lastCall.args[0].method, 'POST');
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+    assert.deepEqual(sendStub.lastCall.args[0].body,
+        {message: 'revising...'});
+  });
+
+  test('startReview', () => {
+    const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve({}));
+    element.startReview('42', {message: 'Please review.'});
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+    assert.equal(sendStub.lastCall.args[0].method, 'POST');
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/ready');
+    assert.deepEqual(sendStub.lastCall.args[0].body,
+        {message: 'Please review.'});
+  });
+
+  test('deleteComment', () => {
+    const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve('some response'));
+    return element.deleteComment('foo', 'bar', '01234', 'removal reason')
+        .then(response => {
+          assert.equal(response, 'some response');
+          assert.isTrue(sendStub.calledOnce);
+          assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
+          assert.equal(sendStub.lastCall.args[0].method, 'POST');
+          assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
+          assert.equal(sendStub.lastCall.args[0].endpoint,
+              '/comments/01234/delete');
+          assert.deepEqual(sendStub.lastCall.args[0].body,
+              {reason: 'removal reason'});
+        });
+  });
+
+  test('createRepo encodes name', () => {
+    const sendStub = sandbox.stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve());
+    return element.createRepo({name: 'x/y'}).then(() => {
+      assert.isTrue(sendStub.calledOnce);
+      assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
+    });
+  });
+
+  test('queryChangeFiles', () => {
+    const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+        .returns(Promise.resolve());
+    return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
+      assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
+      assert.equal(fetchStub.lastCall.args[0].endpoint,
+          '/files?q=test%2Fpath.js');
+      assert.equal(fetchStub.lastCall.args[0].patchNum, 'edit');
+    });
+  });
+
+  test('normal use', () => {
+    const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
+
+    assert.equal(element._getReposUrl('test', 25),
+        '/projects/?n=26&S=0&query=test');
+
+    assert.equal(element._getReposUrl(null, 25),
+        `/projects/?n=26&S=0&query=${defaultQuery}`);
+
+    assert.equal(element._getReposUrl('test', 25, 25),
+        '/projects/?n=26&S=25&query=test');
+  });
+
+  test('invalidateReposCache', () => {
+    const url = '/projects/?n=26&S=0&query=test';
+
+    element._cache.set(url, {});
+
+    element.invalidateReposCache();
+
+    assert.isUndefined(element._sharedFetchPromises[url]);
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  test('invalidateAccountsCache', () => {
+    const url = '/accounts/self/detail';
+
+    element._cache.set(url, {});
+
+    element.invalidateAccountsCache();
+
+    assert.isUndefined(element._sharedFetchPromises[url]);
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  suite('getRepos', () => {
+    const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
+    let fetchCacheURLStub;
+    setup(() => {
+      fetchCacheURLStub =
+          sandbox.stub(element._restApiHelper, 'fetchCacheURL');
+    });
+
+    test('normal use', () => {
+      element.getRepos('test', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=test');
+
+      element.getRepos(null, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          `/projects/?n=26&S=0&query=${defaultQuery}`);
+
+      element.getRepos('test', 25, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=25&query=test');
+    });
+
+    test('with blank', () => {
+      element.getRepos('test/test', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Atest%20AND%20inname%3Atest');
+    });
+
+    test('with hyphen', () => {
+      element.getRepos('foo-bar', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+    });
+
+    test('with leading hyphen', () => {
+      element.getRepos('-bar', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Abar');
+    });
+
+    test('with trailing hyphen', () => {
+      element.getRepos('foo-bar-', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+    });
+
+    test('with underscore', () => {
+      element.getRepos('foo_bar', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+    });
+
+    test('with underscore', () => {
+      element.getRepos('foo_bar', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+    });
+
+    test('hyphen only', () => {
+      element.getRepos('-', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          `/projects/?n=26&S=0&query=${defaultQuery}`);
+    });
+  });
+
+  test('_getGroupsUrl normal use', () => {
+    assert.equal(element._getGroupsUrl('test', 25),
+        '/groups/?n=26&S=0&m=test');
+
+    assert.equal(element._getGroupsUrl(null, 25),
+        '/groups/?n=26&S=0');
+
+    assert.equal(element._getGroupsUrl('test', 25, 25),
+        '/groups/?n=26&S=25&m=test');
+  });
+
+  test('invalidateGroupsCache', () => {
+    const url = '/groups/?n=26&S=0&m=test';
+
+    element._cache.set(url, {});
+
+    element.invalidateGroupsCache();
+
+    assert.isUndefined(element._sharedFetchPromises[url]);
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  suite('getGroups', () => {
+    let fetchCacheURLStub;
+    setup(() => {
+      fetchCacheURLStub =
+          sandbox.stub(element._restApiHelper, 'fetchCacheURL');
+    });
+
+    test('normal use', () => {
+      element.getGroups('test', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=0&m=test');
+
+      element.getGroups(null, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=0');
+
+      element.getGroups('test', 25, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=25&m=test');
+    });
+
+    test('regex', () => {
+      element.getGroups('^test.*', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=0&r=%5Etest.*');
+
+      element.getGroups('^test.*', 25, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=25&r=%5Etest.*');
+    });
+  });
+
+  test('gerrit auth is used', () => {
+    sandbox.stub(Gerrit.Auth, 'fetch').returns(Promise.resolve());
+    element._restApiHelper.fetchJSON({url: 'foo'});
+    assert(Gerrit.Auth.fetch.called);
+  });
+
+  test('getSuggestedAccounts does not return _fetchJSON', () => {
+    const _fetchJSONSpy = sandbox.spy(element._restApiHelper, 'fetchJSON');
+    return element.getSuggestedAccounts().then(accts => {
+      assert.isFalse(_fetchJSONSpy.called);
+      assert.equal(accts.length, 0);
+    });
+  });
+
+  test('_fetchJSON gets called by getSuggestedAccounts', () => {
+    const _fetchJSONStub = sandbox.stub(element._restApiHelper, 'fetchJSON',
+        () => Promise.resolve());
+    return element.getSuggestedAccounts('own').then(() => {
+      assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
+        q: 'own',
+        suggest: null,
+      });
+    });
+  });
+
+  suite('getChangeDetail', () => {
+    suite('change detail options', () => {
+      let toHexStub;
+
+      setup(() => {
+        toHexStub = sandbox.stub(element, 'listChangesOptionsToHex',
+            options => 'deadbeef');
+        sandbox.stub(element, '_getChangeDetail',
+            async (changeNum, options) => { return {changeNum, options}; });
+      });
+
+      test('signed pushes disabled', async () => {
+        const {PUSH_CERTIFICATES} = element.ListChangesOption;
+        sandbox.stub(element, 'getConfig', async () => { return {}; });
+        const {changeNum, options} = await element.getChangeDetail(123);
+        assert.strictEqual(123, changeNum);
+        assert.strictEqual('deadbeef', options);
+        assert.isTrue(toHexStub.calledOnce);
+        assert.isFalse(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
+      });
+
+      test('signed pushes enabled', async () => {
+        const {PUSH_CERTIFICATES} = element.ListChangesOption;
+        sandbox.stub(element, 'getConfig', async () => {
+          return {receive: {enable_signed_push: true}};
+        });
+        const {changeNum, options} = await element.getChangeDetail(123);
+        assert.strictEqual(123, changeNum);
+        assert.strictEqual('deadbeef', options);
+        assert.isTrue(toHexStub.calledOnce);
+        assert.isTrue(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
+      });
+    });
+
+    test('GrReviewerUpdatesParser.parse is used', () => {
+      sandbox.stub(GrReviewerUpdatesParser, 'parse').returns(
+          Promise.resolve('foo'));
+      return element.getChangeDetail(42).then(result => {
+        assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
+        assert.equal(result, 'foo');
+      });
+    });
+
+    test('_getChangeDetail passes params to ETags decorator', () => {
+      const changeNum = 4321;
+      element._projectLookup[changeNum] = 'test';
+      const expectedUrl =
+          window.CANONICAL_PATH + '/changes/test~4321/detail?'+
+          '0=5&1=1&2=6&3=7&4=1&5=4';
+      sandbox.stub(element._etags, 'getOptions');
+      sandbox.stub(element._etags, 'collect');
+      return element._getChangeDetail(changeNum, '516714').then(() => {
+        assert.isTrue(element._etags.getOptions.calledWithExactly(
+            expectedUrl));
+        assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
+      });
+    });
+
+    test('_getChangeDetail calls errFn on 500', () => {
+      const errFn = sinon.stub();
+      sandbox.stub(element, 'getChangeActionURL')
+          .returns(Promise.resolve(''));
+      sandbox.stub(element._restApiHelper, 'fetchRawJSON')
+          .returns(Promise.resolve({ok: false, status: 500}));
+      return element._getChangeDetail(123, '516714', errFn).then(() => {
+        assert.isTrue(errFn.called);
+      });
+    });
+
+    test('_getChangeDetail populates _projectLookup', () => {
+      sandbox.stub(element, 'getChangeActionURL')
+          .returns(Promise.resolve(''));
+      sandbox.stub(element._restApiHelper, 'fetchRawJSON')
+          .returns(Promise.resolve({ok: true}));
+
+      const mockResponse = {_number: 1, project: 'test'};
+      sandbox.stub(element._restApiHelper, 'readResponsePayload')
+          .returns(Promise.resolve({
+            parsed: mockResponse,
+            raw: JSON.stringify(mockResponse),
+          }));
+      return element._getChangeDetail(1, '516714').then(() => {
+        assert.equal(Object.keys(element._projectLookup).length, 1);
+        assert.equal(element._projectLookup[1], 'test');
+      });
+    });
+
+    suite('_getChangeDetail ETag cache', () => {
+      let requestUrl;
+      let mockResponseSerial;
+      let collectSpy;
+      let getPayloadSpy;
+
+      setup(() => {
+        requestUrl = '/foo/bar';
+        const mockResponse = {foo: 'bar', baz: 42};
+        mockResponseSerial = element.JSON_PREFIX +
+            JSON.stringify(mockResponse);
+        sandbox.stub(element._restApiHelper, 'urlWithParams')
+            .returns(requestUrl);
+        sandbox.stub(element, 'getChangeActionURL')
+            .returns(Promise.resolve(requestUrl));
+        collectSpy = sandbox.spy(element._etags, 'collect');
+        getPayloadSpy = sandbox.spy(element._etags, 'getCachedPayload');
+      });
+
+      test('contributes to cache', () => {
+        sandbox.stub(element._restApiHelper, 'fetchRawJSON')
+            .returns(Promise.resolve({
+              text: () => Promise.resolve(mockResponseSerial),
+              status: 200,
+              ok: true,
+            }));
+
+        return element._getChangeDetail(123, '516714').then(detail => {
+          assert.isFalse(getPayloadSpy.called);
+          assert.isTrue(collectSpy.calledOnce);
+          const cachedResponse = element._etags.getCachedPayload(requestUrl);
+          assert.equal(cachedResponse, mockResponseSerial);
+        });
+      });
+
+      test('uses cache on HTTP 304', () => {
+        sandbox.stub(element._restApiHelper, 'fetchRawJSON')
+            .returns(Promise.resolve({
+              text: () => Promise.resolve(mockResponseSerial),
+              status: 304,
+              ok: true,
+            }));
+
+        return element._getChangeDetail(123, {}).then(detail => {
+          assert.isFalse(collectSpy.called);
+          assert.isTrue(getPayloadSpy.calledOnce);
+        });
+      });
+    });
+  });
+
+  test('setInProjectLookup', () => {
+    element.setInProjectLookup('test', 'project');
+    assert.deepEqual(element._projectLookup, {test: 'project'});
+  });
+
+  suite('getFromProjectLookup', () => {
+    test('getChange fails', () => {
+      sandbox.stub(element, 'getChange')
+          .returns(Promise.resolve(null));
+      return element.getFromProjectLookup().then(val => {
+        assert.strictEqual(val, undefined);
+        assert.deepEqual(element._projectLookup, {});
+      });
+    });
+
+    test('getChange succeeds, no project', () => {
+      sandbox.stub(element, 'getChange').returns(Promise.resolve(null));
+      return element.getFromProjectLookup().then(val => {
+        assert.strictEqual(val, undefined);
+        assert.deepEqual(element._projectLookup, {});
+      });
+    });
+
+    test('getChange succeeds with project', () => {
+      sandbox.stub(element, 'getChange')
+          .returns(Promise.resolve({project: 'project'}));
+      return element.getFromProjectLookup('test').then(val => {
+        assert.equal(val, 'project');
+        assert.deepEqual(element._projectLookup, {test: 'project'});
+      });
+    });
+  });
+
+  suite('getChanges populates _projectLookup', () => {
+    test('multiple queries', () => {
+      sandbox.stub(element._restApiHelper, 'fetchJSON')
+          .returns(Promise.resolve([
+            [
+              {_number: 1, project: 'test'},
+              {_number: 2, project: 'test'},
+            ], [
+              {_number: 3, project: 'test/test'},
+            ],
+          ]));
+      // When opt_query instanceof Array, _fetchJSON returns
+      // Array<Array<Object>>.
+      return element.getChanges(null, []).then(() => {
+        assert.equal(Object.keys(element._projectLookup).length, 3);
+        assert.equal(element._projectLookup[1], 'test');
+        assert.equal(element._projectLookup[2], 'test');
+        assert.equal(element._projectLookup[3], 'test/test');
+      });
+    });
+
+    test('no query', () => {
+      sandbox.stub(element._restApiHelper, 'fetchJSON')
+          .returns(Promise.resolve([
+            {_number: 1, project: 'test'},
+            {_number: 2, project: 'test'},
+            {_number: 3, project: 'test/test'},
+          ]));
+
+      // When opt_query !instanceof Array, _fetchJSON returns
+      // Array<Object>.
+      return element.getChanges().then(() => {
+        assert.equal(Object.keys(element._projectLookup).length, 3);
+        assert.equal(element._projectLookup[1], 'test');
+        assert.equal(element._projectLookup[2], 'test');
+        assert.equal(element._projectLookup[3], 'test/test');
+      });
+    });
+  });
+
+  test('_getChangeURLAndFetch', () => {
+    element._projectLookup = {1: 'test'};
+    const fetchStub = sandbox.stub(element._restApiHelper, 'fetchJSON')
+        .returns(Promise.resolve());
+    const req = {changeNum: 1, endpoint: '/test', patchNum: 1};
+    return element._getChangeURLAndFetch(req).then(() => {
+      assert.equal(fetchStub.lastCall.args[0].url,
+          '/changes/test~1/revisions/1/test');
+    });
+  });
+
+  test('_getChangeURLAndSend', () => {
+    element._projectLookup = {1: 'test'};
+    const sendStub = sandbox.stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve());
+
+    const req = {
+      changeNum: 1,
+      method: 'POST',
+      patchNum: 1,
+      endpoint: '/test',
+    };
+    return element._getChangeURLAndSend(req).then(() => {
+      assert.isTrue(sendStub.calledOnce);
+      assert.equal(sendStub.lastCall.args[0].method, 'POST');
+      assert.equal(sendStub.lastCall.args[0].url,
+          '/changes/test~1/revisions/1/test');
+    });
+  });
+
+  suite('reading responses', () => {
+    test('_readResponsePayload', () => {
+      const mockObject = {foo: 'bar', baz: 'foo'};
+      const serial = element.JSON_PREFIX + JSON.stringify(mockObject);
+      const mockResponse = {text: () => Promise.resolve(serial)};
+      return element._restApiHelper.readResponsePayload(mockResponse)
+          .then(payload => {
+            assert.deepEqual(payload.parsed, mockObject);
+            assert.equal(payload.raw, serial);
+          });
+    });
+
+    test('_parsePrefixedJSON', () => {
+      const obj = {x: 3, y: {z: 4}, w: 23};
+      const serial = element.JSON_PREFIX + JSON.stringify(obj);
+      const result = element._restApiHelper.parsePrefixedJSON(serial);
+      assert.deepEqual(result, obj);
+    });
+  });
+
+  test('setChangeTopic', () => {
+    const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
+    return element.setChangeTopic(123, 'foo-bar').then(() => {
+      assert.isTrue(sendSpy.calledOnce);
+      assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
+    });
+  });
+
+  test('setChangeHashtag', () => {
+    const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
+    return element.setChangeHashtag(123, 'foo-bar').then(() => {
+      assert.isTrue(sendSpy.calledOnce);
+      assert.equal(sendSpy.lastCall.args[0].body, 'foo-bar');
+    });
+  });
+
+  test('generateAccountHttpPassword', () => {
+    const sendSpy = sandbox.spy(element._restApiHelper, 'send');
+    return element.generateAccountHttpPassword().then(() => {
+      assert.isTrue(sendSpy.calledOnce);
+      assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
+    });
+  });
+
+  suite('getChangeFiles', () => {
+    test('patch only', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      const range = {basePatchNum: 'PARENT', patchNum: 2};
+      return element.getChangeFiles(123, range).then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+        assert.isNotOk(fetchStub.lastCall.args[0].params);
+      });
+    });
+
+    test('simple range', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      const range = {basePatchNum: 4, patchNum: 5};
+      return element.getChangeFiles(123, range).then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+      });
+    });
+
+    test('parent index', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      const range = {basePatchNum: -3, patchNum: 5};
+      return element.getChangeFiles(123, range).then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
+      });
+    });
+  });
+
+  suite('getDiff', () => {
+    test('patchOnly', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+      });
+    });
+
+    test('simple range', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
+      });
+    });
+
+    test('parent index', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
+      });
+    });
+  });
+
+  test('getDashboard', () => {
+    const fetchCacheURLStub = sandbox.stub(element._restApiHelper,
+        'fetchCacheURL');
+    element.getDashboard('gerrit/project', 'default:main');
+    assert.isTrue(fetchCacheURLStub.calledOnce);
+    assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/gerrit%2Fproject/dashboards/default%3Amain');
+  });
+
+  test('getFileContent', () => {
+    sandbox.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve({
+          ok: 'true',
+          headers: {
+            get(header) {
+              if (header === 'X-FYI-Content-Type') {
+                return 'text/java';
+              }
+            },
+          },
+        }));
+
+    sandbox.stub(element, 'getResponseObject')
+        .returns(Promise.resolve('new content'));
+
+    const edit = element.getFileContent('1', 'tst/path', 'EDIT').then(res => {
+      assert.deepEqual(res,
+          {content: 'new content', type: 'text/java', ok: true});
+    });
+
+    const normal = element.getFileContent('1', 'tst/path', '3').then(res => {
+      assert.deepEqual(res,
+          {content: 'new content', type: 'text/java', ok: true});
+    });
+
+    return Promise.all([edit, normal]);
+  });
+
+  test('getFileContent suppresses 404s', done => {
+    const res = {status: 404};
+    const handler = e => {
+      assert.isFalse(e.detail.res.status === 404);
+      done();
+    };
+    element.addEventListener('server-error', handler);
+    sandbox.stub(Gerrit.Auth, 'fetch').returns(Promise.resolve(res));
+    sandbox.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
+    element.getFileContent('1', 'tst/path', '1').then(() => {
+      flushAsynchronousOperations();
+
+      res.status = 500;
+      element.getFileContent('1', 'tst/path', '1');
+    });
+  });
+
+  test('getChangeFilesOrEditFiles is edit-sensitive', () => {
+    const fn = element.getChangeOrEditFiles.bind(element);
+    const getChangeFilesStub = sandbox.stub(element, 'getChangeFiles')
+        .returns(Promise.resolve({}));
+    const getChangeEditFilesStub = sandbox.stub(element, 'getChangeEditFiles')
+        .returns(Promise.resolve({}));
+
+    return fn('1', {patchNum: 'edit'}).then(() => {
+      assert.isTrue(getChangeEditFilesStub.calledOnce);
+      assert.isFalse(getChangeFilesStub.called);
+      return fn('1', {patchNum: '1'}).then(() => {
+        assert.isTrue(getChangeEditFilesStub.calledOnce);
+        assert.isTrue(getChangeFilesStub.calledOnce);
+      });
+    });
+  });
+
+  test('_fetch forwards request and logs', () => {
+    const logStub = sandbox.stub(element._restApiHelper, '_logCall');
+    const response = {status: 404, text: sinon.stub()};
+    const url = 'my url';
+    const fetchOptions = {method: 'DELETE'};
+    sandbox.stub(element._auth, 'fetch').returns(Promise.resolve(response));
+    const startTime = 123;
+    sandbox.stub(Date, 'now').returns(startTime);
+    const req = {url, fetchOptions};
+    return element._restApiHelper.fetch(req).then(() => {
+      assert.isTrue(logStub.calledOnce);
+      assert.isTrue(logStub.calledWith(req, startTime, response.status));
+      assert.isFalse(response.text.called);
+    });
+  });
+
+  test('_logCall only reports requests with anonymized URLss', () => {
+    sandbox.stub(Date, 'now').returns(200);
+    const handler = sinon.stub();
+    element.addEventListener('rpc-log', handler);
+
+    element._restApiHelper._logCall({url: 'url'}, 100, 200);
+    assert.isFalse(handler.called);
+
+    element._restApiHelper
+        ._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
+    flushAsynchronousOperations();
+    assert.isTrue(handler.calledOnce);
+  });
+
+  test('saveChangeStarred', async () => {
+    sandbox.stub(element, 'getFromProjectLookup')
+        .returns(Promise.resolve('test'));
+    const sendStub =
+        sandbox.stub(element._restApiHelper, 'send').returns(Promise.resolve());
+
+    await element.saveChangeStarred(123, true);
+    assert.isTrue(sendStub.calledOnce);
+    assert.deepEqual(sendStub.lastCall.args[0], {
+      method: 'PUT',
+      url: '/accounts/self/starred.changes/test~123',
+      anonymizedUrl: '/accounts/self/starred.changes/*',
+    });
+
+    await element.saveChangeStarred(456, false);
+    assert.isTrue(sendStub.calledTwice);
+    assert.deepEqual(sendStub.lastCall.args[0], {
+      method: 'DELETE',
+      url: '/accounts/self/starred.changes/test~456',
+      anonymizedUrl: '/accounts/self/starred.changes/*',
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
index 310063c..7f86953 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
@@ -19,158 +19,169 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-rest-api-helper</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../../test/common-test-setup.html"/>
-<script src="../../../../scripts/util.js"></script>
-<script src="../gr-auth.js"></script>
-<script src="gr-rest-api-helper.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../../scripts/util.js"></script>
+<script type="module" src="../gr-auth.js"></script>
+<script type="module" src="./gr-rest-api-helper.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../../test/test-pre-setup.js';
+import '../../../../test/common-test-setup.js';
+import '../../../../scripts/util.js';
+import '../gr-auth.js';
+import './gr-rest-api-helper.js';
+void(0);
+</script>
 
-<script>
-  suite('gr-rest-api-helper tests', async () => {
-    await readyToTest();
-    let helper;
-    let sandbox;
-    let cache;
-    let fetchPromisesCache;
+<script type="module">
+import '../../../../test/test-pre-setup.js';
+import '../../../../test/common-test-setup.js';
+import '../../../../scripts/util.js';
+import '../gr-auth.js';
+import './gr-rest-api-helper.js';
+suite('gr-rest-api-helper tests', () => {
+  let helper;
+  let sandbox;
+  let cache;
+  let fetchPromisesCache;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      cache = new SiteBasedCache();
-      fetchPromisesCache = new FetchPromisesCache();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    cache = new SiteBasedCache();
+    fetchPromisesCache = new FetchPromisesCache();
 
-      window.CANONICAL_PATH = 'testhelper';
+    window.CANONICAL_PATH = 'testhelper';
 
-      const mockRestApiInterface = {
-        getBaseUrl: sinon.stub().returns(window.CANONICAL_PATH),
-        fire: sinon.stub(),
-      };
+    const mockRestApiInterface = {
+      getBaseUrl: sinon.stub().returns(window.CANONICAL_PATH),
+      fire: sinon.stub(),
+    };
 
-      const testJSON = ')]}\'\n{"hello": "bonjour"}';
-      sandbox.stub(window, 'fetch').returns(Promise.resolve({
-        ok: true,
-        text() {
-          return Promise.resolve(testJSON);
-        },
-      }));
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    sandbox.stub(window, 'fetch').returns(Promise.resolve({
+      ok: true,
+      text() {
+        return Promise.resolve(testJSON);
+      },
+    }));
 
-      helper = new GrRestApiHelper(cache, Gerrit.Auth, fetchPromisesCache,
-          mockRestApiInterface);
+    helper = new GrRestApiHelper(cache, Gerrit.Auth, fetchPromisesCache,
+        mockRestApiInterface);
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('fetchJSON()', () => {
+    test('Sets header to accept application/json', () => {
+      const authFetchStub = sandbox.stub(helper._auth, 'fetch')
+          .returns(Promise.resolve());
+      helper.fetchJSON({url: '/dummy/url'});
+      assert.isTrue(authFetchStub.called);
+      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
+          'application/json');
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('fetchJSON()', () => {
-      test('Sets header to accept application/json', () => {
-        const authFetchStub = sandbox.stub(helper._auth, 'fetch')
-            .returns(Promise.resolve());
-        helper.fetchJSON({url: '/dummy/url'});
-        assert.isTrue(authFetchStub.called);
-        assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-            'application/json');
-      });
-
-      test('Use header option accept when provided', () => {
-        const authFetchStub = sandbox.stub(helper._auth, 'fetch')
-            .returns(Promise.resolve());
-        const headers = new Headers();
-        headers.append('Accept', '*/*');
-        const fetchOptions = {headers};
-        helper.fetchJSON({url: '/dummy/url', fetchOptions});
-        assert.isTrue(authFetchStub.called);
-        assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-            '*/*');
-      });
-    });
-
-    test('JSON prefix is properly removed', done => {
-      helper.fetchJSON({url: '/dummy/url'}).then(obj => {
-        assert.deepEqual(obj, {hello: 'bonjour'});
-        done();
-      });
-    });
-
-    test('cached results', done => {
-      let n = 0;
-      sandbox.stub(helper, 'fetchJSON', () => Promise.resolve(++n));
-      const promises = [];
-      promises.push(helper.fetchCacheURL('/foo'));
-      promises.push(helper.fetchCacheURL('/foo'));
-      promises.push(helper.fetchCacheURL('/foo'));
-
-      Promise.all(promises).then(results => {
-        assert.deepEqual(results, [1, 1, 1]);
-        helper.fetchCacheURL('/foo').then(foo => {
-          assert.equal(foo, 1);
-          done();
-        });
-      });
-    });
-
-    test('cached promise', done => {
-      const promise = Promise.reject(new Error('foo'));
-      cache.set('/foo', promise);
-      helper.fetchCacheURL({url: '/foo'}).catch(p => {
-        assert.equal(p.message, 'foo');
-        done();
-      });
-    });
-
-    test('cache invalidation', () => {
-      cache.set('/foo/bar', 1);
-      cache.set('/bar', 2);
-      fetchPromisesCache.set('/foo/bar', 3);
-      fetchPromisesCache.set('/bar', 4);
-      helper.invalidateFetchPromisesPrefix('/foo/');
-      assert.isFalse(cache.has('/foo/bar'));
-      assert.isTrue(cache.has('/bar'));
-      assert.isUndefined(fetchPromisesCache.get('/foo/bar'));
-      assert.strictEqual(4, fetchPromisesCache.get('/bar'));
-    });
-
-    test('params are properly encoded', () => {
-      let url = helper.urlWithParams('/path/', {
-        sp: 'hola',
-        gr: 'guten tag',
-        noval: null,
-      });
-      assert.equal(url,
-          window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
-
-      url = helper.urlWithParams('/path/', {
-        sp: 'hola',
-        en: ['hey', 'hi'],
-      });
-      assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
-
-      // Order must be maintained with array params.
-      url = helper.urlWithParams('/path/', {
-        l: ['c', 'b', 'a'],
-      });
-      assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
-    });
-
-    test('request callbacks can be canceled', done => {
-      let cancelCalled = false;
-      window.fetch.returns(Promise.resolve({
-        body: {
-          cancel() { cancelCalled = true; },
-        },
-      }));
-      const cancelCondition = () => true;
-      helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(
-          obj => {
-            assert.isUndefined(obj);
-            assert.isTrue(cancelCalled);
-            done();
-          });
+    test('Use header option accept when provided', () => {
+      const authFetchStub = sandbox.stub(helper._auth, 'fetch')
+          .returns(Promise.resolve());
+      const headers = new Headers();
+      headers.append('Accept', '*/*');
+      const fetchOptions = {headers};
+      helper.fetchJSON({url: '/dummy/url', fetchOptions});
+      assert.isTrue(authFetchStub.called);
+      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
+          '*/*');
     });
   });
+
+  test('JSON prefix is properly removed', done => {
+    helper.fetchJSON({url: '/dummy/url'}).then(obj => {
+      assert.deepEqual(obj, {hello: 'bonjour'});
+      done();
+    });
+  });
+
+  test('cached results', done => {
+    let n = 0;
+    sandbox.stub(helper, 'fetchJSON', () => Promise.resolve(++n));
+    const promises = [];
+    promises.push(helper.fetchCacheURL('/foo'));
+    promises.push(helper.fetchCacheURL('/foo'));
+    promises.push(helper.fetchCacheURL('/foo'));
+
+    Promise.all(promises).then(results => {
+      assert.deepEqual(results, [1, 1, 1]);
+      helper.fetchCacheURL('/foo').then(foo => {
+        assert.equal(foo, 1);
+        done();
+      });
+    });
+  });
+
+  test('cached promise', done => {
+    const promise = Promise.reject(new Error('foo'));
+    cache.set('/foo', promise);
+    helper.fetchCacheURL({url: '/foo'}).catch(p => {
+      assert.equal(p.message, 'foo');
+      done();
+    });
+  });
+
+  test('cache invalidation', () => {
+    cache.set('/foo/bar', 1);
+    cache.set('/bar', 2);
+    fetchPromisesCache.set('/foo/bar', 3);
+    fetchPromisesCache.set('/bar', 4);
+    helper.invalidateFetchPromisesPrefix('/foo/');
+    assert.isFalse(cache.has('/foo/bar'));
+    assert.isTrue(cache.has('/bar'));
+    assert.isUndefined(fetchPromisesCache.get('/foo/bar'));
+    assert.strictEqual(4, fetchPromisesCache.get('/bar'));
+  });
+
+  test('params are properly encoded', () => {
+    let url = helper.urlWithParams('/path/', {
+      sp: 'hola',
+      gr: 'guten tag',
+      noval: null,
+    });
+    assert.equal(url,
+        window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
+
+    url = helper.urlWithParams('/path/', {
+      sp: 'hola',
+      en: ['hey', 'hi'],
+    });
+    assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
+
+    // Order must be maintained with array params.
+    url = helper.urlWithParams('/path/', {
+      l: ['c', 'b', 'a'],
+    });
+    assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
+  });
+
+  test('request callbacks can be canceled', done => {
+    let cancelCalled = false;
+    window.fetch.returns(Promise.resolve({
+      body: {
+        cancel() { cancelCalled = true; },
+      },
+    }));
+    const cancelCondition = () => true;
+    helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(
+        obj => {
+          assert.isUndefined(obj);
+          assert.isTrue(cancelCalled);
+          done();
+        });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
index 678e02a..6dcdc48 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
@@ -19,289 +19,292 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-updates-parser</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
-<script src="gr-reviewer-updates-parser.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="../../../scripts/util.js"></script>
+<script type="module" src="./gr-reviewer-updates-parser.js"></script>
 
-<script>
-  suite('gr-reviewer-updates-parser tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let instance;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import '../../../scripts/util.js';
+import './gr-reviewer-updates-parser.js';
+suite('gr-reviewer-updates-parser tests', () => {
+  let sandbox;
+  let instance;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('ignores changes without messages', () => {
-      const change = {};
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_groupUpdates');
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_formatUpdates');
-      assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._groupUpdates.called);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._formatUpdates.called);
-    });
+  test('ignores changes without messages', () => {
+    const change = {};
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_groupUpdates');
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_formatUpdates');
+    assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._groupUpdates.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._formatUpdates.called);
+  });
 
-    test('ignores changes without reviewer updates', () => {
-      const change = {
-        messages: [],
-      };
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_groupUpdates');
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_formatUpdates');
-      assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._groupUpdates.called);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._formatUpdates.called);
-    });
+  test('ignores changes without reviewer updates', () => {
+    const change = {
+      messages: [],
+    };
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_groupUpdates');
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_formatUpdates');
+    assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._groupUpdates.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._formatUpdates.called);
+  });
 
-    test('ignores changes with empty reviewer updates', () => {
-      const change = {
-        messages: [],
-        reviewer_updates: [],
-      };
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_groupUpdates');
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_formatUpdates');
-      assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._groupUpdates.called);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._formatUpdates.called);
-    });
+  test('ignores changes with empty reviewer updates', () => {
+    const change = {
+      messages: [],
+      reviewer_updates: [],
+    };
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_groupUpdates');
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_formatUpdates');
+    assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._groupUpdates.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._formatUpdates.called);
+  });
 
-    test('filter removed messages', () => {
-      const change = {
-        messages: [
-          {
-            message: 'msg1',
-            tag: 'autogenerated:gerrit:deleteReviewer',
-          },
-          {
-            message: 'msg2',
-            tag: 'foo',
-          },
-        ],
-      };
-      instance = new GrReviewerUpdatesParser(change);
-      instance._filterRemovedMessages();
-      assert.deepEqual(instance.result, {
-        messages: [{
+  test('filter removed messages', () => {
+    const change = {
+      messages: [
+        {
+          message: 'msg1',
+          tag: 'autogenerated:gerrit:deleteReviewer',
+        },
+        {
           message: 'msg2',
           tag: 'foo',
-        }],
-      });
-    });
-
-    test('group reviewer updates', () => {
-      const reviewer1 = {_account_id: 1};
-      const reviewer2 = {_account_id: 2};
-      const date1 = '2017-01-26 12:11:50.000000000';
-      const date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
-      const date3 = '2017-01-26 12:33:50.000000000';
-      const date4 = '2017-01-26 12:44:50.000000000';
-      const makeItem = function(state, reviewer, opt_date, opt_author) {
-        return {
-          reviewer,
-          updated: opt_date || date1,
-          updated_by: opt_author || reviewer1,
-          state,
-        };
-      };
-      let change = {
-        reviewer_updates: [
-          makeItem('REVIEWER', reviewer1), // New group.
-          makeItem('CC', reviewer2), // Appended.
-          makeItem('REVIEWER', reviewer2, date2), // Overrides previous one.
-
-          makeItem('CC', reviewer1, date2, reviewer2), // New group.
-
-          makeItem('REMOVED', reviewer2, date3), // Group has no state change.
-          makeItem('REVIEWER', reviewer2, date3),
-
-          makeItem('CC', reviewer1, date4), // No change, removed.
-          makeItem('REVIEWER', reviewer1, date4), // Forms new group
-          makeItem('REMOVED', reviewer2, date4), // Should be grouped.
-        ],
-      };
-
-      instance = new GrReviewerUpdatesParser(change);
-      instance._groupUpdates();
-      change = instance.result;
-
-      assert.equal(change.reviewer_updates.length, 3);
-      assert.equal(change.reviewer_updates[0].updates.length, 2);
-      assert.equal(change.reviewer_updates[1].updates.length, 1);
-      assert.equal(change.reviewer_updates[2].updates.length, 2);
-
-      assert.equal(change.reviewer_updates[0].date, date1);
-      assert.deepEqual(change.reviewer_updates[0].author, reviewer1);
-      assert.deepEqual(change.reviewer_updates[0].updates, [
-        {
-          reviewer: reviewer1,
-          state: 'REVIEWER',
         },
-        {
-          reviewer: reviewer2,
-          state: 'REVIEWER',
-        },
-      ]);
-
-      assert.equal(change.reviewer_updates[1].date, date2);
-      assert.deepEqual(change.reviewer_updates[1].author, reviewer2);
-      assert.deepEqual(change.reviewer_updates[1].updates, [
-        {
-          reviewer: reviewer1,
-          state: 'CC',
-          prev_state: 'REVIEWER',
-        },
-      ]);
-
-      assert.equal(change.reviewer_updates[2].date, date4);
-      assert.deepEqual(change.reviewer_updates[2].author, reviewer1);
-      assert.deepEqual(change.reviewer_updates[2].updates, [
-        {
-          reviewer: reviewer1,
-          prev_state: 'CC',
-          state: 'REVIEWER',
-        },
-        {
-          reviewer: reviewer2,
-          prev_state: 'REVIEWER',
-          state: 'REMOVED',
-        },
-      ]);
-    });
-
-    test('format reviewer updates', () => {
-      const reviewer1 = {_account_id: 1};
-      const reviewer2 = {_account_id: 2};
-      const makeItem = function(prev, state, opt_reviewer) {
-        return {
-          reviewer: opt_reviewer || reviewer1,
-          prev_state: prev,
-          state,
-        };
-      };
-      const makeUpdate = function(items) {
-        return {
-          author: reviewer1,
-          updated: '',
-          updates: items,
-        };
-      };
-      const change = {
-        reviewer_updates: [
-          makeUpdate([
-            makeItem(undefined, 'CC'),
-            makeItem(undefined, 'CC', reviewer2),
-          ]),
-          makeUpdate([
-            makeItem('CC', 'REVIEWER'),
-            makeItem('REVIEWER', 'REMOVED'),
-            makeItem('REMOVED', 'REVIEWER'),
-            makeItem(undefined, 'REVIEWER', reviewer2),
-          ]),
-        ],
-      };
-
-      instance = new GrReviewerUpdatesParser(change);
-      instance._formatUpdates();
-
-      assert.equal(change.reviewer_updates.length, 2);
-      assert.equal(change.reviewer_updates[0].updates.length, 1);
-      assert.equal(change.reviewer_updates[1].updates.length, 3);
-
-      let items = change.reviewer_updates[0].updates;
-      assert.equal(items[0].message, 'Added to cc: ');
-      assert.deepEqual(items[0].reviewers, [reviewer1, reviewer2]);
-
-      items = change.reviewer_updates[1].updates;
-      assert.equal(items[0].message, 'Moved from cc to reviewer: ');
-      assert.deepEqual(items[0].reviewers, [reviewer1]);
-      assert.equal(items[1].message, 'Removed from reviewer: ');
-      assert.deepEqual(items[1].reviewers, [reviewer1]);
-      assert.equal(items[2].message, 'Added to reviewer: ');
-      assert.deepEqual(items[2].reviewers, [reviewer1, reviewer2]);
-    });
-
-    test('_advanceUpdates', () => {
-      const T0 = util.parseDate('2017-02-17 19:04:18.000000000').getTime();
-      const tplus = delta => new Date(T0 + delta)
-          .toISOString()
-          .replace('T', ' ')
-          .replace('Z', '000000');
-      const change = {
-        reviewer_updates: [{
-          date: tplus(0),
-          type: 'REVIEWER_UPDATE',
-          updates: [{
-            message: 'same time update',
-          }],
-        }, {
-          date: tplus(200),
-          type: 'REVIEWER_UPDATE',
-          updates: [{
-            message: 'update within threshold',
-          }],
-        }, {
-          date: tplus(600),
-          type: 'REVIEWER_UPDATE',
-          updates: [{
-            message: 'update between messages',
-          }],
-        }, {
-          date: tplus(1000),
-          type: 'REVIEWER_UPDATE',
-          updates: [{
-            message: 'late update',
-          }],
-        }],
-        messages: [{
-          id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
-          date: tplus(0),
-          message: 'Uploaded patch set 1.',
-        }, {
-          id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
-          date: tplus(800),
-          message: 'Uploaded patch set 2.',
-        }],
-      };
-      instance = new GrReviewerUpdatesParser(change);
-      instance._advanceUpdates();
-      const updates = instance.result.reviewer_updates;
-      assert.isBelow(util.parseDate(updates[0].date).getTime(), T0);
-      assert.isBelow(util.parseDate(updates[1].date).getTime(), T0);
-      assert.equal(updates[2].date, tplus(100));
-      assert.equal(updates[3].date, tplus(500));
+      ],
+    };
+    instance = new GrReviewerUpdatesParser(change);
+    instance._filterRemovedMessages();
+    assert.deepEqual(instance.result, {
+      messages: [{
+        message: 'msg2',
+        tag: 'foo',
+      }],
     });
   });
+
+  test('group reviewer updates', () => {
+    const reviewer1 = {_account_id: 1};
+    const reviewer2 = {_account_id: 2};
+    const date1 = '2017-01-26 12:11:50.000000000';
+    const date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
+    const date3 = '2017-01-26 12:33:50.000000000';
+    const date4 = '2017-01-26 12:44:50.000000000';
+    const makeItem = function(state, reviewer, opt_date, opt_author) {
+      return {
+        reviewer,
+        updated: opt_date || date1,
+        updated_by: opt_author || reviewer1,
+        state,
+      };
+    };
+    let change = {
+      reviewer_updates: [
+        makeItem('REVIEWER', reviewer1), // New group.
+        makeItem('CC', reviewer2), // Appended.
+        makeItem('REVIEWER', reviewer2, date2), // Overrides previous one.
+
+        makeItem('CC', reviewer1, date2, reviewer2), // New group.
+
+        makeItem('REMOVED', reviewer2, date3), // Group has no state change.
+        makeItem('REVIEWER', reviewer2, date3),
+
+        makeItem('CC', reviewer1, date4), // No change, removed.
+        makeItem('REVIEWER', reviewer1, date4), // Forms new group
+        makeItem('REMOVED', reviewer2, date4), // Should be grouped.
+      ],
+    };
+
+    instance = new GrReviewerUpdatesParser(change);
+    instance._groupUpdates();
+    change = instance.result;
+
+    assert.equal(change.reviewer_updates.length, 3);
+    assert.equal(change.reviewer_updates[0].updates.length, 2);
+    assert.equal(change.reviewer_updates[1].updates.length, 1);
+    assert.equal(change.reviewer_updates[2].updates.length, 2);
+
+    assert.equal(change.reviewer_updates[0].date, date1);
+    assert.deepEqual(change.reviewer_updates[0].author, reviewer1);
+    assert.deepEqual(change.reviewer_updates[0].updates, [
+      {
+        reviewer: reviewer1,
+        state: 'REVIEWER',
+      },
+      {
+        reviewer: reviewer2,
+        state: 'REVIEWER',
+      },
+    ]);
+
+    assert.equal(change.reviewer_updates[1].date, date2);
+    assert.deepEqual(change.reviewer_updates[1].author, reviewer2);
+    assert.deepEqual(change.reviewer_updates[1].updates, [
+      {
+        reviewer: reviewer1,
+        state: 'CC',
+        prev_state: 'REVIEWER',
+      },
+    ]);
+
+    assert.equal(change.reviewer_updates[2].date, date4);
+    assert.deepEqual(change.reviewer_updates[2].author, reviewer1);
+    assert.deepEqual(change.reviewer_updates[2].updates, [
+      {
+        reviewer: reviewer1,
+        prev_state: 'CC',
+        state: 'REVIEWER',
+      },
+      {
+        reviewer: reviewer2,
+        prev_state: 'REVIEWER',
+        state: 'REMOVED',
+      },
+    ]);
+  });
+
+  test('format reviewer updates', () => {
+    const reviewer1 = {_account_id: 1};
+    const reviewer2 = {_account_id: 2};
+    const makeItem = function(prev, state, opt_reviewer) {
+      return {
+        reviewer: opt_reviewer || reviewer1,
+        prev_state: prev,
+        state,
+      };
+    };
+    const makeUpdate = function(items) {
+      return {
+        author: reviewer1,
+        updated: '',
+        updates: items,
+      };
+    };
+    const change = {
+      reviewer_updates: [
+        makeUpdate([
+          makeItem(undefined, 'CC'),
+          makeItem(undefined, 'CC', reviewer2),
+        ]),
+        makeUpdate([
+          makeItem('CC', 'REVIEWER'),
+          makeItem('REVIEWER', 'REMOVED'),
+          makeItem('REMOVED', 'REVIEWER'),
+          makeItem(undefined, 'REVIEWER', reviewer2),
+        ]),
+      ],
+    };
+
+    instance = new GrReviewerUpdatesParser(change);
+    instance._formatUpdates();
+
+    assert.equal(change.reviewer_updates.length, 2);
+    assert.equal(change.reviewer_updates[0].updates.length, 1);
+    assert.equal(change.reviewer_updates[1].updates.length, 3);
+
+    let items = change.reviewer_updates[0].updates;
+    assert.equal(items[0].message, 'Added to cc: ');
+    assert.deepEqual(items[0].reviewers, [reviewer1, reviewer2]);
+
+    items = change.reviewer_updates[1].updates;
+    assert.equal(items[0].message, 'Moved from cc to reviewer: ');
+    assert.deepEqual(items[0].reviewers, [reviewer1]);
+    assert.equal(items[1].message, 'Removed from reviewer: ');
+    assert.deepEqual(items[1].reviewers, [reviewer1]);
+    assert.equal(items[2].message, 'Added to reviewer: ');
+    assert.deepEqual(items[2].reviewers, [reviewer1, reviewer2]);
+  });
+
+  test('_advanceUpdates', () => {
+    const T0 = util.parseDate('2017-02-17 19:04:18.000000000').getTime();
+    const tplus = delta => new Date(T0 + delta)
+        .toISOString()
+        .replace('T', ' ')
+        .replace('Z', '000000');
+    const change = {
+      reviewer_updates: [{
+        date: tplus(0),
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'same time update',
+        }],
+      }, {
+        date: tplus(200),
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'update within threshold',
+        }],
+      }, {
+        date: tplus(600),
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'update between messages',
+        }],
+      }, {
+        date: tplus(1000),
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'late update',
+        }],
+      }],
+      messages: [{
+        id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
+        date: tplus(0),
+        message: 'Uploaded patch set 1.',
+      }, {
+        id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
+        date: tplus(800),
+        message: 'Uploaded patch set 2.',
+      }],
+    };
+    instance = new GrReviewerUpdatesParser(change);
+    instance._advanceUpdates();
+    const updates = instance.result.reviewer_updates;
+    assert.isBelow(util.parseDate(updates[0].date).getTime(), T0);
+    assert.isBelow(util.parseDate(updates[1].date).getTime(), T0);
+    assert.equal(updates[2].date, tplus(100));
+    assert.equal(updates[3].date, tplus(500));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.js
new file mode 100644
index 0000000..e29d300
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.js
@@ -0,0 +1,167 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 '../../../scripts/bundled-polymer.js';
+
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const RESPONSE = {
+  meta_a: {
+    name: 'lorem-ipsum.txt',
+    content_type: 'text/plain',
+    lines: 45,
+  },
+  meta_b: {
+    name: 'lorem-ipsum.txt',
+    content_type: 'text/plain',
+    lines: 48,
+  },
+  intraline_status: 'OK',
+  change_type: 'MODIFIED',
+  diff_header: [
+    'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+    'index b2adcf4..554ae49 100644',
+    '--- a/lorem-ipsum.txt',
+    '+++ b/lorem-ipsum.txt',
+  ],
+  content: [
+    {
+      ab: [
+        'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
+          'nulla phasellus.',
+        'Mattis lectus.',
+        'Sodales duis.',
+        'Orci a faucibus.',
+      ],
+    },
+    {
+      b: [
+        'Nullam neque, ligula ac, id blandit.',
+        'Sagittis tincidunt torquent, tempor nunc amet.',
+        'At rhoncus id.',
+      ],
+    },
+    {
+      ab: [
+        'Sem nascetur, erat ut, non in.',
+        'A donec, venenatis pellentesque dis.',
+        'Mauris mauris.',
+        'Quisque nisl duis, facilisis viverra.',
+        'Justo purus, semper eget et.',
+      ],
+    },
+    {
+      a: [
+        'Est amet, vestibulum pellentesque.',
+        'Erat ligula.',
+        'Justo eros.',
+        'Fringilla quisque.',
+      ],
+    },
+    {
+      ab: [
+        'Arcu eget, rhoncus amet cursus, ipsum elementum.',
+        'Eros suspendisse.',
+      ],
+    },
+    {
+      a: [
+        'Rhoncus tempor, ultricies aliquam ipsum.',
+      ],
+      b: [
+        'Rhoncus tempor, ultricies praesent ipsum.',
+      ],
+      edit_a: [
+        [
+          26,
+          7,
+        ],
+      ],
+      edit_b: [
+        [
+          26,
+          8,
+        ],
+      ],
+    },
+    {
+      ab: [
+        'Sollicitudin duis.',
+        'Blandit blandit, ante nisl fusce.',
+        'Felis ac at, tellus consectetuer.',
+        'Sociis ligula sapien, egestas leo.',
+        'Cum pulvinar, sed mauris, cursus neque velit.',
+        'Augue porta lobortis.',
+        'Nibh lorem, amet fermentum turpis, vel pulvinar diam.',
+        'Id quam ipsum, id urna et, massa suspendisse.',
+        'Ac nec, nibh praesent.',
+        'Rutrum vestibulum.',
+        'Est tellus, bibendum habitasse.',
+        'Justo facilisis, vel nulla.',
+        'Donec eu, vulputate neque aliquam, nulla dui.',
+        'Risus adipiscing in.',
+        'Lacus arcu arcu.',
+        'Urna velit.',
+        'Urna a dolor.',
+        'Lectus magna augue, convallis mattis tortor, sed tellus ' +
+          'consequat.',
+        'Etiam dui, blandit wisi.',
+        'Mi nec.',
+        'Vitae eget vestibulum.',
+        'Ullamcorper nunc ante, nec imperdiet felis, consectetur in.',
+        'Ac eget.',
+        'Vel fringilla, interdum pellentesque placerat, proin ante.',
+      ],
+    },
+    {
+      b: [
+        'Eu congue risus.',
+        'Enim ac, quis elementum.',
+        'Non et elit.',
+        'Etiam aliquam, diam vel nunc.',
+      ],
+    },
+    {
+      ab: [
+        'Nec at.',
+        'Arcu mauris, venenatis lacus fermentum, praesent duis.',
+        'Pellentesque amet et, tellus duis.',
+        'Ipsum arcu vitae, justo elit, sed libero tellus.',
+        'Metus rutrum euismod, vivamus sodales, vel arcu nisl.',
+      ],
+    },
+  ],
+};
+
+Polymer({
+  _template: html`
+
+`,
+
+  is: 'mock-diff-response',
+
+  properties: {
+    diffResponse: {
+      type: Object,
+      value() {
+        return RESPONSE;
+      },
+    },
+  },
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
deleted file mode 100644
index f1ef86a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-
-<dom-module id="gr-select">
-  <slot></slot>
-  <script src="gr-select.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
index 3e59aee..18be73d 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -14,77 +14,89 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.FireMixin
-   * @extends Polymer.Element
-   */
-  class GrSelect extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-select'; }
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+const $_documentContainer = document.createElement('template');
 
-    static get properties() {
-      return {
-        bindValue: {
-          type: String,
-          notify: true,
-          observer: '_updateValue',
-        },
-      };
-    }
+$_documentContainer.innerHTML = `<dom-module id="gr-select">
+  <slot></slot>
+  
+</dom-module>`;
 
-    get nativeSelect() {
-      // gr-select is not a shadow component
-      // TODO(taoalpha): maybe we should convert
-      // it into a shadow dom component instead
-      return this.querySelector('select');
-    }
+document.head.appendChild($_documentContainer.content);
 
-    _updateValue() {
-      // It's possible to have a value of 0.
-      if (this.bindValue !== undefined) {
-        // Set for chrome/safari so it happens instantly
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @extends Polymer.Element
+ */
+class GrSelect extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get is() { return 'gr-select'; }
+
+  static get properties() {
+    return {
+      bindValue: {
+        type: String,
+        notify: true,
+        observer: '_updateValue',
+      },
+    };
+  }
+
+  get nativeSelect() {
+    // gr-select is not a shadow component
+    // TODO(taoalpha): maybe we should convert
+    // it into a shadow dom component instead
+    return this.querySelector('select');
+  }
+
+  _updateValue() {
+    // It's possible to have a value of 0.
+    if (this.bindValue !== undefined) {
+      // Set for chrome/safari so it happens instantly
+      this.nativeSelect.value = this.bindValue;
+      // Async needed for firefox to populate value. It was trying to do it
+      // before options from a dom-repeat were rendered previously.
+      // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
+      this.async(() => {
         this.nativeSelect.value = this.bindValue;
-        // Async needed for firefox to populate value. It was trying to do it
-        // before options from a dom-repeat were rendered previously.
-        // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
-        this.async(() => {
-          this.nativeSelect.value = this.bindValue;
-        }, 1);
-      }
-    }
-
-    _valueChanged() {
-      this.bindValue = this.nativeSelect.value;
-    }
-
-    focus() {
-      this.nativeSelect.focus();
-    }
-
-    /** @override */
-    created() {
-      super.created();
-      this.addEventListener('change',
-          () => this._valueChanged());
-      this.addEventListener('dom-change',
-          () => this._updateValue());
-    }
-
-    /** @override */
-    ready() {
-      super.ready();
-      // If not set via the property, set bind-value to the element value.
-      if (this.bindValue == undefined && this.nativeSelect.options.length > 0) {
-        this.bindValue = this.nativeSelect.value;
-      }
+      }, 1);
     }
   }
 
-  customElements.define(GrSelect.is, GrSelect);
-})();
+  _valueChanged() {
+    this.bindValue = this.nativeSelect.value;
+  }
+
+  focus() {
+    this.nativeSelect.focus();
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('change',
+        () => this._valueChanged());
+    this.addEventListener('dom-change',
+        () => this._updateValue());
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    // If not set via the property, set bind-value to the element value.
+    if (this.bindValue == undefined && this.nativeSelect.options.length > 0) {
+      this.bindValue = this.nativeSelect.value;
+    }
+  }
+}
+
+customElements.define(GrSelect.is, GrSelect);
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
index 536f4f8..a4d28a3 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-select</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-select.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-select.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-select.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -50,71 +55,73 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-select tests', async () => {
-    await readyToTest();
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-select.js';
+suite('gr-select tests', () => {
+  let element;
+
+  setup(() => {
+    element = fixture('basic');
+  });
+
+  test('bindValue must be set to the first option value', () => {
+    assert.equal(element.bindValue, '1');
+  });
+
+  test('value of 0 should still trigger value updates', () => {
+    element.bindValue = 0;
+    assert.equal(element.nativeSelect.value, 0);
+  });
+
+  test('bidirectional binding property-to-attribute', () => {
+    const changeStub = sinon.stub();
+    element.addEventListener('bind-value-changed', changeStub);
+
+    // The selected element should be the first one by default.
+    assert.equal(element.nativeSelect.value, '1');
+    assert.equal(element.bindValue, '1');
+    assert.isFalse(changeStub.called);
+
+    // Now change the value.
+    element.bindValue = '2';
+
+    // It should be updated.
+    assert.equal(element.nativeSelect.value, '2');
+    assert.equal(element.bindValue, '2');
+    assert.isTrue(changeStub.called);
+  });
+
+  test('bidirectional binding attribute-to-property', () => {
+    const changeStub = sinon.stub();
+    element.addEventListener('bind-value-changed', changeStub);
+
+    // The selected element should be the first one by default.
+    assert.equal(element.nativeSelect.value, '1');
+    assert.equal(element.bindValue, '1');
+    assert.isFalse(changeStub.called);
+
+    // Now change the value.
+    element.nativeSelect.value = '3';
+    element.fire('change');
+
+    // It should be updated.
+    assert.equal(element.nativeSelect.value, '3');
+    assert.equal(element.bindValue, '3');
+    assert.isTrue(changeStub.called);
+  });
+
+  suite('gr-select no options tests', () => {
     let element;
 
     setup(() => {
-      element = fixture('basic');
+      element = fixture('noOptions');
     });
 
-    test('bindValue must be set to the first option value', () => {
-      assert.equal(element.bindValue, '1');
-    });
-
-    test('value of 0 should still trigger value updates', () => {
-      element.bindValue = 0;
-      assert.equal(element.nativeSelect.value, 0);
-    });
-
-    test('bidirectional binding property-to-attribute', () => {
-      const changeStub = sinon.stub();
-      element.addEventListener('bind-value-changed', changeStub);
-
-      // The selected element should be the first one by default.
-      assert.equal(element.nativeSelect.value, '1');
-      assert.equal(element.bindValue, '1');
-      assert.isFalse(changeStub.called);
-
-      // Now change the value.
-      element.bindValue = '2';
-
-      // It should be updated.
-      assert.equal(element.nativeSelect.value, '2');
-      assert.equal(element.bindValue, '2');
-      assert.isTrue(changeStub.called);
-    });
-
-    test('bidirectional binding attribute-to-property', () => {
-      const changeStub = sinon.stub();
-      element.addEventListener('bind-value-changed', changeStub);
-
-      // The selected element should be the first one by default.
-      assert.equal(element.nativeSelect.value, '1');
-      assert.equal(element.bindValue, '1');
-      assert.isFalse(changeStub.called);
-
-      // Now change the value.
-      element.nativeSelect.value = '3';
-      element.fire('change');
-
-      // It should be updated.
-      assert.equal(element.nativeSelect.value, '3');
-      assert.equal(element.bindValue, '3');
-      assert.isTrue(changeStub.called);
-    });
-
-    suite('gr-select no options tests', () => {
-      let element;
-
-      setup(() => {
-        element = fixture('noOptions');
-      });
-
-      test('bindValue must not be changed', () => {
-        assert.isUndefined(element.bindValue);
-      });
+    test('bindValue must not be changed', () => {
+      assert.isUndefined(element.bindValue);
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
index 63dbcbd..151498c 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
@@ -14,26 +14,33 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrShellCommand extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-shell-command'; }
+import '../../../styles/shared-styles.js';
+import '../gr-copy-clipboard/gr-copy-clipboard.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-shell-command_html.js';
 
-    static get properties() {
-      return {
-        command: String,
-        label: String,
-      };
-    }
+/** @extends Polymer.Element */
+class GrShellCommand extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    focusOnCopy() {
-      this.shadowRoot.querySelector('gr-copy-clipboard').focusOnCopy();
-    }
+  static get is() { return 'gr-shell-command'; }
+
+  static get properties() {
+    return {
+      command: String,
+      label: String,
+    };
   }
 
-  customElements.define(GrShellCommand.is, GrShellCommand);
-})();
+  focusOnCopy() {
+    this.shadowRoot.querySelector('gr-copy-clipboard').focusOnCopy();
+  }
+}
+
+customElements.define(GrShellCommand.is, GrShellCommand);
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.js
index 15e282f..8fbf2b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.js
@@ -1,26 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-
-<dom-module id="gr-shell-command">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       .commandContainer {
         margin-bottom: var(--spacing-m);
@@ -33,7 +29,7 @@
         width: 100%;
       }
       .commandContainer:before {
-        content: '$';
+        content: '\$';
         position: absolute;
         display: block;
         box-sizing: border-box;
@@ -58,6 +54,4 @@
     <div class="commandContainer">
       <gr-copy-clipboard text="[[command]]"></gr-copy-clipboard>
     </div>
-  </template>
-  <script src="gr-shell-command.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
index b596a4a..4e5be4d 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-shell-command</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-shell-command.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-shell-command.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-shell-command.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,30 +40,32 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-shell-command tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-shell-command.js';
+suite('gr-shell-command tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.text = `git fetch http://gerrit@localhost:8080/a/test-project
-          refs/changes/05/5/1 && git checkout FETCH_HEAD`;
-      flushAsynchronousOperations();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('focusOnCopy', () => {
-      const focusStub = sandbox.stub(element.shadowRoot
-          .querySelector('gr-copy-clipboard'),
-      'focusOnCopy');
-      element.focusOnCopy();
-      assert.isTrue(focusStub.called);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git checkout FETCH_HEAD`;
+    flushAsynchronousOperations();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('focusOnCopy', () => {
+    const focusStub = sandbox.stub(element.shadowRoot
+        .querySelector('gr-copy-clipboard'),
+    'focusOnCopy');
+    element.focusOnCopy();
+    assert.isTrue(focusStub.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
deleted file mode 100644
index 7215b26..0000000
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
+++ /dev/null
@@ -1,20 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<dom-module id="gr-storage">
-  <script src="gr-storage.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index 8cc9de9..1597439 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -14,148 +14,150 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const DURATION_DAY = 24 * 60 * 60 * 1000;
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 
-  // Clean up old entries no more frequently than one day.
-  const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
+const DURATION_DAY = 24 * 60 * 60 * 1000;
 
-  const CLEANUP_PREFIXES_MAX_AGE_MAP = {
-    // respectfultip has a 3 day expiration
-    'respectfultip:': 3 * DURATION_DAY,
-    'draft:': DURATION_DAY,
-    'editablecontent:': DURATION_DAY,
-  };
+// Clean up old entries no more frequently than one day.
+const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
 
-  /** @extends Polymer.Element */
-  class GrStorage extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-storage'; }
+const CLEANUP_PREFIXES_MAX_AGE_MAP = {
+  // respectfultip has a 3 day expiration
+  'respectfultip:': 3 * DURATION_DAY,
+  'draft:': DURATION_DAY,
+  'editablecontent:': DURATION_DAY,
+};
 
-    static get properties() {
-      return {
-        _lastCleanup: Number,
-        /** @type {?Storage} */
-        _storage: {
-          type: Object,
-          value() {
-            return window.localStorage;
-          },
+/** @extends Polymer.Element */
+class GrStorage extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get is() { return 'gr-storage'; }
+
+  static get properties() {
+    return {
+      _lastCleanup: Number,
+      /** @type {?Storage} */
+      _storage: {
+        type: Object,
+        value() {
+          return window.localStorage;
         },
-        _exceededQuota: {
-          type: Boolean,
-          value: false,
-        },
-      };
+      },
+      _exceededQuota: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
+
+  getDraftComment(location) {
+    this._cleanupItems();
+    return this._getObject(this._getDraftKey(location));
+  }
+
+  setDraftComment(location, message) {
+    const key = this._getDraftKey(location);
+    this._setObject(key, {message, updated: Date.now()});
+  }
+
+  eraseDraftComment(location) {
+    const key = this._getDraftKey(location);
+    this._storage.removeItem(key);
+  }
+
+  getEditableContentItem(key) {
+    this._cleanupItems();
+    return this._getObject(this._getEditableContentKey(key));
+  }
+
+  setEditableContentItem(key, message) {
+    this._setObject(this._getEditableContentKey(key),
+        {message, updated: Date.now()});
+  }
+
+  getRespectfulTipVisibility() {
+    this._cleanupItems();
+    return this._getObject('respectfultip:visibility');
+  }
+
+  setRespectfulTipVisibility(delayDays = 0) {
+    this._cleanupItems();
+    this._setObject(
+        'respectfultip:visibility',
+        {updated: Date.now() + delayDays * DURATION_DAY}
+    );
+  }
+
+  eraseEditableContentItem(key) {
+    this._storage.removeItem(this._getEditableContentKey(key));
+  }
+
+  _getDraftKey(location) {
+    const range = location.range ?
+      `${location.range.start_line}-${location.range.start_character}` +
+            `-${location.range.end_character}-${location.range.end_line}` :
+      null;
+    let key = ['draft', location.changeNum, location.patchNum, location.path,
+      location.line || ''].join(':');
+    if (range) {
+      key = key + ':' + range;
     }
+    return key;
+  }
 
-    getDraftComment(location) {
-      this._cleanupItems();
-      return this._getObject(this._getDraftKey(location));
+  _getEditableContentKey(key) {
+    return `editablecontent:${key}`;
+  }
+
+  _cleanupItems() {
+    // Throttle cleanup to the throttle interval.
+    if (this._lastCleanup &&
+        Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL) {
+      return;
     }
+    this._lastCleanup = Date.now();
 
-    setDraftComment(location, message) {
-      const key = this._getDraftKey(location);
-      this._setObject(key, {message, updated: Date.now()});
-    }
-
-    eraseDraftComment(location) {
-      const key = this._getDraftKey(location);
-      this._storage.removeItem(key);
-    }
-
-    getEditableContentItem(key) {
-      this._cleanupItems();
-      return this._getObject(this._getEditableContentKey(key));
-    }
-
-    setEditableContentItem(key, message) {
-      this._setObject(this._getEditableContentKey(key),
-          {message, updated: Date.now()});
-    }
-
-    getRespectfulTipVisibility() {
-      this._cleanupItems();
-      return this._getObject('respectfultip:visibility');
-    }
-
-    setRespectfulTipVisibility(delayDays = 0) {
-      this._cleanupItems();
-      this._setObject(
-          'respectfultip:visibility',
-          {updated: Date.now() + delayDays * DURATION_DAY}
-      );
-    }
-
-    eraseEditableContentItem(key) {
-      this._storage.removeItem(this._getEditableContentKey(key));
-    }
-
-    _getDraftKey(location) {
-      const range = location.range ?
-        `${location.range.start_line}-${location.range.start_character}` +
-              `-${location.range.end_character}-${location.range.end_line}` :
-        null;
-      let key = ['draft', location.changeNum, location.patchNum, location.path,
-        location.line || ''].join(':');
-      if (range) {
-        key = key + ':' + range;
-      }
-      return key;
-    }
-
-    _getEditableContentKey(key) {
-      return `editablecontent:${key}`;
-    }
-
-    _cleanupItems() {
-      // Throttle cleanup to the throttle interval.
-      if (this._lastCleanup &&
-          Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL) {
-        return;
-      }
-      this._lastCleanup = Date.now();
-
-      let item;
-      Object.keys(this._storage).forEach(key => {
-        Object.keys(CLEANUP_PREFIXES_MAX_AGE_MAP).forEach(prefix => {
-          if (key.startsWith(prefix)) {
-            item = this._getObject(key);
-            const expiration = CLEANUP_PREFIXES_MAX_AGE_MAP[prefix];
-            if (Date.now() - item.updated > expiration) {
-              this._storage.removeItem(key);
-            }
+    let item;
+    Object.keys(this._storage).forEach(key => {
+      Object.keys(CLEANUP_PREFIXES_MAX_AGE_MAP).forEach(prefix => {
+        if (key.startsWith(prefix)) {
+          item = this._getObject(key);
+          const expiration = CLEANUP_PREFIXES_MAX_AGE_MAP[prefix];
+          if (Date.now() - item.updated > expiration) {
+            this._storage.removeItem(key);
           }
-        });
-      });
-    }
-
-    _getObject(key) {
-      const serial = this._storage.getItem(key);
-      if (!serial) { return null; }
-      return JSON.parse(serial);
-    }
-
-    _setObject(key, obj) {
-      if (this._exceededQuota) { return; }
-      try {
-        this._storage.setItem(key, JSON.stringify(obj));
-      } catch (exc) {
-        // Catch for QuotaExceededError and disable writes on local storage the
-        // first time that it occurs.
-        if (exc.code === 22) {
-          this._exceededQuota = true;
-          console.warn('Local storage quota exceeded: disabling');
-          return;
-        } else {
-          throw exc;
         }
+      });
+    });
+  }
+
+  _getObject(key) {
+    const serial = this._storage.getItem(key);
+    if (!serial) { return null; }
+    return JSON.parse(serial);
+  }
+
+  _setObject(key, obj) {
+    if (this._exceededQuota) { return; }
+    try {
+      this._storage.setItem(key, JSON.stringify(obj));
+    } catch (exc) {
+      // Catch for QuotaExceededError and disable writes on local storage the
+      // first time that it occurs.
+      if (exc.code === 22) {
+        this._exceededQuota = true;
+        console.warn('Local storage quota exceeded: disabling');
+        return;
+      } else {
+        throw exc;
       }
     }
   }
+}
 
-  customElements.define(GrStorage.is, GrStorage);
-})();
+customElements.define(GrStorage.is, GrStorage);
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
index 66e7f98..06e5915 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -18,15 +18,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-storage</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-storage.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-storage.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-storage.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -34,165 +39,167 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-storage tests', async () => {
-    await readyToTest();
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-storage.js';
+suite('gr-storage tests', () => {
+  let element;
+  let sandbox;
 
-    function mockStorage(opt_quotaExceeded) {
-      return {
-        getItem(key) { return this[key]; },
-        removeItem(key) { delete this[key]; },
-        setItem(key, value) {
-          // eslint-disable-next-line no-throw-literal
-          if (opt_quotaExceeded) { throw {code: 22}; /* Quota exceeded */ }
-          this[key] = value;
-        },
-      };
-    }
+  function mockStorage(opt_quotaExceeded) {
+    return {
+      getItem(key) { return this[key]; },
+      removeItem(key) { delete this[key]; },
+      setItem(key, value) {
+        // eslint-disable-next-line no-throw-literal
+        if (opt_quotaExceeded) { throw {code: 22}; /* Quota exceeded */ }
+        this[key] = value;
+      },
+    };
+  }
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      element._storage = mockStorage();
-    });
-
-    teardown(() => sandbox.restore());
-
-    test('storing, retrieving and erasing drafts', () => {
-      const changeNum = 1234;
-      const patchNum = 5;
-      const path = 'my_source_file.js';
-      const line = 123;
-      const location = {
-        changeNum,
-        patchNum,
-        path,
-        line,
-      };
-
-      // The key is in the expected format.
-      const key = element._getDraftKey(location);
-      assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
-
-      // There should be no draft initially.
-      const draft = element.getDraftComment(location);
-      assert.isNotOk(draft);
-
-      // Setting the draft stores it under the expected key.
-      element.setDraftComment(location, 'my comment');
-      assert.isOk(element._storage.getItem(key));
-      assert.equal(JSON.parse(element._storage.getItem(key)).message,
-          'my comment');
-      assert.isOk(JSON.parse(element._storage.getItem(key)).updated);
-
-      // Erasing the draft removes the key.
-      element.eraseDraftComment(location);
-      assert.isNotOk(element._storage.getItem(key));
-    });
-
-    test('automatically removes old drafts', () => {
-      const changeNum = 1234;
-      const patchNum = 5;
-      const path = 'my_source_file.js';
-      const line = 123;
-      const location = {
-        changeNum,
-        patchNum,
-        path,
-        line,
-      };
-
-      const key = element._getDraftKey(location);
-
-      // Make sure that the call to cleanup doesn't get throttled.
-      element._lastCleanup = 0;
-
-      const cleanupSpy = sandbox.spy(element, '_cleanupItems');
-
-      // Create a message with a timestamp that is a second behind the max age.
-      element._storage.setItem(key, JSON.stringify({
-        message: 'old message',
-        updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
-      }));
-
-      // Getting the draft should cause it to be removed.
-      const draft = element.getDraftComment(location);
-
-      assert.isTrue(cleanupSpy.called);
-      assert.isNotOk(draft);
-      assert.isNotOk(element._storage.getItem(key));
-    });
-
-    test('_getDraftKey', () => {
-      const changeNum = 1234;
-      const patchNum = 5;
-      const path = 'my_source_file.js';
-      const line = 123;
-      const location = {
-        changeNum,
-        patchNum,
-        path,
-        line,
-      };
-      let expectedResult = 'draft:1234:5:my_source_file.js:123';
-      assert.equal(element._getDraftKey(location), expectedResult);
-      location.range = {
-        start_character: 1,
-        start_line: 1,
-        end_character: 1,
-        end_line: 2,
-      };
-      expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
-      assert.equal(element._getDraftKey(location), expectedResult);
-    });
-
-    test('exceeded quota disables storage', () => {
-      element._storage = mockStorage(true);
-      assert.isFalse(element._exceededQuota);
-
-      const changeNum = 1234;
-      const patchNum = 5;
-      const path = 'my_source_file.js';
-      const line = 123;
-      const location = {
-        changeNum,
-        patchNum,
-        path,
-        line,
-      };
-      const key = element._getDraftKey(location);
-      element.setDraftComment(location, 'my comment');
-      assert.isTrue(element._exceededQuota);
-      assert.isNotOk(element._storage.getItem(key));
-    });
-
-    test('editable content items', () => {
-      const cleanupStub = sandbox.stub(element, '_cleanupItems');
-      const key = 'testKey';
-      const computedKey = element._getEditableContentKey(key);
-      // Key correctly computed.
-      assert.equal(computedKey, 'editablecontent:testKey');
-
-      element.setEditableContentItem(key, 'my content');
-
-      // Setting the draft stores it under the expected key.
-      let item = element._storage.getItem(computedKey);
-      assert.isOk(item);
-      assert.equal(JSON.parse(item).message, 'my content');
-      assert.isOk(JSON.parse(item).updated);
-
-      // getEditableContentItem performs as expected.
-      item = element.getEditableContentItem(key);
-      assert.isOk(item);
-      assert.equal(item.message, 'my content');
-      assert.isOk(item.updated);
-      assert.isTrue(cleanupStub.called);
-
-      // eraseEditableContentItem performs as expected.
-      element.eraseEditableContentItem(key);
-      assert.isNotOk(element._storage.getItem(computedKey));
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    element._storage = mockStorage();
   });
+
+  teardown(() => sandbox.restore());
+
+  test('storing, retrieving and erasing drafts', () => {
+    const changeNum = 1234;
+    const patchNum = 5;
+    const path = 'my_source_file.js';
+    const line = 123;
+    const location = {
+      changeNum,
+      patchNum,
+      path,
+      line,
+    };
+
+    // The key is in the expected format.
+    const key = element._getDraftKey(location);
+    assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
+
+    // There should be no draft initially.
+    const draft = element.getDraftComment(location);
+    assert.isNotOk(draft);
+
+    // Setting the draft stores it under the expected key.
+    element.setDraftComment(location, 'my comment');
+    assert.isOk(element._storage.getItem(key));
+    assert.equal(JSON.parse(element._storage.getItem(key)).message,
+        'my comment');
+    assert.isOk(JSON.parse(element._storage.getItem(key)).updated);
+
+    // Erasing the draft removes the key.
+    element.eraseDraftComment(location);
+    assert.isNotOk(element._storage.getItem(key));
+  });
+
+  test('automatically removes old drafts', () => {
+    const changeNum = 1234;
+    const patchNum = 5;
+    const path = 'my_source_file.js';
+    const line = 123;
+    const location = {
+      changeNum,
+      patchNum,
+      path,
+      line,
+    };
+
+    const key = element._getDraftKey(location);
+
+    // Make sure that the call to cleanup doesn't get throttled.
+    element._lastCleanup = 0;
+
+    const cleanupSpy = sandbox.spy(element, '_cleanupItems');
+
+    // Create a message with a timestamp that is a second behind the max age.
+    element._storage.setItem(key, JSON.stringify({
+      message: 'old message',
+      updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
+    }));
+
+    // Getting the draft should cause it to be removed.
+    const draft = element.getDraftComment(location);
+
+    assert.isTrue(cleanupSpy.called);
+    assert.isNotOk(draft);
+    assert.isNotOk(element._storage.getItem(key));
+  });
+
+  test('_getDraftKey', () => {
+    const changeNum = 1234;
+    const patchNum = 5;
+    const path = 'my_source_file.js';
+    const line = 123;
+    const location = {
+      changeNum,
+      patchNum,
+      path,
+      line,
+    };
+    let expectedResult = 'draft:1234:5:my_source_file.js:123';
+    assert.equal(element._getDraftKey(location), expectedResult);
+    location.range = {
+      start_character: 1,
+      start_line: 1,
+      end_character: 1,
+      end_line: 2,
+    };
+    expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
+    assert.equal(element._getDraftKey(location), expectedResult);
+  });
+
+  test('exceeded quota disables storage', () => {
+    element._storage = mockStorage(true);
+    assert.isFalse(element._exceededQuota);
+
+    const changeNum = 1234;
+    const patchNum = 5;
+    const path = 'my_source_file.js';
+    const line = 123;
+    const location = {
+      changeNum,
+      patchNum,
+      path,
+      line,
+    };
+    const key = element._getDraftKey(location);
+    element.setDraftComment(location, 'my comment');
+    assert.isTrue(element._exceededQuota);
+    assert.isNotOk(element._storage.getItem(key));
+  });
+
+  test('editable content items', () => {
+    const cleanupStub = sandbox.stub(element, '_cleanupItems');
+    const key = 'testKey';
+    const computedKey = element._getEditableContentKey(key);
+    // Key correctly computed.
+    assert.equal(computedKey, 'editablecontent:testKey');
+
+    element.setEditableContentItem(key, 'my content');
+
+    // Setting the draft stores it under the expected key.
+    let item = element._storage.getItem(computedKey);
+    assert.isOk(item);
+    assert.equal(JSON.parse(item).message, 'my content');
+    assert.isOk(JSON.parse(item).updated);
+
+    // getEditableContentItem performs as expected.
+    item = element.getEditableContentItem(key);
+    assert.isOk(item);
+    assert.equal(item.message, 'my content');
+    assert.isOk(item.updated);
+    assert.isTrue(cleanupStub.called);
+
+    // eraseEditableContentItem performs as expected.
+    element.eraseEditableContentItem(key);
+    assert.isNotOk(element._storage.getItem(computedKey));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
index 07b664b2..6f4c75d 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
@@ -14,319 +14,335 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const MAX_ITEMS_DROPDOWN = 10;
+import '../../../behaviors/fire-behavior/fire-behavior.js';
+import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown.js';
+import '../gr-cursor-manager/gr-cursor-manager.js';
+import '../gr-overlay/gr-overlay.js';
+import '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior.js';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-textarea_html.js';
 
-  const ALL_SUGGESTIONS = [
-    {value: '😊', match: 'smile :)'},
-    {value: '👍', match: 'thumbs up'},
-    {value: '😄', match: 'laugh :D'},
-    {value: '🎉', match: 'party'},
-    {value: '😞', match: 'sad :('},
-    {value: '😂', match: 'tears :\')'},
-    {value: '🙏', match: 'pray'},
-    {value: '😐', match: 'neutral :|'},
-    {value: '😮', match: 'shock :O'},
-    {value: '👎', match: 'thumbs down'},
-    {value: '😎', match: 'cool |;)'},
-    {value: '😕', match: 'confused'},
-    {value: '👌', match: 'ok'},
-    {value: '🔥', match: 'fire'},
-    {value: '👊', match: 'fistbump'},
-    {value: '💯', match: '100'},
-    {value: '💔', match: 'broken heart'},
-    {value: '🍺', match: 'beer'},
-    {value: '✔', match: 'check'},
-    {value: '😋', match: 'tongue'},
-    {value: '😭', match: 'crying :\'('},
-    {value: '🐨', match: 'koala'},
-    {value: '🤓', match: 'glasses'},
-    {value: '😆', match: 'grin'},
-    {value: '💩', match: 'poop'},
-    {value: '😢', match: 'tear'},
-    {value: '😒', match: 'unamused'},
-    {value: '😉', match: 'wink ;)'},
-    {value: '🍷', match: 'wine'},
-    {value: '😜', match: 'winking tongue ;)'},
-  ];
+const MAX_ITEMS_DROPDOWN = 10;
 
+const ALL_SUGGESTIONS = [
+  {value: '😊', match: 'smile :)'},
+  {value: '👍', match: 'thumbs up'},
+  {value: '😄', match: 'laugh :D'},
+  {value: '🎉', match: 'party'},
+  {value: '😞', match: 'sad :('},
+  {value: '😂', match: 'tears :\')'},
+  {value: '🙏', match: 'pray'},
+  {value: '😐', match: 'neutral :|'},
+  {value: '😮', match: 'shock :O'},
+  {value: '👎', match: 'thumbs down'},
+  {value: '😎', match: 'cool |;)'},
+  {value: '😕', match: 'confused'},
+  {value: '👌', match: 'ok'},
+  {value: '🔥', match: 'fire'},
+  {value: '👊', match: 'fistbump'},
+  {value: '💯', match: '100'},
+  {value: '💔', match: 'broken heart'},
+  {value: '🍺', match: 'beer'},
+  {value: '✔', match: 'check'},
+  {value: '😋', match: 'tongue'},
+  {value: '😭', match: 'crying :\'('},
+  {value: '🐨', match: 'koala'},
+  {value: '🤓', match: 'glasses'},
+  {value: '😆', match: 'grin'},
+  {value: '💩', match: 'poop'},
+  {value: '😢', match: 'tear'},
+  {value: '😒', match: 'unamused'},
+  {value: '😉', match: 'wink ;)'},
+  {value: '🍷', match: 'wine'},
+  {value: '😜', match: 'winking tongue ;)'},
+];
+
+/**
+ * @appliesMixin Gerrit.FireMixin
+ * @appliesMixin Gerrit.KeyboardShortcutMixin
+ * @extends Polymer.Element
+ */
+class GrTextarea extends mixinBehaviors( [
+  Gerrit.FireBehavior,
+  Gerrit.KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-textarea'; }
   /**
-   * @appliesMixin Gerrit.FireMixin
-   * @appliesMixin Gerrit.KeyboardShortcutMixin
-   * @extends Polymer.Element
+   * @event bind-value-changed
    */
-  class GrTextarea extends Polymer.mixinBehaviors( [
-    Gerrit.FireBehavior,
-    Gerrit.KeyboardShortcutBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-textarea'; }
-    /**
-     * @event bind-value-changed
-     */
 
-    static get properties() {
-      return {
-        autocomplete: Boolean,
-        disabled: Boolean,
-        rows: Number,
-        maxRows: Number,
-        placeholder: String,
-        text: {
-          type: String,
-          notify: true,
-          observer: '_handleTextChanged',
-        },
-        hideBorder: {
-          type: Boolean,
-          value: false,
-        },
-        /** Text input should be rendered in monspace font.  */
-        monospace: {
-          type: Boolean,
-          value: false,
-        },
-        /** Text input should be rendered in code font, which is smaller than the
-          standard monospace font. */
-        code: {
-          type: Boolean,
-          value: false,
-        },
-        /** @type {?number} */
-        _colonIndex: Number,
-        _currentSearchString: {
-          type: String,
-          observer: '_determineSuggestions',
-        },
-        _hideAutocomplete: {
-          type: Boolean,
-          value: true,
-        },
-        _index: Number,
-        _suggestions: Array,
-        // Offset makes dropdown appear below text.
-        _verticalOffset: {
-          type: Number,
-          value: 20,
-          readOnly: true,
-        },
-      };
+  static get properties() {
+    return {
+      autocomplete: Boolean,
+      disabled: Boolean,
+      rows: Number,
+      maxRows: Number,
+      placeholder: String,
+      text: {
+        type: String,
+        notify: true,
+        observer: '_handleTextChanged',
+      },
+      hideBorder: {
+        type: Boolean,
+        value: false,
+      },
+      /** Text input should be rendered in monspace font.  */
+      monospace: {
+        type: Boolean,
+        value: false,
+      },
+      /** Text input should be rendered in code font, which is smaller than the
+        standard monospace font. */
+      code: {
+        type: Boolean,
+        value: false,
+      },
+      /** @type {?number} */
+      _colonIndex: Number,
+      _currentSearchString: {
+        type: String,
+        observer: '_determineSuggestions',
+      },
+      _hideAutocomplete: {
+        type: Boolean,
+        value: true,
+      },
+      _index: Number,
+      _suggestions: Array,
+      // Offset makes dropdown appear below text.
+      _verticalOffset: {
+        type: Number,
+        value: 20,
+        readOnly: true,
+      },
+    };
+  }
+
+  get keyBindings() {
+    return {
+      esc: '_handleEscKey',
+      tab: '_handleEnterByKey',
+      enter: '_handleEnterByKey',
+      up: '_handleUpKey',
+      down: '_handleDownKey',
+    };
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    if (this.monospace) {
+      this.classList.add('monospace');
     }
-
-    get keyBindings() {
-      return {
-        esc: '_handleEscKey',
-        tab: '_handleEnterByKey',
-        enter: '_handleEnterByKey',
-        up: '_handleUpKey',
-        down: '_handleDownKey',
-      };
+    if (this.code) {
+      this.classList.add('code');
     }
-
-    /** @override */
-    ready() {
-      super.ready();
-      if (this.monospace) {
-        this.classList.add('monospace');
-      }
-      if (this.code) {
-        this.classList.add('code');
-      }
-      if (this.hideBorder) {
-        this.$.textarea.classList.add('noBorder');
-      }
-    }
-
-    closeDropdown() {
-      return this.$.emojiSuggestions.close();
-    }
-
-    getNativeTextarea() {
-      return this.$.textarea.textarea;
-    }
-
-    putCursorAtEnd() {
-      const textarea = this.getNativeTextarea();
-      // Put the cursor at the end always.
-      textarea.selectionStart = textarea.value.length;
-      textarea.selectionEnd = textarea.selectionStart;
-      this.async(() => {
-        textarea.focus();
-      });
-    }
-
-    _handleEscKey(e) {
-      if (this._hideAutocomplete) { return; }
-      e.preventDefault();
-      e.stopPropagation();
-      this._resetEmojiDropdown();
-    }
-
-    _handleUpKey(e) {
-      if (this._hideAutocomplete) { return; }
-      e.preventDefault();
-      e.stopPropagation();
-      this.$.emojiSuggestions.cursorUp();
-      this.$.textarea.textarea.focus();
-      this.disableEnterKeyForSelectingEmoji = false;
-    }
-
-    _handleDownKey(e) {
-      if (this._hideAutocomplete) { return; }
-      e.preventDefault();
-      e.stopPropagation();
-      this.$.emojiSuggestions.cursorDown();
-      this.$.textarea.textarea.focus();
-      this.disableEnterKeyForSelectingEmoji = false;
-    }
-
-    _handleEnterByKey(e) {
-      if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
-        return;
-      }
-      e.preventDefault();
-      e.stopPropagation();
-      this._setEmoji(this.$.emojiSuggestions.getCurrentText());
-    }
-
-    _handleEmojiSelect(e) {
-      this._setEmoji(e.detail.selected.dataset.value);
-    }
-
-    _setEmoji(text) {
-      const colonIndex = this._colonIndex;
-      this.text = this._getText(text);
-      this.$.textarea.selectionStart = colonIndex + 1;
-      this.$.textarea.selectionEnd = colonIndex + 1;
-      this.$.reporting.reportInteraction('select-emoji', {type: text});
-      this._resetEmojiDropdown();
-    }
-
-    _getText(value) {
-      return this.text.substr(0, this._colonIndex || 0) +
-          value + this.text.substr(this.$.textarea.selectionStart);
-    }
-
-    /**
-     * Uses a hidden element with the same width and styling of the textarea and
-     * the text up until the point of interest. Then caratSpan element is added
-     * to the end and is set to be the positionTarget for the dropdown. Together
-     * this allows the dropdown to appear near where the user is typing.
-     */
-    _updateCaratPosition() {
-      this._hideAutocomplete = false;
-      this.$.hiddenText.textContent = this.$.textarea.value.substr(0,
-          this.$.textarea.selectionStart);
-
-      const caratSpan = this.$.caratSpan;
-      this.$.hiddenText.appendChild(caratSpan);
-      this.$.emojiSuggestions.positionTarget = caratSpan;
-      this._openEmojiDropdown();
-    }
-
-    _getFontSize() {
-      const fontSizePx = getComputedStyle(this).fontSize || '12px';
-      return parseInt(fontSizePx.substr(0, fontSizePx.length - 2),
-          10);
-    }
-
-    _getScrollTop() {
-      return document.body.scrollTop;
-    }
-
-    /**
-     * _handleKeydown used for key handling in the this.$.textarea AND all child
-     * autocomplete options.
-     */
-    _onValueChanged(e) {
-      // Relay the event.
-      this.fire('bind-value-changed', e);
-
-      // If cursor is not in textarea (just opened with colon as last char),
-      // Don't do anything.
-      if (!e.currentTarget.focused) { return; }
-
-      const charAtCursor = e.detail && e.detail.value ?
-        e.detail.value[this.$.textarea.selectionStart - 1] : '';
-      if (charAtCursor !== ':' && this._colonIndex == null) { return; }
-
-      // When a colon is detected, set a colon index. We are interested only on
-      // colons after space or in beginning of textarea
-      if (charAtCursor === ':') {
-        if (this.$.textarea.selectionStart < 2 ||
-            e.detail.value[this.$.textarea.selectionStart - 2] === ' ') {
-          this._colonIndex = this.$.textarea.selectionStart - 1;
-        }
-      }
-
-      this._currentSearchString = e.detail.value.substr(this._colonIndex + 1,
-          this.$.textarea.selectionStart - this._colonIndex - 1);
-      // Under the following conditions, close and reset the dropdown:
-      // - The cursor is no longer at the end of the current search string
-      // - The search string is an space or new line
-      // - The colon has been removed
-      // - There are no suggestions that match the search string
-      if (this.$.textarea.selectionStart !==
-          this._currentSearchString.length + this._colonIndex + 1 ||
-          this._currentSearchString === ' ' ||
-          this._currentSearchString === '\n' ||
-          !(e.detail.value[this._colonIndex] === ':') ||
-          !this._suggestions.length) {
-        this._resetEmojiDropdown();
-      // Otherwise open the dropdown and set the position to be just below the
-      // cursor.
-      } else if (this.$.emojiSuggestions.isHidden) {
-        this._updateCaratPosition();
-      }
-      this.$.textarea.textarea.focus();
-    }
-
-    _openEmojiDropdown() {
-      this.$.emojiSuggestions.open();
-      this.$.reporting.reportInteraction('open-emoji-dropdown');
-    }
-
-    _formatSuggestions(matchedSuggestions) {
-      const suggestions = [];
-      for (const suggestion of matchedSuggestions) {
-        suggestion.dataValue = suggestion.value;
-        suggestion.text = suggestion.value + ' ' + suggestion.match;
-        suggestions.push(suggestion);
-      }
-      this.set('_suggestions', suggestions);
-    }
-
-    _determineSuggestions(emojiText) {
-      if (!emojiText.length) {
-        this._formatSuggestions(ALL_SUGGESTIONS);
-        this.disableEnterKeyForSelectingEmoji = true;
-      } else {
-        const matches = ALL_SUGGESTIONS
-            .filter(suggestion => suggestion.match.includes(emojiText))
-            .slice(0, MAX_ITEMS_DROPDOWN);
-        this._formatSuggestions(matches);
-        this.disableEnterKeyForSelectingEmoji = false;
-      }
-    }
-
-    _resetEmojiDropdown() {
-      // hide and reset the autocomplete dropdown.
-      Polymer.dom.flush();
-      this._currentSearchString = '';
-      this._hideAutocomplete = true;
-      this.closeDropdown();
-      this._colonIndex = null;
-      this.$.textarea.textarea.focus();
-    }
-
-    _handleTextChanged(text) {
-      this.dispatchEvent(
-          new CustomEvent('value-changed', {detail: {value: text}}));
+    if (this.hideBorder) {
+      this.$.textarea.classList.add('noBorder');
     }
   }
 
-  customElements.define(GrTextarea.is, GrTextarea);
-})();
+  closeDropdown() {
+    return this.$.emojiSuggestions.close();
+  }
+
+  getNativeTextarea() {
+    return this.$.textarea.textarea;
+  }
+
+  putCursorAtEnd() {
+    const textarea = this.getNativeTextarea();
+    // Put the cursor at the end always.
+    textarea.selectionStart = textarea.value.length;
+    textarea.selectionEnd = textarea.selectionStart;
+    this.async(() => {
+      textarea.focus();
+    });
+  }
+
+  _handleEscKey(e) {
+    if (this._hideAutocomplete) { return; }
+    e.preventDefault();
+    e.stopPropagation();
+    this._resetEmojiDropdown();
+  }
+
+  _handleUpKey(e) {
+    if (this._hideAutocomplete) { return; }
+    e.preventDefault();
+    e.stopPropagation();
+    this.$.emojiSuggestions.cursorUp();
+    this.$.textarea.textarea.focus();
+    this.disableEnterKeyForSelectingEmoji = false;
+  }
+
+  _handleDownKey(e) {
+    if (this._hideAutocomplete) { return; }
+    e.preventDefault();
+    e.stopPropagation();
+    this.$.emojiSuggestions.cursorDown();
+    this.$.textarea.textarea.focus();
+    this.disableEnterKeyForSelectingEmoji = false;
+  }
+
+  _handleEnterByKey(e) {
+    if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+      return;
+    }
+    e.preventDefault();
+    e.stopPropagation();
+    this._setEmoji(this.$.emojiSuggestions.getCurrentText());
+  }
+
+  _handleEmojiSelect(e) {
+    this._setEmoji(e.detail.selected.dataset.value);
+  }
+
+  _setEmoji(text) {
+    const colonIndex = this._colonIndex;
+    this.text = this._getText(text);
+    this.$.textarea.selectionStart = colonIndex + 1;
+    this.$.textarea.selectionEnd = colonIndex + 1;
+    this.$.reporting.reportInteraction('select-emoji', {type: text});
+    this._resetEmojiDropdown();
+  }
+
+  _getText(value) {
+    return this.text.substr(0, this._colonIndex || 0) +
+        value + this.text.substr(this.$.textarea.selectionStart);
+  }
+
+  /**
+   * Uses a hidden element with the same width and styling of the textarea and
+   * the text up until the point of interest. Then caratSpan element is added
+   * to the end and is set to be the positionTarget for the dropdown. Together
+   * this allows the dropdown to appear near where the user is typing.
+   */
+  _updateCaratPosition() {
+    this._hideAutocomplete = false;
+    this.$.hiddenText.textContent = this.$.textarea.value.substr(0,
+        this.$.textarea.selectionStart);
+
+    const caratSpan = this.$.caratSpan;
+    this.$.hiddenText.appendChild(caratSpan);
+    this.$.emojiSuggestions.positionTarget = caratSpan;
+    this._openEmojiDropdown();
+  }
+
+  _getFontSize() {
+    const fontSizePx = getComputedStyle(this).fontSize || '12px';
+    return parseInt(fontSizePx.substr(0, fontSizePx.length - 2),
+        10);
+  }
+
+  _getScrollTop() {
+    return document.body.scrollTop;
+  }
+
+  /**
+   * _handleKeydown used for key handling in the this.$.textarea AND all child
+   * autocomplete options.
+   */
+  _onValueChanged(e) {
+    // Relay the event.
+    this.fire('bind-value-changed', e);
+
+    // If cursor is not in textarea (just opened with colon as last char),
+    // Don't do anything.
+    if (!e.currentTarget.focused) { return; }
+
+    const charAtCursor = e.detail && e.detail.value ?
+      e.detail.value[this.$.textarea.selectionStart - 1] : '';
+    if (charAtCursor !== ':' && this._colonIndex == null) { return; }
+
+    // When a colon is detected, set a colon index. We are interested only on
+    // colons after space or in beginning of textarea
+    if (charAtCursor === ':') {
+      if (this.$.textarea.selectionStart < 2 ||
+          e.detail.value[this.$.textarea.selectionStart - 2] === ' ') {
+        this._colonIndex = this.$.textarea.selectionStart - 1;
+      }
+    }
+
+    this._currentSearchString = e.detail.value.substr(this._colonIndex + 1,
+        this.$.textarea.selectionStart - this._colonIndex - 1);
+    // Under the following conditions, close and reset the dropdown:
+    // - The cursor is no longer at the end of the current search string
+    // - The search string is an space or new line
+    // - The colon has been removed
+    // - There are no suggestions that match the search string
+    if (this.$.textarea.selectionStart !==
+        this._currentSearchString.length + this._colonIndex + 1 ||
+        this._currentSearchString === ' ' ||
+        this._currentSearchString === '\n' ||
+        !(e.detail.value[this._colonIndex] === ':') ||
+        !this._suggestions.length) {
+      this._resetEmojiDropdown();
+    // Otherwise open the dropdown and set the position to be just below the
+    // cursor.
+    } else if (this.$.emojiSuggestions.isHidden) {
+      this._updateCaratPosition();
+    }
+    this.$.textarea.textarea.focus();
+  }
+
+  _openEmojiDropdown() {
+    this.$.emojiSuggestions.open();
+    this.$.reporting.reportInteraction('open-emoji-dropdown');
+  }
+
+  _formatSuggestions(matchedSuggestions) {
+    const suggestions = [];
+    for (const suggestion of matchedSuggestions) {
+      suggestion.dataValue = suggestion.value;
+      suggestion.text = suggestion.value + ' ' + suggestion.match;
+      suggestions.push(suggestion);
+    }
+    this.set('_suggestions', suggestions);
+  }
+
+  _determineSuggestions(emojiText) {
+    if (!emojiText.length) {
+      this._formatSuggestions(ALL_SUGGESTIONS);
+      this.disableEnterKeyForSelectingEmoji = true;
+    } else {
+      const matches = ALL_SUGGESTIONS
+          .filter(suggestion => suggestion.match.includes(emojiText))
+          .slice(0, MAX_ITEMS_DROPDOWN);
+      this._formatSuggestions(matches);
+      this.disableEnterKeyForSelectingEmoji = false;
+    }
+  }
+
+  _resetEmojiDropdown() {
+    // hide and reset the autocomplete dropdown.
+    flush();
+    this._currentSearchString = '';
+    this._hideAutocomplete = true;
+    this.closeDropdown();
+    this._colonIndex = null;
+    this.$.textarea.textarea.focus();
+  }
+
+  _handleTextChanged(text) {
+    this.dispatchEvent(
+        new CustomEvent('value-changed', {detail: {value: text}}));
+  }
+}
+
+customElements.define(GrTextarea.is, GrTextarea);
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.js
index 42a4f3b..99dd52d 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.js
@@ -1,33 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-
-<dom-module id="gr-textarea">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         display: flex;
@@ -82,27 +71,8 @@
       hiddenText in order to correctly position the dropdown. After being moved,
       it is set as the positionTarget for the emojiSuggestions dropdown. -->
     <span id="caratSpan"></span>
-    <gr-autocomplete-dropdown
-        vertical-align="top"
-        horizontal-align="left"
-        dynamic-align
-        id="emojiSuggestions"
-        suggestions="[[_suggestions]]"
-        index="[[_index]]"
-        vertical-offset="[[_verticalOffset]]"
-        on-dropdown-closed="_resetEmojiDropdown"
-        on-item-selected="_handleEmojiSelect">
+    <gr-autocomplete-dropdown vertical-align="top" horizontal-align="left" dynamic-align="" id="emojiSuggestions" suggestions="[[_suggestions]]" index="[[_index]]" vertical-offset="[[_verticalOffset]]" on-dropdown-closed="_resetEmojiDropdown" on-item-selected="_handleEmojiSelect">
     </gr-autocomplete-dropdown>
-    <iron-autogrow-textarea
-        id="textarea"
-        autocomplete="[[autocomplete]]"
-        placeholder=[[placeholder]]
-        disabled="[[disabled]]"
-        rows="[[rows]]"
-        max-rows="[[maxRows]]"
-        value="{{text}}"
-        on-bind-value-changed="_onValueChanged"></iron-autogrow-textarea>
+    <iron-autogrow-textarea id="textarea" autocomplete="[[autocomplete]]" placeholder="[[placeholder]]" disabled="[[disabled]]" rows="[[rows]]" max-rows="[[maxRows]]" value="{{text}}" on-bind-value-changed="_onValueChanged"></iron-autogrow-textarea>
     <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-textarea.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
index 674089d..9ede81c 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
@@ -19,15 +19,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-textarea</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-textarea.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-textarea.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-textarea.js';
+void(0);
+</script>
 <test-fixture id="basic">
   <template>
     <gr-textarea></gr-textarea>
@@ -46,16 +51,301 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-textarea tests', async () => {
-    await readyToTest();
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-textarea.js';
+suite('gr-textarea tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    sandbox.stub(element.$.reporting, 'reportInteraction');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('monospace is set properly', () => {
+    assert.isFalse(element.classList.contains('monospace'));
+  });
+
+  test('hideBorder is set properly', () => {
+    assert.isFalse(element.$.textarea.classList.contains('noBorder'));
+  });
+
+  test('emoji selector is not open with the textarea lacks focus', () => {
+    element.$.textarea.selectionStart = 1;
+    element.$.textarea.selectionEnd = 1;
+    element.text = ':';
+    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+  });
+
+  test('emoji selector is not open when a general text is entered', () => {
+    MockInteractions.focus(element.$.textarea);
+    element.$.textarea.selectionStart = 9;
+    element.$.textarea.selectionEnd = 9;
+    element.text = 'some text';
+    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+  });
+
+  test('emoji selector opens when a colon is typed & the textarea has focus',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        element.text = ':';
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.emojiSuggestions.isHidden);
+        assert.equal(element._colonIndex, 0);
+        assert.isFalse(element._hideAutocomplete);
+        assert.equal(element._currentSearchString, '');
+      });
+
+  test('emoji selector opens when a colon is typed after space',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 2;
+        element.$.textarea.selectionEnd = 2;
+        element.text = ' :';
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.emojiSuggestions.isHidden);
+        assert.equal(element._colonIndex, 1);
+        assert.isFalse(element._hideAutocomplete);
+        assert.equal(element._currentSearchString, '');
+      });
+
+  test('emoji selector doesn\`t open when a colon is typed after character',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 5;
+        element.$.textarea.selectionEnd = 5;
+        element.text = 'test:';
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.emojiSuggestions.isHidden);
+        assert.isTrue(element._hideAutocomplete);
+      });
+
+  test('emoji selector opens when a colon is typed and some substring',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        element.text = ':';
+        element.$.textarea.selectionStart = 2;
+        element.$.textarea.selectionEnd = 2;
+        element.text = ':t';
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.emojiSuggestions.isHidden);
+        assert.equal(element._colonIndex, 0);
+        assert.isFalse(element._hideAutocomplete);
+        assert.equal(element._currentSearchString, 't');
+      });
+
+  test('emoji selector opens when a colon is typed in middle of text',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        // Since selectionStart is on Chrome set always on end of text, we
+        // stub it to 1
+        const text = ': hello';
+        sandbox.stub(element.$, 'textarea', {
+          selectionStart: 1,
+          value: text,
+          textarea: {
+            focus: () => {},
+          },
+        });
+        element.text = text;
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.emojiSuggestions.isHidden);
+        assert.equal(element._colonIndex, 0);
+        assert.isFalse(element._hideAutocomplete);
+        assert.equal(element._currentSearchString, '');
+      });
+  test('emoji selector closes when text changes before the colon', () => {
+    const resetStub = sandbox.stub(element, '_resetEmojiDropdown');
+    MockInteractions.focus(element.$.textarea);
+    flushAsynchronousOperations();
+    element.$.textarea.selectionStart = 10;
+    element.$.textarea.selectionEnd = 10;
+    element.text = 'test test ';
+    element.$.textarea.selectionStart = 12;
+    element.$.textarea.selectionEnd = 12;
+    element.text = 'test test :';
+    element.$.textarea.selectionStart = 15;
+    element.$.textarea.selectionEnd = 15;
+    element.text = 'test test :smi';
+
+    assert.equal(element._currentSearchString, 'smi');
+    assert.isFalse(resetStub.called);
+    element.text = 'test test test :smi';
+    assert.isTrue(resetStub.called);
+  });
+
+  test('_resetEmojiDropdown', () => {
+    const closeSpy = sandbox.spy(element, 'closeDropdown');
+    element._resetEmojiDropdown();
+    assert.equal(element._currentSearchString, '');
+    assert.isTrue(element._hideAutocomplete);
+    assert.equal(element._colonIndex, null);
+
+    element.$.emojiSuggestions.open();
+    flushAsynchronousOperations();
+    element._resetEmojiDropdown();
+    assert.isTrue(closeSpy.called);
+  });
+
+  test('_determineSuggestions', () => {
+    const emojiText = 'tear';
+    const formatSpy = sandbox.spy(element, '_formatSuggestions');
+    element._determineSuggestions(emojiText);
+    assert.isTrue(formatSpy.called);
+    assert.isTrue(formatSpy.lastCall.calledWithExactly(
+        [{dataValue: '😂', value: '😂', match: 'tears :\')',
+          text: '😂 tears :\')'},
+        {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
+        ]));
+  });
+
+  test('_formatSuggestions', () => {
+    const matchedSuggestions = [{value: '😢', match: 'tear'},
+      {value: '😂', match: 'tears'}];
+    element._formatSuggestions(matchedSuggestions);
+    assert.deepEqual(
+        [{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
+          {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'}],
+        element._suggestions);
+  });
+
+  test('_handleEmojiSelect', () => {
+    element.$.textarea.selectionStart = 16;
+    element.$.textarea.selectionEnd = 16;
+    element.text = 'test test :tears';
+    element._colonIndex = 10;
+    const selectedItem = {dataset: {value: '😂'}};
+    const event = {detail: {selected: selectedItem}};
+    element._handleEmojiSelect(event);
+    assert.equal(element.text, 'test test 😂');
+  });
+
+  test('_updateCaratPosition', () => {
+    element.$.textarea.selectionStart = 4;
+    element.$.textarea.selectionEnd = 4;
+    element.text = 'test';
+    element._updateCaratPosition();
+    assert.deepEqual(element.$.hiddenText.innerHTML, element.text +
+        element.$.caratSpan.outerHTML);
+  });
+
+  test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
+    const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
+    element.$.emojiSuggestions.fire('dropdown-closed');
+    assert.isTrue(resetSpy.called);
+  });
+
+  test('_onValueChanged fires bind-value-changed', () => {
+    const listenerStub = sinon.stub();
+    const eventObject = {currentTarget: {focused: false}};
+    element.addEventListener('bind-value-changed', listenerStub);
+    element._onValueChanged(eventObject);
+    assert.isTrue(listenerStub.called);
+  });
+
+  suite('keyboard shortcuts', () => {
+    function setupDropdown(callback) {
+      MockInteractions.focus(element.$.textarea);
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 1;
+      element.text = ':';
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 2;
+      element.text = ':1';
+      flushAsynchronousOperations();
+    }
+
+    test('escape key', () => {
+      const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+      assert.isFalse(resetSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+      assert.isTrue(resetSpy.called);
+      assert.isFalse(!element.$.emojiSuggestions.isHidden);
+    });
+
+    test('up key', () => {
+      const upSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorUp');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+      assert.isFalse(upSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+      assert.isTrue(upSpy.called);
+    });
+
+    test('down key', () => {
+      const downSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorDown');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+      assert.isFalse(downSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+      assert.isTrue(downSpy.called);
+    });
+
+    test('enter key', () => {
+      const enterSpy = sandbox.spy(element.$.emojiSuggestions,
+          'getCursorTarget');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isTrue(enterSpy.called);
+      flushAsynchronousOperations();
+      assert.equal(element.text, '💯');
+    });
+
+    test('enter key - ignored on just colon without more information', () => {
+      const enterSpy = sandbox.spy(element.$.emojiSuggestions,
+          'getCursorTarget');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+      MockInteractions.focus(element.$.textarea);
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 1;
+      element.text = ':';
+      flushAsynchronousOperations();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+    });
+  });
+
+  suite('gr-textarea monospace', () => {
+  // gr-textarea set monospace class in the ready() method.
+  // In Polymer2, ready() is called from the fixture(...) method,
+  // If ready() is called again later, some nested elements doesn't
+  // handle it correctly. A separate test-fixture is used to set
+  // properties before ready() is called.
+
     let element;
     let sandbox;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      sandbox.stub(element.$.reporting, 'reportInteraction');
+      element = fixture('monospace');
     });
 
     teardown(() => {
@@ -63,315 +353,32 @@
     });
 
     test('monospace is set properly', () => {
-      assert.isFalse(element.classList.contains('monospace'));
+      assert.isTrue(element.classList.contains('monospace'));
+    });
+  });
+
+  suite('gr-textarea hideBorder', () => {
+  // gr-textarea set noBorder class in the ready() method.
+  // In Polymer2, ready() is called from the fixture(...) method,
+  // If ready() is called again later, some nested elements doesn't
+  // handle it correctly. A separate test-fixture is used to set
+  // properties before ready() is called.
+
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('hideBorder');
+    });
+
+    teardown(() => {
+      sandbox.restore();
     });
 
     test('hideBorder is set properly', () => {
-      assert.isFalse(element.$.textarea.classList.contains('noBorder'));
-    });
-
-    test('emoji selector is not open with the textarea lacks focus', () => {
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 1;
-      element.text = ':';
-      assert.isFalse(!element.$.emojiSuggestions.isHidden);
-    });
-
-    test('emoji selector is not open when a general text is entered', () => {
-      MockInteractions.focus(element.$.textarea);
-      element.$.textarea.selectionStart = 9;
-      element.$.textarea.selectionEnd = 9;
-      element.text = 'some text';
-      assert.isFalse(!element.$.emojiSuggestions.isHidden);
-    });
-
-    test('emoji selector opens when a colon is typed & the textarea has focus',
-        () => {
-          MockInteractions.focus(element.$.textarea);
-          // Needed for Safari tests. selectionStart is not updated when text is
-          // updated.
-          element.$.textarea.selectionStart = 1;
-          element.$.textarea.selectionEnd = 1;
-          element.text = ':';
-          flushAsynchronousOperations();
-          assert.isFalse(element.$.emojiSuggestions.isHidden);
-          assert.equal(element._colonIndex, 0);
-          assert.isFalse(element._hideAutocomplete);
-          assert.equal(element._currentSearchString, '');
-        });
-
-    test('emoji selector opens when a colon is typed after space',
-        () => {
-          MockInteractions.focus(element.$.textarea);
-          // Needed for Safari tests. selectionStart is not updated when text is
-          // updated.
-          element.$.textarea.selectionStart = 2;
-          element.$.textarea.selectionEnd = 2;
-          element.text = ' :';
-          flushAsynchronousOperations();
-          assert.isFalse(element.$.emojiSuggestions.isHidden);
-          assert.equal(element._colonIndex, 1);
-          assert.isFalse(element._hideAutocomplete);
-          assert.equal(element._currentSearchString, '');
-        });
-
-    test('emoji selector doesn\`t open when a colon is typed after character',
-        () => {
-          MockInteractions.focus(element.$.textarea);
-          // Needed for Safari tests. selectionStart is not updated when text is
-          // updated.
-          element.$.textarea.selectionStart = 5;
-          element.$.textarea.selectionEnd = 5;
-          element.text = 'test:';
-          flushAsynchronousOperations();
-          assert.isTrue(element.$.emojiSuggestions.isHidden);
-          assert.isTrue(element._hideAutocomplete);
-        });
-
-    test('emoji selector opens when a colon is typed and some substring',
-        () => {
-          MockInteractions.focus(element.$.textarea);
-          // Needed for Safari tests. selectionStart is not updated when text is
-          // updated.
-          element.$.textarea.selectionStart = 1;
-          element.$.textarea.selectionEnd = 1;
-          element.text = ':';
-          element.$.textarea.selectionStart = 2;
-          element.$.textarea.selectionEnd = 2;
-          element.text = ':t';
-          flushAsynchronousOperations();
-          assert.isFalse(element.$.emojiSuggestions.isHidden);
-          assert.equal(element._colonIndex, 0);
-          assert.isFalse(element._hideAutocomplete);
-          assert.equal(element._currentSearchString, 't');
-        });
-
-    test('emoji selector opens when a colon is typed in middle of text',
-        () => {
-          MockInteractions.focus(element.$.textarea);
-          // Needed for Safari tests. selectionStart is not updated when text is
-          // updated.
-          element.$.textarea.selectionStart = 1;
-          element.$.textarea.selectionEnd = 1;
-          // Since selectionStart is on Chrome set always on end of text, we
-          // stub it to 1
-          const text = ': hello';
-          sandbox.stub(element.$, 'textarea', {
-            selectionStart: 1,
-            value: text,
-            textarea: {
-              focus: () => {},
-            },
-          });
-          element.text = text;
-          flushAsynchronousOperations();
-          assert.isFalse(element.$.emojiSuggestions.isHidden);
-          assert.equal(element._colonIndex, 0);
-          assert.isFalse(element._hideAutocomplete);
-          assert.equal(element._currentSearchString, '');
-        });
-    test('emoji selector closes when text changes before the colon', () => {
-      const resetStub = sandbox.stub(element, '_resetEmojiDropdown');
-      MockInteractions.focus(element.$.textarea);
-      flushAsynchronousOperations();
-      element.$.textarea.selectionStart = 10;
-      element.$.textarea.selectionEnd = 10;
-      element.text = 'test test ';
-      element.$.textarea.selectionStart = 12;
-      element.$.textarea.selectionEnd = 12;
-      element.text = 'test test :';
-      element.$.textarea.selectionStart = 15;
-      element.$.textarea.selectionEnd = 15;
-      element.text = 'test test :smi';
-
-      assert.equal(element._currentSearchString, 'smi');
-      assert.isFalse(resetStub.called);
-      element.text = 'test test test :smi';
-      assert.isTrue(resetStub.called);
-    });
-
-    test('_resetEmojiDropdown', () => {
-      const closeSpy = sandbox.spy(element, 'closeDropdown');
-      element._resetEmojiDropdown();
-      assert.equal(element._currentSearchString, '');
-      assert.isTrue(element._hideAutocomplete);
-      assert.equal(element._colonIndex, null);
-
-      element.$.emojiSuggestions.open();
-      flushAsynchronousOperations();
-      element._resetEmojiDropdown();
-      assert.isTrue(closeSpy.called);
-    });
-
-    test('_determineSuggestions', () => {
-      const emojiText = 'tear';
-      const formatSpy = sandbox.spy(element, '_formatSuggestions');
-      element._determineSuggestions(emojiText);
-      assert.isTrue(formatSpy.called);
-      assert.isTrue(formatSpy.lastCall.calledWithExactly(
-          [{dataValue: '😂', value: '😂', match: 'tears :\')',
-            text: '😂 tears :\')'},
-          {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
-          ]));
-    });
-
-    test('_formatSuggestions', () => {
-      const matchedSuggestions = [{value: '😢', match: 'tear'},
-        {value: '😂', match: 'tears'}];
-      element._formatSuggestions(matchedSuggestions);
-      assert.deepEqual(
-          [{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
-            {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'}],
-          element._suggestions);
-    });
-
-    test('_handleEmojiSelect', () => {
-      element.$.textarea.selectionStart = 16;
-      element.$.textarea.selectionEnd = 16;
-      element.text = 'test test :tears';
-      element._colonIndex = 10;
-      const selectedItem = {dataset: {value: '😂'}};
-      const event = {detail: {selected: selectedItem}};
-      element._handleEmojiSelect(event);
-      assert.equal(element.text, 'test test 😂');
-    });
-
-    test('_updateCaratPosition', () => {
-      element.$.textarea.selectionStart = 4;
-      element.$.textarea.selectionEnd = 4;
-      element.text = 'test';
-      element._updateCaratPosition();
-      assert.deepEqual(element.$.hiddenText.innerHTML, element.text +
-          element.$.caratSpan.outerHTML);
-    });
-
-    test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
-      const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
-      element.$.emojiSuggestions.fire('dropdown-closed');
-      assert.isTrue(resetSpy.called);
-    });
-
-    test('_onValueChanged fires bind-value-changed', () => {
-      const listenerStub = sinon.stub();
-      const eventObject = {currentTarget: {focused: false}};
-      element.addEventListener('bind-value-changed', listenerStub);
-      element._onValueChanged(eventObject);
-      assert.isTrue(listenerStub.called);
-    });
-
-    suite('keyboard shortcuts', () => {
-      function setupDropdown(callback) {
-        MockInteractions.focus(element.$.textarea);
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        element.text = ':';
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 2;
-        element.text = ':1';
-        flushAsynchronousOperations();
-      }
-
-      test('escape key', () => {
-        const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
-        assert.isFalse(resetSpy.called);
-        setupDropdown();
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
-        assert.isTrue(resetSpy.called);
-        assert.isFalse(!element.$.emojiSuggestions.isHidden);
-      });
-
-      test('up key', () => {
-        const upSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorUp');
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
-        assert.isFalse(upSpy.called);
-        setupDropdown();
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
-        assert.isTrue(upSpy.called);
-      });
-
-      test('down key', () => {
-        const downSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorDown');
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
-        assert.isFalse(downSpy.called);
-        setupDropdown();
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
-        assert.isTrue(downSpy.called);
-      });
-
-      test('enter key', () => {
-        const enterSpy = sandbox.spy(element.$.emojiSuggestions,
-            'getCursorTarget');
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-        assert.isFalse(enterSpy.called);
-        setupDropdown();
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-        assert.isTrue(enterSpy.called);
-        flushAsynchronousOperations();
-        assert.equal(element.text, '💯');
-      });
-
-      test('enter key - ignored on just colon without more information', () => {
-        const enterSpy = sandbox.spy(element.$.emojiSuggestions,
-            'getCursorTarget');
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-        assert.isFalse(enterSpy.called);
-        MockInteractions.focus(element.$.textarea);
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        element.text = ':';
-        flushAsynchronousOperations();
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-        assert.isFalse(enterSpy.called);
-      });
-    });
-
-    suite('gr-textarea monospace', () => {
-    // gr-textarea set monospace class in the ready() method.
-    // In Polymer2, ready() is called from the fixture(...) method,
-    // If ready() is called again later, some nested elements doesn't
-    // handle it correctly. A separate test-fixture is used to set
-    // properties before ready() is called.
-
-      let element;
-      let sandbox;
-
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-        element = fixture('monospace');
-      });
-
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      test('monospace is set properly', () => {
-        assert.isTrue(element.classList.contains('monospace'));
-      });
-    });
-
-    suite('gr-textarea hideBorder', () => {
-    // gr-textarea set noBorder class in the ready() method.
-    // In Polymer2, ready() is called from the fixture(...) method,
-    // If ready() is called again later, some nested elements doesn't
-    // handle it correctly. A separate test-fixture is used to set
-    // properties before ready() is called.
-
-      let element;
-      let sandbox;
-
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-        element = fixture('hideBorder');
-      });
-
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      test('hideBorder is set properly', () => {
-        assert.isTrue(element.$.textarea.classList.contains('noBorder'));
-      });
+      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
index baa0fc9..3c9181f 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
@@ -14,33 +14,41 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * @appliesMixin Gerrit.TooltipMixin
-   * @extends Polymer.Element
-   */
-  class GrTooltipContent extends Polymer.mixinBehaviors( [
-    Gerrit.TooltipBehavior,
-  ], Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element))) {
-    static get is() { return 'gr-tooltip-content'; }
+import '../gr-icons/gr-icons.js';
+import '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-tooltip-content_html.js';
 
-    static get properties() {
-      return {
-        maxWidth: {
-          type: String,
-          reflectToAttribute: true,
-        },
-        showIcon: {
-          type: Boolean,
-          value: false,
-        },
-      };
-    }
+/**
+ * @appliesMixin Gerrit.TooltipMixin
+ * @extends Polymer.Element
+ */
+class GrTooltipContent extends mixinBehaviors( [
+  Gerrit.TooltipBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-tooltip-content'; }
+
+  static get properties() {
+    return {
+      maxWidth: {
+        type: String,
+        reflectToAttribute: true,
+      },
+      showIcon: {
+        type: Boolean,
+        value: false,
+      },
+    };
   }
+}
 
-  customElements.define(GrTooltipContent.is, GrTooltipContent);
-})();
+customElements.define(GrTooltipContent.is, GrTooltipContent);
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.js
index ec56912..e4b5891 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.js
@@ -1,26 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../gr-icons/gr-icons.html">
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-
-<dom-module id="gr-tooltip-content">
-  <template>
+export const htmlTemplate = html`
     <style>
       iron-icon {
         width: var(--line-height-normal);
@@ -29,7 +25,5 @@
       }
     </style>
     <slot></slot><!--
- --><iron-icon icon="gr-icons:info" hidden$="[[!showIcon]]"></iron-icon>
-  </template>
-  <script src="gr-tooltip-content.js"></script>
-</dom-module>
+ --><iron-icon icon="gr-icons:info" hidden\$="[[!showIcon]]"></iron-icon>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
index 8237552..853f4c2 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
@@ -18,15 +18,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-storage</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-tooltip-content.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-tooltip-content.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-tooltip-content.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,29 +40,32 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-tooltip-content tests', async () => {
-    await readyToTest();
-    let element;
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('icon is not visible by default', () => {
-      assert.equal(Polymer.dom(element.root)
-          .querySelector('iron-icon').hidden, true);
-    });
-
-    test('position-below attribute is reflected', () => {
-      assert.isFalse(element.hasAttribute('position-below'));
-      element.positionBelow = true;
-      assert.isTrue(element.hasAttribute('position-below'));
-    });
-
-    test('icon is visible with showIcon property', () => {
-      element.showIcon = true;
-      assert.equal(Polymer.dom(element.root)
-          .querySelector('iron-icon').hidden, false);
-    });
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-tooltip-content.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-tooltip-content tests', () => {
+  let element;
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('icon is not visible by default', () => {
+    assert.equal(dom(element.root)
+        .querySelector('iron-icon').hidden, true);
+  });
+
+  test('position-below attribute is reflected', () => {
+    assert.isFalse(element.hasAttribute('position-below'));
+    element.positionBelow = true;
+    assert.isTrue(element.hasAttribute('position-below'));
+  });
+
+  test('icon is visible with showIcon property', () => {
+    element.showIcon = true;
+    assert.equal(dom(element.root)
+        .querySelector('iron-icon').hidden, false);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
index 6f458d1..0cd2d7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
@@ -14,33 +14,39 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /** @extends Polymer.Element */
-  class GrTooltip extends Polymer.GestureEventListeners(
-      Polymer.LegacyElementMixin(
-          Polymer.Element)) {
-    static get is() { return 'gr-tooltip'; }
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-tooltip_html.js';
 
-    static get properties() {
-      return {
-        text: String,
-        maxWidth: {
-          type: String,
-          observer: '_updateWidth',
-        },
-        positionBelow: {
-          type: Boolean,
-          reflectToAttribute: true,
-        },
-      };
-    }
+/** @extends Polymer.Element */
+class GrTooltip extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _updateWidth(maxWidth) {
-      this.updateStyles({'--tooltip-max-width': maxWidth});
-    }
+  static get is() { return 'gr-tooltip'; }
+
+  static get properties() {
+    return {
+      text: String,
+      maxWidth: {
+        type: String,
+        observer: '_updateWidth',
+      },
+      positionBelow: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
+    };
   }
 
-  customElements.define(GrTooltip.is, GrTooltip);
-})();
+  _updateWidth(maxWidth) {
+    this.updateStyles({'--tooltip-max-width': maxWidth});
+  }
+}
+
+customElements.define(GrTooltip.is, GrTooltip);
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.js
index d78d554..5f9ce51 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.js
@@ -1,25 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @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.js';
 
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-tooltip">
-  <template>
+export const htmlTemplate = html`
     <style include="shared-styles">
       :host {
         --gr-tooltip-arrow-size: .5em;
@@ -66,6 +63,4 @@
       [[text]]
       <i class="arrowPositionAbove arrow"></i>
     </div>
-  </template>
-  <script src="gr-tooltip.js"></script>
-</dom-module>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
index 4c9b954..be5e26e 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
@@ -18,15 +18,20 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-storage</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-tooltip.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-tooltip.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-tooltip.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,35 +40,37 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-tooltip tests', async () => {
-    await readyToTest();
-    let element;
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('max-width is respected if set', () => {
-      element.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
-          ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
-      element.maxWidth = '50px';
-      assert.equal(getComputedStyle(element).width, '50px');
-    });
-
-    test('the correct arrow is displayed', () => {
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('.arrowPositionBelow')).display,
-      'none');
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.arrowPositionAbove'))
-          .display, 'none');
-      element.positionBelow = true;
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.arrowPositionBelow'))
-          .display, 'none');
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('.arrowPositionAbove'))
-          .display, 'none');
-    });
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-tooltip.js';
+suite('gr-tooltip tests', () => {
+  let element;
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('max-width is respected if set', () => {
+    element.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
+        ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
+    element.maxWidth = '50px';
+    assert.equal(getComputedStyle(element).width, '50px');
+  });
+
+  test('the correct arrow is displayed', () => {
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.arrowPositionBelow')).display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.arrowPositionAbove'))
+        .display, 'none');
+    element.positionBelow = true;
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.arrowPositionBelow'))
+        .display, 'none');
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.arrowPositionAbove'))
+        .display, 'none');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.js b/polygerrit-ui/app/elements/shared/revision-info/revision-info.js
index 239e0fa..f89234f 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.js
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.js
@@ -1,88 +1,83 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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 '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 
-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
+/**
+ * @constructor
+ * @param {Object} change A change object resulting from a change detail
+ *     call that includes revision information.
+ */
+function RevisionInfo(change) {
+  this._change = change;
+}
 
-http://www.apache.org/licenses/LICENSE-2.0
+/**
+ * Get the largest number of parents of the commit in any revision. For
+ * example, with normal changes this will always return 1. For merge changes
+ * wherein the revisions are merge commits this will return 2 or potentially
+ * more.
+ *
+ * @return {number}
+ */
+RevisionInfo.prototype.getMaxParents = function() {
+  if (!this._change || !this._change.revisions) {
+    return 0;
+  }
+  return Object.values(this._change.revisions)
+      .reduce((acc, rev) => Math.max(rev.commit.parents.length, acc), 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.
--->
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<script>
-  (function() {
-    'use strict';
+/**
+ * Get an object that maps revision numbers to the number of parents of the
+ * commit of that revision.
+ *
+ * @return {!Object}
+ */
+RevisionInfo.prototype.getParentCountMap = function() {
+  const result = {};
+  if (!this._change || !this._change.revisions) {
+    return {};
+  }
+  Object.values(this._change.revisions)
+      .forEach(rev => { result[rev._number] = rev.commit.parents.length; });
+  return result;
+};
 
-    /**
-     * @constructor
-     * @param {Object} change A change object resulting from a change detail
-     *     call that includes revision information.
-     */
-    function RevisionInfo(change) {
-      this._change = change;
-    }
+/**
+ * @param {number|string} patchNum
+ * @return {number}
+ */
+RevisionInfo.prototype.getParentCount = function(patchNum) {
+  return this.getParentCountMap()[patchNum];
+};
 
-    /**
-     * Get the largest number of parents of the commit in any revision. For
-     * example, with normal changes this will always return 1. For merge changes
-     * wherein the revisions are merge commits this will return 2 or potentially
-     * more.
-     *
-     * @return {number}
-     */
-    RevisionInfo.prototype.getMaxParents = function() {
-      if (!this._change || !this._change.revisions) {
-        return 0;
-      }
-      return Object.values(this._change.revisions)
-          .reduce((acc, rev) => Math.max(rev.commit.parents.length, acc), 0);
-    };
+/**
+ * Get the commit ID of the (0-offset) indexed parent in the given revision
+ * number.
+ *
+ * @param {number|string} patchNum
+ * @param {number} parentIndex (0-offset)
+ * @return {string}
+ */
+RevisionInfo.prototype.getParentId = function(patchNum, parentIndex) {
+  const rev = Object.values(this._change.revisions).find(rev =>
+    Gerrit.PatchSetBehavior.patchNumEquals(rev._number, patchNum));
+  return rev.commit.parents[parentIndex].commit;
+};
 
-    /**
-     * Get an object that maps revision numbers to the number of parents of the
-     * commit of that revision.
-     *
-     * @return {!Object}
-     */
-    RevisionInfo.prototype.getParentCountMap = function() {
-      const result = {};
-      if (!this._change || !this._change.revisions) {
-        return {};
-      }
-      Object.values(this._change.revisions)
-          .forEach(rev => { result[rev._number] = rev.commit.parents.length; });
-      return result;
-    };
-
-    /**
-     * @param {number|string} patchNum
-     * @return {number}
-     */
-    RevisionInfo.prototype.getParentCount = function(patchNum) {
-      return this.getParentCountMap()[patchNum];
-    };
-
-    /**
-     * Get the commit ID of the (0-offset) indexed parent in the given revision
-     * number.
-     *
-     * @param {number|string} patchNum
-     * @param {number} parentIndex (0-offset)
-     * @return {string}
-     */
-    RevisionInfo.prototype.getParentId = function(patchNum, parentIndex) {
-      const rev = Object.values(this._change.revisions).find(rev =>
-        Gerrit.PatchSetBehavior.patchNumEquals(rev._number, patchNum));
-      return rev.commit.parents[parentIndex].commit;
-    };
-
-    window.Gerrit = window.Gerrit || {};
-    window.Gerrit.RevisionInfo = RevisionInfo;
-  })();
-</script>
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.RevisionInfo = RevisionInfo;
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
index fb7a011..4946e2e 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
@@ -19,72 +19,74 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>revision-info</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="revision-info.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../../test/test-pre-setup.js"></script>
+<script type="module" src="../../../test/common-test-setup.js"></script>
+<script type="module" src="./revision-info.js"></script>
 
-<script>
-  suite('revision-info tests', async () => {
-    await readyToTest();
-    let mockChange;
+<script type="module">
+import '../../../test/test-pre-setup.js';
+import '../../../test/common-test-setup.js';
+import './revision-info.js';
+suite('revision-info tests', () => {
+  let mockChange;
 
-    setup(() => {
-      mockChange = {
-        revisions: {
-          r1: {_number: 1, commit: {parents: [
-            {commit: 'p1'},
-            {commit: 'p2'},
-            {commit: 'p3'},
-          ]}},
-          r2: {_number: 2, commit: {parents: [
-            {commit: 'p1'},
-            {commit: 'p4'},
-          ]}},
-          r3: {_number: 3, commit: {parents: [{commit: 'p5'}]}},
-          r4: {_number: 4, commit: {parents: [
-            {commit: 'p2'},
-            {commit: 'p3'},
-          ]}},
-          r5: {_number: 5, commit: {parents: [
-            {commit: 'p5'},
-            {commit: 'p2'},
-            {commit: 'p3'},
-          ]}},
-        },
-      };
-    });
-
-    test('getMaxParents', () => {
-      const ri = new window.Gerrit.RevisionInfo(mockChange);
-      assert.equal(ri.getMaxParents(), 3);
-    });
-
-    test('getParentCountMap', () => {
-      const ri = new window.Gerrit.RevisionInfo(mockChange);
-      assert.deepEqual(ri.getParentCountMap(), {1: 3, 2: 2, 3: 1, 4: 2, 5: 3});
-    });
-
-    test('getParentCount', () => {
-      const ri = new window.Gerrit.RevisionInfo(mockChange);
-      assert.deepEqual(ri.getParentCount(1), 3);
-      assert.deepEqual(ri.getParentCount(3), 1);
-    });
-
-    test('getParentCount', () => {
-      const ri = new window.Gerrit.RevisionInfo(mockChange);
-      assert.deepEqual(ri.getParentCount(1), 3);
-      assert.deepEqual(ri.getParentCount(3), 1);
-    });
-
-    test('getParentId', () => {
-      const ri = new window.Gerrit.RevisionInfo(mockChange);
-      assert.deepEqual(ri.getParentId(1, 2), 'p3');
-      assert.deepEqual(ri.getParentId(2, 1), 'p4');
-      assert.deepEqual(ri.getParentId(3, 0), 'p5');
-    });
+  setup(() => {
+    mockChange = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: [
+          {commit: 'p1'},
+          {commit: 'p2'},
+          {commit: 'p3'},
+        ]}},
+        r2: {_number: 2, commit: {parents: [
+          {commit: 'p1'},
+          {commit: 'p4'},
+        ]}},
+        r3: {_number: 3, commit: {parents: [{commit: 'p5'}]}},
+        r4: {_number: 4, commit: {parents: [
+          {commit: 'p2'},
+          {commit: 'p3'},
+        ]}},
+        r5: {_number: 5, commit: {parents: [
+          {commit: 'p5'},
+          {commit: 'p2'},
+          {commit: 'p3'},
+        ]}},
+      },
+    };
   });
+
+  test('getMaxParents', () => {
+    const ri = new window.Gerrit.RevisionInfo(mockChange);
+    assert.equal(ri.getMaxParents(), 3);
+  });
+
+  test('getParentCountMap', () => {
+    const ri = new window.Gerrit.RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCountMap(), {1: 3, 2: 2, 3: 1, 4: 2, 5: 3});
+  });
+
+  test('getParentCount', () => {
+    const ri = new window.Gerrit.RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCount(1), 3);
+    assert.deepEqual(ri.getParentCount(3), 1);
+  });
+
+  test('getParentCount', () => {
+    const ri = new window.Gerrit.RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCount(1), 3);
+    assert.deepEqual(ri.getParentCount(3), 1);
+  });
+
+  test('getParentId', () => {
+    const ri = new window.Gerrit.RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentId(1, 2), 'p3');
+    assert.deepEqual(ri.getParentId(2, 1), 'p4');
+    assert.deepEqual(ri.getParentId(3, 0), 'p5');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
index 0ab41de..b9ddac62 100644
--- a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
+++ b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
@@ -19,123 +19,125 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-display-name-utils</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<script src="gr-display-name-utils.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="./gr-display-name-utils.js"></script>
 
-<script>
-  suite('gr-display-name-utils tests', async () => {
-    await readyToTest();
-    // eslint-disable-next-line no-unused-vars
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import './gr-display-name-utils.js';
+suite('gr-display-name-utils tests', () => {
+  // eslint-disable-next-line no-unused-vars
+  const config = {
+    user: {
+      anonymous_coward_name: 'Anonymous Coward',
+    },
+  };
+
+  test('getUserName name only', () => {
+    const account = {
+      name: 'test-name',
+    };
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
+        'test-name');
+  });
+
+  test('getUserName username only', () => {
+    const account = {
+      username: 'test-user',
+    };
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
+        'test-user');
+  });
+
+  test('getUserName email only', () => {
+    const account = {
+      email: 'test-user@test-url.com',
+    };
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
+        'test-user@test-url.com');
+  });
+
+  test('getUserName returns not Anonymous Coward as the anon name', () => {
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, null, true),
+        'Anonymous');
+  });
+
+  test('getUserName for the config returning the anon name', () => {
     const config = {
       user: {
-        anonymous_coward_name: 'Anonymous Coward',
+        anonymous_coward_name: 'Test Anon',
       },
     };
-
-    test('getUserName name only', () => {
-      const account = {
-        name: 'test-name',
-      };
-      assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
-          'test-name');
-    });
-
-    test('getUserName username only', () => {
-      const account = {
-        username: 'test-user',
-      };
-      assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
-          'test-user');
-    });
-
-    test('getUserName email only', () => {
-      const account = {
-        email: 'test-user@test-url.com',
-      };
-      assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
-          'test-user@test-url.com');
-    });
-
-    test('getUserName returns not Anonymous Coward as the anon name', () => {
-      assert.deepEqual(GrDisplayNameUtils.getUserName(config, null, true),
-          'Anonymous');
-    });
-
-    test('getUserName for the config returning the anon name', () => {
-      const config = {
-        user: {
-          anonymous_coward_name: 'Test Anon',
-        },
-      };
-      assert.deepEqual(GrDisplayNameUtils.getUserName(config, null, true),
-          'Test Anon');
-    });
-
-    test('getAccountDisplayName - account with name only', () => {
-      assert.equal(
-          GrDisplayNameUtils.getAccountDisplayName(config,
-              {name: 'Some user name'}),
-          'Some user name');
-    });
-
-    test('getAccountDisplayName - account with email only', () => {
-      assert.equal(
-          GrDisplayNameUtils.getAccountDisplayName(config,
-              {email: 'my@example.com'}),
-          'Anonymous <my@example.com>');
-    });
-
-    test('getAccountDisplayName - account with email only - allowEmail', () => {
-      assert.equal(
-          GrDisplayNameUtils.getAccountDisplayName(config,
-              {email: 'my@example.com'}, true),
-          'my@example.com <my@example.com>');
-    });
-
-    test('getAccountDisplayName - account with name and status', () => {
-      assert.equal(
-          GrDisplayNameUtils.getAccountDisplayName(config, {
-            name: 'Some name',
-            status: 'OOO',
-          }),
-          'Some name (OOO)');
-    });
-
-    test('getAccountDisplayName - account with name and email', () => {
-      assert.equal(
-          GrDisplayNameUtils.getAccountDisplayName(config, {
-            name: 'Some name',
-            email: 'my@example.com',
-          }),
-          'Some name <my@example.com>');
-    });
-
-    test('getAccountDisplayName - account with name, email and status', () => {
-      assert.equal(
-          GrDisplayNameUtils.getAccountDisplayName(config, {
-            name: 'Some name',
-            email: 'my@example.com',
-            status: 'OOO',
-          }),
-          'Some name <my@example.com> (OOO)');
-    });
-
-    test('getGroupDisplayName', () => {
-      assert.equal(
-          GrDisplayNameUtils.getGroupDisplayName({name: 'Some user name'}),
-          'Some user name (group)');
-    });
-
-    test('_accountEmail', () => {
-      assert.equal(
-          GrDisplayNameUtils._accountEmail('email@gerritreview.com'),
-          '<email@gerritreview.com>');
-      assert.equal(GrDisplayNameUtils._accountEmail(undefined), '');
-    });
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, null, true),
+        'Test Anon');
   });
+
+  test('getAccountDisplayName - account with name only', () => {
+    assert.equal(
+        GrDisplayNameUtils.getAccountDisplayName(config,
+            {name: 'Some user name'}),
+        'Some user name');
+  });
+
+  test('getAccountDisplayName - account with email only', () => {
+    assert.equal(
+        GrDisplayNameUtils.getAccountDisplayName(config,
+            {email: 'my@example.com'}),
+        'Anonymous <my@example.com>');
+  });
+
+  test('getAccountDisplayName - account with email only - allowEmail', () => {
+    assert.equal(
+        GrDisplayNameUtils.getAccountDisplayName(config,
+            {email: 'my@example.com'}, true),
+        'my@example.com <my@example.com>');
+  });
+
+  test('getAccountDisplayName - account with name and status', () => {
+    assert.equal(
+        GrDisplayNameUtils.getAccountDisplayName(config, {
+          name: 'Some name',
+          status: 'OOO',
+        }),
+        'Some name (OOO)');
+  });
+
+  test('getAccountDisplayName - account with name and email', () => {
+    assert.equal(
+        GrDisplayNameUtils.getAccountDisplayName(config, {
+          name: 'Some name',
+          email: 'my@example.com',
+        }),
+        'Some name <my@example.com>');
+  });
+
+  test('getAccountDisplayName - account with name, email and status', () => {
+    assert.equal(
+        GrDisplayNameUtils.getAccountDisplayName(config, {
+          name: 'Some name',
+          email: 'my@example.com',
+          status: 'OOO',
+        }),
+        'Some name <my@example.com> (OOO)');
+  });
+
+  test('getGroupDisplayName', () => {
+    assert.equal(
+        GrDisplayNameUtils.getGroupDisplayName({name: 'Some user name'}),
+        'Some user name (group)');
+  });
+
+  test('_accountEmail', () => {
+    assert.equal(
+        GrDisplayNameUtils._accountEmail('email@gerritreview.com'),
+        '<email@gerritreview.com>');
+    assert.equal(GrDisplayNameUtils._accountEmail(undefined), '');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html
index f64d9ef..be47863 100644
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html
@@ -19,18 +19,25 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-email-suggestions-provider</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.html"/>
-<script src="../gr-display-name-utils/gr-display-name-utils.js"></script>
-<script src="gr-email-suggestions-provider.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js"></script>
+<script type="module" src="../gr-display-name-utils/gr-display-name-utils.js"></script>
+<script type="module" src="./gr-email-suggestions-provider.js"></script>
 
 
-<script>void(0);</script>
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-display-name-utils/gr-display-name-utils.js';
+import './gr-email-suggestions-provider.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -38,64 +45,68 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('GrEmailSuggestionsProvider tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let restAPI;
-    let provider;
-    const account1 = {
-      name: 'Some name',
-      email: 'some@example.com',
-    };
-    const account2 = {
-      email: 'other@example.com',
-      _account_id: 3,
-    };
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-display-name-utils/gr-display-name-utils.js';
+import './gr-email-suggestions-provider.js';
+suite('GrEmailSuggestionsProvider tests', () => {
+  let sandbox;
+  let restAPI;
+  let provider;
+  const account1 = {
+    name: 'Some name',
+    email: 'some@example.com',
+  };
+  const account2 = {
+    email: 'other@example.com',
+    _account_id: 3,
+  };
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
 
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      restAPI = fixture('basic');
-      provider = new GrEmailSuggestionsProvider(restAPI);
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
     });
+    restAPI = fixture('basic');
+    provider = new GrEmailSuggestionsProvider(restAPI);
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('getSuggestions', done => {
-      const getSuggestedAccountsStub =
-          sandbox.stub(restAPI, 'getSuggestedAccounts')
-              .returns(Promise.resolve([account1, account2]));
+  test('getSuggestions', done => {
+    const getSuggestedAccountsStub =
+        sandbox.stub(restAPI, 'getSuggestedAccounts')
+            .returns(Promise.resolve([account1, account2]));
 
-      provider.getSuggestions('Some input').then(res => {
-        assert.deepEqual(res, [account1, account2]);
-        assert.isTrue(getSuggestedAccountsStub.calledOnce);
-        assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-        done();
-      });
-    });
-
-    test('makeSuggestionItem', () => {
-      assert.deepEqual(provider.makeSuggestionItem(account1), {
-        name: 'Some name <some@example.com>',
-        value: {
-          account: account1,
-          count: 1,
-        },
-      });
-
-      assert.deepEqual(provider.makeSuggestionItem(account2), {
-        name: 'other@example.com <other@example.com>',
-        value: {
-          account: account2,
-          count: 1,
-        },
-      });
+    provider.getSuggestions('Some input').then(res => {
+      assert.deepEqual(res, [account1, account2]);
+      assert.isTrue(getSuggestedAccountsStub.calledOnce);
+      assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
+      done();
     });
   });
+
+  test('makeSuggestionItem', () => {
+    assert.deepEqual(provider.makeSuggestionItem(account1), {
+      name: 'Some name <some@example.com>',
+      value: {
+        account: account1,
+        count: 1,
+      },
+    });
+
+    assert.deepEqual(provider.makeSuggestionItem(account2), {
+      name: 'other@example.com <other@example.com>',
+      value: {
+        account: account2,
+        count: 1,
+      },
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html
index c46b443..21c5085 100644
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html
@@ -19,17 +19,24 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group-suggestions-provider</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.html"/>
-<script src="../gr-display-name-utils/gr-display-name-utils.js"></script>
-<script src="gr-group-suggestions-provider.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js"></script>
+<script type="module" src="../gr-display-name-utils/gr-display-name-utils.js"></script>
+<script type="module" src="./gr-group-suggestions-provider.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-display-name-utils/gr-display-name-utils.js';
+import './gr-group-suggestions-provider.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,72 +44,76 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('GrGroupSuggestionsProvider tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let restAPI;
-    let provider;
-    const group1 = {
-      name: 'Some name',
-      id: 1,
-    };
-    const group2 = {
-      name: 'Other name',
-      id: 3,
-      url: 'abcd',
-    };
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-display-name-utils/gr-display-name-utils.js';
+import './gr-group-suggestions-provider.js';
+suite('GrGroupSuggestionsProvider tests', () => {
+  let sandbox;
+  let restAPI;
+  let provider;
+  const group1 = {
+    name: 'Some name',
+    id: 1,
+  };
+  const group2 = {
+    name: 'Other name',
+    id: 3,
+    url: 'abcd',
+  };
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
 
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      restAPI = fixture('basic');
-      provider = new GrGroupSuggestionsProvider(restAPI);
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
     });
+    restAPI = fixture('basic');
+    provider = new GrGroupSuggestionsProvider(restAPI);
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('getSuggestions', done => {
-      const getSuggestedAccountsStub =
-          sandbox.stub(restAPI, 'getSuggestedGroups')
-              .returns(Promise.resolve({
-                'Some name': {id: 1},
-                'Other name': {id: 3, url: 'abcd'},
-              }));
+  test('getSuggestions', done => {
+    const getSuggestedAccountsStub =
+        sandbox.stub(restAPI, 'getSuggestedGroups')
+            .returns(Promise.resolve({
+              'Some name': {id: 1},
+              'Other name': {id: 3, url: 'abcd'},
+            }));
 
-      provider.getSuggestions('Some input').then(res => {
-        assert.deepEqual(res, [group1, group2]);
-        assert.isTrue(getSuggestedAccountsStub.calledOnce);
-        assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-        done();
-      });
-    });
-
-    test('makeSuggestionItem', () => {
-      assert.deepEqual(provider.makeSuggestionItem(group1), {
-        name: 'Some name',
-        value: {
-          group: {
-            name: 'Some name',
-            id: 1,
-          },
-        },
-      });
-
-      assert.deepEqual(provider.makeSuggestionItem(group2), {
-        name: 'Other name',
-        value: {
-          group: {
-            name: 'Other name',
-            id: 3,
-          },
-        },
-      });
+    provider.getSuggestions('Some input').then(res => {
+      assert.deepEqual(res, [group1, group2]);
+      assert.isTrue(getSuggestedAccountsStub.calledOnce);
+      assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
+      done();
     });
   });
+
+  test('makeSuggestionItem', () => {
+    assert.deepEqual(provider.makeSuggestionItem(group1), {
+      name: 'Some name',
+      value: {
+        group: {
+          name: 'Some name',
+          id: 1,
+        },
+      },
+    });
+
+    assert.deepEqual(provider.makeSuggestionItem(group2), {
+      name: 'Other name',
+      value: {
+        group: {
+          name: 'Other name',
+          id: 3,
+        },
+      },
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html
index 9696c2e..7821386 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html
@@ -19,17 +19,24 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-suggestions-provider</title>
 
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../test/test-pre-setup.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.html"/>
-<script src="../gr-display-name-utils/gr-display-name-utils.js"></script>
-<script src="gr-reviewer-suggestions-provider.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module" src="../../test/test-pre-setup.js"></script>
+<script type="module" src="../../test/common-test-setup.js"></script>
+<script type="module" src="../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js"></script>
+<script type="module" src="../gr-display-name-utils/gr-display-name-utils.js"></script>
+<script type="module" src="./gr-reviewer-suggestions-provider.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-display-name-utils/gr-display-name-utils.js';
+import './gr-reviewer-suggestions-provider.js';
+void(0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,227 +44,231 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('GrReviewerSuggestionsProvider tests', async () => {
-    await readyToTest();
-    let sandbox;
-    let _nextAccountId = 0;
-    const makeAccount = function(opt_status) {
-      const accountId = ++_nextAccountId;
-      return {
-        _account_id: accountId,
-        name: 'name ' + accountId,
-        email: 'email ' + accountId,
-        status: opt_status,
-      };
+<script type="module">
+import '../../test/test-pre-setup.js';
+import '../../test/common-test-setup.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-display-name-utils/gr-display-name-utils.js';
+import './gr-reviewer-suggestions-provider.js';
+suite('GrReviewerSuggestionsProvider tests', () => {
+  let sandbox;
+  let _nextAccountId = 0;
+  const makeAccount = function(opt_status) {
+    const accountId = ++_nextAccountId;
+    return {
+      _account_id: accountId,
+      name: 'name ' + accountId,
+      email: 'email ' + accountId,
+      status: opt_status,
     };
-    let _nextAccountId2 = 0;
-    const makeAccount2 = function(opt_status) {
-      const accountId2 = ++_nextAccountId2;
-      return {
-        _account_id: accountId2,
-        name: 'name ' + accountId2,
-        status: opt_status,
-      };
+  };
+  let _nextAccountId2 = 0;
+  const makeAccount2 = function(opt_status) {
+    const accountId2 = ++_nextAccountId2;
+    return {
+      _account_id: accountId2,
+      name: 'name ' + accountId2,
+      status: opt_status,
+    };
+  };
+
+  let owner;
+  let existingReviewer1;
+  let existingReviewer2;
+  let suggestion1;
+  let suggestion2;
+  let suggestion3;
+  let restAPI;
+  let provider;
+
+  let redundantSuggestion1;
+  let redundantSuggestion2;
+  let redundantSuggestion3;
+  let change;
+
+  setup(done => {
+    owner = makeAccount();
+    existingReviewer1 = makeAccount();
+    existingReviewer2 = makeAccount();
+    suggestion1 = {account: makeAccount()};
+    suggestion2 = {account: makeAccount()};
+    suggestion3 = {
+      group: {
+        id: 'suggested group id',
+        name: 'suggested group',
+      },
     };
 
-    let owner;
-    let existingReviewer1;
-    let existingReviewer2;
-    let suggestion1;
-    let suggestion2;
-    let suggestion3;
-    let restAPI;
-    let provider;
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getConfig() { return Promise.resolve({}); },
+    });
 
-    let redundantSuggestion1;
-    let redundantSuggestion2;
-    let redundantSuggestion3;
-    let change;
+    restAPI = fixture('basic');
+    change = {
+      _number: 42,
+      owner,
+      reviewers: {
+        CC: [existingReviewer1],
+        REVIEWER: [existingReviewer2],
+      },
+    };
+    sandbox = sinon.sandbox.create();
+    return flush(done);
+  });
 
+  teardown(() => {
+    sandbox.restore();
+  });
+  suite('allowAnyUser set to false', () => {
     setup(done => {
-      owner = makeAccount();
-      existingReviewer1 = makeAccount();
-      existingReviewer2 = makeAccount();
-      suggestion1 = {account: makeAccount()};
-      suggestion2 = {account: makeAccount()};
-      suggestion3 = {
-        group: {
-          id: 'suggested group id',
-          name: 'suggested group',
-        },
-      };
-
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getConfig() { return Promise.resolve({}); },
+      provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
+          Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
+      provider.init().then(done);
+    });
+    suite('stubbed values for _getReviewerSuggestions', () => {
+      setup(() => {
+        stub('gr-rest-api-interface', {
+          getChangeSuggestedReviewers() {
+            redundantSuggestion1 = {account: existingReviewer1};
+            redundantSuggestion2 = {account: existingReviewer2};
+            redundantSuggestion3 = {account: owner};
+            return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
+              redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
+          },
+        });
       });
 
-      restAPI = fixture('basic');
-      change = {
-        _number: 42,
-        owner,
-        reviewers: {
-          CC: [existingReviewer1],
-          REVIEWER: [existingReviewer2],
-        },
-      };
-      sandbox = sinon.sandbox.create();
-      return flush(done);
-    });
+      test('makeSuggestionItem formats account or group accordingly', () => {
+        let account = makeAccount();
+        const account3 = makeAccount2();
+        let suggestion = provider.makeSuggestionItem({account});
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '>',
+          value: {account},
+        });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-    suite('allowAnyUser set to false', () => {
-      setup(done => {
-        provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
-            Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
-        provider.init().then(done);
+        const group = {name: 'test'};
+        suggestion = provider.makeSuggestionItem({group});
+        assert.deepEqual(suggestion, {
+          name: group.name + ' (group)',
+          value: {group},
+        });
+
+        suggestion = provider.makeSuggestionItem(account);
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '>',
+          value: {account, count: 1},
+        });
+
+        suggestion = provider.makeSuggestionItem({account: {}});
+        assert.deepEqual(suggestion, {
+          name: 'Anonymous',
+          value: {account: {}},
+        });
+
+        provider._config = {
+          user: {
+            anonymous_coward_name: 'Anonymous Coward Name',
+          },
+        };
+
+        suggestion = provider.makeSuggestionItem({account: {}});
+        assert.deepEqual(suggestion, {
+          name: 'Anonymous Coward Name',
+          value: {account: {}},
+        });
+
+        account = makeAccount('OOO');
+
+        suggestion = provider.makeSuggestionItem({account});
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '> (OOO)',
+          value: {account},
+        });
+
+        suggestion = provider.makeSuggestionItem(account);
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '> (OOO)',
+          value: {account, count: 1},
+        });
+
+        sandbox.stub(GrDisplayNameUtils, '_accountEmail',
+            () => '');
+
+        suggestion = provider.makeSuggestionItem(account3);
+        assert.deepEqual(suggestion, {
+          name: account3.name,
+          value: {account: account3, count: 1},
+        });
       });
-      suite('stubbed values for _getReviewerSuggestions', () => {
-        setup(() => {
-          stub('gr-rest-api-interface', {
-            getChangeSuggestedReviewers() {
-              redundantSuggestion1 = {account: existingReviewer1};
-              redundantSuggestion2 = {account: existingReviewer2};
-              redundantSuggestion3 = {account: owner};
-              return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
-                redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
-            },
-          });
-        });
 
-        test('makeSuggestionItem formats account or group accordingly', () => {
-          let account = makeAccount();
-          const account3 = makeAccount2();
-          let suggestion = provider.makeSuggestionItem({account});
-          assert.deepEqual(suggestion, {
-            name: account.name + ' <' + account.email + '>',
-            value: {account},
-          });
+      test('getSuggestions', done => {
+        provider.getSuggestions()
+            .then(reviewers => {
+              // Default is no filtering.
+              assert.equal(reviewers.length, 6);
+              assert.deepEqual(reviewers,
+                  [redundantSuggestion1, redundantSuggestion2,
+                    redundantSuggestion3, suggestion1,
+                    suggestion2, suggestion3]);
+            })
+            .then(done);
+      });
 
-          const group = {name: 'test'};
-          suggestion = provider.makeSuggestionItem({group});
-          assert.deepEqual(suggestion, {
-            name: group.name + ' (group)',
-            value: {group},
-          });
-
-          suggestion = provider.makeSuggestionItem(account);
-          assert.deepEqual(suggestion, {
-            name: account.name + ' <' + account.email + '>',
-            value: {account, count: 1},
-          });
-
-          suggestion = provider.makeSuggestionItem({account: {}});
-          assert.deepEqual(suggestion, {
-            name: 'Anonymous',
-            value: {account: {}},
-          });
-
-          provider._config = {
-            user: {
-              anonymous_coward_name: 'Anonymous Coward Name',
-            },
-          };
-
-          suggestion = provider.makeSuggestionItem({account: {}});
-          assert.deepEqual(suggestion, {
-            name: 'Anonymous Coward Name',
-            value: {account: {}},
-          });
-
-          account = makeAccount('OOO');
-
-          suggestion = provider.makeSuggestionItem({account});
-          assert.deepEqual(suggestion, {
-            name: account.name + ' <' + account.email + '> (OOO)',
-            value: {account},
-          });
-
-          suggestion = provider.makeSuggestionItem(account);
-          assert.deepEqual(suggestion, {
-            name: account.name + ' <' + account.email + '> (OOO)',
-            value: {account, count: 1},
-          });
-
-          sandbox.stub(GrDisplayNameUtils, '_accountEmail',
-              () => '');
-
-          suggestion = provider.makeSuggestionItem(account3);
-          assert.deepEqual(suggestion, {
-            name: account3.name,
-            value: {account: account3, count: 1},
-          });
-        });
-
-        test('getSuggestions', done => {
-          provider.getSuggestions()
-              .then(reviewers => {
-                // Default is no filtering.
-                assert.equal(reviewers.length, 6);
-                assert.deepEqual(reviewers,
-                    [redundantSuggestion1, redundantSuggestion2,
-                      redundantSuggestion3, suggestion1,
-                      suggestion2, suggestion3]);
-              })
-              .then(done);
-        });
-
-        test('getSuggestions short circuits when logged out', () => {
-          // API call is already stubbed.
-          const xhrSpy = restAPI.getChangeSuggestedReviewers;
-          provider._loggedIn = false;
+      test('getSuggestions short circuits when logged out', () => {
+        // API call is already stubbed.
+        const xhrSpy = restAPI.getChangeSuggestedReviewers;
+        provider._loggedIn = false;
+        return provider.getSuggestions('').then(() => {
+          assert.isFalse(xhrSpy.called);
+          provider._loggedIn = true;
           return provider.getSuggestions('').then(() => {
-            assert.isFalse(xhrSpy.called);
-            provider._loggedIn = true;
-            return provider.getSuggestions('').then(() => {
-              assert.isTrue(xhrSpy.called);
-            });
+            assert.isTrue(xhrSpy.called);
           });
         });
       });
-
-      test('getChangeSuggestedReviewers is used', done => {
-        const suggestReviewerStub =
-            sandbox.stub(restAPI, 'getChangeSuggestedReviewers')
-                .returns(Promise.resolve([]));
-        const suggestAccountStub =
-            sandbox.stub(restAPI, 'getSuggestedAccounts')
-                .returns(Promise.resolve([]));
-
-        provider.getSuggestions('').then(() => {
-          assert.isTrue(suggestReviewerStub.calledOnce);
-          assert.isTrue(suggestReviewerStub.calledWith(42, ''));
-          assert.isFalse(suggestAccountStub.called);
-          done();
-        });
-      });
     });
 
-    suite('allowAnyUser set to true', () => {
-      setup(done => {
-        provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
-            Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
-        provider.init().then(done);
-      });
+    test('getChangeSuggestedReviewers is used', done => {
+      const suggestReviewerStub =
+          sandbox.stub(restAPI, 'getChangeSuggestedReviewers')
+              .returns(Promise.resolve([]));
+      const suggestAccountStub =
+          sandbox.stub(restAPI, 'getSuggestedAccounts')
+              .returns(Promise.resolve([]));
 
-      test('getSuggestedAccounts is used', done => {
-        const suggestReviewerStub =
-            sandbox.stub(restAPI, 'getChangeSuggestedReviewers')
-                .returns(Promise.resolve([]));
-        const suggestAccountStub =
-            sandbox.stub(restAPI, 'getSuggestedAccounts')
-                .returns(Promise.resolve([]));
-
-        provider.getSuggestions('').then(() => {
-          assert.isFalse(suggestReviewerStub.called);
-          assert.isTrue(suggestAccountStub.calledOnce);
-          assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
-          done();
-        });
+      provider.getSuggestions('').then(() => {
+        assert.isTrue(suggestReviewerStub.calledOnce);
+        assert.isTrue(suggestReviewerStub.calledWith(42, ''));
+        assert.isFalse(suggestAccountStub.called);
+        done();
       });
     });
   });
+
+  suite('allowAnyUser set to true', () => {
+    setup(done => {
+      provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
+          Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
+      provider.init().then(done);
+    });
+
+    test('getSuggestedAccounts is used', done => {
+      const suggestReviewerStub =
+          sandbox.stub(restAPI, 'getChangeSuggestedReviewers')
+              .returns(Promise.resolve([]));
+      const suggestAccountStub =
+          sandbox.stub(restAPI, 'getSuggestedAccounts')
+              .returns(Promise.resolve([]));
+
+      provider.getSuggestions('').then(() => {
+        assert.isFalse(suggestReviewerStub.called);
+        assert.isTrue(suggestAccountStub.calledOnce);
+        assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
+        done();
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.js b/polygerrit-ui/app/styles/dashboard-header-styles.js
index 88d50c0..683202e 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.js
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.js
@@ -1,21 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2018 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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-
-<dom-module id="dashboard-header-styles">
+$_documentContainer.innerHTML = `<dom-module id="dashboard-header-styles">
   <template>
     <style>
       :host {
@@ -45,4 +46,13 @@
       }
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.js b/polygerrit-ui/app/styles/gr-change-list-styles.js
index a8f754d..148943b 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.js
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.js
@@ -1,20 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2015 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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-<dom-module id="gr-change-list-styles">
+$_documentContainer.innerHTML = `<dom-module id="gr-change-list-styles">
   <template>
     <style>
       gr-change-list-item {
@@ -176,4 +178,13 @@
       }
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.js b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.js
index 84692ba..51cf6d3 100644
--- a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.js
+++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.js
@@ -1,20 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-<dom-module id="gr-change-metadata-shared-styles">
+$_documentContainer.innerHTML = `<dom-module id="gr-change-metadata-shared-styles">
   <template>
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
@@ -47,4 +49,13 @@
       }
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.js b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.js
index 8b26c95..4bfb742 100644
--- a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.js
+++ b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.js
@@ -1,29 +1,22 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2019 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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-<!--
-  This is shared styles for change-view-integration endpoints.
-  All plugins that registered that endpoint should include this in
-  the component to have a consistent UX:
-
-  <style include="gr-change-view-integration-shared-styles"></style>
-
-  And use those defined class to apply these styles.
--->
-<dom-module id="gr-change-view-integration-shared-styles">
+$_documentContainer.innerHTML = `<dom-module id="gr-change-view-integration-shared-styles">
   <template>
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
@@ -59,4 +52,22 @@
       }
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  This is shared styles for change-view-integration endpoints.
+  All plugins that registered that endpoint should include this in
+  the component to have a consistent UX:
+
+  <style include="gr-change-view-integration-shared-styles"></style>
+
+  And use those defined class to apply these styles.
+*/
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-form-styles.js b/polygerrit-ui/app/styles/gr-form-styles.js
index 5133051..91763c5 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.js
+++ b/polygerrit-ui/app/styles/gr-form-styles.js
@@ -1,20 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-<dom-module id="gr-form-styles">
+$_documentContainer.innerHTML = `<dom-module id="gr-form-styles">
   <template>
     <style>
       .gr-form-styles input {
@@ -113,4 +115,13 @@
       }
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.js b/polygerrit-ui/app/styles/gr-menu-page-styles.js
index 47c874b..e52a895 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.js
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.js
@@ -1,20 +1,22 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-<dom-module id="gr-menu-page-styles">
+$_documentContainer.innerHTML = `<dom-module id="gr-menu-page-styles">
   <template>
     <style>
       :host {
@@ -68,4 +70,13 @@
       }
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.js b/polygerrit-ui/app/styles/gr-page-nav-styles.js
index ced6ecb..97f1a03 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.js
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.js
@@ -1,20 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-<dom-module id="gr-page-nav-styles">
+$_documentContainer.innerHTML = `<dom-module id="gr-page-nav-styles">
   <template>
     <style>
       .navStyles ul {
@@ -61,4 +63,13 @@
       }
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.js b/polygerrit-ui/app/styles/gr-subpage-styles.js
index 222c38b..f94cc9c 100644
--- a/polygerrit-ui/app/styles/gr-subpage-styles.js
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.js
@@ -1,20 +1,22 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2018 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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-<dom-module id="gr-subpage-styles">
+$_documentContainer.innerHTML = `<dom-module id="gr-subpage-styles">
   <template>
     <style>
       main {
@@ -31,4 +33,13 @@
       }
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-table-styles.js b/polygerrit-ui/app/styles/gr-table-styles.js
index 26b6db0..ceac675 100644
--- a/polygerrit-ui/app/styles/gr-table-styles.js
+++ b/polygerrit-ui/app/styles/gr-table-styles.js
@@ -1,21 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-
-<dom-module id="gr-table-styles">
+$_documentContainer.innerHTML = `<dom-module id="gr-table-styles">
   <template>
     <style>
       .genericList {
@@ -106,4 +107,13 @@
       }
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.js b/polygerrit-ui/app/styles/gr-voting-styles.js
index eec79be..4860428 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.js
+++ b/polygerrit-ui/app/styles/gr-voting-styles.js
@@ -1,21 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-
-<dom-module id="gr-voting-styles">
+$_documentContainer.innerHTML = `<dom-module id="gr-voting-styles">
   <template>
     <style>
       :host {
@@ -29,4 +30,13 @@
       }
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/shared-styles.js b/polygerrit-ui/app/styles/shared-styles.js
index 3a0de59..dc4735e 100644
--- a/polygerrit-ui/app/styles/shared-styles.js
+++ b/polygerrit-ui/app/styles/shared-styles.js
@@ -1,20 +1,22 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-<dom-module id="shared-styles">
+$_documentContainer.innerHTML = `<dom-module id="shared-styles">
   <template>
     <style>
 
@@ -125,7 +127,7 @@
         --iron-icon-width: 20px;
       }
 
-      /* Stopgap solution until we remove hidden$ attributes. */
+      /* Stopgap solution until we remove hidden\$ attributes. */
 
       [hidden] {
         display: none !important;
@@ -180,4 +182,13 @@
       /** END: loading spiner */
     </style>
   </template>
-</dom-module>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/themes/app-theme.js b/polygerrit-ui/app/styles/themes/app-theme.js
index 8aaaa01..295cb017 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.js
+++ b/polygerrit-ui/app/styles/themes/app-theme.js
@@ -1,20 +1,22 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2015 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.
+ */
+const $_documentContainer = document.createElement('template');
 
-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.
--->
-<custom-style><style is="custom-style">
+$_documentContainer.innerHTML = `<custom-style><style is="custom-style">
 html {
   /**
    * When adding a new color variable make sure to also add it to the other
@@ -205,4 +207,13 @@
     --spacing-xxl: 16px;
   }
 }
-</style></custom-style>
+</style></custom-style>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
index 7a391b7..ab9cc39 100644
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -1,63 +1,54 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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 '../scripts/bundled-polymer.js';
 
-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.
--->
-
-<link rel="import"
-    href="/bower_components/polymer-resin/standalone/polymer-resin.html" />
-<link rel="import" href="../behaviors/safe-types-behavior/safe-types-behavior.html">
-<script>
-  security.polymer_resin.install({
-    allowedIdentifierPrefixes: [''],
-    reportHandler(isViolation, fmt, ...args) {
-      const log = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
-      log(isViolation, fmt, ...args);
-      if (isViolation) {
-        // This will cause the test to fail if there is a data binding
-        // violation.
-        throw new Error(
-            'polymer-resin violation: ' + fmt +
-          JSON.stringify(args));
-      }
-    },
-    safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
+import 'polymer-resin/standalone/polymer-resin.js';
+import '../behaviors/safe-types-behavior/safe-types-behavior.js';
+import '@polymer/iron-test-helpers/iron-test-helpers.js';
+import './test-router.js';
+import moment from 'moment/src/moment.js';
+self.moment = moment;
+security.polymer_resin.install({
+  allowedIdentifierPrefixes: [''],
+  reportHandler(isViolation, fmt, ...args) {
+    const log = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
+    log(isViolation, fmt, ...args);
+    if (isViolation) {
+      // This will cause the test to fail if there is a data binding
+      // violation.
+      throw new Error(
+          'polymer-resin violation: ' + fmt +
+        JSON.stringify(args));
+    }
+  },
+  safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
+});
+self.mockPromise = () => {
+  let res;
+  const promise = new Promise(resolve => {
+    res = resolve;
   });
-</script>
-<script>
-  self.mockPromise = () => {
-    let res;
-    const promise = new Promise(resolve => {
-      res = resolve;
-    });
-    promise.resolve = res;
-    return promise;
-  };
-  self.isHidden = el => getComputedStyle(el).display === 'none';
-</script>
-<script>
-  (function() {
-    setup(() => {
-      if (!window.Gerrit) { return; }
-      if (Gerrit._testOnly_resetPlugins) {
-        Gerrit._testOnly_resetPlugins();
-      }
-    });
-  })();
-</script>
-<link rel="import"
-    href="/bower_components/iron-test-helpers/iron-test-helpers.html" />
-<link rel="import" href="test-router.html" />
-<script src="/bower_components/moment/moment.js"></script>
+  promise.resolve = res;
+  return promise;
+};
+self.isHidden = el => getComputedStyle(el).display === 'none';
+setup(() => {
+  if (!window.Gerrit) { return; }
+  if (Gerrit._testOnly_resetPlugins) {
+    Gerrit._testOnly_resetPlugins();
+  }
+});
diff --git a/polygerrit-ui/app/test/test-router.js b/polygerrit-ui/app/test/test-router.js
index 34ff374..914537c 100644
--- a/polygerrit-ui/app/test/test-router.js
+++ b/polygerrit-ui/app/test/test-router.js
@@ -1,22 +1,19 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @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 '../elements/core/gr-navigation/gr-navigation.js';
 
-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.
--->
-
-<link rel="import" href="../elements/core/gr-navigation/gr-navigation.html">
-<script>
-  Gerrit.Nav.setup(url => { /* noop */ }, params => '', () => []);
-</script>
+Gerrit.Nav.setup(url => { /* noop */ }, params => '', () => []);