Merge branch 'stable-3.4' into stable-3.5

* stable-3.4:
  Adapt to new ProjectCache interface

Depends-On: https://gerrit-review.googlesource.com/c/gerrit/+/324619
Change-Id: Ie236754fc3d3412b68a7e291752f71920d6d4ff2
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index e69de29..0000000
--- a/.eslintignore
+++ /dev/null
diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index 6d9ae7c..0000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,166 +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 4c0afce..81322bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
 /.settings
 /bazel-*
 /eclipse-out
+/node_modules
diff --git a/BUILD b/BUILD
index d560d98..1f7aabb 100644
--- a/BUILD
+++ b/BUILD
@@ -1,60 +1,26 @@
 load("@rules_java//java:defs.bzl", "java_library")
-load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
 load("//tools/bzl:junit.bzl", "junit_tests")
-load("//tools/js:eslint.bzl", "eslint")
 load(
     "//tools/bzl:plugin.bzl",
     "PLUGIN_DEPS",
     "PLUGIN_TEST_DEPS",
     "gerrit_plugin",
 )
-load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:js.bzl", "polygerrit_plugin")
 
 gerrit_plugin(
     name = "delete-project",
     srcs = glob(["src/main/java/**/*.java"]),
     manifest_entries = [
         "Gerrit-PluginName: delete-project",
-        "Gerrit-Module: com.googlesource.gerrit.plugins.deleteproject.Module",
+        "Gerrit-Module: com.googlesource.gerrit.plugins.deleteproject.PluginModule",
         "Gerrit-HttpModule: com.googlesource.gerrit.plugins.deleteproject.HttpModule",
         "Gerrit-SshModule: com.googlesource.gerrit.plugins.deleteproject.SshModule",
     ],
-    resource_jars = [":gr-delete-repo-static"],
+    resource_jars = ["//plugins/delete-project/web:gr-delete-repo"],
     resources = glob(["src/main/resources/Documentation/*.md"]),
     deps = ["@commons-io//jar"],
 )
 
-genrule2(
-    name = "gr-delete-repo-static",
-    srcs = [":gr-delete-repo"],
-    outs = ["gr-delete-repo-static.jar"],
-    cmd = " && ".join([
-        "mkdir $$TMP/static",
-        "cp $(locations :gr-delete-repo) $$TMP/static",
-        "cd $$TMP",
-        "zip -Drq $$ROOT/$@ -g .",
-    ]),
-)
-
-polygerrit_plugin(
-    name = "gr-delete-repo",
-    app = "delete-project-bundle.js",
-    plugin_name = "delete-project",
-)
-
-rollup_bundle(
-    name = "delete-project-bundle",
-    srcs = glob(["gr-delete-repo/*.js"]),
-    entry_point = "gr-delete-repo/plugin.js",
-    format = "iife",
-    rollup_bin = "//tools/node_tools:rollup-bin",
-    sourcemap = "hidden",
-    deps = [
-        "@tools_npm//rollup-plugin-node-resolve",
-    ],
-)
-
 junit_tests(
     name = "delete-project_tests",
     srcs = glob(["src/test/java/**/*.java"]),
@@ -74,24 +40,3 @@
         "@mockito//jar",
     ],
 )
