Merge branch 'stable-3.5'

* stable-3.5:
  Count uploader as a resolved reviewer
  UI: Don't suggest unsupported system groups
  Document that configured groups must be visible to uploader
  Bazel: Remove unnecessary plugin_name parameter

Change-Id: I5f88aa319db363ab4939b17ce42c804b88c7af09
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..626625c 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,10 +21,33 @@
     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([
+        "web/**/*.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"]),
-    entry_point = "rv-reviewers/plugin.js",
+    srcs = [":rv-reviewers-ts"],
+    entry_point = "web/plugin.js",
 )
 
 junit_tests(
@@ -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/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_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_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 338b428..0000000
--- a/rv-reviewers/rv-reviewer.js
+++ /dev/null
@@ -1,171 +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';
-
-const SYSTEM_GROUP_PREFIX = 'global%3A';
-
-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) ||
-             groups[key].id.startsWith(SYSTEM_GROUP_PREFIX) ||
-             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_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_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/src/main/java/com/googlesource/gerrit/plugins/reviewers/ForProjectValidator.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/ForProjectValidator.java
new file mode 100644
index 0000000..3492e03
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/ForProjectValidator.java
@@ -0,0 +1,112 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.reviewers;
+
+import autovaluegson.factory.shaded.com.google.common.collect.Lists;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.PatchSet.Id;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.MergeValidationException;
+import com.google.gerrit.server.git.validators.MergeValidationListener;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.reviewers.config.ForProject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+
+/** Validates changes to reviewers.config through push or merge. */
+@Singleton
+public class ForProjectValidator implements MergeValidationListener, CommitValidationListener {
+  @VisibleForTesting public static String MALFORMED_CONFIG = "Malformed reviewers.config";
+
+  private final ForProject.Factory forProjectFactory;
+
+  @Inject
+  public ForProjectValidator(ForProject.Factory forProjectFactory) {
+    this.forProjectFactory = forProjectFactory;
+  }
+
+  @Override
+  public void onPreMerge(
+      Repository repo,
+      CodeReviewCommit.CodeReviewRevWalk crrw,
+      CodeReviewCommit commit,
+      ProjectState destProject,
+      BranchNameKey destBranch,
+      Id patchSetId,
+      IdentifiedUser caller)
+      throws MergeValidationException {
+    if (!RefNames.REFS_CONFIG.equals(destBranch.branch())) {
+      return;
+    }
+
+    ForProject cfg = forProjectFactory.create();
+    try {
+      cfg.load(destProject.getNameKey(), repo, commit);
+    } catch (IOException ioe) {
+      throw new MergeValidationException("Unable to read config.", ioe);
+    } catch (ConfigInvalidException cie) {
+      throw new MergeValidationException(MALFORMED_CONFIG, cie);
+    }
+
+    if (!cfg.getValidationErrors().isEmpty()) {
+      throw new MergeValidationException(formatValidationErrors(cfg.getValidationErrors()));
+    }
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    if (RefNames.REFS_CONFIG.equals(receiveEvent.getBranchNameKey().branch())) {
+      ForProject cfg = forProjectFactory.create();
+      try {
+        cfg.load(receiveEvent.getProjectNameKey(), receiveEvent.revWalk, receiveEvent.commit);
+      } catch (IOException ioe) {
+        throw new CommitValidationException("Unable to read config.", ioe);
+      } catch (ConfigInvalidException cie) {
+        throw new CommitValidationException(MALFORMED_CONFIG);
+      }
+      if (!cfg.getValidationErrors().isEmpty()) {
+        ArrayList<CommitValidationMessage> messages = Lists.newArrayList();
+        messages.add(new CommitValidationMessage(MALFORMED_CONFIG, true));
+        cfg.getValidationErrors()
+            .forEach(ve -> messages.add(new CommitValidationMessage("  " + ve.getMessage(), true)));
+        throw new CommitValidationException(MALFORMED_CONFIG, messages);
+      }
+    }
+    return Collections.emptyList();
+  }
+
+  private static String formatValidationErrors(List<ValidationError> errors) {
+    StringBuilder errorMessage = new StringBuilder();
+    errorMessage.append("[\"");
+    errors.forEach(ve -> errorMessage.append(ve.getMessage() + "\", \""));
+    errorMessage.append("]");
+    return "Malformed reviewers.config filters: " + errorMessage.toString();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/Module.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/Module.java
index c069a0c..20b09be 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/Module.java
@@ -28,8 +28,11 @@
 import com.google.gerrit.extensions.webui.JavaScriptPlugin;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.change.ReviewerSuggestion;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.MergeValidationListener;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.reviewers.config.ConfigModule;
 import com.googlesource.gerrit.plugins.reviewers.config.GlobalConfig;
 
 public class Module extends FactoryModule {
@@ -52,7 +55,8 @@
     bind(CapabilityDefinition.class)
         .annotatedWith(Exports.named(MODIFY_REVIEWERS_CONFIG))
         .to(ModifyReviewersConfigCapability.class);
-
+    DynamicSet.bind(binder(), MergeValidationListener.class).to(ForProjectValidator.class);
+    DynamicSet.bind(binder(), CommitValidationListener.class).to(ForProjectValidator.class);
     if (suggestOnly) {
       install(
           new AbstractModule() {
@@ -89,6 +93,7 @@
                 .toInstance(new JavaScriptPlugin("rv-reviewers.js"));
           }
         });
+    install(new ConfigModule());
   }
 
   protected void bindWorkQueue() {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/PutReviewers.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/PutReviewers.java
index c108177..58ae003 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/PutReviewers.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/PutReviewers.java
@@ -16,12 +16,15 @@
 
 import static com.googlesource.gerrit.plugins.reviewers.ModifyReviewersConfigCapability.MODIFY_REVIEWERS_CONFIG;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -30,6 +33,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -45,6 +49,7 @@
 import com.googlesource.gerrit.plugins.reviewers.config.ForProject;
 import java.io.IOException;
 import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
@@ -67,6 +72,7 @@
 
   private final String pluginName;
   private final FiltersFactory filters;
+  private final ForProject.Factory forProjectFactory;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final ProjectCache projectCache;
   private final AccountResolver accountResolver;
@@ -77,6 +83,7 @@
   PutReviewers(
       @PluginName String pluginName,
       FiltersFactory filters,
+      ForProject.Factory forProjectFactory,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       ProjectCache projectCache,
       AccountResolver accountResolver,
@@ -84,6 +91,7 @@
       PermissionBackend permissionBackend) {
     this.pluginName = pluginName;
     this.filters = filters;
+    this.forProjectFactory = forProjectFactory;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.projectCache = projectCache;
     this.accountResolver = accountResolver;
@@ -95,8 +103,7 @@
   public Response<List<ReviewerFilter>> apply(ProjectResource rsrc, Input input)
       throws RestApiException, PermissionBackendException {
     Project.NameKey projectName = rsrc.getNameKey();
-    ForProject forProject = new ForProject();
-
+    ForProject forProject = forProjectFactory.create();
     PermissionBackend.WithUser userPermission = permissionBackend.user(rsrc.getUser());
     if (!userPermission.project(rsrc.getNameKey()).testOrFalse(ProjectPermission.WRITE_CONFIG)
         && !userPermission.testOrFalse(new PluginPermission(pluginName, MODIFY_REVIEWERS_CONFIG))) {
@@ -113,6 +120,7 @@
       try {
         StringBuilder message = new StringBuilder(pluginName).append(" plugin: ");
         forProject.load(md);
+        Set<ValidationError> previousErrors = ImmutableSet.copyOf(forProject.getValidationErrors());
         if (input.action == Action.ADD) {
           message
               .append("Add ")
@@ -131,11 +139,17 @@
               .append(input.filter);
           forProject.removeReviewer(input.filter, input.reviewer, input.type);
         }
+        Set<ValidationError> errors =
+            Sets.difference(ImmutableSet.copyOf(forProject.getValidationErrors()), previousErrors);
+        if (!errors.isEmpty()) {
+          throw new BadRequestException(
+              String.format("Unsupported query: %s", errors.iterator().next().getMessage()));
+        }
         message.append("\n");
         md.setMessage(message.toString());
         try {
           forProject.commit(md);
-          projectCache.evict(projectName);
+          projectCache.evictAndReindex(projectName);
         } catch (IOException e) {
           if (e.getCause() instanceof ConfigInvalidException) {
             throw new ResourceConflictException(
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/ReviewerFilter.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/ReviewerFilter.java
index b991760..810c2bd 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/ReviewerFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/ReviewerFilter.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.reviewers;
 
+import com.google.common.annotations.VisibleForTesting;
 import java.util.Objects;
 import java.util.Set;
 
@@ -30,6 +31,7 @@
   protected String filter;
   protected Set<String> reviewers;
   protected Set<String> ccs;
+  protected String filterError;
 
   String getFilter() {
     return filter;
@@ -43,6 +45,11 @@
     return ccs;
   }
 
+  @VisibleForTesting
+  public String getFilterError() {
+    return filterError;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ReviewerFilter) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/ReviewerSuggest.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/ReviewerSuggest.java
index d397579..06a365f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/ReviewerSuggest.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/ReviewerSuggest.java
@@ -67,7 +67,7 @@
             .collect(toSet());
       }
     } catch (StorageException | QueryParseException x) {
-      logger.atSevere().withCause(x).log(x.getMessage());
+      logger.atSevere().withCause(x).log("%s", x.getMessage());
     }
     return ImmutableSet.of();
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/Reviewers.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/Reviewers.java
index fb5855c..865c289 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/Reviewers.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/Reviewers.java
@@ -121,7 +121,7 @@
           "Could not add default reviewers for change %d of project %s, filter is invalid: %s",
           changeNumber, projectName.get(), e.getMessage());
     } catch (StorageException x) {
-      logger.atSevere().withCause(x).log(x.getMessage());
+      logger.atSevere().withCause(x).log("%s", x.getMessage());
     }
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ConfigModule.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ConfigModule.java
new file mode 100644
index 0000000..fbf7d64
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ConfigModule.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2022 The Android Open 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.
+package com.googlesource.gerrit.plugins.reviewers.config;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+
+public class ConfigModule extends FactoryModule {
+
+  @Override
+  protected void configure() {
+    factory(ForProject.Factory.class);
+    factory(ReviewerFilterCollection.Factory.class);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/FiltersFactory.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/FiltersFactory.java
index 04c3d62..9bdf676 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/FiltersFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/FiltersFactory.java
@@ -30,11 +30,16 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PluginConfigFactory configFactory;
+  private final ReviewerFilterCollection.Factory filterCollectionFactory;
   private final String pluginName;
 
   @Inject
-  public FiltersFactory(PluginConfigFactory configFactory, @PluginName String pluginName) {
+  public FiltersFactory(
+      PluginConfigFactory configFactory,
+      ReviewerFilterCollection.Factory filterCollectionFactory,
+      @PluginName String pluginName) {
     this.configFactory = configFactory;
+    this.filterCollectionFactory = filterCollectionFactory;
     this.pluginName = pluginName;
   }
 
@@ -46,6 +51,6 @@
       logger.atSevere().log("Unable to get config for project %s", projectName.get());
       cfg = new Config();
     }
-    return new ReviewerFilterCollection(cfg).getAll();
+    return filterCollectionFactory.create(cfg).getAll();
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ForProject.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ForProject.java
index c53fdba..125fc9d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ForProject.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ForProject.java
@@ -17,21 +17,37 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
+import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.reviewers.ReviewerType;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 
-public class ForProject extends VersionedMetaData {
+public class ForProject extends VersionedMetaData implements ValidationError.Sink {
   @VisibleForTesting public static final String FILENAME = "reviewers.config";
   @VisibleForTesting public static final String SECTION_FILTER = "filter";
   @VisibleForTesting public static final String KEY_CC = "cc";
   @VisibleForTesting public static final String KEY_REVIEWER = "reviewer";
 
+  public interface Factory {
+    public ForProject create();
+  }
+
+  private final ReviewerFilterCollection.Factory filterCollectionFactory;
   private Config cfg;
   private ReviewerFilterCollection filters;
+  private List<ValidationError> validationErrors;
+
+  @Inject
+  public ForProject(ReviewerFilterCollection.Factory filterCollectionFactory) {
+    this.filterCollectionFactory = filterCollectionFactory;
+  }
 
   public void addReviewer(String filter, String reviewer, ReviewerType type) {
     switch (type) {
@@ -53,6 +69,26 @@
     }
   }
 
+  /**
+   * Get the validation errors, if any were discovered.
+   *
+   * @return list of errors; empty list if there are no errors.
+   */
+  public List<ValidationError> getValidationErrors() {
+    if (validationErrors != null) {
+      return Collections.unmodifiableList(validationErrors);
+    }
+    return Collections.emptyList();
+  }
+
+  @Override
+  public void error(ValidationError error) {
+    if (validationErrors == null) {
+      validationErrors = new ArrayList<>(4);
+    }
+    validationErrors.add(error);
+  }
+
   @Override
   protected String getRefName() {
     return RefNames.REFS_CONFIG;
@@ -61,7 +97,7 @@
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     this.cfg = readConfig(FILENAME);
-    this.filters = new ReviewerFilterCollection(cfg);
+    this.filters = filterCollectionFactory.create(cfg, this);
   }
 
   @Override
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollection.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollection.java
index bd44ccd..91b49e8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollection.java
@@ -21,29 +21,80 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import com.googlesource.gerrit.plugins.reviewers.ReviewerFilter;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 
 /** Representation of the collection of {@link ReviewerFilter}s in a {@link Config}. */
 class ReviewerFilterCollection {
 
+  private final ReviewersQueryValidator queryValidator;
   private final Config cfg;
+  private final Optional<ValidationError.Sink> validationErrorSink;
 
-  ReviewerFilterCollection(Config cfg) {
+  interface Factory {
+    ReviewerFilterCollection create(Config cfg);
+
+    ReviewerFilterCollection create(Config cfg, ValidationError.Sink validationErrorSink);
+  }
+
+  @AssistedInject
+  ReviewerFilterCollection(ReviewersQueryValidator queryValidator, @Assisted Config cfg) {
+    this(queryValidator, cfg, null);
+  }
+
+  @AssistedInject
+  ReviewerFilterCollection(
+      ReviewersQueryValidator queryValidator,
+      @Assisted Config cfg,
+      @Assisted ValidationError.Sink validationErrorSink) {
+    this.queryValidator = queryValidator;
     this.cfg = cfg;
+    this.validationErrorSink = Optional.ofNullable(validationErrorSink);
+    check();
   }
 
   List<ReviewerFilter> getAll() {
     ImmutableList.Builder<ReviewerFilter> b = ImmutableList.builder();
     for (String f : cfg.getSubsections(SECTION_FILTER)) {
-      b.add(new ReviewerFilterSection(f));
+      b.add(newReviewerFilter(f));
     }
     return b.build();
   }
 
+  /* Validates all the filter in this collection and adds the ValidationErrors
+   * to the ValidationError.Sink. */
+  private void check() {
+    for (String f : cfg.getSubsections(SECTION_FILTER)) {
+      checkForErrors(f);
+    }
+  }
+
   ReviewerFilterSection get(String filter) {
-    return new ReviewerFilterSection(filter);
+    return newReviewerFilter(filter);
+  }
+
+  private ReviewerFilterSection newReviewerFilter(String filter) {
+    ReviewerFilterSection section = new ReviewerFilterSection(filter);
+    checkForErrors(filter).ifPresent(err -> section.filterError(err));
+    return section;
+  }
+
+  /* Checks if filterQuery is a valid query. If not it adds the corresponding
+   * ValidationError to the ValidationError.Sink and returns the error. */
+  private Optional<String> checkForErrors(String filterQuery) {
+    try {
+      queryValidator.validateQuery(filterQuery);
+    } catch (QueryParseException qpe) {
+      validationErrorSink.ifPresent(ves -> ves.error(ValidationError.create(qpe.getMessage())));
+      return Optional.of(qpe.getMessage());
+    }
+    return Optional.empty();
   }
 
   class ReviewerFilterSection extends ReviewerFilter {
@@ -74,6 +125,10 @@
       save();
     }
 
+    public void filterError(String error) {
+      this.filterError = error;
+    }
+
     private void save() {
       if (this.reviewers.isEmpty() && this.ccs.isEmpty()) {
         cfg.unsetSection(SECTION_FILTER, filter);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersQueryValidator.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersQueryValidator.java
new file mode 100644
index 0000000..4793a57
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersQueryValidator.java
@@ -0,0 +1,36 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.reviewers.config;
+
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+/** Validates that a reviewer filter query is formatted correctly. */
+@Singleton
+public class ReviewersQueryValidator {
+  private final Provider<ChangeQueryBuilder> queryBuilder;
+
+  @Inject
+  public ReviewersQueryValidator(Provider<ChangeQueryBuilder> queryBuilder) {
+    this.queryBuilder = queryBuilder;
+  }
+
+  void validateQuery(String query) throws QueryParseException {
+    queryBuilder.get().parse(query);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/reviewers/AbstractReviewersPluginTest.java b/src/test/java/com/googlesource/gerrit/plugins/reviewers/AbstractReviewersPluginTest.java
index cd13812..a9ab6e4 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/reviewers/AbstractReviewersPluginTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/reviewers/AbstractReviewersPluginTest.java
@@ -23,11 +23,13 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.RefNames;
 import com.google.inject.Inject;
 import java.util.Arrays;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -39,10 +41,21 @@
   @Inject protected ProjectOperations projectOperations;
 
   protected void createFilters(TestFilter... filters) throws Exception {
-    createFiltersFor(testRepo, filters);
+    createFiltersFor(testRepo, Optional.empty(), filters);
   }
 
   protected void createFiltersFor(TestRepository<?> repo, TestFilter... filters) throws Exception {
+    createFiltersFor(repo, Optional.empty(), filters);
+  }
+
+  protected void createFiltersWithError(String errorMessage, TestFilter... filters)
+      throws Exception {
+    createFiltersFor(testRepo, Optional.of(errorMessage), filters);
+  }
+
+  protected void createFiltersFor(
+      TestRepository<?> repo, Optional<String> errorMessage, TestFilter... filters)
+      throws Exception {
     String previousHead = repo.getRepository().getBranch();
     checkoutRefsMetaConfig(repo);
     Config cfg = new Config();
@@ -53,10 +66,15 @@
                   SECTION_FILTER, f.filter, KEY_REVIEWER, Lists.newArrayList(f.reviewers));
               cfg.setStringList(SECTION_FILTER, f.filter, KEY_CC, Lists.newArrayList(f.ccs));
             });
-    pushFactory
-        .create(admin.newIdent(), repo, "Add reviewers", FILENAME, cfg.toText())
-        .to(RefNames.REFS_CONFIG)
-        .assertOkStatus();
+    PushOneCommit.Result result =
+        pushFactory
+            .create(admin.newIdent(), repo, "Add reviewers", FILENAME, cfg.toText())
+            .to(RefNames.REFS_CONFIG);
+    if (errorMessage.isPresent()) {
+      result.assertErrorStatus(errorMessage.get());
+    } else {
+      result.assertOkStatus();
+    }
     repo.reset(previousHead);
   }
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollectionIT.java b/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollectionIT.java
new file mode 100644
index 0000000..7d4fd4f
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollectionIT.java
@@ -0,0 +1,55 @@
+// 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.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.reviewers.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.reviewers.config.ForProject.KEY_REVIEWER;
+import static com.googlesource.gerrit.plugins.reviewers.config.ForProject.SECTION_FILTER;
+
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.extensions.config.FactoryModule;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+@NoHttpd
+@TestPlugin(
+    name = "reviewers",
+    sysModule =
+        "com.googlesource.gerrit.plugins.reviewers.config.ReviewerFilterCollectionIT$TestModule")
+public class ReviewerFilterCollectionIT extends LightweightPluginDaemonTest {
+
+  @Test
+  public void malformedFilterErrorPropagated() throws Exception {
+    String malformedQuery = "malformed:query";
+    Config malformed = new Config();
+    malformed.setString(SECTION_FILTER, malformedQuery, KEY_REVIEWER, "User");
+    assertThat(filters().create(malformed).get(malformedQuery).getFilterError())
+        .isEqualTo(String.format("Unsupported operator %s", malformedQuery));
+  }
+
+  private ReviewerFilterCollection.Factory filters() {
+    return plugin.getSysInjector().getInstance(ReviewerFilterCollection.Factory.class);
+  }
+
+  public static class TestModule extends FactoryModule {
+
+    @Override
+    public void configure() {
+      factory(ReviewerFilterCollection.Factory.class);
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersConfigIT.java b/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersConfigIT.java
index 5f71d91..be761fb 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersConfigIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersConfigIT.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.entities.Project;
 import com.googlesource.gerrit.plugins.reviewers.AbstractReviewersPluginTest;
+import com.googlesource.gerrit.plugins.reviewers.ForProjectValidator;
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Before;
 import org.junit.Test;
@@ -29,6 +30,7 @@
 @TestPlugin(name = "reviewers", sysModule = "com.googlesource.gerrit.plugins.reviewers.Module")
 public class ReviewersConfigIT extends AbstractReviewersPluginTest {
   private static final String BRANCH_MAIN = "branch:main";
+  private static final String MALFORMED_FILTER = "branches:master,stable2";
   private static final String NO_FILTER = "*";
   private static final String JANE_DOE = "jane.doe@example.com";
   private static final String JOHN_DOE = "john.doe@example.com";
@@ -77,6 +79,12 @@
         filter(BRANCH_MAIN).reviewer(JOHN_DOE).cc(JANE_DOE));
   }
 
+  @Test
+  public void malformedFilterQuery() throws Exception {
+    createFiltersWithError(
+        ForProjectValidator.MALFORMED_CONFIG, filter(MALFORMED_FILTER).reviewer(JOHN_DOE));
+  }
+
   private void assertProjectHasFilters(Project.NameKey project, TestFilter... filters) {
     assertThat(filters().withInheritance(project))
         .containsExactlyElementsIn(ImmutableList.copyOf(filters));
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..a9d186e
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,6 @@
+{
+  "extends": "../tsconfig-plugins-base.json",
+  "include": [
+    "web/**/*",
+  ]
+}
diff --git a/rv-reviewers/plugin.js b/web/plugin.ts
similarity index 77%
rename from rv-reviewers/plugin.js
rename to web/plugin.ts
index ba7e1d2..3b73980 100644
--- a/rv-reviewers/plugin.js
+++ b/web/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/web/rv-edit-screen.ts b/web/rv-edit-screen.ts
new file mode 100644
index 0000000..c75cafb
--- /dev/null
+++ b/web/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%;
+        }
+      `,
+    ];
+  }
+
+  override 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>
+    `;
+  }
+
+  override 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/web/rv-filter-section.ts b/web/rv-filter-section.ts
new file mode 100644
index 0000000..a062de9
--- /dev/null
+++ b/web/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;
+
+  override 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;
+        }
+      `,
+    ];
+  }
+
+  override 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/web/rv-reviewer.ts b/web/rv-reviewer.ts
new file mode 100644
index 0000000..9ac7424
--- /dev/null
+++ b/web/rv-reviewer.ts
@@ -0,0 +1,268 @@
+/**
+ * @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);
+        }
+      `,
+    ];
+  }
+
+  override 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/'))
+        .filter(name => !groups[name].id.startsWith('global%3A'))
+        .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/web/rv-reviewers.ts b/web/rv-reviewers.ts
new file mode 100644
index 0000000..bf7bf08
--- /dev/null
+++ b/web/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;
+        }
+      `,
+    ];
+  }
+
+  override 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>
+    `;
+  }
+
+  override 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/plugin.js b/web/util.ts
similarity index 68%
copy from rv-reviewers/plugin.js
copy to web/util.ts
index ba7e1d2..c2559c5 100644
--- a/rv-reviewers/plugin.js
+++ b/web/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,
+    })
+  );
+}