Merge changes from topic "reviewers-to-ts-and-lit"

* changes:
  Convert the reviewers plugin to TypeScript and Lit
  Rename .js files to .ts
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index e69de29..0000000
--- a/.eslintignore
+++ /dev/null
diff --git a/rv-reviewers/plugin.js b/.eslintrc.js
similarity index 74%
copy from rv-reviewers/plugin.js
copy to .eslintrc.js
index ba7e1d2..749f7c4 100644
--- a/rv-reviewers/plugin.js
+++ b/.eslintrc.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
+ * Copyright (C) 2021 The Android Open 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,9 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import './rv-reviewers.js';
-
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent(
-      'repo-command', 'rv-reviewers');
-});
+__plugindir = 'reviewers';
+module.exports = {
+  extends: '../.eslintrc.js',
+};
diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index da33ea2..0000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,165 +0,0 @@
-{
-  "extends": ["eslint:recommended", "google"],
-  "parserOptions": {
-    "ecmaVersion": 8,
-    "sourceType": "module"
-  },
-  "env": {
-    "browser": true,
-    "es6": true
-  },
-  "globals": {
-    "__dirname": false,
-    "app": false,
-    "page": false,
-    "Polymer": false,
-    "process": false,
-    "require": false,
-    "Gerrit": false,
-    "Promise": false,
-    "assert": false,
-    "test": false,
-    "flushAsynchronousOperations": false
-  },
-  "rules": {
-    "arrow-parens": ["error", "as-needed"],
-    "block-spacing": ["error", "always"],
-    "brace-style": ["error", "1tbs", { "allowSingleLine": true }],
-    "camelcase": "off",
-    "comma-dangle": ["error", {
-      "arrays": "always-multiline",
-      "objects": "always-multiline",
-      "imports": "always-multiline",
-      "exports": "always-multiline",
-      "functions": "never"
-    }],
-    "eol-last": "off",
-    "indent": ["error", 2, {
-      "MemberExpression": 2,
-      "FunctionDeclaration": {"body": 1, "parameters": 2},
-      "FunctionExpression": {"body": 1, "parameters": 2},
-      "CallExpression": {"arguments": 2 },
-      "ArrayExpression": 1,
-      "ObjectExpression": 1,
-      "SwitchCase": 1
-    }],
-    "keyword-spacing": ["error", { "after": true, "before": true }],
-    "lines-between-class-members": ["error", "always"],
-    "max-len": [
-      "error",
-      80,
-      2,
-      {
-        "ignoreComments": true,
-        "ignorePattern": "^import .*;$"
-      }
-    ],
-    "new-cap": ["error", { "capIsNewExceptions": ["Polymer", "LegacyElementMixin", "GestureEventListeners", "LegacyDataMixin"] }],
-    "no-console": "off",
-    "no-multiple-empty-lines": [ "error", { "max": 1 } ],
-    "no-prototype-builtins": "off",
-    "no-redeclare": "off",
-    "no-restricted-syntax": [
-      "error",
-      {
-        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']",
-        "message": "Remove test.only."
-      },
-      {
-        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']",
-        "message": "Remove suite.only."
-      }
-    ],
-    "no-undef": "off",
-    "no-useless-escape": "off",
-    "no-var": "error",
-    "object-shorthand": ["error", "always"],
-    "padding-line-between-statements": [
-      "error",
-      {
-        "blankLine": "always",
-        "prev": "class",
-        "next": "*"
-      },
-      {
-        "blankLine": "always",
-        "prev": "*",
-        "next": "class"
-      }
-    ],
-    "prefer-arrow-callback": "error",
-    "prefer-const": "error",
-    "prefer-spread": "error",
-    "quote-props": ["error", "consistent-as-needed"],
-    "semi": [2, "always"],
-    "template-curly-spacing": "error",
-    "valid-jsdoc": "off",
-    "require-jsdoc": 0,
-    "valid-jsdoc": 0,
-    "jsdoc/check-alignment": 2,
-    "jsdoc/check-examples": 0,
-    "jsdoc/check-indentation": 0,
-    "jsdoc/check-param-names": 0,
-    "jsdoc/check-syntax": 0,
-    "jsdoc/check-tag-names": 0,
-    "jsdoc/check-types": 0,
-    "jsdoc/implements-on-classes": 2,
-    "jsdoc/match-description": 0,
-    "jsdoc/newline-after-description": 2,
-    "jsdoc/no-types": 0,
-    "jsdoc/no-undefined-types": 0,
-    "jsdoc/require-description": 0,
-    "jsdoc/require-description-complete-sentence": 0,
-    "jsdoc/require-example": 0,
-    "jsdoc/require-hyphen-before-param-description": 0,
-    "jsdoc/require-jsdoc": 0,
-    "jsdoc/require-param": 0,
-    "jsdoc/require-param-description": 0,
-    "jsdoc/require-param-name": 2,
-    "jsdoc/require-param-type": 2,
-    "jsdoc/require-returns": 0,
-    "jsdoc/require-returns-check": 0,
-    "jsdoc/require-returns-description": 0,
-    "jsdoc/require-returns-type": 2,
-    "jsdoc/valid-types": 2,
-    "jsdoc/require-file-overview": ["error", {
-      "tags": {
-        "license": {
-          "mustExist": true,
-          "preventDuplicates": true
-        }
-      }
-    }],
-    "import/named": 2,
-    "import/no-unresolved": 2,
-    "import/no-self-import": 2,
-    // The no-cycle rule is slow, because it doesn't cache dependencies.
-    // Disable it.
-    "import/no-cycle": 0,
-    "import/no-useless-path-segments": 2,
-    "import/no-unused-modules": 2,
-    "import/no-default-export": 2
-  },
-  "plugins": [
-    "html",
-    "jsdoc",
-    "import"
-  ],
-  "settings": {
-    "html/report-bad-indent": "error"
-  },
-  "overrides": [
-    {
-      "files": ["*_html.js", "*-styles.js", "externs.js"],
-      "rules": {
-        "max-len": "off"
-      }
-    },
-    {
-      "files": ["*.html"],
-      "rules": {
-        "jsdoc/require-file-overview": "off"
-      }
-    }
-  ]
-}
diff --git a/.gitignore b/.gitignore
index 1c805b7..cd156c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
 /local.properties
 *.pyc
 /package-lock.json
+/node_modules
diff --git a/BUILD b/BUILD
index 52f43b1..6ad9310 100644
--- a/BUILD
+++ b/BUILD
@@ -1,6 +1,6 @@
 load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:junit.bzl", "junit_tests")