-
-# Define the eslinter for the plugin
-# The eslint macro creates 2 rules: lint_test and lint_bin
-eslint(
-    name = "lint",
-    srcs = glob([
-        "gr-delete-repo/**/*.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",
-    ],
-)
diff --git a/README.md b/README.md
index 1444d1a..5a87b38 100644
--- a/README.md
+++ b/README.md
@@ -5,3 +5,24 @@
 
 [![Build Status](https://gerrit-ci.gerritforge.com/view/Plugins-master/job/plugin-delete-project-bazel-master/badge/icon
 )](https://gerrit-ci.gerritforge.com/view/Plugins-master/job/plugin-delete-project-bazel-master/)
+
+## JavaScript Plugin Development
+
+For running unit tests execute:
+
+    bazel test --test_output=all //plugins/delete-project/web:karma_test
+
+For checking or fixing eslint formatter problems run:
+
+    bazel test //plugins/delete-project/web:lint_test
+    bazel run //plugins/delete-project/web:lint_bin -- --fix "$(pwd)/plugins/delete-project/web"
+
+For testing the plugin with
+[Gerrit FE Dev Helper](https://gerrit.googlesource.com/gerrit-fe-dev-helper/)
+build the JavaScript bundle and copy it to the `plugins/` folder:
+
+    bazel build //plugins/delete-project/web:gr-delete-repo
+    cp -f bazel-bin/plugins/delete-project/web/gr-delete-repo.js plugins/
+
+and let the Dev Helper redirect from `.+/plugins/delete-project/static/gr-delete-repo.js` to
+`http://localhost:8081/plugins_/gr-delete-repo.js`.
diff --git a/gr-delete-repo/gr-delete-repo.js b/gr-delete-repo/gr-delete-repo.js
deleted file mode 100644
index 20c3555..0000000
--- a/gr-delete-repo/gr-delete-repo.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {htmlTemplate} from './gr-delete-repo_html.js';
-
-class GrDeleteRepo extends Polymer.Element {
-  /** @returns {string} name of the component */
-  static get is() { return 'gr-delete-repo'; }
-
-  /** @returns {?} template for this component */
-  static get template() { return htmlTemplate; }
-
-  /**
-   * Defines properties of the component
-   *
-   * @returns {?}
-   */
-  static get properties() {
-    return {
-      repoName: String,
-      config: Object,
-      action: Object,
-      actionId: String,
-    };
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this.actionId = this.plugin.getPluginName() + '~delete';
-    this.action = this.config.actions[this.actionId];
-    this.hidden = !this.action;
-  }
-
-  _handleCommandTap() {
-    this.$.deleteRepoOverlay.open();
-  }
-
-  _handleCloseDeleteRepo() {
-    this.$.deleteRepoOverlay.close();
-  }
-
-  _handleDeleteRepo() {
-    const endpoint = '/projects/' +
-        encodeURIComponent(this.repoName) + '/' +
-        this.actionId;
-
-    const json = {
-      force: this.$.forceDeleteOpenChangesCheckBox.checked,
-      preserve: this.$.preserveGitRepoCheckBox.checked,
-    };
-
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        bubbles: true,
-        composed: true,
-      }));
-    };
-
-    return this.plugin.restApi().send(
-        this.action.method, endpoint, json, errFn)
-        .then(r => {
-          this.plugin.restApi().invalidateReposCache();
-          Gerrit.Nav.navigateToRelativeUrl('/admin/repos');
-        });
-  }
-}
-
-customElements.define(GrDeleteRepo.is, GrDeleteRepo);
diff --git a/gr-delete-repo/gr-delete-repo_html.js b/gr-delete-repo/gr-delete-repo_html.js
deleted file mode 100644
index e3a985d..0000000
--- a/gr-delete-repo/gr-delete-repo_html.js
+++ /dev/null
@@ -1,66 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-export const htmlTemplate = Polymer.html`
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-xxl);
-    }
-    </style>
-    <h3 class="heading-3">
-      [[action.label]]
-    </h3>
-    <gr-button
-      title="[[action.title]]"
-      loading="[[_deleting]]"
-      disabled="[[!action.enabled]]"
-      on-click="_handleCommandTap"
-    >
-      [[action.label]]
-    </gr-button>
-    <gr-overlay id="deleteRepoOverlay" with-backdrop>
-      <gr-dialog
-          id="deleteRepoDialog"
-          confirm-label="Delete"
-          on-confirm="_handleDeleteRepo"
-          on-cancel="_handleCloseDeleteRepo">
-        <div class="header" slot="header">
-          Are you really sure you want to delete the repo: "[[repoName]]"?
-        </div>
-        <div class="main" slot="main">
-          <div class="gr-form-styles">
-            <div id="form">
-                <section>
-                  <input
-                      type="checkbox"
-                      id="forceDeleteOpenChangesCheckBox">
-                  <label for="forceDeleteOpenChangesCheckBox">Delete repo even if open changes exist?</label>
-                </section>
-                <section>
-                  <input
-                      type="checkbox"
-                      id="preserveGitRepoCheckBox">
-                  <label for="preserveGitRepoCheckBox">Preserve GIT Repository?</label>
-                </section>
-            </div>
-          </div>
-        </div>
-      </gr-dialog>
-    </gr-overlay>
-`;
diff --git a/package.json b/package.json
deleted file mode 100644
index 5a46795..0000000
--- a/package.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
-  "name": "delete-project",
-  "description": "Delete project plugin",
-  "browser": true,
-  "scripts": {
-    "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
-    "eslint": "npm run safe_bazelisk test :lint_test",
-    "eslintfix": "npm run safe_bazelisk run :lint_bin -- -- --fix $(pwd)"
-  },
-  "devDependencies": {},
-  "license": "Apache-2.0",
-  "private": true
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/HttpModule.java
index e0d8541..563b1ab 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/HttpModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/HttpModule.java
@@ -23,6 +23,6 @@
   @Override
   protected void configureServlets() {
     DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new JavaScriptPlugin("delete-project.js"));
