Convert gr-repo to lit

Change-Id: I07fe7564acffbff3e7651ce590f30457305cebe2
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 47c820a..a7a615fc 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -101,7 +101,6 @@
     "elements/admin/gr-repo-access/gr-repo-access_html.ts",
     "elements/admin/gr-repo-commands/gr-repo-commands_html.ts",
     "elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts",
-    "elements/admin/gr-repo/gr-repo_html.ts",
     "elements/admin/gr-rule-editor/gr-rule-editor_html.ts",
     "elements/change-list/gr-change-list-view/gr-change-list-view_html.ts",
     "elements/change-list/gr-change-list/gr-change-list_html.ts",
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index 7092c9b..37c88f2 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -43,7 +43,6 @@
 export interface ConfigChangeInfo {
   _key: string; // parameterName of PluginParameterToConfigParameterInfoMap
   info: ConfigParameterInfo;
-  notifyPath: string;
 }
 
 export interface PluginData {
@@ -54,7 +53,6 @@
 export interface PluginConfigChangeDetail {
   name: string; // parameterName of PluginParameterToConfigParameterInfoMap
   config: PluginParameterToConfigParameterInfoMap;
-  notifyPath: string;
 }
 
 @customElement('gr-repo-plugin-config')
@@ -248,7 +246,6 @@
     return {
       _key,
       info,
-      notifyPath: `${_key}.value`,
     };
   }
 
@@ -256,7 +253,7 @@
     this._handleChange(e.detail);
   }
 
-  _handleChange({_key, info, notifyPath}: ConfigChangeInfo) {
+  _handleChange({_key, info}: ConfigChangeInfo) {
     // If pluginData is not set, editors are not created and this method
     // can't be called
     const {name, config} = this.pluginData!;
@@ -265,7 +262,6 @@
     const detail: PluginConfigChangeDetail = {
       name,
       config: {...config, [_key]: info},
-      notifyPath: `${name}.${notifyPath}`,
     };
 
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
index 8c2e6b3..4076b747f 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
@@ -44,7 +44,6 @@
     element._handleChange({
       _key: 'plugin',
       info: {value: 'newTest'},
-      notifyPath: 'plugin.value',
     });
 
     assert.isTrue(eventStub.called);
@@ -52,7 +51,6 @@
     const {detail} = eventStub.lastCall.args[0];
     assert.equal(detail.name, 'testName');
     assert.deepEqual(detail.config, {plugin: {value: 'newTest'}});
-    assert.equal(detail.notifyPath, 'testName.plugin.value');
   });
 
   suite('option types', () => {
@@ -151,7 +149,6 @@
     const detail = element._buildConfigChangeInfo('newTest', 'plugin');
     assert.equal(detail._key, 'plugin');
     assert.deepEqual(detail.info, {value: 'newTest'});
-    assert.equal(detail.notifyPath, 'plugin.value');
   });
 });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 72fc0bfb..47a4362 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -21,32 +21,35 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-download-commands/gr-download-commands';
 import '../../shared/gr-select/gr-select';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/shared-styles';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe} from '@polymer/decorators';
 import {
   ConfigInfo,
   RepoName,
   InheritedBooleanInfo,
   SchemesInfoMap,
   ConfigInput,
+  MaxObjectSizeLimitInfo,
   PluginParameterToConfigParameterInfoMap,
-  PluginNameToPluginParametersMap,
 } from '../../../types/common';
-import {PluginData} from '../gr-repo-plugin-config/gr-repo-plugin-config';
-import {ProjectState} from '../../../constants/constants';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {
+  InheritedBooleanInfoConfiguredValue,
+  ProjectState,
+  SubmitType,
+} from '../../../constants/constants';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {WebLinkInfo} from '../../../types/diff';
 import {ErrorCallback} from '../../../api/rest';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {BindValueChangeEvent} from '../../../types/events';
+import {deepClone} from '../../../utils/object-util';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
 
 const STATES = {
   active: {value: ProjectState.ACTIVE, label: 'Active'},
@@ -82,92 +85,667 @@
   },
 };
 
-@customElement('gr-repo')
-export class GrRepo extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo': GrRepo;
   }
+}
+
+@customElement('gr-repo')
+export class GrRepo extends LitElement {
+  private schemes: string[] = [];
 
   @property({type: String})
   repo?: RepoName;
 
-  @property({type: Boolean})
-  _configChanged = false;
+  /* private but used in test */
+  @state() loading = true;
 
-  @property({type: Boolean})
-  _loading = true;
+  /* private but used in test */
+  @state() repoConfig?: ConfigInfo;
 
-  @property({type: Boolean, observer: '_loggedInChanged'})
-  _loggedIn = false;
+  /* private but used in test */
+  @state() readOnly = true;
 
-  @property({type: Object})
-  _repoConfig?: ConfigInfo;
+  @state() private states = Object.values(STATES);
 
-  @property({
-    type: Array,
-    computed: '_computePluginData(_repoConfig.plugin_config.*)',
-  })
-  _pluginData?: PluginData[];
+  @state() private originalConfig?: ConfigInfo;
 
-  @property({type: Boolean})
-  _readOnly = true;
+  @state() private selectedScheme?: string;
 
-  @property({type: Array})
-  _states = Object.values(STATES);
+  /* private but used in test */
+  @state() schemesObj?: SchemesInfoMap;
 
-  @property({
-    type: Array,
-    computed: '_computeSchemes(_schemesDefault, _schemesObj)',
-    observer: '_schemesChanged',
-  })
-  _schemes: string[] = [];
+  @state() private weblinks: WebLinkInfo[] = [];
 
-  // This is workaround to have _schemes with default value [],
-  // because assignment doesn't work when property has a computed attribute.
-  @property({type: Array})
-  _schemesDefault: string[] = [];
-
-  @property({type: String})
-  _selectedCommand = 'Clone';
-
-  @property({type: String})
-  _selectedScheme?: string;
-
-  @property({type: Object})
-  _schemesObj?: SchemesInfoMap;
-
-  @property({type: Array})
-  weblinks: WebLinkInfo[] = [];
+  @state() private pluginConfigChanged = false;
 
   private readonly restApiService = appContext.restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadRepo();
+    this.loadRepo();
 
     fireTitleChange(this, `${this.repo}`);
   }
 