-load("//tools/js:eslint.bzl", "eslint")
+load("//tools/js:eslint.bzl", "plugin_eslint")
 load(
     "//tools/bzl:plugin.bzl",
     "PLUGIN_DEPS",
@@ -8,6 +8,7 @@
     "gerrit_plugin",
 )
 load("//tools/bzl:js.bzl", "gerrit_js_bundle")
+load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
 
 gerrit_plugin(
     name = "reviewers",
@@ -20,9 +21,32 @@
     resources = glob(["src/main/resources/**/*"]),
 )
 
+ts_config(
+    name = "tsconfig",
+    src = "tsconfig.json",
+    deps = [
+        "//plugins:tsconfig-plugins-base.json",
+    ],
+)
+
+ts_project(
+    name = "rv-reviewers-ts",
+    srcs = glob([
+        "rv-reviewers/**/*.ts",
+    ]),
+    incremental = True,
+    supports_workers = True,
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":tsconfig",
+    deps = [
+        "@plugins_npm//@gerritcodereview/typescript-api",
+        "@plugins_npm//lit",
+    ],
+)
+
 gerrit_js_bundle(
     name = "rv-reviewers",
-    srcs = glob(["rv-reviewers/*.js"]),
+    srcs = [":rv-reviewers-ts"],
     entry_point = "rv-reviewers/plugin.js",
 )
 
@@ -36,23 +60,4 @@
     ],
 )
 
-# Define the eslinter for the plugin
-# The eslint macro creates 2 rules: lint_test and lint_bin
-eslint(
-    name = "lint",
-    srcs = glob([
-        "rv-reviewers/*.js",
-    ]),
-    config = ".eslintrc.json",
-    data = [],
-    extensions = [
-        ".js",
-    ],
-    ignore = ".eslintignore",
-    plugins = [
-        "@npm//eslint-config-google",
-        "@npm//eslint-plugin-html",
-        "@npm//eslint-plugin-import",
-        "@npm//eslint-plugin-jsdoc",
-    ],
-)
+plugin_eslint()
diff --git a/rv-reviewers/plugin.js b/rv-reviewers/plugin.ts
similarity index 78%
rename from rv-reviewers/plugin.js
rename to rv-reviewers/plugin.ts
index ba7e1d2..3b73980 100644
--- a/rv-reviewers/plugin.js
+++ b/rv-reviewers/plugin.ts
@@ -14,9 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import './rv-reviewers.js';
+import '@gerritcodereview/typescript-api/gerrit';
+import './rv-reviewers';
 
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent(
-      'repo-command', 'rv-reviewers');
+window.Gerrit.install(plugin => {
+  plugin.registerCustomComponent('repo-command', 'rv-reviewers');
 });
