Merge changes I38ea8a25,I5e79bba6,I9f0ef814,Iec0e7f1d

* changes:
  gr-group: Use gr-textarea
  Convert gr-group to lit
  Convert gr-group_test.js to typescript
  Fix template problems with gr-group
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 896f9ac..980abb4 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -97,7 +97,6 @@
     "elements/admin/gr-admin-view/gr-admin-view_html.ts",
     "elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts",
     "elements/admin/gr-group-members/gr-group-members_html.ts",
-    "elements/admin/gr-group/gr-group_html.ts",
     "elements/admin/gr-permission/gr-permission_html.ts",
     "elements/admin/gr-plugin-list/gr-plugin-list_html.ts",
     "elements/admin/gr-repo-access/gr-repo-access_html.ts",
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index cf0fdd4..6bd1ac5 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -505,7 +505,7 @@
     suite('groups', () => {
       let getGroupConfigStub;
       setup(() => {
-        stub('gr-group', '_loadGroup').callsFake(() => Promise.resolve({}));
+        stub('gr-group', 'loadGroup').callsFake(() => Promise.resolve({}));
         stub('gr-group-members', '_loadGroupDetails').callsFake(() => {});
 
         getGroupConfigStub = stubRestApi('getGroupConfig');
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 596fe5b..d7ffbaf 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -15,29 +15,27 @@
  * limitations under the License.
  */
 
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-select/gr-select';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-group_html';
-import {customElement, property, observe} from '@polymer/decorators';
+import '../../shared/gr-textarea/gr-textarea';
 import {
   AutocompleteSuggestion,
   AutocompleteQuery,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {
-  fireEvent,
-  firePageError,
-  fireTitleChange,
-} from '../../../utils/event-util';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {convertToString} from '../../../utils/string-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -52,90 +50,267 @@
   },
 };
 
-export interface GrGroup {
-  $: {
-    loading: HTMLDivElement;
-  };
-}
-
 export interface GroupNameChangedDetail {
   name: GroupName;
   external: boolean;
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'text-changed': CustomEvent;
+    'value-changed': CustomEvent;
+  }
   interface HTMLElementTagNameMap {
     'gr-group': GrGroup;
   }
 }
 
 @customElement('gr-group')
-export class GrGroup extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrGroup extends LitElement {
   /**
    * Fired when the group name changes.
    *
    * @event name-changed
    */
 
+  private readonly query: AutocompleteQuery;
+
   @property({type: String})
   groupId?: GroupId;
 
-  @property({type: Boolean})
-  _rename = false;
+  @state() private originalOwnerName?: string;
 
-  @property({type: Boolean})
-  _groupIsInternal = false;
+  @state() private originalDescriptionName?: string;
 
-  @property({type: Boolean})
-  _description = false;
+  @state() private originalOptionsVisibleToAll?: boolean;
 
-  @property({type: Boolean})
-  _owner = false;
+  @state() private submitTypes = Object.values(OPTIONS);
 
-  @property({type: Boolean})
-  _options = false;
+  /* private but used in test */
+  @state() isAdmin = false;
 
-  @property({type: Boolean})
-  _loading = true;
+  /* private but used in test */
+  @state() groupOwner = false;
 
-  @property({type: Object})
-  _groupConfig?: GroupInfo;
+  /* private but used in test */
+  @state() groupIsInternal = false;
 
-  @property({type: String})
-  _groupConfigOwner?: string;
+  /* private but used in test */
+  @state() loading = true;
 
-  @property({type: Object})
-  _groupName?: string;
+  /* private but used in test */
+  @state() groupConfig?: GroupInfo;
 
-  @property({type: Boolean})
-  _groupOwner = false;
+  /* private but used in test */
+  @state() groupConfigOwner?: string;
 
-  @property({type: Array})
-  _submitTypes = Object.values(OPTIONS);
-
-  @property({type: Object})
-  _query: AutocompleteQuery;
-
-  @property({type: Boolean})
-  _isAdmin = false;
+  /* private but used in test */
+  @state() originalName?: GroupName;
 
   private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
-    this._query = (input: string) => this._getGroupSuggestions(input);
+    this.query = (input: string) => this.getGroupSuggestions(input);
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadGroup();
+    this.loadGroup();
   }
 
-  _loadGroup() {
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      sharedStyles,
+      subpageStyles,
+      css`
+        h3.edited:after {
+          color: var(--deemphasized-text-color);
+          content: ' *';
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="main gr-form-styles read-only">
+        <div id="loading" class="${this.computeLoadingClass()}">Loading...</div>
+        <div id="loadedContent" class="${this.computeLoadingClass()}">
+          <h1 id="Title" class="heading-1">
+            ${convertToString(this.originalName)}
+          </h1>
+          <h2 id="configurations" class="heading-2">General</h2>
+          <div id="form">
+            <fieldset>
+              ${this.renderGroupUUID()} ${this.renderGroupName()}
+              ${this.renderGroupOwner()} ${this.renderGroupDescription()}
+              ${this.renderGroupOptions()}
+            </fieldset>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderGroupUUID() {
+    return html`
+      <h3 id="groupUUID" class="heading-3">Group UUID</h3>
+      <fieldset>
+        <gr-copy-clipboard
+          id="uuid"
+          .text=${this.getGroupUUID()}
+        ></gr-copy-clipboard>
+      </fieldset>
+    `;
+  }
+
+  private renderGroupName() {
+    const groupNameEdited = this.originalName !== this.groupConfig?.name;
+    return html`
+      <h3
+        id="groupName"
+        class="heading-3 ${this.computeHeaderClass(groupNameEdited)}"
+      >
+        Group Name
+      </h3>
+      <fieldset>
+        <span class="value">
+          <gr-autocomplete
+            id="groupNameInput"
+            .text=${convertToString(this.groupConfig?.name)}
+            ?disabled=${this.computeGroupDisabled()}
+            @text-changed=${this.handleNameTextChanged}
+          ></gr-autocomplete>
+        </span>
+        <span class="value" ?disabled=${this.computeGroupDisabled()}>
+          <gr-button
+            id="inputUpdateNameBtn"
+            ?disabled=${!groupNameEdited}
+            @click=${this.handleSaveName}
+          >
+            Rename Group</gr-button
+          >
+        </span>
+      </fieldset>
+    `;
+  }
+
+  private renderGroupOwner() {
+    const groupOwnerNameEdited =
+      this.originalOwnerName !== this.groupConfig?.owner;
+    return html`
+      <h3
+        id="groupOwner"
+        class="heading-3 ${this.computeHeaderClass(groupOwnerNameEdited)}"
+      >
+        Owners
+      </h3>
+      <fieldset>
+        <span class="value">
+          <gr-autocomplete
+            id="groupOwnerInput"
+            .text=${convertToString(this.groupConfig?.owner)}
+            .value=${convertToString(this.groupConfigOwner)}
+            .query=${this.query}
+            ?disabled=${this.computeGroupDisabled()}
+            @text-changed=${this.handleOwnerTextChanged}
+            @value-changed=${this.handleOwnerValueChanged}
+          >
+          </gr-autocomplete>
+        </span>
+        <span class="value" ?disabled=${this.computeGroupDisabled()}>
+          <gr-button
+            id="inputUpdateOwnerBtn"
+            ?disabled=${!groupOwnerNameEdited}
+            @click=${this.handleSaveOwner}
+          >
+            Change Owners</gr-button
+          >
+        </span>
+      </fieldset>
+    `;
+  }
+
+  private renderGroupDescription() {
+    const groupDescriptionEdited =
+      this.originalDescriptionName !== this.groupConfig?.description;
+    return html`
+      <h3 class="heading-3 ${this.computeHeaderClass(groupDescriptionEdited)}">
+        Description
+      </h3>
+      <fieldset>
+        <div>
+          <gr-textarea
+            class="description"
+            autocomplete="on"
+            rows="4"
+            monospace
+            ?disabled=${this.computeGroupDisabled()}
+            .text=${convertToString(this.groupConfig?.description)}
+            @text-changed=${this.handleDescriptionTextChanged}
+          >
+        </div>
+        <span class="value" ?disabled=${this.computeGroupDisabled()}>
+          <gr-button
+            ?disabled=${!groupDescriptionEdited}
+            @click=${this.handleSaveDescription}
+          >
+            Save Description
+          </gr-button>
+        </span>
+      </fieldset>
+    `;
+  }
+
+  private renderGroupOptions() {
+    const groupOptionsEdited =
+      this.originalOptionsVisibleToAll !==
+      this.groupConfig?.options?.visible_to_all;
+    return html`
+      <h3
+        id="options"
+        class="heading-3 ${this.computeHeaderClass(groupOptionsEdited)}"
+      >
+        Group Options
+      </h3>
+      <fieldset>
+        <section>
+          <span class="title">
+            Make group visible to all registered users
+          </span>
+          <span class="value">
+            <gr-select
+              id="visibleToAll"
+              .bindValue="${this.groupConfig?.options?.visible_to_all}"
+              @bind-value-changed=${this.handleOptionsBindValueChanged}
+            >
+              <select ?disabled=${this.computeGroupDisabled()}>
+                ${this.submitTypes.map(
+                  item => html`
+                    <option value=${item.value}>${item.label}</option>
+                  `
+                )}
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <span class="value" ?disabled=${this.computeGroupDisabled()}>
+          <gr-button
+            ?disabled=${!groupOptionsEdited}
+            @click=${this.handleSaveOptions}
+          >
+            Save Group Options
+          </gr-button>
+        </span>
+      </fieldset>
+    `;
+  }
+
+  /* private but used in test */
+  async loadGroup() {
     if (!this.groupId) {
       return;
     }
@@ -146,154 +321,127 @@
       firePageError(response);
     };
 
-    return this.restApiService
-      .getGroupConfig(this.groupId, errFn)
-      .then(config => {
-        if (!config || !config.name) {
-          return Promise.resolve();
-        }
+    const config = await this.restApiService.getGroupConfig(
+      this.groupId,
+      errFn
+    );
+    if (!config || !config.name) return;
 
-        this._groupName = config.name;
-        this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
+    if (config.description === undefined) {
+      config.description = '';
+    }
 
-        promises.push(
-          this.restApiService.getIsAdmin().then(isAdmin => {
-            this._isAdmin = !!isAdmin;
-          })
-        );
+    this.originalName = config.name;
+    this.originalOwnerName = config.owner;
+    this.originalDescriptionName = config.description;
+    this.groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
 
-        promises.push(
-          this.restApiService.getIsGroupOwner(config.name).then(isOwner => {
-            this._groupOwner = !!isOwner;
-          })
-        );
+    promises.push(
+      this.restApiService.getIsAdmin().then(isAdmin => {
+        this.isAdmin = !!isAdmin;
+      })
+    );
 
-        // If visible to all is undefined, set to false. If it is defined
-        // as false, setting to false is fine. If any optional values
-        // are added with a default of true, then this would need to be an
-        // undefined check and not a truthy/falsy check.
-        if (config.options && !config.options.visible_to_all) {
-          config.options.visible_to_all = false;
-        }
-        this._groupConfig = config;
+    promises.push(
+      this.restApiService.getIsGroupOwner(config.name).then(isOwner => {
+        this.groupOwner = !!isOwner;
+      })
+    );
 
-        fireTitleChange(this, config.name);
+    // If visible to all is undefined, set to false. If it is defined
+    // as false, setting to false is fine. If any optional values
+    // are added with a default of true, then this would need to be an
+    // undefined check and not a truthy/falsy check.
+    if (config.options && !config.options.visible_to_all) {
+      config.options.visible_to_all = false;
+    }
+    this.groupConfig = config;
+    this.originalOptionsVisibleToAll = config?.options?.visible_to_all;
 
-        return Promise.all(promises).then(() => {
-          this._loading = false;
-        });
-      });
+    fireTitleChange(this, config.name);
+
+    await Promise.all(promises);
+    this.loading = false;
   }
 
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
+  /* private but used in test */
+  computeLoadingClass() {
+    return this.loading ? 'loading' : '';
   }
 
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _handleSaveName() {
-    const groupConfig = this._groupConfig;
+  /* private but used in test */
+  async handleSaveName() {
+    const groupConfig = this.groupConfig;
     if (!this.groupId || !groupConfig || !groupConfig.name) {
       return Promise.reject(new Error('invalid groupId or config name'));
     }
     const groupName = groupConfig.name;
-    return this.restApiService
-      .saveGroupName(this.groupId, groupName)
-      .then(config => {
-        if (config.status === 200) {
-          this._groupName = groupName;
-          const detail: GroupNameChangedDetail = {
-            name: groupName,
-            external: !this._groupIsInternal,
-          };
-          fireEvent(this, 'name-changed');
-          this.dispatchEvent(
-            new CustomEvent('name-changed', {
-              detail,
-              composed: true,
-              bubbles: true,
-            })
-          );
-          this._rename = false;
-        }
-      });
+    const config = await this.restApiService.saveGroupName(
+      this.groupId,
+      groupName
+    );
+    if (config.status === 200) {
+      this.originalName = groupName;
+      const detail: GroupNameChangedDetail = {
+        name: groupName,
+        external: !this.groupIsInternal,
+      };
+      this.dispatchEvent(
+        new CustomEvent('name-changed', {
+          detail,
+          composed: true,
+          bubbles: true,
+        })
+      );
+      this.requestUpdate();
+    }
+
+    return;
   }
 
-  _handleSaveOwner() {
-    if (!this.groupId || !this._groupConfig) return;
-    let owner = this._groupConfig.owner;
-    if (this._groupConfigOwner) {
-      owner = decodeURIComponent(this._groupConfigOwner);
+  /* private but used in test */
+  async handleSaveOwner() {
+    if (!this.groupId || !this.groupConfig) return;
+    let owner = this.groupConfig.owner;
+    if (this.groupConfigOwner) {
+      owner = decodeURIComponent(this.groupConfigOwner);
     }
     if (!owner) return;
-    return this.restApiService.saveGroupOwner(this.groupId, owner).then(() => {
-      this._owner = false;
-    });
+    await this.restApiService.saveGroupOwner(this.groupId, owner);
+    this.originalOwnerName = this.groupConfig?.owner;
+    this.groupConfigOwner = undefined;
   }
 
-  _handleSaveDescription() {
-    if (!this.groupId || !this._groupConfig || !this._groupConfig.description)
+  /* private but used in test */
+  async handleSaveDescription() {
+    if (
+      !this.groupId ||
+      !this.groupConfig ||
+      this.groupConfig.description === undefined
+    )
       return;
-    return this.restApiService
-      .saveGroupDescription(this.groupId, this._groupConfig.description)
-      .then(() => {
-        this._description = false;
-      });
+    await this.restApiService.saveGroupDescription(
+      this.groupId,
+      this.groupConfig.description
+    );
+    this.originalDescriptionName = this.groupConfig.description;
   }
 
-  _handleSaveOptions() {
-    if (!this.groupId || !this._groupConfig || !this._groupConfig.options)
-      return;
-    const visible = this._groupConfig.options.visible_to_all;
-
+  /* private but used in test */
+  async handleSaveOptions() {
+    if (!this.groupId || !this.groupConfig || !this.groupConfig.options) return;
+    const visible = this.groupConfig.options.visible_to_all;
     const options = {visible_to_all: visible};
-
-    return this.restApiService
-      .saveGroupOptions(this.groupId, options)
-      .then(() => {
-        this._options = false;
-      });
+    await this.restApiService.saveGroupOptions(this.groupId, options);
+    this.originalOptionsVisibleToAll =
+      this.groupConfig?.options?.visible_to_all;
   }
 
-  @observe('_groupConfig.name')
-  _handleConfigName() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._rename = true;
-  }
-
-  @observe('_groupConfig.owner', '_groupConfigOwner')
-  _handleConfigOwner() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._owner = true;
-  }
-
-  @observe('_groupConfig.description')
-  _handleConfigDescription() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._description = true;
-  }
-
-  @observe('_groupConfig.options.visible_to_all')
-  _handleConfigOptions() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._options = true;
-  }
-
-  _computeHeaderClass(configChanged: boolean) {
+  private computeHeaderClass(configChanged: boolean) {
     return configChanged ? 'edited' : '';
   }
 
-  _getGroupSuggestions(input: string) {
+  private getGroupSuggestions(input: string) {
     return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups: AutocompleteSuggestion[] = [];
       for (const [name, group] of Object.entries(response ?? {})) {
@@ -303,17 +451,45 @@
     });
   }
 
-  _computeGroupDisabled(
-    owner: boolean,
-    admin: boolean,
-    groupIsInternal: boolean
-  ) {
-    return !(groupIsInternal && (admin || owner));
+  /* private but used in test */
+  computeGroupDisabled() {
+    return !(this.groupIsInternal && (this.isAdmin || this.groupOwner));
   }
 
-  _getGroupUUID(id: GroupId) {
+  private getGroupUUID() {
+    const id = this.groupConfig?.id;
     if (!id) return;
-
     return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
   }
+
+  private handleNameTextChanged(e: CustomEvent) {
+    if (!this.groupConfig || this.loading) return;
+    this.groupConfig.name = e.detail.value as GroupName;
+    this.requestUpdate();
+  }
+
+  private handleOwnerTextChanged(e: CustomEvent) {
+    if (!this.groupConfig || this.loading) return;
+    this.groupConfig.owner = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleOwnerValueChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.groupConfigOwner = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleDescriptionTextChanged(e: CustomEvent) {
+    if (!this.groupConfig || this.loading) return;
+    this.groupConfig.description = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleOptionsBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.groupConfig || !this.groupConfig.options || this.loading) return;
+    this.groupConfig.options.visible_to_all = e.detail
+      .value as unknown as boolean;
+    this.requestUpdate();
+  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
deleted file mode 100644
index 6bc5d2a..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
+++ /dev/null
@@ -1,168 +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">
-    h3.edited:after {
-      color: var(--deemphasized-text-color);
-      content: ' *';
-    }
-  </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">
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h1 id="Title" class="heading-1">[[_groupName]]</h1>
-      <h2 id="configurations" class="heading-2">General</h2>
-      <div id="form">
-        <fieldset>
-          <h3 id="groupUUID" class="heading-3">Group UUID</h3>
-          <fieldset>
-            <gr-copy-clipboard
-              id="uuid"
-              text="[[_getGroupUUID(_groupConfig.id)]]"
-            ></gr-copy-clipboard>
-          </fieldset>
-          <h3
-            id="groupName"
-            class$="heading-3 [[_computeHeaderClass(_rename)]]"
-          >
-            Group Name
-          </h3>
-          <fieldset>
-            <span class="value">
-              <gr-autocomplete
-                id="groupNameInput"
-                text="{{_groupConfig.name}}"
-                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              ></gr-autocomplete>
-            </span>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button
-                id="inputUpdateNameBtn"
-                on-click="_handleSaveName"
-                disabled="[[!_rename]]"
-              >
-                Rename Group</gr-button
-              >
-            </span>
-          </fieldset>
-          <h3
-            id="groupOwner"
-            class$="heading-3 [[_computeHeaderClass(_owner)]]"
-          >
-            Owners
-          </h3>
-          <fieldset>
-            <span class="value">
-              <gr-autocomplete
-                id="groupOwnerInput"
-                text="{{_groupConfig.owner}}"
-                value="{{_groupConfigOwner}}"
-                query="[[_query]]"
-                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              >
-              </gr-autocomplete>
-            </span>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button
-                id="inputUpdateOwnerBtn"
-                on-click="_handleSaveOwner"
-                disabled="[[!_owner]]"
-              >
-                Change Owners</gr-button
-              >
-            </span>
-          </fieldset>
-          <h3 class$="heading-3 [[_computeHeaderClass(_description)]]">
-            Description
-          </h3>
-          <fieldset>
-            <div>
-              <iron-autogrow-textarea
-                class="description"
-                autocomplete="on"
-                bind-value="{{_groupConfig.description}}"
-                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              ></iron-autogrow-textarea>
-            </div>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button
-                on-click="_handleSaveDescription"
-                disabled="[[!_description]]"
-              >
-                Save Description
-              </gr-button>
-            </span>
-          </fieldset>
-          <h3 id="options" class$="heading-3 [[_computeHeaderClass(_options)]]">
-            Group Options
-          </h3>
-          <fieldset>
-            <section>
-              <span class="title">
-                Make group visible to all registered users
-              </span>
-              <span class="value">
-                <gr-select
-                  id="visibleToAll"
-                  bind-value="{{_groupConfig.options.visible_to_all}}"
-                >
-                  <select
-                    disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-                  >
-                    <template is="dom-repeat" items="[[_submitTypes]]">
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button on-click="_handleSaveOptions" disabled="[[!_options]]">
-                Save Group Options
-              </gr-button>
-            </span>
-          </fieldset>
-        </fieldset>
-      </div>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
deleted file mode 100644
index e390ac5..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
+++ /dev/null
@@ -1,234 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-group.js';
-import {
-  addListenerForTest,
-  mockPromise,
-  stubRestApi,
-} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-group');
-
-suite('gr-group tests', () => {
-  let element;
-
-  let groupStub;
-  const group = {
-    id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    options: {},
-    description: 'Gerrit Site Administrators',
-    group_id: 1,
-    owner: 'Administrators',
-    owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    name: 'Administrators',
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(group));
-  });
-
-  test('loading displays before group config is loaded', () => {
-    assert.isTrue(element.$.loading.classList.contains('loading'));
-    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-    assert.isTrue(getComputedStyle(element.$.loadedContent)
-        .display === 'none');
-  });
-
-  test('default values are populated with internal group', async () => {
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
-    element.groupId = 1;
-    await element._loadGroup();
-    assert.isTrue(element._groupIsInternal);
-    assert.isFalse(element.$.visibleToAll.bindValue);
-  });
-
-  test('default values with external group', async () => {
-    const groupExternal = {...group};
-    groupExternal.id = 'external-group-id';
-    groupStub.restore();
-    groupStub = stubRestApi('getGroupConfig').returns(
-        Promise.resolve(groupExternal));
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
-    element.groupId = 1;
-    await element._loadGroup();
-    assert.isFalse(element._groupIsInternal);
-    assert.isFalse(element.$.visibleToAll.bindValue);
-  });
-
-  test('rename group', async () => {
-    const groupName = 'test-group';
-    const groupName2 = 'test-group2';
-    element.groupId = 1;
-    element._groupConfig = {
-      name: groupName,
-    };
-    element._groupName = groupName;
-
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
-    stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
-
-    const button = element.$.inputUpdateNameBtn;
-
-    await element._loadGroup();
-    assert.isTrue(button.hasAttribute('disabled'));
-    assert.isFalse(element.$.Title.classList.contains('edited'));
-
-    element.$.groupNameInput.text = groupName2;
-
-    await flush();
-    assert.isFalse(button.hasAttribute('disabled'));
-    assert.isTrue(element.$.groupName.classList.contains('edited'));
-
-    await element._handleSaveName();
-    assert.isTrue(button.hasAttribute('disabled'));
-    assert.isFalse(element.$.Title.classList.contains('edited'));
-    assert.equal(element._groupName, groupName2);
-  });
-
-  test('rename group owner', async () => {
-    const groupName = 'test-group';
-    element.groupId = 1;
-    element._groupConfig = {
-      name: groupName,
-    };
-    element._groupConfigOwner = 'testId';
-    element._groupOwner = true;
-
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve({status: 200}));
-
-    const button = element.$.inputUpdateOwnerBtn;
-
-    await element._loadGroup();
-    assert.isTrue(button.hasAttribute('disabled'));
-    assert.isFalse(element.$.Title.classList.contains('edited'));
-
-    element.$.groupOwnerInput.text = 'testId2';
-
-    await flush();
-    assert.isFalse(button.hasAttribute('disabled'));
-    assert.isTrue(element.$.groupOwner.classList.contains('edited'));
-
-    await element._handleSaveOwner();
-    assert.isTrue(button.hasAttribute('disabled'));
-    assert.isFalse(element.$.Title.classList.contains('edited'));
-  });
-
-  test('test for undefined group name', async () => {
-    groupStub.restore();
-
-    stubRestApi('getGroupConfig').returns(Promise.resolve({}));
-
-    assert.isUndefined(element.groupId);
-
-    element.groupId = 1;
-
-    assert.isDefined(element.groupId);
-
-    // Test that loading shows instead of filling
-    // in group details
-    await element._loadGroup();
-    assert.isTrue(element.$.loading.classList.contains('loading'));
-
-    assert.isTrue(element._loading);
-  });
-
-  test('test fire event', async () => {
-    element._groupConfig = {
-      name: 'test-group',
-    };
-    element.groupId = 'gg';
-    stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
-
-    const showStub = sinon.stub(element, 'dispatchEvent');
-    await element._handleSaveName();
-    assert.isTrue(showStub.called);
-  });
-
-  test('_computeGroupDisabled', () => {
-    let admin = true;
-    let owner = false;
-    let groupIsInternal = true;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), false);
-
-    admin = false;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-
-    owner = true;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), false);
-
-    owner = false;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-
-    groupIsInternal = false;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-
-    admin = true;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-  });
-
-  test('_computeLoadingClass', () => {
-    assert.equal(element._computeLoadingClass(true), 'loading');
-    assert.equal(element._computeLoadingClass(false), '');
-  });
-
-  test('fires page-error', async () => {
-    groupStub.restore();
-
-    element.groupId = 1;
-
-    const response = {status: 404};
-    stubRestApi('getGroupConfig').callsFake((group, errFn) => {
-      errFn(response);
-      return Promise.resolve(undefined);
-    });
-
-    const promise = mockPromise();
-    addListenerForTest(document, 'page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      promise.resolve();
-    });
-
-    element._loadGroup();
-    await promise;
-  });
-
-  test('uuid', () => {
-    element._groupConfig = {
-      id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    };
-
-    assert.equal(element._groupConfig.id, element.$.uuid.text);
-
-    element._groupConfig = {
-      id: 'user%2Fgroup',
-    };
-
-    assert.equal('user/group', element.$.uuid.text);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
new file mode 100644
index 0000000..5e96e33
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -0,0 +1,314 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-group';
+import {GrGroup} from './gr-group';
+import {
+  addListenerForTest,
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {createGroupInfo} from '../../../test/test-data-generators.js';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrCopyClipboard} from '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+
+const basicFixture = fixtureFromElement('gr-group');
+
+suite('gr-group tests', () => {
+  let element: GrGroup;
+  let groupStub: sinon.SinonStub;
+
+  const group: GroupInfo = {
+    ...createGroupInfo('6a1e70e1a88782771a91808c8af9bbb7a9871389'),
+    url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    options: {
+      visible_to_all: false,
+    },
+    description: 'Gerrit Site Administrators',
+    group_id: 1,
+    owner: 'Administrators',
+    owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    name: 'Administrators' as GroupName,
+  };
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(group));
+  });
+
+  test('loading displays before group config is loaded', () => {
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
+        'loading'
+      )
+    );
+    assert.isFalse(
+      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '#loading'))
+        .display === 'none'
+    );
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#loadedContent'
+      ).classList.contains('loading')
+    );
+    assert.isTrue(
+      getComputedStyle(
+        queryAndAssert<HTMLDivElement>(element, '#loadedContent')
+      ).display === 'none'
+    );
+  });
+
+  test('default values are populated with internal group', async () => {
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    element.groupId = '1' as GroupId;
+    await element.loadGroup();
+    assert.isTrue(element.groupIsInternal);
+    assert.isFalse(
+      queryAndAssert<GrSelect>(element, '#visibleToAll').bindValue
+    );
+  });
+
+  test('default values with external group', async () => {
+    const groupExternal = {...group};
+    groupExternal.id = 'external-group-id' as GroupId;
+    groupStub.restore();
+    groupStub = stubRestApi('getGroupConfig').returns(
+      Promise.resolve(groupExternal)
+    );
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    element.groupId = '1' as GroupId;
+    await element.loadGroup();
+    assert.isFalse(element.groupIsInternal);
+    assert.isFalse(
+      queryAndAssert<GrSelect>(element, '#visibleToAll').bindValue
+    );
+  });
+
+  test('rename group', async () => {
+    const groupName = 'test-group';
+    const groupName2 = 'test-group2';
+    element.groupId = '1' as GroupId;
+    element.groupConfig = {
+      name: groupName as GroupName,
+      id: '1' as GroupId,
+    };
+    element.originalName = groupName as GroupName;
+
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    stubRestApi('saveGroupName').returns(
+      Promise.resolve({...new Response(), status: 200})
+    );
+
+    const button = queryAndAssert<GrButton>(element, '#inputUpdateNameBtn');
+
+    await element.loadGroup();
+    assert.isTrue(button.hasAttribute('disabled'));
+    assert.isFalse(
+      queryAndAssert<HTMLHeadingElement>(element, '#Title').classList.contains(
+        'edited'
+      )
+    );
+
+    queryAndAssert<GrAutocomplete>(element, '#groupNameInput').text =
+      groupName2;
+
+    await element.updateComplete;
+
+    assert.isFalse(button.hasAttribute('disabled'));
+    assert.isTrue(
+      queryAndAssert<HTMLHeadingElement>(
+        element,
+        '#groupName'
+      ).classList.contains('edited')
+    );
+
+    await element.handleSaveName();
+    assert.isTrue(button.disabled);
+    assert.isFalse(
+      queryAndAssert<HTMLHeadingElement>(element, '#Title').classList.contains(
+        'edited'
+      )
+    );
+    assert.equal(element.originalName, groupName2);
+  });
+
+  test('rename group owner', async () => {
+    const groupName = 'test-group';
+    element.groupId = '1' as GroupId;
+    element.groupConfig = {
+      name: groupName as GroupName,
+      id: '1' as GroupId,
+    };
+    element.groupConfigOwner = 'testId';
+    element.groupOwner = true;
+
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+
+    const button = queryAndAssert<GrButton>(element, '#inputUpdateOwnerBtn');
+
+    await element.loadGroup();
+    assert.isTrue(button.disabled);
+    assert.isFalse(
+      queryAndAssert<HTMLHeadingElement>(element, '#Title').classList.contains(
+        'edited'
+      )
+    );
+
+    queryAndAssert<GrAutocomplete>(element, '#groupOwnerInput').text =
+      'testId2';
+
+    await element.updateComplete;
+    assert.isFalse(button.disabled);
+    assert.isTrue(
+      queryAndAssert<HTMLHeadingElement>(
+        element,
+        '#groupOwner'
+      ).classList.contains('edited')
+    );
+
+    await element.handleSaveOwner();
+    assert.isTrue(button.disabled);
+    assert.isFalse(
+      queryAndAssert<HTMLHeadingElement>(element, '#Title').classList.contains(
+        'edited'
+      )
+    );
+  });
+
+  test('test for undefined group name', async () => {
+    groupStub.restore();
+
+    stubRestApi('getGroupConfig').returns(Promise.resolve(undefined));
+
+    assert.isUndefined(element.groupId);
+
+    element.groupId = '1' as GroupId;
+
+    assert.isDefined(element.groupId);
+
+    // Test that loading shows instead of filling
+    // in group details
+    await element.loadGroup();
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
+        'loading'
+      )
+    );
+
+    assert.isTrue(element.loading);
+  });
+
+  test('test fire event', async () => {
+    element.groupConfig = {
+      name: 'test-group' as GroupName,
+      id: '1' as GroupId,
+    };
+    element.groupId = 'gg' as GroupId;
+    stubRestApi('saveGroupName').returns(
+      Promise.resolve({...new Response(), status: 200})
+    );
+
+    const showStub = sinon.stub(element, 'dispatchEvent');
+    await element.handleSaveName();
+    assert.isTrue(showStub.called);
+  });
+
+  test('computeGroupDisabled', () => {
+    element.isAdmin = true;
+    element.groupOwner = false;
+    element.groupIsInternal = true;
+    assert.equal(element.computeGroupDisabled(), false);
+
+    element.isAdmin = false;
+    assert.equal(element.computeGroupDisabled(), true);
+
+    element.groupOwner = true;
+    assert.equal(element.computeGroupDisabled(), false);
+
+    element.groupOwner = false;
+    assert.equal(element.computeGroupDisabled(), true);
+
+    element.groupIsInternal = false;
+    assert.equal(element.computeGroupDisabled(), true);
+
+    element.isAdmin = true;
+    assert.equal(element.computeGroupDisabled(), true);
+  });
+
+  test('computeLoadingClass', () => {
+    element.loading = true;
+    assert.equal(element.computeLoadingClass(), 'loading');
+    element.loading = false;
+    assert.equal(element.computeLoadingClass(), '');
+  });
+
+  test('fires page-error', async () => {
+    groupStub.restore();
+
+    element.groupId = '1' as GroupId;
+
+    const response = {...new Response(), status: 404};
+    stubRestApi('getGroupConfig').callsFake((_, errFn) => {
+      if (errFn !== undefined) {
+        errFn(response);
+      } else {
+        assert.fail('errFn is undefined');
+      }
+      return Promise.resolve(undefined);
+    });
+
+    const promise = mockPromise();
+    addListenerForTest(document, 'page-error', e => {
+      assert.deepEqual((e as CustomEvent).detail.response, response);
+      promise.resolve();
+    });
+
+    await element.loadGroup();
+    await promise;
+  });
+
+  test('uuid', async () => {
+    element.groupConfig = {
+      id: '6a1e70e1a88782771a91808c8af9bbb7a9871389' as GroupId,
+    };
+
+    await element.updateComplete;
+
+    assert.equal(
+      element.groupConfig.id,
+      queryAndAssert<GrCopyClipboard>(element, '#uuid').text
+    );
+
+    element.groupConfig = {
+      id: 'user%2Fgroup' as GroupId,
+    };
+
+    await element.updateComplete;
+
+    assert.equal(
+      'user/group',
+      queryAndAssert<GrCopyClipboard>(element, '#uuid').text
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 9e6b42a..ce1b282 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -32,6 +32,7 @@
   ItemSelectedEvent,
 } from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {addShortcut, Key} from '../../../utils/dom-util';
+import {BindValueChangeEvent} from '../../../types/events';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -63,10 +64,6 @@
   match: string;
 }
 
-interface ValueChangeEvent {
-  value: string;
-}
-
 export interface GrTextarea {
   $: {
     textarea: IronAutogrowTextareaElement;
@@ -79,7 +76,6 @@
 declare global {
   interface HTMLElementEventMap {
     'item-selected': CustomEvent<ItemSelectedEvent>;
-    'bind-value-changed': CustomEvent<ValueChangeEvent>;
   }
 }
 
@@ -316,7 +312,7 @@
    * _handleKeydown used for key handling in the this.$.textarea AND all child
    * autocomplete options.
    */
-  _onValueChanged(e: CustomEvent<ValueChangeEvent>) {
+  _onValueChanged(e: BindValueChangeEvent) {
     // Relay the event.
     this.dispatchEvent(
       new CustomEvent('bind-value-changed', {
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index b6376c4..f467cf6 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -22,6 +22,7 @@
 import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
 export enum EventType {
+  BIND_VALUE_CHANGED = 'bind-value-changed',
   CHANGE = 'change',
   CHANGED = 'changed',
   CHANGE_MESSAGE_DELETED = 'change-message-deleted',
@@ -56,6 +57,8 @@
 declare global {
   interface HTMLElementEventMap {
     /* prettier-ignore */
+    'bind-value-changed': BindValueChangeEvent;
+    /* prettier-ignore */
     'change': ChangeEvent;
     /* prettier-ignore */
     'changed': ChangedEvent;
@@ -102,6 +105,11 @@
   }
 }
 
+export interface BindValueChangeEventDetail {
+  value: string;
+}
+export type BindValueChangeEvent = CustomEvent<BindValueChangeEventDetail>;
+
 export type ChangeEvent = InputEvent;
 
 export type ChangedEvent = CustomEvent<string>;