-  _computePluginData(
-    configRecord?: PolymerDeepPropertyChange<
-      PluginNameToPluginParametersMap,
-      PluginNameToPluginParametersMap
-    >
-  ) {
-    if (!configRecord || !configRecord.base) {
-      return [];
-    }
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      subpageStyles,
+      sharedStyles,
+      css`
+        .info {
+          margin-bottom: var(--spacing-xl);
+        }
+        h2.edited:after {
+          color: var(--deemphasized-text-color);
+          content: ' *';
+        }
+        .loading,
+        .hide {
+          display: none;
+        }
+        #loading.loading {
+          display: block;
+        }
+        #loading:not(.loading) {
+          display: none;
+        }
+        #options .repositorySettings {
+          display: none;
+        }
+        #options .repositorySettings.showConfig {
+          display: block;
+        }
+      `,
+    ];
+  }
 
-    const pluginConfig = configRecord.base;
+  override render() {
+    const configChanged = this.hasConfigChanged();
+    return html`
+      <div class="main gr-form-styles read-only">
+        <div class="info">
+          <h1 id="Title" class="heading-1">${this.repo}</h1>
+          <hr />
+          <div>
+            <a href=${this.weblinks?.[0]?.url}
+              ><gr-button link ?disabled=${!this.weblinks?.[0]?.url}
+                >Browse</gr-button
+              ></a
+            ><a href=${this.computeChangesUrl(this.repo)}
+              ><gr-button link>View Changes</gr-button></a
+            >
+          </div>
+        </div>
+        <div id="loading" class=${this.loading ? 'loading' : ''}>
+          Loading...
+        </div>
+        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
+          ${this.renderDownloadCommands()}
+          <h2
+            id="configurations"
+            class="heading-2 ${configChanged ? 'edited' : ''}"
+          >
+            Configurations
+          </h2>
+          <div id="form">
+            <fieldset>
+              ${this.renderDescription()} ${this.renderRepoOptions()}
+              ${this.renderPluginConfig()}
+              <gr-button
+                ?disabled=${this.readOnly || !configChanged}
+                @click=${this.handleSaveRepoConfig}
+                >Save changes</gr-button
+              >
+            </fieldset>
+            <gr-endpoint-decorator name="repo-config">
+              <gr-endpoint-param
+                name="repoName"
+                .value=${this.repo}
+              ></gr-endpoint-param>
+              <gr-endpoint-param
+                name="readOnly"
+                .value=${this.readOnly}
+              ></gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderDownloadCommands() {
+    return html`
+      <div
+        id="downloadContent"
+        class=${!this.schemes || !this.schemes.length ? 'hide' : ''}
+      >
+        <h2 id="download" class="heading-2">Download</h2>
+        <fieldset>
+          <gr-download-commands
+            id="downloadCommands"
+            .commands=${this.computeCommands(
+              this.repo,
+              this.schemesObj,
+              this.selectedScheme
+            )}
+            .schemes=${this.schemes}
+            .selectedScheme=${this.selectedScheme}
+            @selected-scheme-changed=${this.handleSelectedSchemeValueChanged}
+          ></gr-download-commands>
+        </fieldset>
+      </div>
+    `;
+  }
+
+  private renderDescription() {
+    return html`
+      <h3 id="Description" class="heading-3">Description</h3>
+      <fieldset>
+        <iron-autogrow-textarea
+          id="descriptionInput"
+          class="description"
+          autocomplete="on"
+          placeholder="&lt;Insert repo description here&gt;"
+          .bindValue=${this.repoConfig?.description}
+          ?disabled=${this.readOnly}
+          @bind-value-changed=${this.handleDescriptionBindValueChanged}
+        ></iron-autogrow-textarea>
+      </fieldset>
+    `;
+  }
+
+  private renderRepoOptions() {
+    return html`
+      <h3 id="Options" class="heading-3">Repository Options</h3>
+      <fieldset id="options">
+        ${this.renderState()} ${this.renderSubmitType()}
+        ${this.renderContentMerges()} ${this.renderNewChange()}
+        ${this.renderChangeId()} ${this.renderEnableSignedPush()}
+        ${this.renderRequireSignedPush()} ${this.renderRejectImplicitMerges()}
+        ${this.renderUnRegisteredCc()} ${this.renderPrivateByDefault()}
+        ${this.renderWorkInProgressByDefault()} ${this.renderMaxGitObjectSize()}
+        ${this.renderMatchAuthoredDateWithCommitterDate()}
+        ${this.renderRejectEmptyCommit()}
+      </fieldset>
+      <h3 id="Options" class="heading-3">Contributor Agreements</h3>
+      <fieldset id="agreements">
+        ${this.renderContributorAgreement()} ${this.renderUseSignedOffBy()}
+      </fieldset>
+    `;
+  }
+
+  private renderState() {
+    return html`
+      <section>
+        <span class="title">State</span>
+        <span class="value">
+          <gr-select
+            id="stateSelect"
+            .bindValue=${this.repoConfig?.state}
+            @bind-value-changed=${this.handleStateSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.states.map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderSubmitType() {
+    return html`
+      <section>
+        <span class="title">Submit type</span>
+        <span class="value">
+          <gr-select
+            id="submitTypeSelect"
+            .bindValue=${this.repoConfig?.submit_type}
+            @bind-value-changed=${this.handleSubmitTypeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatSubmitTypeSelect(this.repoConfig).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderContentMerges() {
+    return html`
+      <section>
+        <span class="title">Allow content merges</span>
+        <span class="value">
+          <gr-select
+            id="contentMergeSelect"
+            .bindValue=${this.repoConfig?.use_content_merge?.configured_value}
+            @bind-value-changed=${this.handleContentMergeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.use_content_merge
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderNewChange() {
+    return html`
+      <section>
+        <span class="title">
+          Create a new change for every commit not in the target branch
+        </span>
+        <span class="value">
+          <gr-select
+            id="newChangeSelect"
+            .bindValue=${this.repoConfig
+              ?.create_new_change_for_all_not_in_target?.configured_value}
+            @bind-value-changed=${this.handleNewChangeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.create_new_change_for_all_not_in_target
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderChangeId() {
+    return html`
+      <section>
+        <span class="title">Require Change-Id in commit message</span>
+        <span class="value">
+          <gr-select
+            id="requireChangeIdSelect"
+            .bindValue=${this.repoConfig?.require_change_id?.configured_value}
+            @bind-value-changed=${this
+              .handleRequireChangeIdSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.require_change_id
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEnableSignedPush() {
+    return html`
+      <section
+        id="enableSignedPushSettings"
+        class="repositorySettings ${this.repoConfig?.enable_signed_push
+          ? 'showConfig'
+          : ''}"
+      >
+        <span class="title">Enable signed push</span>
+        <span class="value">
+          <gr-select
+            id="enableSignedPush"
+            .bindValue=${this.repoConfig?.enable_signed_push?.configured_value}
+            @bind-value-changed=${this.handleEnableSignedPushBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.enable_signed_push
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRequireSignedPush() {
+    return html`
+      <section
+        id="requireSignedPushSettings"
+        class="repositorySettings ${this.repoConfig?.require_signed_push
+          ? 'showConfig'
+          : ''}"
+      >
+        <span class="title">Require signed push</span>
+        <span class="value">
+          <gr-select
+            id="requireSignedPush"
+            .bindValue=${this.repoConfig?.require_signed_push?.configured_value}
+            @bind-value-changed=${this.handleRequireSignedPushBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.require_signed_push
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRejectImplicitMerges() {
+    return html`
+      <section>
+        <span class="title">
+          Reject implicit merges when changes are pushed for review</span
+        >
+        <span class="value">
+          <gr-select
+            id="rejectImplicitMergesSelect"
+            .bindValue=${this.repoConfig?.reject_implicit_merges
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleRejectImplicitMergeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.reject_implicit_merges
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderUnRegisteredCc() {
+    return html`
+      <section>
+        <span class="title">
+          Enable adding unregistered users as reviewers and CCs on changes</span
+        >
+        <span class="value">
+          <gr-select
+            id="unRegisteredCcSelect"
+            .bindValue=${this.repoConfig?.enable_reviewer_by_email
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleUnRegisteredCcSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.enable_reviewer_by_email
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderPrivateByDefault() {
+    return html`
+      <section>
+        <span class="title"> Set all new changes private by default</span>
+        <span class="value">
+          <gr-select
+            id="setAllnewChangesPrivateByDefaultSelect"
+            .bindValue=${this.repoConfig?.private_by_default?.configured_value}
+            @bind-value-changed=${this
+              .handleSetAllNewChangesPrivateByDefaultSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.private_by_default
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderWorkInProgressByDefault() {
+    return html`
+      <section>
+        <span class="title">
+          Set new changes to "work in progress" by default</span
+        >
+        <span class="value">
+          <gr-select
+            id="setAllNewChangesWorkInProgressByDefaultSelect"
+            .bindValue=${this.repoConfig?.work_in_progress_by_default
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleSetAllNewChangesWorkInProgressByDefaultSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.work_in_progress_by_default
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderMaxGitObjectSize() {
+    return html`
+      <section>
+        <span class="title">Maximum Git object size limit</span>
+        <span class="value">
+          <iron-input
+            id="maxGitObjSizeIronInput"
+            .bindValue=${this.repoConfig?.max_object_size_limit
+              ?.configured_value}
+            type="text"
+            ?disabled=${this.readOnly}
+            @bind-value-changed=${this.handleMaxGitObjSizeBindValueChanged}
+          >
+            <input
+              id="maxGitObjSizeInput"
+              type="text"
+              ?disabled=${this.readOnly}
+            />
+          </iron-input>
+          ${this.repoConfig?.max_object_size_limit?.value
+            ? `effective: ${this.repoConfig.max_object_size_limit.value} bytes`
+            : ''}
+        </span>
+      </section>
+    `;
+  }
+
+  private renderMatchAuthoredDateWithCommitterDate() {
+    return html`
+      <section>
+        <span class="title"
+          >Match authored date with committer date upon submit</span
+        >
+        <span class="value">
+          <gr-select
+            id="matchAuthoredDateWithCommitterDateSelect"
+            .bindValue=${this.repoConfig?.match_author_to_committer_date
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleMatchAuthoredDateWithCommitterDateSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.match_author_to_committer_date
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRejectEmptyCommit() {
+    return html`
+      <section>
+        <span class="title">Reject empty commit upon submit</span>
+        <span class="value">
+          <gr-select
+            id="rejectEmptyCommitSelect"
+            .bindValue=${this.repoConfig?.reject_empty_commit?.configured_value}
+            @bind-value-changed=${this
+              .handleRejectEmptyCommitSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.reject_empty_commit
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderContributorAgreement() {
+    return html`
+      <section>
+        <span class="title">
+          Require a valid contributor agreement to upload</span
+        >
+        <span class="value">
+          <gr-select
+            id="contributorAgreementSelect"
+            .bindValue=${this.repoConfig?.use_contributor_agreements
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleUseContributorAgreementsBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.use_contributor_agreements
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderUseSignedOffBy() {
+    return html`
+      <section>
+        <span class="title">Require Signed-off-by in commit message</span>
+        <span class="value">
+          <gr-select
+            id="useSignedOffBySelect"
+            .bindValue=${this.repoConfig?.use_signed_off_by?.configured_value}
+            @bind-value-changed=${this
+              .handleUseSignedOffBySelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.use_signed_off_by
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderPluginConfig() {
+    const pluginData = this.computePluginData();
+    return html` <div
+      class="pluginConfig ${!pluginData || !pluginData.length ? 'hide' : ''}"
+      @plugin-config-changed=${this.handlePluginConfigChanged}
+    >
+      <h3 class="heading-3">Plugins</h3>
+      ${pluginData.map(
+        item => html`
+          <gr-repo-plugin-config .pluginData=${item}></gr-repo-plugin-config>
+        `
+      )}
+    </div>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('schemesObj')) {
+      this.computeSchemesAndDefault();
+    }
+  }
+
+  /* private but used in test */
+  computePluginData() {
+    if (!this.repoConfig || !this.repoConfig.plugin_config) return [];
+    const pluginConfig = this.repoConfig.plugin_config;
     return Object.keys(pluginConfig).map(name => {
       return {name, config: pluginConfig[name]};
     });
   }
 
-  _loadRepo() {
-    if (!this.repo) {
-      return Promise.resolve();
-    }
+  /* private but used in test */
+  async loadRepo() {
+    if (!this.repo) return Promise.resolve();
 
     const promises = [];
 
@@ -176,11 +754,16 @@
     };
 
     promises.push(
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
+      this.restApiService.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           const repo = this.repo;
           if (!repo) throw new Error('undefined repo');
+          this.restApiService.getPreferences().then(prefs => {
+            if (prefs?.download_scheme) {
+              // Note (issue 5180): normalize the download scheme with lower-case.
+              this.selectedScheme = prefs.download_scheme.toLowerCase();
+            }
+          });
           this.restApiService.getRepo(repo).then(repo => {
             if (!repo?.web_links) return;
             this.weblinks = repo.web_links;
@@ -191,71 +774,60 @@
             }
 
             // If the user is not an owner, is_owner is not a property.
-            this._readOnly = !access[repo]?.is_owner;
+            this.readOnly = !access[repo]?.is_owner;
           });
         }
       })
     );
 
-    promises.push(
-      this.restApiService.getProjectConfig(this.repo, errFn).then(config => {
-        if (!config) {
-          return;
-        }
+    const repoConfigHelper = async () => {
+      const config = await this.restApiService.getProjectConfig(
+        this.repo as RepoName,
+        errFn
+      );
+      if (!config) return;
 
-        if (config.default_submit_type) {
-          // The gr-select is bound to submit_type, which needs to be the
-          // *configured* submit type. When default_submit_type is
-          // present, the server reports the *effective* submit type in
-          // submit_type, so we need to overwrite it before storing the
-          // config in this.
-          config.submit_type = config.default_submit_type.configured_value;
-        }
-        if (!config.state) {
-          config.state = STATES.active.value as ProjectState;
-        }
-        this._repoConfig = config;
-        this._loading = false;
-      })
-    );
-
-    promises.push(
-      this.restApiService.getConfig().then(config => {
-        if (!config) {
-          return;
-        }
-
-        this._schemesObj = config.download.schemes;
-      })
-    );
-
-    return Promise.all(promises);
-  }
-
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  _computeHideClass(arr?: PluginData[] | string[]) {
-    return !arr || !arr.length ? 'hide' : '';
-  }
-
-  _loggedInChanged(_loggedIn?: boolean) {
-    if (!_loggedIn) {
-      return;
-    }
-    this.restApiService.getPreferences().then(prefs => {
-      if (prefs?.download_scheme) {
-        // Note (issue 5180): normalize the download scheme with lower-case.
-        this._selectedScheme = prefs.download_scheme.toLowerCase();
+      if (config.default_submit_type) {
+        // The gr-select is bound to submit_type, which needs to be the
+        // *configured* submit type. When default_submit_type is
+        // present, the server reports the *effective* submit type in
+        // submit_type, so we need to overwrite it before storing the
+        // config in this.
+        config.submit_type = config.default_submit_type.configured_value;
       }
-    });
+      if (!config.state) {
+        config.state = STATES.active.value as ProjectState;
+      }
+      // To properly check if the config has changed we need it to be a string
+      // as it's converted to a string in the input.
+      if (config.description === undefined) {
+        config.description = '';
+      }
+      // To properly check if the config has changed we need it to be a string
+      // as it's converted to a string in the input.
+      if (config.max_object_size_limit.configured_value === undefined) {
+        config.max_object_size_limit.configured_value = '';
+      }
+      this.repoConfig = config;
+      this.originalConfig = deepClone(config);
+      this.loading = false;
+    };
+    promises.push(repoConfigHelper());
+
+    const configHelper = async () => {
+      const config = await this.restApiService.getConfig();
+      if (!config) return;
+
+      this.schemesObj = config.download.schemes;
+    };
+    promises.push(configHelper());
+
+    await Promise.all(promises);
   }
 
-  _formatBooleanSelect(item: InheritedBooleanInfo) {
-    if (!item) {
-      return;
-    }
+  /* private but used in test */
+  formatBooleanSelect(item?: InheritedBooleanInfo) {
+    if (!item) return [];
     let inheritLabel = 'Inherit';
     if (!(item.inherited_value === undefined)) {
       inheritLabel = `Inherit (${item.inherited_value})`;
@@ -276,12 +848,10 @@
     ];
   }
 
-  _formatSubmitTypeSelect(projectConfig: ConfigInfo) {
-    if (!projectConfig) {
-      return;
-    }
+  private formatSubmitTypeSelect(repoConfig?: ConfigInfo) {
+    if (!repoConfig) return [];
     const allValues = Object.values(SUBMIT_TYPES);
-    const type = projectConfig.default_submit_type;
+    const type = repoConfig.default_submit_type;
     if (!type) {
       // Server is too old to report default_submit_type, so assume INHERIT
       // is not a valid value.
@@ -307,17 +877,9 @@
     ];
   }
 
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  _formatRepoConfigForSave(repoConfig?: ConfigInfo): ConfigInput {
+  /* private but used in test */
+  formatRepoConfigForSave(repoConfig?: ConfigInfo): ConfigInput {
     if (!repoConfig) return {};
-
     const configInputObj: ConfigInput = {};
     for (const configKey of Object.keys(repoConfig)) {
       const key = configKey as keyof ConfigInfo;
@@ -332,7 +894,7 @@
       } else if (typeof repoConfig[key] === 'object') {
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         const repoConfigObj: any = repoConfig[key];
-        if (repoConfigObj.configured_value) {
+        if (repoConfigObj.configured_value !== undefined) {
           configInputObj[key as keyof ConfigInput] =
             repoConfigObj.configured_value;
         }
@@ -344,56 +906,173 @@
     return configInputObj;
   }
 
-  _handleSaveRepoConfig() {
-    if (!this._repoConfig || !this.repo)
+  /* private but used in test */
+  async handleSaveRepoConfig() {
+    if (!this.repoConfig || !this.repo)
       return Promise.reject(new Error('undefined repoConfig or repo'));
-    return this.restApiService
-      .saveRepoConfig(
-        this.repo,
-        this._formatRepoConfigForSave(this._repoConfig)
+    await this.restApiService.saveRepoConfig(
+      this.repo,
+      this.formatRepoConfigForSave(this.repoConfig)
+    );
+    this.originalConfig = deepClone(this.repoConfig);
+    this.pluginConfigChanged = false;
+    return;
+  }
+
+  private isEdited(
+    original?: InheritedBooleanInfo | MaxObjectSizeLimitInfo,
+    repo?: InheritedBooleanInfo | MaxObjectSizeLimitInfo
+  ) {
+    return original?.configured_value !== repo?.configured_value;
+  }
+
+  private hasConfigChanged() {
+    const {repoConfig, originalConfig} = this;
+
+    if (!repoConfig || !originalConfig) return false;
+
+    if (originalConfig.description !== repoConfig.description) {
+      return true;
+    }
+    if (originalConfig.state !== repoConfig.state) {
+      return true;
+    }
+    if (originalConfig.submit_type !== repoConfig.submit_type) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.use_content_merge,
+        repoConfig.use_content_merge
       )
-      .then(() => {
-        this._configChanged = false;
-      });
-  }
-
-  @observe('_repoConfig.*')
-  _handleConfigChanged() {
-    if (this._isLoading()) {
-      return;
+    ) {
+      return true;
     }
-    this._configChanged = true;
-  }
-
-  _computeButtonDisabled(readOnly: boolean, configChanged: boolean) {
-    return readOnly || !configChanged;
-  }
-
-  _computeHeaderClass(configChanged: boolean) {
-    return configChanged ? 'edited' : '';
-  }
-
-  _computeSchemes(schemesDefault: string[], schemesObj?: SchemesInfoMap) {
-    return !schemesObj ? schemesDefault : Object.keys(schemesObj);
-  }
-
-  _schemesChanged(schemes: string[]) {
-    if (schemes.length === 0) {
-      return;
+    if (
+      this.isEdited(
+        originalConfig.create_new_change_for_all_not_in_target,
+        repoConfig.create_new_change_for_all_not_in_target
+      )
+    ) {
+      return true;
     }
-    if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
-      this._selectedScheme = schemes.sort()[0];
+    if (
+      this.isEdited(
+        originalConfig.require_change_id,
+        repoConfig.require_change_id
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.enable_signed_push,
+        repoConfig.enable_signed_push
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.require_signed_push,
+        repoConfig.require_signed_push
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.reject_implicit_merges,
+        repoConfig.reject_implicit_merges
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.enable_reviewer_by_email,
+        repoConfig.enable_reviewer_by_email
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.private_by_default,
+        repoConfig.private_by_default
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.work_in_progress_by_default,
+        repoConfig.work_in_progress_by_default
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.max_object_size_limit,
+        repoConfig.max_object_size_limit
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.match_author_to_committer_date,
+        repoConfig.match_author_to_committer_date
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.reject_empty_commit,
+        repoConfig.reject_empty_commit
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.use_contributor_agreements,
+        repoConfig.use_contributor_agreements
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.use_signed_off_by,
+        repoConfig.use_signed_off_by
+      )
+    ) {
+      return true;
+    }
+
+    return this.pluginConfigChanged;
+  }
+
+  private computeSchemesAndDefault() {
+    this.schemes = !this.schemesObj ? [] : Object.keys(this.schemesObj);
+    if (this.schemes.length > 0) {
+      if (!this.selectedScheme || !this.schemes.includes(this.selectedScheme)) {
+        this.selectedScheme = this.schemes.sort()[0];
+      }
     }
   }
 
-  _computeCommands(
+  private computeCommands(
     repo?: RepoName,
     schemesObj?: SchemesInfoMap,
-    _selectedScheme?: string
+    selectedScheme?: string
   ) {
-    if (!schemesObj || !repo || !_selectedScheme) return [];
-    if (!hasOwnProperty(schemesObj, _selectedScheme)) return [];
-    const commandObj = schemesObj[_selectedScheme].clone_commands;
+    if (!schemesObj || !repo || !selectedScheme) return [];
+    if (!hasOwnProperty(schemesObj, selectedScheme)) return [];
+    const commandObj = schemesObj[selectedScheme].clone_commands;
     const commands = [];
     for (const [title, command] of Object.entries(commandObj)) {
       commands.push({
@@ -409,36 +1088,171 @@
     return commands;
   }
 
-  _computeRepositoriesClass(config: InheritedBooleanInfo) {
-    return config ? 'showConfig' : '';
+  private computeChangesUrl(name?: RepoName) {
+    if (!name) return '';
+    return GerritNav.getUrlForProjectChanges(name as RepoName);
   }
 
-  _computeChangesUrl(name: RepoName) {
-    return GerritNav.getUrlForProjectChanges(name);
-  }
-
-  _computeBrowseUrl(weblinks: WebLinkInfo[]) {
-    return weblinks?.[0]?.url;
-  }
-
-  _handlePluginConfigChanged({
-    detail: {name, config, notifyPath},
+  /* private but used in test */
+  handlePluginConfigChanged({
+    detail: {name, config},
   }: {
     detail: {
       name: string;
       config: PluginParameterToConfigParameterInfoMap;
-      notifyPath: string;
     };
   }) {
-    if (this._repoConfig?.plugin_config) {
-      this._repoConfig.plugin_config[name] = config;
-      this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+    if (this.repoConfig?.plugin_config) {
+      this.repoConfig.plugin_config[name] = config;
+      this.pluginConfigChanged = true;
+      this.requestUpdate();
     }
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-repo': GrRepo;
+  private handleSelectedSchemeValueChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.selectedScheme = e.detail.value;
+  }
+
+  private handleDescriptionBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig || this.loading) return;
+    this.repoConfig = {
+      ...this.repoConfig,
+      description: e.detail.value,
+    };
+    this.requestUpdate();
+  }
+
+  private handleStateSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig || this.loading) return;
+    this.repoConfig = {
+      ...this.repoConfig,
+      state: e.detail.value as ProjectState,
+    };
+    this.requestUpdate();
+  }
+
+  private handleSubmitTypeSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig || this.loading) return;
+    this.repoConfig = {
+      ...this.repoConfig,
+      submit_type: e.detail.value as SubmitType,
+    };
+    this.requestUpdate();
+  }
+
+  private handleContentMergeSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.use_content_merge || this.loading) return;
+    this.repoConfig.use_content_merge.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleNewChangeSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.repoConfig?.create_new_change_for_all_not_in_target ||
+      this.loading
+    )
+      return;
+    this.repoConfig.create_new_change_for_all_not_in_target.configured_value = e
+      .detail.value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRequireChangeIdSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.require_change_id || this.loading) return;
+    this.repoConfig.require_change_id.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleEnableSignedPushBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.enable_signed_push || this.loading) return;
+    this.repoConfig.enable_signed_push.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRequireSignedPushBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.require_signed_push || this.loading) return;
+    this.repoConfig.require_signed_push.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRejectImplicitMergeSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.reject_implicit_merges || this.loading) return;
+    this.repoConfig.reject_implicit_merges.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleUnRegisteredCcSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.enable_reviewer_by_email || this.loading) return;
+    this.repoConfig.enable_reviewer_by_email.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleSetAllNewChangesPrivateByDefaultSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.private_by_default || this.loading) return;
+    this.repoConfig.private_by_default.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleSetAllNewChangesWorkInProgressByDefaultSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.work_in_progress_by_default || this.loading) return;
+    this.repoConfig.work_in_progress_by_default.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleMaxGitObjSizeBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.max_object_size_limit || this.loading) return;
+    this.repoConfig.max_object_size_limit.value = e.detail.value;
+    this.repoConfig.max_object_size_limit.configured_value = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleMatchAuthoredDateWithCommitterDateSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.match_author_to_committer_date || this.loading)
+      return;
+    this.repoConfig.match_author_to_committer_date.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRejectEmptyCommitSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.reject_empty_commit || this.loading) return;
+    this.repoConfig.reject_empty_commit.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleUseContributorAgreementsBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.use_contributor_agreements || this.loading) return;
+    this.repoConfig.use_contributor_agreements.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleUseSignedOffBySelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.use_signed_off_by || this.loading) return;
+    this.repoConfig.use_signed_off_by.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
deleted file mode 100644
index 71abec0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
+++ /dev/null
@@ -1,449 +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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    .info {
-      margin-bottom: var(--spacing-xl);
-    }
-    h2.edited:after {
-      color: var(--deemphasized-text-color);
-      content: ' *';
-    }
-    .loading,
-    .hide {
-      display: none;
-    }
-    #loading.loading {
-      display: block;
-    }
-    #loading:not(.loading) {
-      display: none;
-    }
-    #options .repositorySettings {
-      display: none;
-    }
-    #options .repositorySettings.showConfig {
-      display: block;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="main gr-form-styles read-only">
-    <style include="shared-styles">
-      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-    </style>
-    <div class="info">
-      <h1 id="Title" class="heading-1">[[repo]]</h1>
-      <hr />
-      <div>
-        <a href$="[[_computeBrowseUrl(weblinks)]]"
-          ><gr-button link disabled="[[!_computeBrowseUrl(weblinks)]]"
-            >Browse</gr-button
-          ></a
-        ><a href$="[[_computeChangesUrl(repo)]]"
-          ><gr-button link>View Changes</gr-button></a
-        >
-      </div>
-    </div>
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <div id="downloadContent" class$="[[_computeHideClass(_schemes)]]">
-        <h2 id="download" class="heading-2">Download</h2>
-        <fieldset>
-          <gr-download-commands
-            id="downloadCommands"
-            commands="[[_computeCommands(repo, _schemesObj, _selectedScheme)]]"
-            schemes="[[_schemes]]"
-            selected-scheme="{{_selectedScheme}}"
-          ></gr-download-commands>
-        </fieldset>
-      </div>
-      <h2
-        id="configurations"
-        class$="heading-2 [[_computeHeaderClass(_configChanged)]]"
-      >
-        Configurations
-      </h2>
-      <div id="form">
-        <fieldset>
-          <h3 id="Description" class="heading-3">Description</h3>
-          <fieldset>
-            <iron-autogrow-textarea
-              id="descriptionInput"
-              class="description"
-              autocomplete="on"
-              placeholder="<Insert repo description here>"
-              bind-value="{{_repoConfig.description}}"
-              disabled$="[[_readOnly]]"
-            ></iron-autogrow-textarea>
-          </fieldset>
-          <h3 id="Options" class="heading-3">Repository Options</h3>
-          <fieldset id="options">
-            <section>
-              <span class="title">State</span>
-              <span class="value">
-                <gr-select id="stateSelect" bind-value="{{_repoConfig.state}}">
-                  <select disabled$="[[_readOnly]]">
-                    <template is="dom-repeat" items="[[_states]]">
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Submit type</span>
-              <span class="value">
-                <gr-select
-                  id="submitTypeSelect"
-                  bind-value="{{_repoConfig.submit_type}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatSubmitTypeSelect(_repoConfig)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Allow content merges</span>
-              <span class="value">
-                <gr-select
-                  id="contentMergeSelect"
-                  bind-value="{{_repoConfig.use_content_merge.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_content_merge)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Create a new change for every commit not in the target branch
-              </span>
-              <span class="value">
-                <gr-select
-                  id="newChangeSelect"
-                  bind-value="{{_repoConfig.create_new_change_for_all_not_in_target.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.create_new_change_for_all_not_in_target)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Require Change-Id in commit message</span>
-              <span class="value">
-                <gr-select
-                  id="requireChangeIdSelect"
-                  bind-value="{{_repoConfig.require_change_id.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.require_change_id)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section
-              id="enableSignedPushSettings"
-              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.enable_signed_push)]]"
-            >
-              <span class="title">Enable signed push</span>
-              <span class="value">
-                <gr-select
-                  id="enableSignedPush"
-                  bind-value="{{_repoConfig.enable_signed_push.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.enable_signed_push)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section
-              id="requireSignedPushSettings"
-              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.require_signed_push)]]"
-            >
-              <span class="title">Require signed push</span>
-              <span class="value">
-                <gr-select
-                  id="requireSignedPush"
-                  bind-value="{{_repoConfig.require_signed_push.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.require_signed_push)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Reject implicit merges when changes are pushed for review</span
-              >
-              <span class="value">
-                <gr-select
-                  id="rejectImplicitMergesSelect"
-                  bind-value="{{_repoConfig.reject_implicit_merges.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.reject_implicit_merges)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Enable adding unregistered users as reviewers and CCs on
-                changes</span
-              >
-              <span class="value">
-                <gr-select
-                  id="unRegisteredCcSelect"
-                  bind-value="{{_repoConfig.enable_reviewer_by_email.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.enable_reviewer_by_email)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title"> Set all new changes private by default</span>
-              <span class="value">
-                <gr-select
-                  id="setAllnewChangesPrivateByDefaultSelect"
-                  bind-value="{{_repoConfig.private_by_default.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.private_by_default)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Set new changes to "work in progress" by default</span
-              >
-              <span class="value">
-                <gr-select
-                  id="setAllNewChangesWorkInProgressByDefaultSelect"
-                  bind-value="{{_repoConfig.work_in_progress_by_default.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.work_in_progress_by_default)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Maximum Git object size limit</span>
-              <span class="value">
-                <iron-input
-                  id="maxGitObjSizeIronInput"
-                  bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                  type="text"
-                  disabled$="[[_readOnly]]"
-                >
-                  <input
-                    id="maxGitObjSizeInput"
-                    bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                    is="iron-input"
-                    type="text"
-                    disabled$="[[_readOnly]]"
-                  />
-                </iron-input>
-                <template
-                  is="dom-if"
-                  if="[[_repoConfig.max_object_size_limit.value]]"
-                >
-                  effective: [[_repoConfig.max_object_size_limit.value]] bytes
-                </template>
-              </span>
-            </section>
-            <section>
-              <span class="title"
-                >Match authored date with committer date upon submit</span
-              >
-              <span class="value">
-                <gr-select
-                  id="matchAuthoredDateWithCommitterDateSelect"
-                  bind-value="{{_repoConfig.match_author_to_committer_date.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.match_author_to_committer_date)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Reject empty commit upon submit</span>
-              <span class="value">
-                <gr-select
-                  id="rejectEmptyCommitSelect"
-                  bind-value="{{_repoConfig.reject_empty_commit.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.reject_empty_commit)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-          </fieldset>
-          <h3 id="Options" class="heading-3">Contributor Agreements</h3>
-          <fieldset id="agreements">
-            <section>
-              <span class="title">
-                Require a valid contributor agreement to upload</span
-              >
-              <span class="value">
-                <gr-select
-                  id="contributorAgreementSelect"
-                  bind-value="{{_repoConfig.use_contributor_agreements.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_contributor_agreements)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Require Signed-off-by in commit message</span>
-              <span class="value">
-                <gr-select
-                  id="useSignedOffBySelect"
-                  bind-value="{{_repoConfig.use_signed_off_by.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_signed_off_by)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-          </fieldset>
-          <div
-            class$="pluginConfig [[_computeHideClass(_pluginData)]]"
-            on-plugin-config-changed="_handlePluginConfigChanged"
-          >
-            <h3 class="heading-3">Plugins</h3>
-            <template is="dom-repeat" items="[[_pluginData]]" as="data">
-              <gr-repo-plugin-config
-                plugin-data="[[data]]"
-              ></gr-repo-plugin-config>
-            </template>
-          </div>
-          <gr-button
-            on-click="_handleSaveRepoConfig"
-            disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]"
-            >Save changes</gr-button
-          >
-        </fieldset>
-        <gr-endpoint-decorator name="repo-config">
-          <gr-endpoint-param
-            name="repoName"
-            value="[[repo]]"
-          ></gr-endpoint-param>
-          <gr-endpoint-param
-            name="readOnly"
-            value="[[_readOnly]]"
-          ></gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </div>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index 642b224..86dccff 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -36,7 +36,6 @@
   GroupName,
   InheritedBooleanInfo,
   MaxObjectSizeLimitInfo,
-  PluginNameToPluginParametersMap,
   PluginParameterToConfigParameterInfoMap,
   ProjectAccessGroups,
   ProjectAccessInfoMap,
@@ -48,7 +47,6 @@
   ProjectState,
   SubmitType,
 } from '../../../constants/constants';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {
   createConfig,
   createDownloadSchemes,
@@ -163,74 +161,63 @@
     return inputs.concat(textareas).concat(selects);
   }
 
-  setup(() => {
+  setup(async () => {
     loggedInStub = stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
     repoStub = stubRestApi('getProjectConfig').returns(
       Promise.resolve(repoConf)
     );
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
-  test('_computePluginData', () => {
-    assert.deepEqual(element._computePluginData(), []);
-    assert.deepEqual(
-      element._computePluginData(
-        {} as unknown as PolymerDeepPropertyChange<
-          PluginNameToPluginParametersMap,
-          PluginNameToPluginParametersMap
-        >
-      ),
-      []
-    );
-    assert.deepEqual(
-      element._computePluginData({base: {}} as PolymerDeepPropertyChange<
-        PluginNameToPluginParametersMap,
-        PluginNameToPluginParametersMap
-      >),
-      []
-    );
-    assert.deepEqual(
-      element._computePluginData({
-        base: {
-          'test-plugin': {test: {display_name: 'test plugin', type: 'STRING'}},
-        } as PluginNameToPluginParametersMap,
-      } as PolymerDeepPropertyChange<PluginNameToPluginParametersMap, PluginNameToPluginParametersMap>),
-      [
-        {
-          name: 'test-plugin',
-          config: {
-            test: {
-              display_name: 'test plugin',
-              type: 'STRING' as ConfigParameterInfoType,
-            },
-          },
-        },
-      ]
-    );
-  });
-
-  test('_handlePluginConfigChanged', async () => {
-    const notifyStub = sinon.stub(element, 'notifyPath');
-    element._repoConfig = {
+  test('_computePluginData', async () => {
+    element.repoConfig = {
       ...createConfig(),
       plugin_config: {},
     };
-    element._handlePluginConfigChanged({
+    await element.updateComplete;
+    assert.deepEqual(element.computePluginData(), []);
+
+    element.repoConfig.plugin_config = {
+      'test-plugin': {
+        test: {display_name: 'test plugin', type: 'STRING'},
+      } as PluginParameterToConfigParameterInfoMap,
+    };
+    await element.updateComplete;
+    assert.deepEqual(element.computePluginData(), [
+      {
+        name: 'test-plugin',
+        config: {
+          test: {
+            display_name: 'test plugin',
+            type: 'STRING' as ConfigParameterInfoType,
+          },
+        },
+      },
+    ]);
+  });
+
+  test('handlePluginConfigChanged', async () => {
+    const requestUpdateStub = sinon.stub(element, 'requestUpdate');
+    element.repoConfig = {
+      ...createConfig(),
+      plugin_config: {},
+    };
+    element.handlePluginConfigChanged({
       detail: {
         name: 'test',
         config: {
           test: {display_name: 'test plugin', type: 'STRING'},
         } as PluginParameterToConfigParameterInfoMap,
-        notifyPath: 'path',
       },
     });
-    await flush();
+    await element.updateComplete;
 
-    assert.deepEqual(element._repoConfig!.plugin_config!.test, {
+    assert.deepEqual(element.repoConfig!.plugin_config!.test, {
       test: {display_name: 'test plugin', type: 'STRING'},
     } as PluginParameterToConfigParameterInfoMap);
-    assert.equal(notifyStub.lastCall.args[0], '_repoConfig.plugin_config.path');
+    assert.isTrue(requestUpdateStub.called);
   });
 
   test('loading displays before repo config is loaded', () => {
@@ -257,8 +244,8 @@
   });
 
   test('download commands visibility', async () => {
-    element._loading = false;
-    await flush();
+    element.loading = false;
+    await element.updateComplete;
     assert.isTrue(
       queryAndAssert<HTMLDivElement>(
         element,
@@ -270,8 +257,8 @@
         queryAndAssert<HTMLDivElement>(element, '#downloadContent')
       ).display === 'none'
     );
-    element._schemesObj = SCHEMES;
-    await flush();
+    element.schemesObj = SCHEMES;
+    await element.updateComplete;
     assert.isFalse(
       queryAndAssert<HTMLDivElement>(
         element,
@@ -286,13 +273,13 @@
   });
 
   test('form defaults to read only', () => {
-    assert.isTrue(element._readOnly);
+    assert.isTrue(element.readOnly);
   });
 
   test('form defaults to read only when not logged in', async () => {
     element.repo = REPO as RepoName;
-    await element._loadRepo();
-    assert.isTrue(element._readOnly);
+    await element.loadRepo();
+    assert.isTrue(element.readOnly);
   });
 
   test('form defaults to read only when logged in and not admin', async () => {
@@ -321,26 +308,26 @@
         },
       } as ProjectAccessInfoMap)
     );
-    await element._loadRepo();
-    assert.isTrue(element._readOnly);
+    await element.loadRepo();
+    assert.isTrue(element.readOnly);
   });
 
   test('all form elements are disabled when not admin', async () => {
     element.repo = REPO as RepoName;
-    await element._loadRepo();
-    flush();
+    await element.loadRepo();
+    await element.updateComplete;
     const formFields = getFormFields();
     for (const field of formFields) {
       assert.isTrue(field.hasAttribute('disabled'));
     }
   });
 
-  test('_formatBooleanSelect', () => {
+  test('formatBooleanSelect', () => {
     let item: InheritedBooleanInfo = {
       ...createInheritedBoolean(true),
       inherited_value: true,
     };
-    assert.deepEqual(element._formatBooleanSelect(item), [
+    assert.deepEqual(element.formatBooleanSelect(item), [
       {
         label: 'Inherit (true)',
         value: 'INHERIT',
@@ -356,7 +343,7 @@
     ]);
 
     item = {...createInheritedBoolean(false), inherited_value: false};
-    assert.deepEqual(element._formatBooleanSelect(item), [
+    assert.deepEqual(element.formatBooleanSelect(item), [
       {
         label: 'Inherit (false)',
         value: 'INHERIT',
@@ -373,7 +360,7 @@
 
     // For items without inherited values
     item = createInheritedBoolean(false);
-    assert.deepEqual(element._formatBooleanSelect(item), [
+    assert.deepEqual(element.formatBooleanSelect(item), [
       {
         label: 'Inherit',
         value: 'INHERIT',
@@ -407,7 +394,7 @@
       pageErrorFired.resolve();
     });
 
-    element._loadRepo();
+    element.loadRepo();
     await pageErrorFired;
   });
 
@@ -442,18 +429,18 @@
     });
 
     test('all form elements are enabled', async () => {
-      await element._loadRepo();
-      await flush();
+      await element.loadRepo();
+      await element.updateComplete;
       const formFields = getFormFields();
       for (const field of formFields) {
         assert.isFalse(field.hasAttribute('disabled'));
       }
-      assert.isFalse(element._loading);
+      assert.isFalse(element.loading);
     });
 
     test('state gets set correctly', async () => {
-      await element._loadRepo();
-      assert.equal(element._repoConfig!.state, ProjectState.ACTIVE);
+      await element.loadRepo();
+      assert.equal(element.repoConfig!.state, ProjectState.ACTIVE);
       assert.equal(
         queryAndAssert<GrSelect>(element, '#stateSelect').bindValue,
         ProjectState.ACTIVE
@@ -461,7 +448,7 @@
     });
 
     test('inherited submit type value is calculated correctly', async () => {
-      await element._loadRepo();
+      await element.loadRepo();
       const sel = queryAndAssert<GrSelect>(element, '#submitTypeSelect');
       assert.equal(sel.bindValue, 'INHERIT');
       assert.equal(
@@ -499,7 +486,7 @@
 
       const button = queryAll<GrButton>(element, 'gr-button')[2];
 
-      await element._loadRepo();
+      await element.loadRepo();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.isFalse(
         queryAndAssert<HTMLHeadingElement>(
@@ -556,6 +543,8 @@
       queryAndAssert<GrSelect>(element, '#unRegisteredCcSelect').bindValue =
         configInputObj.enable_reviewer_by_email;
 
+      await element.updateComplete;
+
       assert.isFalse(button.hasAttribute('disabled'));
       assert.isTrue(
         queryAndAssert<HTMLHeadingElement>(
@@ -564,12 +553,10 @@
         ).classList.contains('edited')
       );
 
-      const formattedObj = element._formatRepoConfigForSave(
-        element._repoConfig
-      );
+      const formattedObj = element.formatRepoConfigForSave(element.repoConfig);
       assert.deepEqual(formattedObj, configInputObj);
 
-      await element._handleSaveRepoConfig();
+      await element.handleSaveRepoConfig();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.isFalse(
         queryAndAssert<HTMLHeadingElement>(
diff --git a/polygerrit-ui/app/utils/object-util.ts b/polygerrit-ui/app/utils/object-util.ts
new file mode 100644
index 0000000..95676c5
--- /dev/null
+++ b/polygerrit-ui/app/utils/object-util.ts
@@ -0,0 +1,24 @@
+/**
+ * @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.
+ */
+
+/**
+ * @param obj Object
+ */
+export function deepClone(obj?: object) {
+  if (!obj) return undefined;
+  return JSON.parse(JSON.stringify(obj));
+}