diff --git a/rv-reviewers/rv-edit-screen.js b/rv-reviewers/rv-edit-screen.js
deleted file mode 100644
index b0419bf..0000000
--- a/rv-reviewers/rv-edit-screen.js
+++ /dev/null
@@ -1,86 +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.
- */
-import {htmlTemplate} from './rv-edit-screen_html.js';
-
-class RvEditScreen extends Polymer.Element {
-  /** @returns {string} name of the component */
-  static get is() { return 'rv-edit-screen'; }
-
-  /** @returns {?} template for this component */
-  static get template() { return htmlTemplate; }
-
-  /**
-   * Defines properties of the component
-   *
-   * @returns {?}
-   */
-  static get properties() {
-    return {
-      pluginRestApi: {
-        type: Object,
-        observer: '_loadFilterSections',
-      },
-      repoName: String,
-      loading: Boolean,
-      canModifyConfig: Boolean,
-      _editingFilter: {
-        type: Boolean,
-        value: false,
-      },
-      _filterSections: Array,
-    };
-  }
-
-  _loadFilterSections() {
-    this.pluginRestApi.get(this._getReviewersUrl(this.repoName))
-        .then(filterSections => {
-          this._filterSections = filterSections;
-        });
-  }
-
-  _computeAddFilterBtnHidden(canModifyConfig, editingFilter) {
-    return !canModifyConfig || editingFilter;
-  }
-
-  _computeLoadingClass(loading) {
-    return loading ? 'loading' : '';
-  }
-
-  _getReviewersUrl(repoName) {
-    return `/projects/${encodeURIComponent(repoName)}/reviewers`;
-  }
-
-  _handleCreateSection() {
-    const section = {filter: '', reviewers: [], ccs: [], editing: true};
-    this._editingFilter = true;
-    this.push('_filterSections', section);
-  }
-
-  _handleCloseTap(e) {
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('close', {
-      composed: true, bubbles: false,
-    }));
-  }
-
-  _handleReviewerChanged(e) {
-    this._filterSections = e.detail.result;
-    this._editingFilter = false;
-  }
-}
-
-customElements.define(RvEditScreen.is, RvEditScreen);
diff --git a/rv-reviewers/rv-edit-screen.ts b/rv-reviewers/rv-edit-screen.ts
new file mode 100644
index 0000000..1d6dcc7
--- /dev/null
+++ b/rv-reviewers/rv-edit-screen.ts
@@ -0,0 +1,161 @@
+/**
+ * @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.
+ */
+import {RepoName} from '@gerritcodereview/typescript-api/rest-api';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import './rv-filter-section';
+import {Section} from './rv-filter-section';
+import {fire} from './util';
+
+function getReviewersUrl(repoName: RepoName) {
+  return `/projects/${encodeURIComponent(repoName)}/reviewers`;
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'rv-edit-screen': RvEditScreen;
+  }
+}
+
+@customElement('rv-edit-screen')
+export class RvEditScreen extends LitElement {
+  @property()
+  pluginRestApi!: RestPluginApi;
+
+  @property()
+  repoName!: RepoName;
+
+  @property()
+  loading = false;
+
+  @property()
+  canModifyConfig = false;
+
+  @state()
+  editingFilter = false;
+
+  @state()
+  filterSections: Section[] = [];
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          padding: var(--spacing-xl);
+          display: block;
+        }
+        .bottomButtons {
+          display: flex;
+          justify-content: flex-end;
+        }
+        gr-button {
+          margin-left: var(--spacing-m);
+        }
+        #filterSections {
+          width: 100%;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    return html`
+      <div>
+        <h3 class="heading-3">Reviewers Config</h3>
+        <table id="filterSections">
+          <tbody>
+            ${this.renderEmpty()}
+            ${this.loading
+              ? html`<tr>
+                  <td>Loading...</td>
+                </tr>`
+              : this.filterSections.map(s => this.renderSection(s))}
+          </tbody>
+        </table>
+        <div class="bottomButtons">
+          <gr-button
+            id="addFilterBtn"
+            @click="${this.handleCreateSection}"
+            ?hidden="${!this.canModifyConfig || this.editingFilter}"
+          >
+            Add New Filter
+          </gr-button>
+          <gr-button id="closeButton" @click="${this.handleCloseTap}">
+            Close
+          </gr-button>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderEmpty() {
+    if (this.loading || this.filterSections.length > 0) return;
+    return html`<tr>
+      <td>No filter defined yet.</td>
+    </tr>`;
+  }
+
+  private renderSection(section: Section) {
+    return html`
+      <tr>
+        <td>
+          <rv-filter-section
+            .filter="${section.filter}"
+            .reviewers="${section.reviewers}"
+            .ccs="${section.ccs}"
+            .reviewersUrl="${getReviewersUrl(this.repoName)}"
+            .repoName="${this.repoName}"
+            .pluginRestApi="${this.pluginRestApi}"
+            .canModifyConfig="${this.canModifyConfig}"
+            @reviewer-changed="${this.handleReviewerChanged}"
+          >
+          </rv-filter-section>
+        </td>
+      </tr>
+    `;
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this.pluginRestApi
+      .get<Section[]>(getReviewersUrl(this.repoName))
+      .then(sections => {
+        this.filterSections = sections;
+      });
+  }
+
+  private handleCreateSection() {
+    const section = {filter: '', reviewers: [], ccs: [], editing: true};
+    this.filterSections = [...this.filterSections, section];
+    this.editingFilter = true;
+    fire(this, 'fit');
+  }
+
+  private handleCloseTap(e: Event) {
+    e.preventDefault();
+    fire(this, 'close');
+  }
+
+  private handleReviewerChanged(e: CustomEvent<Section[]>) {
+    // Even if just one reviewer is changed or deleted, then we still completely
+    // re-render everything from scratch.
+    this.filterSections = e.detail;
+    this.editingFilter = false;
+    fire(this, 'fit');
+  }
+}
diff --git a/rv-reviewers/rv-edit-screen_html.js b/rv-reviewers/rv-edit-screen_html.js
deleted file mode 100644
index 954d46c..0000000
--- a/rv-reviewers/rv-edit-screen_html.js
+++ /dev/null
@@ -1,78 +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.
- */
-import './rv-filter-section.js';
-
-export const htmlTemplate = Polymer.html`
-    <style include="shared-styles"></style>
-    <style include="gr-menu-page-styles"></style>
-    <style include="gr-subpage-styles">
-      :host {
-        padding: var(--spacing-xl);
-      }
-      .bottomButtons {
-        display: flex;
-      }
-      #closeButton {
-        float: right;
-      }
-      #filterSections {
-          width: 100%;
-      }
-      header {
-        border-bottom: 1px solid var(--border-colo);
-        flex-shrink: 0;
-        font-weight: var(--font-weight-bold)
-      }
-    </style>
-    <div>
-      <header>Reviewers Config</header>
-      <table id="filterSections">
-        <tr>
-          <th>Filter Sections</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[_computeLoadingClass(loading)]]">
-          <td>Loading...</td>
-        </tr>
-        <tbody class$="[[_computeLoadingClass(loading)]]">
-          <tr>
-            <template
-                is="dom-repeat"
-                items="[[_filterSections]]"
-                as="section">
-              <rv-filter-section
-                  filter="[[section.filter]]"
-                  reviewers="[[section.reviewers]]"
-                  ccs="[[section.ccs]]"
-                  editing="[[section.editing]]"
-                  reviewers-url="[[_getReviewersUrl(repoName)]]"
-                  repo-name="[[repoName]]"
-                  plugin-rest-api="[[pluginRestApi]]"
-                  can-modify-config="[[canModifyConfig]]"
-                  on-reviewer-changed="_handleReviewerChanged"></rv-filter-section>
-            </template>
-          </tr>
-        </tbody>
-      </table>
-      <div class="bottomButtons">
-        <gr-button id="closeButton" on-tap="_handleCloseTap">Close</gr-button>
-        <gr-button
-            id="addFilterBtn"
-            on-tap="_handleCreateSection"
-            hidden="[[_computeAddFilterBtnHidden(canModifyConfig, _editingFilter)]]">Add New Filter</gr-button>
-      </div>
-    </div>
-`;
diff --git a/rv-reviewers/rv-filter-section.js b/rv-reviewers/rv-filter-section.js
deleted file mode 100644
index ae43a62..0000000
--- a/rv-reviewers/rv-filter-section.js
+++ /dev/null
@@ -1,133 +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.
- */
-import {htmlTemplate} from './rv-filter-section_html.js';
-
-class RvFilterSection extends Polymer.Element {
-  /** @returns {string} name of the component */
-  static get is() { return 'rv-filter-section'; }
-
-  /** @returns {?} template for this component */
-  static get template() { return htmlTemplate; }
-
-  /**
-   * Defines properties of the component
-   *
-   * @returns {?}
-   */
-  static get properties() {
-    return {
-      pluginRestApi: Object,
-      repoName: String,
-      reviewers: Array,
-      ccs: Array,
-      filter: String,
-      canModifyConfig: Boolean,
-      _originalFilter: String,
-      _editingReviewer: {
-        type: Boolean,
-        value: false,
-      },
-      reviewersUrl: String,
-    };
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this._updateSection();
-  }
-
-  _updateSection() {
-    this._originalFilter = this.filter;
-  }
-
-  _computeEditing(filter, _originalFilter) {
-    if (_originalFilter === '') {
-      return true;
-    }
-    return filter === '';
-  }
-
-  _computeCancelHidden(filter, _originalFilter) {
-    return !this._computeEditing(filter, _originalFilter);
-  }
-
-  _computeAddBtnHidden(canModifyConfig, editingReviewer) {
-    return !(canModifyConfig && !editingReviewer);
-  }
-
-  _computeFilterInputDisabled(canModifyConfig, originalFilter) {
-    return !canModifyConfig || originalFilter !== '';
-  }
-
-  _handleCancel() {
-    this.remove();
-  }
-
-  _handleReviewerDeleted(e) {
-    const type = e.detail.type;
-    if (e.detail.editing) {
-      if (type === 'CC') {
-        this.ccs.pop();
-      } else {
-        this.reviewers.pop();
-      }
-      this._editingReviewer = false;
-    } else {
-      const index = e.model.index;
-      const deleted = type === 'CC' ? this.ccs[index] : this.reviewers[index];
-      this._putReviewer(deleted, 'REMOVE', type);
-    }
-  }
-
-  _handleReviewerAdded(e) {
-    this._editingReviewer = false;
-    this._putReviewer(e.detail.reviewer, 'ADD', e.detail.type).catch(err => {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: err,
-        },
-        composed: true, bubbles: true,
-      }));
-      throw err;
-    });
-  }
-
-  _putReviewer(reviewer, action, type) {
-    return this.pluginRestApi.put(this.reviewersUrl, {
-      action,
-      reviewer,
-      type,
-      filter: this.filter,
-    }).then(result => {
-      const detail = {result};
-      this.dispatchEvent(
-          new CustomEvent('reviewer-changed', {detail, bubbles: true}));
-    });
-  }
-
-  _handleAddReviewer() {
-    this.push('reviewers', '');
-    this._editingReviewer = true;
-  }
-
-  _handleAddCc() {
-    this.push('ccs', '');
-    this._editingReviewer = true;
-  }
-}
-
-customElements.define(RvFilterSection.is, RvFilterSection);
diff --git a/rv-reviewers/rv-filter-section.ts b/rv-reviewers/rv-filter-section.ts
new file mode 100644
index 0000000..cfde6b8
--- /dev/null
+++ b/rv-reviewers/rv-filter-section.ts
@@ -0,0 +1,252 @@
+/**
+ * @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.
+ */
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {RepoName} from '@gerritcodereview/typescript-api/rest-api';
+import './rv-reviewer';
+import {
+  ReviewerAddedEventDetail,
+  ReviewerDeletedEventDetail,
+  Type,
+} from './rv-reviewer';
+import {fire} from './util';
+
+enum Action {
+  ADD = 'ADD',
+  REMOVE = 'REMOVE',
+}
+
+export interface Section {
+  filter: string;
+  reviewers: string[];
+  ccs: string[];
+  editing: boolean;
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'rv-filter-section': RvFilterSection;
+  }
+}
+
+@customElement('rv-filter-section')
+export class RvFilterSection extends LitElement {
+  @query('#filterInput')
+  filterInput?: HTMLInputElement;
+
+  @property()
+  pluginRestApi!: RestPluginApi;
+
+  @property()
+  repoName!: RepoName;
+
+  @property()
+  reviewers: string[] = [];
+
+  @property()
+  ccs: string[] = [];
+
+  @property()
+  filter = '';
+
+  @property()
+  canModifyConfig = false;
+
+  @property({type: String})
+  reviewersUrl = '';
+
+  /**
+   * If a filter was already set initially, then you cannot "cancel" creating
+   * this filter.
+   */
+  @state()
+  originalFilter = '';
+
+  /** While a reviewer is being edited you cannot add another. */
+  @state()
+  editingReviewer = false;
+
+  connectedCallback() {
+    super.connectedCallback();
+    this.originalFilter = this.filter;
+  }
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+          margin-bottom: 1em;
+        }
+        #container {
+          display: block;
+          border: 1px solid var(--border-color);
+        }
+        #filter {
+          align-items: center;
+          background: var(--table-header-background-color);
+          border-bottom: 1px solid var(--border-color);
+          display: flex;
+          justify-content: space-between;
+          min-height: 3em;
+          padding: 0 var(--spacing-m);
+        }
+        #filterInput {
+          width: 30vw;
+          max-width: 500px;
+          margin-left: var(--spacing-s);
+        }
+        gr-button {
+          margin-left: var(--spacing-m);
+        }
+        #addReviewer {
+          display: flex;
+          padding: var(--spacing-s) 0;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    return html`
+      <div id="container">
+        <div id="filter">
+          <span class="heading-3">Filter</span>
+          <input
+            id="filterInput"
+            value="${this.filter}"
+            @input="${this.onFilterInput}"
+            ?disabled="${!this.canModifyConfig || this.originalFilter !== ''}"
+          />
+          <gr-button
+            @click="${() => this.remove()}"
+            ?hidden="${this.originalFilter !== '' && this.filter !== ''}"
+          >
+            Cancel
+          </gr-button>
+        </div>
+        <div>
+          ${this.reviewers.map(item =>
+            this.renderReviewer(item, Type.REVIEWER)
+          )}
+          ${this.ccs.map(item => this.renderReviewer(item, Type.CC))}
+          <div id="addReviewer">
+            <gr-button
+              link
+              @click="${this.handleAddReviewer}"
+              ?disabled="${this.filter === ''}"
+              ?hidden="${!this.canModifyConfig || this.editingReviewer}"
+            >
+              Add Reviewer
+            </gr-button>
+            <gr-button
+              link
+              @click="${this.handleAddCc}"
+              ?disabled="${this.filter === ''}"
+              ?hidden="${!this.canModifyConfig || this.editingReviewer}"
+            >
+              Add CC
+            </gr-button>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderReviewer(reviewer: string, type: Type) {
+    return html`
+      <rv-reviewer
+        .reviewer="${reviewer}"
+        .type="${type}"
+        .canModifyConfig="${this.canModifyConfig}"
+        .pluginRestApi="${this.pluginRestApi}"
+        .repoName="${this.repoName}"
+        @reviewer-deleted="${(e: CustomEvent<ReviewerDeletedEventDetail>) =>
+          this.handleReviewerDeleted(e, reviewer)}"
+        @reviewer-added="${(e: CustomEvent<ReviewerAddedEventDetail>) =>
+          this.handleReviewerAdded(e)}"
+      >
+      </rv-reviewer>
+    `;
+  }
+
+  private onFilterInput() {
+    this.filter = this.filterInput?.value ?? '';
+  }
+
+  private handleReviewerDeleted(
+    e: CustomEvent<ReviewerDeletedEventDetail>,
+    reviewer: string
+  ) {
+    const {type, editing} = e.detail;
+    if (editing) {
+      // Just cancelling edit. Nothing was persisted yet, so nothing to delete.
+      if (type === Type.CC) {
+        this.ccs = [...this.ccs.slice(0, -1)];
+      } else {
+        this.reviewers = [...this.reviewers.slice(0, -1)];
+      }
+      this.editingReviewer = false;
+    } else {
+      // The reviewer was not in edit mode, but DELETE was clicked.
+      this.putReviewer(reviewer, Action.REMOVE, type);
+    }
+  }
+
+  private handleReviewerAdded(e: CustomEvent<ReviewerAddedEventDetail>) {
+    this.editingReviewer = false;
+    this.putReviewer(e.detail.reviewer, Action.ADD, e.detail.type).catch(
+      err => {
+        fire(this, 'show-alert', {message: err});
+        throw err;
+      }
+    );
+  }
+
+  private putReviewer(reviewer: string, action: Action, type: Type) {
+    if (this.filter === '') throw new Error('empty filter');
+    if (reviewer === '') throw new Error('empty reviewer');
+    return this.pluginRestApi
+      .put<Section[]>(this.reviewersUrl, {
+        action,
+        reviewer,
+        type,
+        filter: this.filter,
+      })
+      .then((sections: Section[]) => {
+        // Even if just one reviewer is changed or deleted, we will get the
+        // the complete list of sections back from the server, and we dispatch
+        // this event such that the entire dialog re-renders from scratch.
+        // Lit is smart enough to re-use the component though, so we also want
+        // to re-initialize the state here:
+        this.editingReviewer = false;
+        this.originalFilter = this.filter;
+        fire(this, 'reviewer-changed', sections);
+      });
+  }
+
+  private handleAddReviewer() {
+    this.reviewers = [...this.reviewers, ''];
+    this.editingReviewer = true;
+  }
+
+  private handleAddCc() {
+    this.ccs = [...this.ccs, ''];
+    this.editingReviewer = true;
+  }
+}
diff --git a/rv-reviewers/rv-filter-section_html.js b/rv-reviewers/rv-filter-section_html.js
deleted file mode 100644
index 87cc7a3..0000000
--- a/rv-reviewers/rv-filter-section_html.js
+++ /dev/null
@@ -1,123 +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.
- */
-import './rv-reviewer.js';
-
-export const htmlTemplate = Polymer.html`
-    <style include="shared-styles">
-      :host {
-        display: block;
-        margin-bottom: 1em;
-      }
-      fieldset {
-        border: 1px solid var(--border-color);
-      }
-      .name {
-        align-items: center;
-        display: flex;
-      }
-      .name gr-button {
-        margin-left: var(--spacing-m);
-      }
-      .header {
-        align-items: center;
-        background: var(--table-header-background-color);
-        border-bottom: 1px dotted var(--border-color);
-        display: flex;
-        justify-content: space-between;
-        min-height: 3em;
-        padding: 0 .7em;
-      }
-      #addReviewer {
-        display: flex;
-      }
-      #editFilterInput {
-        width: 30vw;
-        max-width: 500px;
-        margin-left: 3px;
-      }
-      #mainContainer {
-        display: block;
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <fieldset id="section"
-        class$="gr-form-styles">
-      <div id="mainContainer">
-        <div class="header">
-          <div class="name">
-            <h3>Filter:</h3>
-            <iron-input
-                id="editFilterInput"
-                bind-value="{{filter}}"
-                type="text"
-                disabled="[[_computeFilterInputDisabled(canModifyConfig, _originalFilter)]]">
-              <input
-                  id="editFilterInput"
-                  bind-value="{{filter}}"
-                  is="iron-input"
-                  type="text"
-                  disabled="[[_computeFilterInputDisabled(canModifyConfig, _originalFilter)]]">
-            </iron-input>
-            <gr-button
-                id="cancelBtn"
-                on-tap="_handleCancel"
-                hidden$="[[_computeCancelHidden(filter, _originalFilter)]]">Cancel</gr-button>
-          </div><!-- name -->
-        </div><!-- header -->
-        <div class="reviewers">
-          <template
-              is="dom-repeat"
-              items="{{reviewers}}">
-            <rv-reviewer
-                reviewer="{{item}}"
-                type="REVIEWER"
-                can-modify-config="[[canModifyConfig]]"
-                plugin-rest-api="[[pluginRestApi]]"
-                repo-name="[[repoName]]"
-                on-reviewer-deleted="_handleReviewerDeleted"
-                on-reviewer-added="_handleReviewerAdded">
-            </rv-reviewer>
-          </template>
-          <template
-          is="dom-repeat"
-          items="{{ccs}}">
-            <rv-reviewer
-                reviewer="{{item}}"
-                type="CC"
-                can-modify-config="[[canModifyConfig]]"
-                plugin-rest-api="[[pluginRestApi]]"
-                repo-name="[[repoName]]"
-                on-reviewer-deleted="_handleReviewerDeleted"
-                on-reviewer-added="_handleReviewerAdded">
-        </rv-reviewer>
-      </template>
-          <div id="addReviewer">
-            <gr-button
-                link
-                id="addRevBtn"
-                on-tap="_handleAddReviewer"
-                hidden="[[_computeAddBtnHidden(canModifyConfig, _editingReviewer)]]">Add Reviewer</gr-button>
-            <gr-button
-                link
-                id="addCcBtn"
-                on-tap="_handleAddCc"
-                hidden="[[_computeAddBtnHidden(canModifyConfig, _editingReviewer)]]">Add Cc</gr-button>
-          </div><!-- addReviewer -->
-        </div><!-- reviewers -->
-      </div>
-    </fieldset>
-`;
diff --git a/rv-reviewers/rv-reviewer.js b/rv-reviewers/rv-reviewer.js
deleted file mode 100644
index 6145013..0000000
--- a/rv-reviewers/rv-reviewer.js
+++ /dev/null
@@ -1,167 +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.
- */
-import {htmlTemplate} from './rv-reviewer_html.js';
-
-class RvReviewer extends Polymer.Element {
-  /** @returns {string} name of the component */
-  static get is() { return 'rv-reviewer'; }
-
-  /** @returns {?} template for this component */
-  static get template() { return htmlTemplate; }
-
-  /**
-   * Defines properties of the component
-   *
-   * @returns {?}
-   */
-  static get properties() {
-    return {
-      canModifyConfig: Boolean,
-      pluginRestAPi: Object,
-      repoName: String,
-      reviewer: String,
-      type: String,
-      _header: {
-        type: String,
-        computed: '_computeHeader(type)',
-      },
-      _reviewerSearchId: String,
-      _queryReviewers: {
-        type: Function,
-        value() {
-          return this._getReviewerSuggestions.bind(this);
-        },
-      },
-      _originalReviewer: String,
-      _deleted: Boolean,
-      _editing: {
-        type: Boolean,
-        computed: '_computeEditing(reviewer, _originalReviewer)',
-      },
-    };
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this._originalReviewer = this.reviewer;
-  }
-
-  _computeHeader(type) {
-    if (type === 'CC') {
-      return 'Cc';
-    }
-    return 'Reviewer';
-  }
-
-  _computeEditing(reviewer, _originalReviewer) {
-    if (_originalReviewer === '') {
-      return true;
-    }
-    return reviewer === '';
-  }
-
-  _computeDeleteCancel(reviewer, _originalReviewer) {
-    return this._computeEditing(reviewer, _originalReviewer) ?
-      'Cancel' : 'Delete';
-  }
-
-  _computeHideAddButton(reviewer, _originalReviewer) {
-    return !(this._computeEditing(reviewer, _originalReviewer) &&
-      this._reviewerSearchId);
-  }
-
-  _computeHideDeleteButton(canModifyConfig) {
-    return !canModifyConfig;
-  }
-
-  _getReviewerSuggestions(input) {
-    if (input.length === 0) { return Promise.resolve([]); }
-    const promises = [];
-    promises.push(this._getSuggestedGroups(input));
-    promises.push(this._getSuggestedAccounts(input));
-    return Promise.all(promises).then(result => {
-      return result.flat();
-    });
-  }
-
-  _getSuggestedGroups(input) {
-    const suggestUrl = `/groups/?suggest=${input}&p=${this.repoName}`;
-    return this.pluginRestApi.get(suggestUrl).then(groups => {
-      if (!groups) { return []; }
-      const groupSuggestions = [];
-      for (const key in groups) {
-        if (!groups.hasOwnProperty(key) || key.startsWith('user/')) {
-          continue;
-        }
-        groupSuggestions.push({
-          name: key,
-          value: key,
-        });
-      }
-      return groupSuggestions;
-    });
-  }
-
-  _getSuggestedAccounts(input) {
-    const suggestUrl = `/accounts/?suggest&q=${input}`;
-    return this.pluginRestApi.get(suggestUrl).then(accounts => {
-      const accountSuggestions = [];
-      let nameAndEmail;
-      let value;
-      if (!accounts) { return []; }
-      for (const key in accounts) {
-        if (!accounts.hasOwnProperty(key)) { continue; }
-        if (accounts[key].email) {
-          nameAndEmail = accounts[key].name +
-            ' <' + accounts[key].email + '>';
-        } else {
-          nameAndEmail = accounts[key].name;
-        }
-        if (accounts[key].username) {
-          value = accounts[key].username;
-        } else if (accounts[key].email) {
-          value = accounts[key].email;
-        } else {
-          value = accounts[key]._account_id;
-        }
-        accountSuggestions.push({
-          name: nameAndEmail,
-          value,
-        });
-      }
-      return accountSuggestions;
-    });
-  }
-
-  _handleDeleteCancel() {
-    const detail = {editing: this._editing, type: this.type};
-    if (this._editing) {
-      this.remove();
-    }
-    this.dispatchEvent(
-        new CustomEvent('reviewer-deleted', {detail, bubbles: true}));
-  }
-
-  _handleAddReviewer() {
-    const detail = {reviewer: this._reviewerSearchId, type: this.type};
-    this._originalReviewer = this.reviewer;
-    this.dispatchEvent(
-        new CustomEvent('reviewer-added', {detail, bubbles: true}));
-  }
-}
-
-customElements.define(RvReviewer.is, RvReviewer);
\ No newline at end of file
diff --git a/rv-reviewers/rv-reviewer.ts b/rv-reviewers/rv-reviewer.ts
new file mode 100644
index 0000000..1158be7
--- /dev/null
+++ b/rv-reviewers/rv-reviewer.ts
@@ -0,0 +1,267 @@
+/**
+ * @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.
+ */
+import {customElement, property, state} from 'lit/decorators';
+import {css, html, LitElement} from 'lit';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {
+  AccountInfo,
+  GroupInfo,
+  RepoName,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {fire} from './util';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'rv-reviewer': RvReviewer;
+  }
+}
+
+export enum Type {
+  REVIEWER = 'REVIEWER',
+  CC = 'CC',
+}
+
+export interface ReviewerDeletedEventDetail {
+  /**
+   * If true, then this means a reviewer addition was just canceled. Not server
+   * update required.
+   * If false, then the entry has to be deleted server side by the event
+   * handler.
+   */
+  editing: boolean;
+  type: Type;
+}
+
+export interface ReviewerAddedEventDetail {
+  reviewer: string;
+  type: Type;
+}
+
+type GroupNameToInfo = {[name: string]: GroupInfo};
+
+interface NameValue {
+  name: string;
+  value: string;
+}
+
+function computeValue(account: AccountInfo): string | undefined {
+  if (account.username) {
+    return account.username;
+  }
+  if (account.email) {
+    return account.email;
+  }
+  return String(account._account_id);
+}
+
+function computeName(account: AccountInfo): string | undefined {
+  if (account.email) {
+    return `${account.name} <${account.email}>`;
+  }
+  return account.name;
+}
+
+@customElement('rv-reviewer')
+export class RvReviewer extends LitElement {
+  /**
+   * Fired when the 'CANCEL' or 'DELETE' button for a reviewer was clicked.
+   *
+   * @event reviewer-deleted
+   */
+
+  /**
+   * Fired when the 'ADD' button for a reviewer was clicked.
+   *
+   * @event reviewer-added
+   */
+
+  @property()
+  canModifyConfig = false;
+
+  @property()
+  pluginRestApi!: RestPluginApi;
+
+  @property()
+  repoName!: RepoName;
+
+  @property()
+  type = Type.REVIEWER;
+
+  /**
+   * This is the value that is persisted on the server side. For new reviewers
+   * this is empty until the user clicks "ADD" and the data was saved.
+   */
+  @property()
+  reviewer = '';
+
+  /**
+   * This is value that the user has picked from the auto-completion. It will
+   * be used for saving (when the user clicks "ADD") and then assigned to the
+   * `reviewer` property.
+   */
+  @state()
+  selectedReviewer = '';
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+          padding: var(--spacing-s) 0;
+        }
+        #editReviewerInput {
+          display: block;
+          width: 250px;
+        }
+        .reviewerRow {
+          align-items: center;
+          display: flex;
+        }
+        #reviewerHeader,
+        #editReviewerInput,
+        #deleteCancelBtn,
+        #addBtn,
+        #reviewerField {
+          margin-left: var(--spacing-m);
+        }
+        #reviewerField {
+          width: 250px;
+          text-indent: 1px;
+          border: 1px solid var(--border-color);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    return html`
+      <div class="reviewerRow">
+        <span class="heading-3" id="reviewerHeader">
+          ${this.type === Type.CC ? 'CC' : 'Reviewer'}
+        </span>
+        ${this.isEditing()
+          ? this.renderAutocomplete()
+          : html`<td id="reviewerField">${this.reviewer}</td>`}
+        <gr-button
+          id="deleteCancelBtn"
+          @click="${this.handleDeleteCancel}"
+          ?hidden="${!this.canModifyConfig}"
+        >
+          ${this.isEditing() ? 'Cancel' : 'Delete'}
+        </gr-button>
+        <gr-button
+          id="addBtn"
+          @click="${this.handleAddReviewer}"
+          ?hidden="${!this.isEditing() || !this.selectedReviewer}"
+        >
+          Add
+        </gr-button>
+      </div>
+    `;
+  }
+
+  renderAutocomplete() {
+    return html`
+      <span class="value">
+        <!--
+              TODO:
+              Investigate whether we could reuse gr-account-list.
+              If the REST API returns AccountInfo instead of an account
+              identifier String we should be able to use gr-account-list(size=1)
+              for all reviewers, including those who are non-editable
+              (#reviewerField below) and align the plugin with how accounts
+              are displayed in core Gerrit's UI.
+            -->
+        <gr-autocomplete
+          id="editReviewerInput"
+          .query="${(input: string) => this.getReviewerSuggestions(input)}"
+          .placeholder="Name Or Email"
+          @value-changed="${this.onReviewerSelected}"
+        >
+        </gr-autocomplete>
+      </span>
+    `;
+  }
+
+  onReviewerSelected(e: CustomEvent<{value: string}>) {
+    if (!e.detail.value) return;
+    this.selectedReviewer = e.detail.value;
+  }
+
+  /**
+   * "Editing" actually just means "adding". This component does not allow
+   * editing. You can only add new entries or delete existing ones.
+   */
+  isEditing() {
+    return this.reviewer === '';
+  }
+
+  getReviewerSuggestions(input: string): Promise<NameValue[]> {
+    if (input.length === 0) return Promise.resolve([]);
+    const p1 = this.getSuggestedGroups(input);
+    const p2 = this.getSuggestedAccounts(input);
+    return Promise.all([p1, p2]).then(result => result.flat());
+  }
+
+  getSuggestedGroups(input: string): Promise<NameValue[]> {
+    const suggestUrl = `/groups/?suggest=${input}&p=${this.repoName}`;
+    return this.pluginRestApi.get<GroupNameToInfo>(suggestUrl).then(groups => {
+      if (!groups) return [];
+      return Object.keys(groups)
+        .filter(name => !name.startsWith('user/'))
+        .map(name => {
+          return {name, value: name};
+        });
+    });
+  }
+
+  getSuggestedAccounts(input: string): Promise<NameValue[]> {
+    const suggestUrl = `/accounts/?suggest&q=${input}`;
+    return this.pluginRestApi.get<AccountInfo[]>(suggestUrl).then(accounts => {
+      const accountSuggestions: NameValue[] = [];
+      if (!accounts) return [];
+      for (const account of accounts) {
+        const name = computeName(account);
+        const value = computeValue(account);
+        if (!name || !value) continue;
+        accountSuggestions.push({name, value});
+      }
+      return accountSuggestions;
+    });
+  }
+
+  handleDeleteCancel() {
+    const detail: ReviewerDeletedEventDetail = {
+      editing: this.isEditing(),
+      type: this.type,
+    };
+    if (this.isEditing()) {
+      this.remove();
+    }
+    fire(this, 'reviewer-deleted', detail);
+  }
+
+  handleAddReviewer() {
+    const detail: ReviewerAddedEventDetail = {
+      reviewer: this.selectedReviewer,
+      type: this.type,
+    };
+    this.reviewer = this.selectedReviewer;
+    this.selectedReviewer = '';
+    fire(this, 'reviewer-added', detail);
+  }
+}
diff --git a/rv-reviewers/rv-reviewer_html.js b/rv-reviewers/rv-reviewer_html.js
deleted file mode 100644
index 29b22de..0000000
--- a/rv-reviewers/rv-reviewer_html.js
+++ /dev/null
@@ -1,80 +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.
- */
-export const htmlTemplate = Polymer.html`
-    <style include="shared-styles">
-      :host {
-        display: block;
-        padding: var(--spacing-s) 0;
-      }
-      #editReviewerInput {
-        display: block;
-        width: 250px;
-      }
-      .reviewerRow {
-        align-items: center;
-        display: flex;
-      }
-      #reviewerHeader,
-      #editReviewerInput,
-      #deleteCancelBtn,
-      #addBtn,
-      #reviewerField {
-        margin-left: var(--spacing-m);
-      }
-      #reviewerField {
-        width: 250px;
-        text-indent: 1px;
-        border: 1px solid var(--border-color);
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <div class="reviewerRow">
-      <h4 id="reviewerHeader">[[_header]]:</h4>
-      <template is="dom-if" if="[[_computeEditing(reviewer, _originalReviewer)]]">
-        <span class="value">
-            <!--
-              TODO:
-              Investigate whether we could reuse gr-account-list.
-              If the REST API returns AccountInfo instead of an account
-              identifier String we should be able to use gr-account-list(size=1)
-              for all reviewers, including those who are non-editable
-              (#reviewerField below) and align the plugin with how accounts
-              are displayed in core Gerrit's UI.
-            -->
-            <gr-autocomplete
-                id="editReviewerInput"
-                text="{{reviewer}}"
-                value="{{_reviewerSearchId}}"
-                query="[[_queryReviewers]]"
-                placeholder="Name Or Email">
-            </gr-autocomplete>
-        </span>
-      </template>
-      <template is="dom-if" if="[[!_computeEditing(reviewer, _originalReviewer)]]">
-        <td id="reviewerField">[[reviewer]]</td>
-      </template>
-      <gr-button
-          id="deleteCancelBtn"
-          on-tap="_handleDeleteCancel"
-          hidden$="[[_computeHideDeleteButton(canModifyConfig)]]"
-          >[[_computeDeleteCancel(reviewer, _originalReviewer)]]</gr-button>
-      <gr-button
-          id="addBtn"
-          on-tap="_handleAddReviewer"
-          hidden$="[[_computeHideAddButton(reviewer, _originalReviewer)]]">Add</gr-button>
-    </div> <!-- reviewerRow -->
-`;
\ No newline at end of file
diff --git a/rv-reviewers/rv-reviewers.js b/rv-reviewers/rv-reviewers.js
deleted file mode 100644
index 9782b8c..0000000
--- a/rv-reviewers/rv-reviewers.js
+++ /dev/null
@@ -1,100 +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.
- */
-import {htmlTemplate} from './rv-reviewers_html.js';
-
-class RvReviewers extends Polymer.Element {
-  /** @returns {string} name of the component */
-  static get is() { return 'rv-reviewers'; }
-
-  /** @returns {?} template for this component */
-  static get template() { return htmlTemplate; }
-
-  /**
-   * Defines properties of the component
-   *
-   * @returns {?}
-   */
-  static get properties() {
-    return {
-      pluginRestApi: Object,
-      repoName: String,
-      _canModifyConfig: {
-        type: Boolean,
-        computed: '_computeCanModifyConfig(_isOwner, _hasModifyCapability)',
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _isOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _hasModifyCapability: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this.pluginRestApi = this.plugin.restApi();
-    this._setCanModifyConfig();
-  }
-
-  _handleCommandTap() {
-    this.$.rvScreenOverlay.open();
-  }
-
-  _handleRvEditScreenClose() {
-    this.$.rvScreenOverlay.close();
-  }
-
-  _setCanModifyConfig() {
-    const promises = [];
-    promises.push(this._getRepoAccess(this.repoName).then( access => {
-      if (access && access[this.repoName] && access[this.repoName].is_owner) {
-        this._isOwner = true;
-      }
-    }));
-    promises.push(this._getCapabilities().then(capabilities => {
-      if (capabilities['reviewers-modifyReviewersConfig']) {
-        this._hasModifyCapability = true;
-      }
-    }));
-    Promise.all(promises).then(() => {
-      this._loading = false;
-    });
-  }
-
-  _computeCanModifyConfig(isOwner, hasModifyCapability) {
-    return isOwner || hasModifyCapability;
-  }
-
-  _getRepoAccess(repoName) {
-    return this.pluginRestApi.get(
-        '/access/?project=' + encodeURIComponent(repoName));
-  }
-
-  _getCapabilities() {
-    return this.pluginRestApi.get('/accounts/self/capabilities');
-  }
-}
-
-customElements.define(RvReviewers.is, RvReviewers);
-
diff --git a/rv-reviewers/rv-reviewers.ts b/rv-reviewers/rv-reviewers.ts
new file mode 100644
index 0000000..28f4096
--- /dev/null
+++ b/rv-reviewers/rv-reviewers.ts
@@ -0,0 +1,131 @@
+/**
+ * @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.
+ */
+import {RepoName} from '@gerritcodereview/typescript-api/rest-api';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+import {customElement, property, query, state} from 'lit/decorators';
+import {css, html, LitElement} from 'lit';
+import './rv-edit-screen';
+
+// TODO: This should be defined and exposed by @gerritcodereview/typescript-api
+type GrOverlay = Element & {
+  open(): void;
+  close(): void;
+  fit(): void;
+};
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'rv-reviewers': RvReviewers;
+  }
+}
+
+declare interface AccountCapabilityInfo {
+  'reviewers-modifyReviewersConfig'?: boolean;
+}
+
+type ProjectAccessInfoMap = {[projectName: string]: ProjectAccessInfo};
+
+declare interface ProjectAccessInfo {
+  is_owner?: boolean;
+}
+
+@customElement('rv-reviewers')
+export class RvReviewers extends LitElement {
+  @query('#rvScreenOverlay')
+  rvScreenOverlay?: GrOverlay;
+
+  /** Guaranteed to be set by the `repo-command` endpoint. */
+  @property({type: Object})
+  plugin!: PluginApi;
+
+  /** Guaranteed to be set by the `repo-command` endpoint. */
+  @property({type: String})
+  repoName!: RepoName;
+
+  @state()
+  pluginRestApi!: RestPluginApi;
+
+  @state()
+  canModifyConfig = false;
+
+  @state()
+  loading = true;
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+          margin-bottom: var(--spacing-xxl);
+        }
+        #rvScreenOverlay {
+          width: 50em;
+          overflow: auto;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    return html`
+      <h3 class="heading-3">Reviewers Config</h3>
+      <gr-button @click="${() => this.rvScreenOverlay?.open()}">
+        Reviewers Config
+      </gr-button>
+      <gr-overlay id="rvScreenOverlay" with-backdrop>
+        <rv-edit-screen
+          .pluginRestApi="${this.pluginRestApi}"
+          .repoName="${this.repoName}"
+          .loading="${this.loading}"
+          .canModifyConfig="${this.canModifyConfig}"
+          @close="${() => this.rvScreenOverlay?.close()}"
+          @fit="${() => this.rvScreenOverlay?.fit()}"
+        >
+        </rv-edit-screen>
+      </gr-overlay>
+    `;
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this.pluginRestApi = this.plugin.restApi();
+    const p1 = this.getRepoAccess(this.repoName).then(access => {
+      if (access[this.repoName]?.is_owner) {
+        this.canModifyConfig = true;
+      }
+    });
+    const p2 = this.getCapabilities().then(capabilities => {
+      if (capabilities['reviewers-modifyReviewersConfig']) {
+        this.canModifyConfig = true;
+      }
+    });
+    Promise.all([p1, p2]).then(() => (this.loading = false));
+  }
+
+  getRepoAccess(repoName: RepoName) {
+    return this.pluginRestApi.get<ProjectAccessInfoMap>(
+      '/access/?project=' + encodeURIComponent(repoName)
+    );
+  }
+
+  getCapabilities() {
+    return this.pluginRestApi.get<AccountCapabilityInfo>(
+      '/accounts/self/capabilities'
+    );
+  }
+}
diff --git a/rv-reviewers/rv-reviewers_html.js b/rv-reviewers/rv-reviewers_html.js
deleted file mode 100644
index 53a8aec..0000000
--- a/rv-reviewers/rv-reviewers_html.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import './rv-edit-screen.js';
-
-export const htmlTemplate = Polymer.html`
-    <style include="shared-styles">
-      :host {
-        display: block;
-        margin-bottom: var(--spacing-xxl);
-      }
-      #rvScreenOverlay {
-        width: 50em;
-        overflow: auto;
-      }
-    </style>
-    <h3>Reviewers Config</h3>
-    <gr-button
-      on-click="_handleCommandTap"
-    >
-      Reviewers Config
-    </gr-button>
-    <gr-overlay id="rvScreenOverlay" with-backdrop>
-      <rv-edit-screen
-          plugin-rest-api="[[pluginRestApi]]"
-          repo-name="[[repoName]]"
-          loading="[[_loading]]"
-          can-modify-config="[[_canModifyConfig]]"
-          on-close="_handleRvEditScreenClose"></rv-edit-screen>
-    </gr-overlay>
-`;
diff --git a/rv-reviewers/plugin.js b/rv-reviewers/util.ts
similarity index 69%
copy from rv-reviewers/plugin.js
copy to rv-reviewers/util.ts
index ba7e1d2..c2559c5 100644
--- a/rv-reviewers/plugin.js
+++ b/rv-reviewers/util.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
+ * Copyright (C) 2021 The Android Open 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,9 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import './rv-reviewers.js';
 
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent(
-      'repo-command', 'rv-reviewers');
-});
+export function fire<T>(target: EventTarget, type: string, detail?: T) {
+  target.dispatchEvent(
+    new CustomEvent<T>(type, {
+      detail,
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..f0a5ff3
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,6 @@
+{
+  "extends": "../tsconfig-plugins-base.json",
+  "include": [
+    "rv-reviewers/**/*",
+  ]
+}