+        .toInstance(new JavaScriptPlugin("gr-delete-repo.js"));
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Module.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/PluginModule.java
similarity index 96%
rename from src/main/java/com/googlesource/gerrit/plugins/deleteproject/Module.java
rename to src/main/java/com/googlesource/gerrit/plugins/deleteproject/PluginModule.java
index 7a91b29..4d0a802 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/PluginModule.java
@@ -30,12 +30,12 @@
 import com.googlesource.gerrit.plugins.deleteproject.fs.DeleteTrashFolders;
 import com.googlesource.gerrit.plugins.deleteproject.fs.FilesystemDeleteHandler;
 
-public class Module extends AbstractModule {
+public class PluginModule extends AbstractModule {
 
   private final boolean scheduleCleaning;
 
   @Inject
-  Module(Configuration config) {
+  PluginModule(Configuration config) {
     this.scheduleCleaning = config.getArchiveDuration() > 0;
   }
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProjectIT.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProjectIT.java
index e878096..bfc44a1 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProjectIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProjectIT.java
@@ -46,6 +46,7 @@
 import java.nio.file.Paths;
 import java.util.stream.Stream;
 import org.apache.commons.io.FileUtils;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -57,7 +58,7 @@
 @UseSsh
 @TestPlugin(
     name = "delete-project",
-    sysModule = "com.googlesource.gerrit.plugins.deleteproject.Module",
+    sysModule = "com.googlesource.gerrit.plugins.deleteproject.PluginModule",
     sshModule = "com.googlesource.gerrit.plugins.deleteproject.SshModule",
     httpModule = "com.googlesource.gerrit.plugins.deleteproject.HttpModule")
 public class DeleteProjectIT extends LightweightPluginDaemonTest {
@@ -258,7 +259,12 @@
     String projectName = createProjectOverAPI(name, null, true, null).get();
     File projectDir = verifyProjectRepoExists(Project.NameKey.parse(projectName));
 
-    Path parentFolder = projectDir.toPath().getParent().resolve(PARENT_FOLDER).resolve(projectName);
+    Path parentFolder =
+        projectDir
+            .toPath()
+            .getParent()
+            .resolve(PARENT_FOLDER)
+            .resolve(projectName + Constants.DOT_GIT);
     parentFolder.toFile().mkdirs();
     assertThat(parentFolder.toFile().exists()).isTrue();
     assertThat(isEmpty(parentFolder)).isTrue();
diff --git a/gr-delete-repo/plugin.js b/web/.eslintrc.js
similarity index 74%
copy from gr-delete-repo/plugin.js
copy to web/.eslintrc.js
index 2e22d59..ee166d3 100644
--- a/gr-delete-repo/plugin.js
+++ b/web/.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 './gr-delete-repo.js';
-
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent(
-      'repo-command', 'gr-delete-repo');
-});
+__plugindir = 'delete-project/web';
+module.exports = {
+  extends: '../../.eslintrc.js',
+};
diff --git a/web/BUILD b/web/BUILD
new file mode 100644
index 0000000..5490c23
--- /dev/null
+++ b/web/BUILD
@@ -0,0 +1,61 @@
+load("//tools/js:eslint.bzl", "plugin_eslint")
+load("//tools/bzl:js.bzl", "gerrit_js_bundle", "karma_test")
+load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
+
+package_group(
+    name = "visibility",
+    packages = ["//plugins/delete-project/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+ts_config(
+    name = "tsconfig",
+    src = "tsconfig.json",
+    deps = [
+        "//plugins:tsconfig-plugins-base.json",
+    ],
+)
+
+ts_project(
+    name = "delete-project-ts",
+    srcs = glob(
+        ["**/*.ts"],
+        exclude = ["**/*test*"],
+    ),
+    incremental = True,
+    out_dir = "_bazel_ts_out",
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":tsconfig",
+    deps = [
+        "@plugins_npm//@gerritcodereview/typescript-api",
+        "@plugins_npm//lit",
+    ],
+)
+
+ts_project(
+    name = "delete-project-ts-tests",
+    srcs = glob(["**/*.ts"]),
+    incremental = True,
+    out_dir = "_bazel_ts_out_tests",
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":tsconfig",
+    deps = [
+        "@plugins_npm//:node_modules",
+        "@ui_dev_npm//:node_modules",
+    ],
+)
+
+gerrit_js_bundle(
+    name = "gr-delete-repo",
+    srcs = [":delete-project-ts"],
+    entry_point = "_bazel_ts_out/plugin.js",
+)
+
+karma_test(
+    name = "karma_test",
+    srcs = ["karma_test.sh"],
+    data = [":delete-project-ts-tests"],
+)
+
+plugin_eslint()
diff --git a/web/gr-delete-repo.ts b/web/gr-delete-repo.ts
new file mode 100644
index 0000000..5a2ece1
--- /dev/null
+++ b/web/gr-delete-repo.ts
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+import {
+  ActionInfo,
+  ConfigInfo,
+  RepoName,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+
+// TODO: This should be defined and exposed by @gerritcodereview/typescript-api
+type GrOverlay = Element & {
+  open(): void;
+  close(): void;
+};
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-delete-repo': GrDeleteRepo;
+  }
+  interface Window {
+    CANONICAL_PATH?: string;
+  }
+}
+
+@customElement('gr-delete-repo')
+export class GrDeleteRepo extends LitElement {
+  @query('#deleteRepoOverlay')
+  deleteRepoOverlay?: GrOverlay;
+
+  @query('#preserveGitRepoCheckBox')
+  preserveGitRepoCheckBox?: HTMLInputElement;
+
+  @query('#forceDeleteOpenChangesCheckBox')
+  forceDeleteOpenChangesCheckBox?: HTMLInputElement;
+
+  /** Guaranteed to be provided by the 'repo-command' endpoint. */
+  @property({type: Object})
+  plugin!: PluginApi;
+
+  /** Guaranteed to be provided by the 'repo-command' endpoint. */
+  @property({type: Object})
+  config!: ConfigInfo;
+
+  /** Guaranteed to be provided by the 'repo-command' endpoint. */
+  @property({type: String})
+  repoName!: RepoName;
+
+  @state()
+  private error?: string;
+
+  static override styles = css`
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-xxl);
+    }
+    /* TODO: Find a way to use shared styles in lit elements in plugins. */
+    h3 {
+      font: inherit;
+      margin: 0;
+    }
+    .error {
+      color: red;
+    }
+  `;
+
+  get action(): ActionInfo | undefined {
+    return this.config.actions?.[this.actionId];
+  }
+
+  get actionId(): string {
+    return `${this.plugin.getPluginName()}~delete`;
+  }
+
+  private renderError() {
+    if (!this.error) return;
+    return html`<div class="error">${this.error}</div>`;
+  }
+
+  override render() {
+    if (!this.action) return;
+    return html`
+      <h3>${this.action.label}</h3>
+      <gr-button
+        title="${this.action.title}"
+        ?disabled="${!this.action.enabled}"
+        @click="${() => {
+          this.error = undefined;
+          this.deleteRepoOverlay?.open();
+        }}"
+      >
+        ${this.action.label}
+      </gr-button>
+      ${this.renderError()}
+      <gr-overlay id="deleteRepoOverlay" with-backdrop>
+        <gr-dialog
+          id="deleteRepoDialog"
+          confirm-label="Delete"
+          @confirm="${this.deleteRepo}"
+          @cancel="${() => this.deleteRepoOverlay?.close()}"
+        >
+          <div class="header" slot="header">
+            Are you really sure you want to delete the repo: "${this.repoName}"?
+          </div>
+          <div class="main" slot="main">
+            <div>
+              <div id="form">
+                <section>
+                  <input type="checkbox" id="forceDeleteOpenChangesCheckBox" />
+                  <label for="forceDeleteOpenChangesCheckBox"
+                    >Delete repo even if open changes exist?</label
+                  >
+                </section>
+                <section>
+                  <input type="checkbox" id="preserveGitRepoCheckBox" />
+                  <label for="preserveGitRepoCheckBox"
+                    >Preserve GIT Repository?</label
+                  >
+                </section>
+              </div>
+            </div>
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private deleteRepo() {
+    if (!this.action) {
+      this.error = 'delete action undefined';
+      this.deleteRepoOverlay?.close();
+      return;
+    }
+    if (!this.action.method) {
+      this.error = 'delete action does not have a HTTP method set';
+      this.deleteRepoOverlay?.close();
+      return;
+    }
+    this.error = undefined;
+
+    const endpoint = `/projects/${encodeURIComponent(this.repoName)}/${
+      this.actionId
+    }`;
+    const json = {
+      force: this.forceDeleteOpenChangesCheckBox?.checked ?? false,
+      preserve: this.preserveGitRepoCheckBox?.checked ?? false,
+    };
+    return this.plugin
+      .restApi()
+      .send(this.action.method, endpoint, json)
+      .then(_ => {
+        this.plugin.restApi().invalidateReposCache();
+        this.deleteRepoOverlay?.close();
+        window.location.href = `${this.getBaseUrl()}/admin/repos`;
+      })
+      .catch(e => {
+        this.error = e;
+        this.deleteRepoOverlay?.close();
+      });
+  }
+
+  private getBaseUrl() {
+    return window.CANONICAL_PATH || '';
+  }
+}
diff --git a/web/gr-delete-repo_test.ts b/web/gr-delete-repo_test.ts
new file mode 100644
index 0000000..2da6b2b
--- /dev/null
+++ b/web/gr-delete-repo_test.ts
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * 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.
+ */
+import './test/test-setup';
+import './gr-delete-repo';
+import {queryAndAssert} from './test/test-util';
+import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+import {GrDeleteRepo} from './gr-delete-repo';
+import {
+  ConfigInfo,
+  HttpMethod,
+  RepoName,
+} from '@gerritcodereview/typescript-api/rest-api';
+
+suite('gr-delete-repo tests', () => {
+  let element: GrDeleteRepo;
+  let sendStub: sinon.SinonStub;
+
+  setup(async () => {
+    sendStub = sinon.stub();
+    sendStub.returns(Promise.resolve({}));
+
+    element = document.createElement('gr-delete-repo');
+    element.repoName = 'test-repo-name' as RepoName;
+    element.config = {
+      actions: {
+        'delete-project~delete': {
+          label: 'test-action-label',
+          title: 'test-aciton-title',
+          enabled: true,
+          method: HttpMethod.DELETE,
+        },
+      },
+    } as unknown as ConfigInfo;
+    element.plugin = {
+      getPluginName: () => 'delete-project',
+      restApi: () => {
+        return {
+          send: sendStub,
+          invalidateReposCache: () => {},
+        };
+      },
+    } as unknown as PluginApi;
+    document.body.appendChild(element);
+    await element.updateComplete;
+  });
+
+  teardown(() => {
+    document.body.removeChild(element);
+  });
+
+  test('confirm and send', () => {
+    const dialog = queryAndAssert<HTMLElement>(element, '#deleteRepoDialog');
+    dialog.dispatchEvent(new CustomEvent('confirm'));
+    assert.isTrue(sendStub.called);
+    const method = sendStub.firstCall.args[0] as HttpMethod;
+    const endpoint = sendStub.firstCall.args[1] as string;
+    const json = sendStub.firstCall.args[2];
+    assert.equal(method, HttpMethod.DELETE);
+    assert.equal(endpoint, '/projects/test-repo-name/delete-project~delete');
+    assert.deepEqual(json, {force: false, preserve: false});
+  });
+});
diff --git a/web/karma_test.sh b/web/karma_test.sh
new file mode 100755
index 0000000..35430ad
--- /dev/null
+++ b/web/karma_test.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+set -euo pipefail
+./$1 start $2 --single-run \
+  --root 'plugins/delete-project/web/_bazel_ts_out_tests/' \
+  --test-files '*_test.js'
diff --git a/gr-delete-repo/plugin.js b/web/plugin.ts
similarity index 77%
rename from gr-delete-repo/plugin.js
rename to web/plugin.ts
index 2e22d59..7355b50 100644
--- a/gr-delete-repo/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 './gr-delete-repo.js';
+import '@gerritcodereview/typescript-api/gerrit';
+import './gr-delete-repo';
 
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent(
-      'repo-command', 'gr-delete-repo');
+window.Gerrit.install(plugin => {
+  plugin.registerCustomComponent('repo-command', 'gr-delete-repo');
 });
diff --git a/web/test/test-setup.ts b/web/test/test-setup.ts
new file mode 100644
index 0000000..ddeaf89
--- /dev/null
+++ b/web/test/test-setup.ts
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * 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.
+ */
+import 'chai/chai';
+import '@gerritcodereview/typescript-api/gerrit';
+import {css} from 'lit';
+
+declare global {
+  interface Window {
+    assert: typeof chai.assert;
+    expect: typeof chai.expect;
+    sinon: typeof sinon;
+  }
+  let assert: typeof chai.assert;
+  let expect: typeof chai.expect;
+  let sinon: typeof sinon;
+}
+window.assert = chai.assert;
+window.expect = chai.expect;
+window.sinon = sinon;
+
+window.Gerrit = {
+  install: () => {},
+  styles: {
+    form: css``,
+    menuPage: css``,
+    subPage: css``,
+    table: css``,
+  },
+};
diff --git a/web/test/test-util.ts b/web/test/test-util.ts
new file mode 100644
index 0000000..25a1c79
--- /dev/null
+++ b/web/test/test-util.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * 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.
+ */
+export function queryAll<E extends Element = Element>(
+  el: Element,
+  selector: string
+): NodeListOf<E> {
+  if (!el) throw new Error('element not defined');
+  const root = el.shadowRoot ?? el;
+  return root.querySelectorAll<E>(selector);
+}
+
+export function query<E extends Element = Element>(
+  el: Element | undefined,
+  selector: string
+): E | undefined {
+  if (!el) return undefined;
+  const root = el.shadowRoot ?? el;
+  return root.querySelector<E>(selector) ?? undefined;
+}
+
+export function queryAndAssert<E extends Element = Element>(
+  el: Element | undefined,
+  selector: string
+): E {
+  const found = query<E>(el, selector);
+  if (!found) throw new Error(`selector '${selector}' did not match anything'`);
+  return found;
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..d12f85e
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig-plugins-base.json",
+  "compilerOptions": {
+    "outDir": "../../../.ts-out/plugins/delete-project", /* overridden by bazel */
+  },
+  "include": [
+    "**/*.ts"
+  ]
+}