Convert gr-rule-editor to lit

Change-Id: If48d85c6ad627f8e9e452a889ffd9bd24bb26b49
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 2754963..c953be4 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -55,6 +55,7 @@
 import {PolymerDomRepeatEvent} from '../../../types/types';
 import {getAppContext} from '../../../services/app-context';
 import {fireEvent} from '../../../utils/event-util';
+import {PolymerDomRepeatCustomEvent} from '../../../types/types';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
 
@@ -412,6 +413,19 @@
   _onTapExclusiveToggle(e: Event) {
     e.preventDefault();
   }
+
+  _handleRuleChanged(e: PolymerDomRepeatCustomEvent) {
+    if (
+      this._rules === undefined ||
+      (e as CustomEvent).detail.value === undefined
+    )
+      return;
+    const index = Number(e.model.index);
+    if (isNaN(index)) {
+      return;
+    }
+    this.splice('_rules', index, (e as CustomEvent).detail.value);
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
index a8405df..eacd5ef 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
@@ -115,9 +115,10 @@
             group-id="[[rule.id]]"
             group-name="[[_computeGroupName(groups, rule.id)]]"
             permission="[[permission.id]]"
-            rule="{{rule}}"
+            rule="[[rule]]"
             section="[[section]]"
             on-added-rule-removed="_handleAddedRuleRemoved"
+            on-rule-changed="_handleRuleChanged"
           ></gr-rule-editor>
         </template>
         <div id="addRule">
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index f811ee2..3b180df 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -15,16 +15,16 @@
  * limitations under the License.
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-rule-editor_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {AccessPermissionId} from '../../../utils/access-util';
-import {property, customElement, observe} from '@polymer/decorators';
 import {fireEvent} from '../../../utils/event-util';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
 
 /**
  * Fired when the rule has been modified or removed.
@@ -100,18 +100,14 @@
 }
 
 @customElement('gr-rule-editor')
-export class GrRuleEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrRuleEditor extends LitElement {
   @property({type: Boolean})
   hasRange?: boolean;
 
   @property({type: Object})
   label?: RuleLabel;
 
-  @property({type: Boolean, observer: '_handleEditingChanged'})
+  @property({type: Boolean})
   editing = false;
 
   @property({type: String})
@@ -124,97 +120,280 @@
   @property({type: String})
   permission!: AccessPermissionId;
 
-  @property({type: Object, notify: true})
+  @property({type: Object})
   rule?: Rule;
 
   @property({type: String})
   section?: string;
 
-  @property({type: Boolean})
-  _deleted = false;
+  // private but used in test
+  @state() deleted = false;
 
-  @property({type: Object})
-  _originalRuleValues?: RuleValue;
+  // private but used in test
+  @state() originalRuleValues?: RuleValue;
 
   constructor() {
     super();
-    this.addEventListener('access-saved', () => this._handleAccessSaved());
-  }
-
-  override ready() {
-    super.ready();
-    // Called on ready rather than the observer because when new rules are
-    // added, the observer is triggered prior to being ready.
-    if (!this.rule) {
-      return;
-    } // Check needed for test purposes.
-    this._setupValues(this.rule);
+    this.addEventListener('access-saved', () => this.handleAccessSaved());
   }
 
   override connectedCallback() {
     super.connectedCallback();
+    if (this.rule) {
+      this.setupValues();
+    }
     // Check needed for test purposes.
-    if (!this._originalRuleValues && this.rule) {
-      // Observer _handleValueChange is called after the ready()
-      // method finishes. Original values must be set later to
-      // avoid set .modified flag to true
-      this._setOriginalRuleValues(this.rule?.value);
+    if (!this.originalRuleValues && this.rule) {
+      this.setOriginalRuleValues();
     }
   }
 
-  _setupValues(rule?: Rule) {
-    if (!rule?.value) {
-      this._setDefaultRuleValues();
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      css`
+        :host {
+          border-bottom: 1px solid var(--border-color);
+          padding: var(--spacing-m);
+          display: block;
+        }
+        #removeBtn {
+          display: none;
+        }
+        .editing #removeBtn {
+          display: flex;
+        }
+        #options {
+          align-items: baseline;
+          display: flex;
+        }
+        #options > * {
+          margin-right: var(--spacing-m);
+        }
+        #mainContainer {
+          align-items: baseline;
+          display: flex;
+          flex-wrap: nowrap;
+          justify-content: space-between;
+        }
+        #deletedContainer.deleted {
+          align-items: baseline;
+          display: flex;
+          justify-content: space-between;
+        }
+        #undoBtn,
+        #force,
+        #deletedContainer,
+        #mainContainer.deleted {
+          display: none;
+        }
+        #undoBtn.modified,
+        #force.force {
+          display: block;
+        }
+        .groupPath {
+          color: var(--deemphasized-text-color);
+        }
+        iron-autogrow-textarea {
+          width: 14em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div
+        id="mainContainer"
+        class="gr-form-styles ${this.computeSectionClass()}"
+      >
+        <div id="options">
+          <gr-select
+            id="action"
+            .bindValue=${this.rule?.value?.action}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.handleActionBindValueChanged(e);
+            }}
+          >
+            <select ?disabled=${!this.editing}>
+              ${this.computeOptions().map(
+                item => html` <option value=${item}>${item}</option> `
+              )}
+            </select>
+          </gr-select>
+          ${this.renderMinAndMaxLabel()} ${this.renderMinAndMaxInput()}
+          <a class="groupPath" href="${this.computeGroupPath(this.groupId)}">
+            ${this.groupName}
+          </a>
+          <gr-select
+            id="force"
+            class="${this.computeForce(this.rule?.value?.action)
+              ? 'force'
+              : ''}"
+            .bindValue=${this.rule?.value?.force}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.handleForceBindValueChanged(e);
+            }}
+          >
+            <select ?disabled=${!this.editing}>
+              ${this.computeForceOptions(this.rule?.value?.action).map(
+                item => html`
+                  <option value=${item.value}>${item.value}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </div>
+        <gr-button
+          link
+          id="removeBtn"
+          @click=${() => {
+            this.handleRemoveRule();
+          }}
+          >Remove</gr-button
+        >
+      </div>
+      <div
+        id="deletedContainer"
+        class="gr-form-styles ${this.computeSectionClass()}"
+      >
+        ${this.groupName} was deleted
+        <gr-button
+          link
+          id="undoRemoveBtn"
+          @click=${() => {
+            this.handleUndoRemove();
+          }}
+          >Undo</gr-button
+        >
+      </div>
+    `;
+  }
+
+  private renderMinAndMaxLabel() {
+    if (!this.label) return;
+
+    return html`
+      <gr-select
+        id="labelMin"
+        .bindValue=${this.rule?.value?.min}
+        @bind-value-changed=${(e: BindValueChangeEvent) => {
+          this.handleMinBindValueChanged(e);
+        }}
+      >
+        <select ?disabled=${!this.editing}>
+          ${this.label.values.map(
+            item => html` <option value=${item.value}>${item.value}</option> `
+          )}
+        </select>
+      </gr-select>
+      <gr-select
+        id="labelMax"
+        .bindValue=${this.rule?.value?.max}
+        @bind-value-changed=${(e: BindValueChangeEvent) => {
+          this.handleMaxBindValueChanged(e);
+        }}
+      >
+        <select ?disabled=${!this.editing}>
+          ${this.label.values.map(
+            item => html` <option value=${item.value}>${item.value}</option> `
+          )}
+        </select>
+      </gr-select>
+    `;
+  }
+
+  private renderMinAndMaxInput() {
+    if (!this.hasRange) return;
+
+    return html`
+      <iron-autogrow-textarea
+        id="minInput"
+        class="min"
+        autocomplete="on"
+        placeholder="Min value"
+        .bindValue=${this.rule?.value?.min}
+        ?disabled=${!this.editing}
+        @bind-value-changed=${(e: BindValueChangeEvent) => {
+          this.handleMinBindValueChanged(e);
+        }}
+      ></iron-autogrow-textarea>
+      <iron-autogrow-textarea
+        id="maxInput"
+        class="max"
+        autocomplete="on"
+        placeholder="Max value"
+        .bindValue=${this.rule?.value?.max}
+        ?disabled=${!this.editing}
+        @bind-value-changed=${(e: BindValueChangeEvent) => {
+          this.handleMaxBindValueChanged(e);
+        }}
+      ></iron-autogrow-textarea>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('editing')) {
+      this.handleEditingChanged(changedProperties.get('editing') as boolean);
     }
   }
 
-  _computeForce(permission: AccessPermissionId, action?: string) {
-    if (AccessPermissionId.PUSH === permission && action !== Action.DENY) {
+  // private but used in test
+  setupValues() {
+    if (!this.rule?.value) {
+      this.setDefaultRuleValues();
+    }
+  }
+
+  // private but used in test
+  computeForce(action?: string) {
+    if (AccessPermissionId.PUSH === this.permission && action !== Action.DENY) {
       return true;
     }
 
-    return AccessPermissionId.EDIT_TOPIC_NAME === permission;
+    return AccessPermissionId.EDIT_TOPIC_NAME === this.permission;
   }
 
-  _computeForceClass(permission: AccessPermissionId, action?: string) {
-    return this._computeForce(permission, action) ? 'force' : '';
+  // private but used in test
+  computeGroupPath(groupId?: string) {
+    if (!groupId) return;
+    return `${getBaseUrl()}/admin/groups/${encodeURL(groupId, true)}`;
   }
 
-  _computeGroupPath(group: string) {
-    return `${getBaseUrl()}/admin/groups/${encodeURL(group, true)}`;
-  }
-
-  _handleAccessSaved() {
-    if (!this.rule) return;
+  // private but used in test
+  handleAccessSaved() {
     // Set a new 'original' value to keep track of after the value has been
     // saved.
-    this._setOriginalRuleValues(this.rule.value);
+    this.setOriginalRuleValues();
   }
 
-  _handleEditingChanged(editing: boolean, editingOld: boolean) {
+  private handleEditingChanged(editingOld: boolean) {
     // Ignore when editing gets set initially.
     if (!editingOld) {
       return;
     }
     // Restore original values if no longer editing.
-    if (!editing) {
-      this._handleUndoChange();
+    if (!this.editing) {
+      this.handleUndoChange();
     }
   }
 
-  _computeSectionClass(editing: boolean, deleted: boolean) {
+  // private but used in test
+  computeSectionClass() {
     const classList = [];
-    if (editing) {
+    if (this.editing) {
       classList.push('editing');
     }
-    if (deleted) {
+    if (this.deleted) {
       classList.push('deleted');
     }
     return classList.join(' ');
   }
 
-  _computeForceOptions(permission: string, action?: string) {
-    if (permission === AccessPermissionId.PUSH) {
+  // private but used in test
+  computeForceOptions(action?: string) {
+    if (this.permission === AccessPermissionId.PUSH) {
       if (action === Action.ALLOW) {
         return ForcePushOptions.ALLOW;
       } else if (action === Action.BLOCK) {
@@ -222,83 +401,158 @@
       } else {
         return [];
       }
-    } else if (permission === AccessPermissionId.EDIT_TOPIC_NAME) {
+    } else if (this.permission === AccessPermissionId.EDIT_TOPIC_NAME) {
       return FORCE_EDIT_OPTIONS;
     }
     return [];
   }
 
-  _getDefaultRuleValues(permission: AccessPermissionId, label?: RuleLabel) {
+  // private but used in test
+  getDefaultRuleValues() {
     const ruleAction = Action.ALLOW;
     const value: RuleValue = {};
-    if (permission === AccessPermissionId.PRIORITY) {
+    if (this.permission === AccessPermissionId.PRIORITY) {
       value.action = PRIORITY_OPTIONS[0];
       return value;
-    } else if (label) {
-      value.min = label.values[0].value;
-      value.max = label.values[label.values.length - 1].value;
-    } else if (this._computeForce(permission, ruleAction)) {
-      value.force = this._computeForceOptions(permission, ruleAction)[0].value;
+    } else if (this.label) {
+      value.min = this.label.values[0].value;
+      value.max = this.label.values[this.label.values.length - 1].value;
+    } else if (this.computeForce(ruleAction)) {
+      value.force = this.computeForceOptions(ruleAction)[0].value;
     }
     value.action = DROPDOWN_OPTIONS[0];
     return value;
   }
 
-  _setDefaultRuleValues() {
-    this.set(
-      'rule.value',
-      this._getDefaultRuleValues(this.permission, this.label)
-    );
+  // private but used in test
+  setDefaultRuleValues() {
+    this.rule!.value = this.getDefaultRuleValues();
+
+    this.handleRuleChange();
   }
 
-  _computeOptions(permission: string) {
-    if (permission === 'priority') {
+  // private but used in test
+  computeOptions() {
+    if (this.permission === 'priority') {
       return PRIORITY_OPTIONS;
     }
     return DROPDOWN_OPTIONS;
   }
 
-  _handleRemoveRule() {
+  private handleRemoveRule() {
     if (!this.rule?.value) return;
     if (this.rule.value.added) {
       fireEvent(this, 'added-rule-removed');
     }
-    this._deleted = true;
+    this.deleted = true;
     this.rule.value.deleted = true;
+
+    this.handleRuleChange();
+
     fireEvent(this, 'access-modified');
   }
 
-  _handleUndoRemove() {
+  private handleUndoRemove() {
     if (!this.rule?.value) return;
-    this._deleted = false;
+    this.deleted = false;
     delete this.rule.value.deleted;
+
+    this.handleRuleChange();
   }
 
-  _handleUndoChange() {
+  private handleUndoChange() {
     if (!this.rule?.value) return;
     // gr-permission will take care of removing rules that were added but
     // unsaved. We need to keep the added bit for the filter.
     if (this.rule.value.added) {
       return;
     }
-    this.set('rule.value', {...this._originalRuleValues});
-    this._deleted = false;
+    this.rule.value = {...this.originalRuleValues};
+    this.deleted = false;
     delete this.rule.value.deleted;
     delete this.rule.value.modified;
+
+    this.handleRuleChange();
   }
 
-  @observe('rule.value.*')
-  _handleValueChange() {
-    if (!this._originalRuleValues || !this.rule?.value) {
+  // private but used in test
+  handleValueChange() {
+    if (!this.originalRuleValues || !this.rule?.value) {
       return;
     }
     this.rule.value.modified = true;
+
+    this.handleRuleChange();
+
     // Allows overall access page to know a change has been made.
     fireEvent(this, 'access-modified');
   }
 
-  _setOriginalRuleValues(value?: RuleValue) {
-    if (value === undefined) return;
-    this._originalRuleValues = {...value};
+  // private but used in test
+  setOriginalRuleValues() {
+    if (!this.rule?.value) return;
+    this.originalRuleValues = {...this.rule!.value};
+  }
+
+  private handleActionBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.rule?.value ||
+      e.detail.value === undefined ||
+      this.rule.value.action === String(e.detail.value)
+    )
+      return;
+
+    this.rule.value.action = String(e.detail.value);
+
+    this.handleValueChange();
+  }
+
+  private handleMinBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.rule?.value ||
+      e.detail.value === undefined ||
+      this.rule.value.min === Number(e.detail.value)
+    )
+      return;
+    this.rule.value.min = Number(e.detail.value);
+
+    this.handleValueChange();
+  }
+
+  private handleMaxBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.rule?.value ||
+      e.detail.value === undefined ||
+      this.rule.value.max === Number(e.detail.value)
+    )
+      return;
+    this.rule.value.max = Number(e.detail.value);
+
+    this.handleValueChange();
+  }
+
+  private handleForceBindValueChanged(e: BindValueChangeEvent) {
+    const forceValue = String(e.detail.value) === 'true' ? true : false;
+    if (
+      !this.rule?.value ||
+      e.detail.value === undefined ||
+      this.rule.value.force === forceValue
+    )
+      return;
+    this.rule.value.force = forceValue;
+
+    this.handleValueChange();
+  }
+
+  private handleRuleChange() {
+    this.requestUpdate('rule');
+
+    this.dispatchEvent(
+      new CustomEvent('rule-changed', {
+        detail: {value: this.rule},
+        composed: true,
+        bubbles: true,
+      })
+    );
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
deleted file mode 100644
index c4d7688..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
+++ /dev/null
@@ -1,159 +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="shared-styles">
-    :host {
-      border-bottom: 1px solid var(--border-color);
-      padding: var(--spacing-m);
-      display: block;
-    }
-    #removeBtn {
-      display: none;
-    }
-    .editing #removeBtn {
-      display: flex;
-    }
-    #options {
-      align-items: baseline;
-      display: flex;
-    }
-    #options > * {
-      margin-right: var(--spacing-m);
-    }
-    #mainContainer {
-      align-items: baseline;
-      display: flex;
-      flex-wrap: nowrap;
-      justify-content: space-between;
-    }
-    #deletedContainer.deleted {
-      align-items: baseline;
-      display: flex;
-      justify-content: space-between;
-    }
-    #undoBtn,
-    #force,
-    #deletedContainer,
-    #mainContainer.deleted {
-      display: none;
-    }
-    #undoBtn.modified,
-    #force.force {
-      display: block;
-    }
-    .groupPath {
-      color: var(--deemphasized-text-color);
-    }
-  </style>
-  <style include="gr-form-styles">
-    iron-autogrow-textarea {
-      width: 14em;
-    }
-  </style>
-  <div
-    id="mainContainer"
-    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
-  >
-    <div id="options">
-      <gr-select
-        id="action"
-        bind-value="{{rule.value.action}}"
-        on-change="_handleValueChange"
-      >
-        <select disabled$="[[!editing]]">
-          <template is="dom-repeat" items="[[_computeOptions(permission)]]">
-            <option value="[[item]]">[[item]]</option>
-          </template>
-        </select>
-      </gr-select>
-      <template is="dom-if" if="[[label]]">
-        <gr-select
-          id="labelMin"
-          bind-value="{{rule.value.min}}"
-          on-change="_handleValueChange"
-        >
-          <select disabled$="[[!editing]]">
-            <template is="dom-repeat" items="[[label.values]]">
-              <option value="[[item.value]]">[[item.value]]</option>
-            </template>
-          </select>
-        </gr-select>
-        <gr-select
-          id="labelMax"
-          bind-value="{{rule.value.max}}"
-          on-change="_handleValueChange"
-        >
-          <select disabled$="[[!editing]]">
-            <template is="dom-repeat" items="[[label.values]]">
-              <option value="[[item.value]]">[[item.value]]</option>
-            </template>
-          </select>
-        </gr-select>
-      </template>
-      <template is="dom-if" if="[[hasRange]]">
-        <iron-autogrow-textarea
-          id="minInput"
-          class="min"
-          autocomplete="on"
-          placeholder="Min value"
-          bind-value="{{rule.value.min}}"
-          disabled$="[[!editing]]"
-        ></iron-autogrow-textarea>
-        <iron-autogrow-textarea
-          id="maxInput"
-          class="max"
-          autocomplete="on"
-          placeholder="Max value"
-          bind-value="{{rule.value.max}}"
-          disabled$="[[!editing]]"
-        ></iron-autogrow-textarea>
-      </template>
-      <a class="groupPath" href$="[[_computeGroupPath(groupId)]]">
-        [[groupName]]
-      </a>
-      <gr-select
-        id="force"
-        class$="[[_computeForceClass(permission, rule.value.action)]]"
-        bind-value="{{rule.value.force}}"
-        on-change="_handleValueChange"
-      >
-        <select disabled$="[[!editing]]">
-          <template
-            is="dom-repeat"
-            items="[[_computeForceOptions(permission, rule.value.action)]]"
-          >
-            <option value="[[item.value]]">[[item.name]]</option>
-          </template>
-        </select>
-      </gr-select>
-    </div>
-    <gr-button link="" id="removeBtn" on-click="_handleRemoveRule"
-      >Remove</gr-button
-    >
-  </div>
-  <div
-    id="deletedContainer"
-    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
-  >
-    [[groupName]] was deleted
-    <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
-      >Undo</gr-button
-    >
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
index 1afd123..47c3bea 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
@@ -29,12 +29,13 @@
 suite('gr-rule-editor tests', () => {
   let element: GrRuleEditor;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = basicFixture.instantiate() as GrRuleEditor;
+    await element.updateComplete;
   });
 
   suite('unit tests', () => {
-    test('_computeForce, _computeForceClass, and _computeForceOptions', () => {
+    test('computeForce and computeForceOptions', () => {
       const ForcePushOptions = {
         ALLOW: [
           {name: 'Allow pushing (but not force pushing)', value: false},
@@ -56,67 +57,55 @@
           value: true,
         },
       ];
-      let permission = 'push' as AccessPermissionId;
+      element.permission = 'push' as AccessPermissionId;
       let action = 'ALLOW';
-      assert.isTrue(element._computeForce(permission, action));
-      assert.equal(element._computeForceClass(permission, action), 'force');
+      assert.isTrue(element.computeForce(action));
       assert.deepEqual(
-        element._computeForceOptions(permission, action),
+        element.computeForceOptions(action),
         ForcePushOptions.ALLOW
       );
 
       action = 'BLOCK';
-      assert.isTrue(element._computeForce(permission, action));
-      assert.equal(element._computeForceClass(permission, action), 'force');
+      assert.isTrue(element.computeForce(action));
       assert.deepEqual(
-        element._computeForceOptions(permission, action),
+        element.computeForceOptions(action),
         ForcePushOptions.BLOCK
       );
 
       action = 'DENY';
-      assert.isFalse(element._computeForce(permission, action));
-      assert.equal(element._computeForceClass(permission, action), '');
-      assert.equal(element._computeForceOptions(permission, action).length, 0);
+      assert.isFalse(element.computeForce(action));
+      assert.equal(element.computeForceOptions(action).length, 0);
 
-      permission = 'editTopicName' as AccessPermissionId;
-      assert.isTrue(element._computeForce(permission));
-      assert.equal(element._computeForceClass(permission), 'force');
-      assert.deepEqual(
-        element._computeForceOptions(permission),
-        FORCE_EDIT_OPTIONS
-      );
-      permission = 'submit' as AccessPermissionId;
-      assert.isFalse(element._computeForce(permission));
-      assert.equal(element._computeForceClass(permission), '');
-      assert.deepEqual(element._computeForceOptions(permission), []);
+      element.permission = 'editTopicName' as AccessPermissionId;
+      assert.isTrue(element.computeForce());
+      assert.deepEqual(element.computeForceOptions(), FORCE_EDIT_OPTIONS);
+      element.permission = 'submit' as AccessPermissionId;
+      assert.isFalse(element.computeForce());
+      assert.deepEqual(element.computeForceOptions(), []);
     });
 
-    test('_computeSectionClass', () => {
-      let deleted = true;
-      let editing = false;
-      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
+    test('computeSectionClass', () => {
+      element.deleted = true;
+      element.editing = false;
+      assert.equal(element.computeSectionClass(), 'deleted');
 
-      deleted = false;
-      assert.equal(element._computeSectionClass(editing, deleted), '');
+      element.deleted = false;
+      assert.equal(element.computeSectionClass(), '');
 
-      editing = true;
-      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+      element.editing = true;
+      assert.equal(element.computeSectionClass(), 'editing');
 
-      deleted = true;
-      assert.equal(
-        element._computeSectionClass(editing, deleted),
-        'editing deleted'
-      );
+      element.deleted = true;
+      assert.equal(element.computeSectionClass(), 'editing deleted');
     });
 
-    test('_getDefaultRuleValues', () => {
-      let permission = 'priority' as AccessPermissionId;
-      let label;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+    test('getDefaultRuleValues', () => {
+      element.permission = 'priority' as AccessPermissionId;
+      assert.deepEqual(element.getDefaultRuleValues(), {
         action: 'BATCH',
       });
-      permission = 'label-Code-Review' as AccessPermissionId;
-      label = {
+      element.permission = 'label-Code-Review' as AccessPermissionId;
+      element.label = {
         values: [
           {value: -2, text: 'This shall not be merged'},
           {value: -1, text: 'I would prefer this is not merged as is'},
@@ -125,71 +114,73 @@
           {value: 2, text: 'Looks good to me, approved'},
         ],
       };
-      assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+      assert.deepEqual(element.getDefaultRuleValues(), {
         action: 'ALLOW',
         max: 2,
         min: -2,
       });
-      permission = 'push' as AccessPermissionId;
-      label = undefined;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+      element.permission = 'push' as AccessPermissionId;
+      element.label = undefined;
+      assert.deepEqual(element.getDefaultRuleValues(), {
         action: 'ALLOW',
         force: false,
       });
-      permission = 'submit' as AccessPermissionId;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+      element.permission = 'submit' as AccessPermissionId;
+      assert.deepEqual(element.getDefaultRuleValues(), {
         action: 'ALLOW',
       });
     });
 
-    test('_setDefaultRuleValues', async () => {
+    test('setDefaultRuleValues', async () => {
       element.rule = {value: {}};
       const defaultValue = {action: 'ALLOW'};
       const getDefaultRuleValuesStub = sinon
-        .stub(element, '_getDefaultRuleValues')
+        .stub(element, 'getDefaultRuleValues')
         .returns(defaultValue);
-      element._setDefaultRuleValues();
+      element.setDefaultRuleValues();
       assert.isTrue(getDefaultRuleValuesStub.called);
       assert.equal(element.rule!.value, defaultValue);
     });
 
-    test('_computeOptions', () => {
+    test('computeOptions', () => {
       const PRIORITY_OPTIONS = ['BATCH', 'INTERACTIVE'];
       const DROPDOWN_OPTIONS = ['ALLOW', 'DENY', 'BLOCK'];
-      let permission = 'priority';
-      assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
-      permission = 'submit';
-      assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
+      element.permission = 'priority' as AccessPermissionId;
+      assert.deepEqual(element.computeOptions(), PRIORITY_OPTIONS);
+      element.permission = 'submit' as AccessPermissionId;
+      assert.deepEqual(element.computeOptions(), DROPDOWN_OPTIONS);
     });
 
-    test('_handleValueChange', () => {
+    test('handleValueChange', () => {
       const modifiedHandler = sinon.stub();
       element.rule = {value: {}};
       element.addEventListener('access-modified', modifiedHandler);
-      element._handleValueChange();
+      element.handleValueChange();
       assert.isNotOk(element.rule!.value!.modified);
-      element._originalRuleValues = {};
-      element._handleValueChange();
+      element.originalRuleValues = {};
+      element.handleValueChange();
       assert.isTrue(element.rule!.value!.modified);
       assert.isTrue(modifiedHandler.called);
     });
 
-    test('_handleAccessSaved', () => {
+    test('handleAccessSaved', () => {
       const originalValue = {action: 'DENY'};
       const newValue = {action: 'ALLOW'};
-      element._originalRuleValues = originalValue;
+      element.originalRuleValues = originalValue;
       element.rule = {value: newValue};
-      element._handleAccessSaved();
-      assert.deepEqual(element._originalRuleValues, newValue);
+      element.handleAccessSaved();
+      assert.deepEqual(element.originalRuleValues, newValue);
     });
 
-    test('_setOriginalRuleValues', () => {
-      const value = {
-        action: 'ALLOW',
-        force: false,
+    test('setOriginalRuleValues', () => {
+      element.rule = {
+        value: {
+          action: 'ALLOW',
+          force: false,
+        },
       };
-      element._setOriginalRuleValues(value);
-      assert.deepEqual(element._originalRuleValues, value);
+      element.setOriginalRuleValues();
+      assert.deepEqual(element.originalRuleValues, element.rule.value);
     });
   });
 
@@ -204,16 +195,13 @@
         },
       };
       element.section = 'refs/*';
-
-      // Typically called on ready since elements will have properties defined
-      // by the parent element.
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
+      element.setupValues();
+      element.setOriginalRuleValues();
+      await element.updateComplete;
     });
 
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
     });
 
     test('values are set correctly', () => {
@@ -228,8 +216,10 @@
       );
     });
 
-    test('modify and cancel restores original values', () => {
+    test('modify and cancel restores original values', async () => {
+      element.rule = {value: {}};
       element.editing = true;
+      await element.updateComplete;
       assert.notEqual(
         getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
           .display,
@@ -240,12 +230,13 @@
       actionBindValue.bindValue = 'DENY';
       assert.isTrue(element.rule!.value!.modified);
       element.editing = false;
+      await element.updateComplete;
       assert.equal(
         getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
           .display,
         'none'
       );
-      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
       assert.equal(
         queryAndAssert<GrSelect>(element, '#action').bindValue,
         'ALLOW'
@@ -253,31 +244,33 @@
       assert.isNotOk(element.rule!.value!.modified);
     });
 
-    test('modify value', () => {
+    test('modify value', async () => {
       assert.isNotOk(element.rule!.value!.modified);
       const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
       actionBindValue.bindValue = 'DENY';
-      flush();
+      await element.updateComplete;
       assert.isTrue(element.rule!.value!.modified);
 
       // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
     });
 
-    test('all selects are disabled when not in edit mode', () => {
+    test('all selects are disabled when not in edit mode', async () => {
       const selects = queryAll<HTMLSelectElement>(element, 'select');
       for (const select of selects) {
         assert.isTrue(select.disabled);
       }
       element.editing = true;
+      await element.updateComplete;
       for (const select of selects) {
         assert.isFalse(select.disabled);
       }
     });
 
-    test('remove rule and undo remove', () => {
+    test('remove rule and undo remove', async () => {
       element.editing = true;
       element.rule = {value: {action: 'ALLOW'}};
+      await element.updateComplete;
       assert.isFalse(
         queryAndAssert<HTMLDivElement>(
           element,
@@ -285,22 +278,25 @@
         ).classList.contains('deleted')
       );
       MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      await element.updateComplete;
       assert.isTrue(
         queryAndAssert<HTMLDivElement>(
           element,
           '#deletedContainer'
         ).classList.contains('deleted')
       );
-      assert.isTrue(element._deleted);
+      assert.isTrue(element.deleted);
       assert.isTrue(element.rule!.value!.deleted);
 
       MockInteractions.tap(queryAndAssert<GrButton>(element, '#undoRemoveBtn'));
-      assert.isFalse(element._deleted);
+      await element.updateComplete;
+      assert.isFalse(element.deleted);
       assert.isNotOk(element.rule!.value!.deleted);
     });
 
-    test('remove rule and cancel', () => {
+    test('remove rule and cancel', async () => {
       element.editing = true;
+      await element.updateComplete;
       assert.notEqual(
         getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
           .display,
@@ -314,7 +310,9 @@
       );
 
       element.rule = {value: {action: 'ALLOW'}};
+      await element.updateComplete;
       MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      await element.updateComplete;
       assert.notEqual(
         getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
           .display,
@@ -326,15 +324,16 @@
         ).display,
         'none'
       );
-      assert.isTrue(element._deleted);
+      assert.isTrue(element.deleted);
       assert.isTrue(element.rule!.value!.deleted);
 
       element.editing = false;
-      assert.isFalse(element._deleted);
+      await element.updateComplete;
+      assert.isFalse(element.deleted);
       assert.isNotOk(element.rule!.value!.deleted);
       assert.isNotOk(element.rule!.value!.modified);
 
-      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
       assert.equal(
         getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
           .display,
@@ -348,9 +347,9 @@
       );
     });
 
-    test('_computeGroupPath', () => {
+    test('computeGroupPath', () => {
       const group = '123';
-      assert.equal(element._computeGroupPath(group), '/admin/groups/123');
+      assert.equal(element.computeGroupPath(group), '/admin/groups/123');
     });
   });
 
@@ -360,14 +359,14 @@
       element.permission = 'editTopicName' as AccessPermissionId;
       element.rule = {};
       element.section = 'refs/*';
-      element._setupValues(element.rule!);
-      await flush();
+      element.setupValues();
+      await element.updateComplete;
       element.rule!.value!.added = true;
-      await flush();
+      await element.updateComplete;
       element.connectedCallback();
     });
 
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
+    test('_ruleValues and originalRuleValues are set correctly', () => {
       // Since the element does not already have default values, they should
       // be set. The original values should be set to those too.
       assert.isNotOk(element.rule!.value!.modified);
@@ -389,23 +388,23 @@
       });
     });
 
-    test('modify value', () => {
+    test('modify value', async () => {
       assert.isNotOk(element.rule!.value!.modified);
       const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
-      forceBindValue.bindValue = true;
-      flush();
+      forceBindValue.bindValue = 'true';
+      await element.updateComplete;
       assert.isTrue(element.rule!.value!.modified);
 
       // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
     });
 
-    test('remove value', () => {
+    test('remove value', async () => {
       element.editing = true;
       const removeStub = sinon.stub();
       element.addEventListener('added-rule-removed', removeStub);
       MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
-      flush();
+      await element.updateComplete;
       assert.isTrue(removeStub.called);
     });
   });
@@ -432,13 +431,13 @@
         },
       };
       element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
+      element.setupValues();
+      await element.updateComplete;
       element.connectedCallback();
     });
 
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
     });
 
     test('values are set correctly', () => {
@@ -459,18 +458,18 @@
       );
     });
 
-    test('modify value', () => {
+    test('modify value', async () => {
       const removeStub = sinon.stub();
       element.addEventListener('added-rule-removed', removeStub);
       assert.isNotOk(element.rule!.value!.modified);
       const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
       labelMinBindValue.bindValue = 1;
-      flush();
+      await element.updateComplete;
       assert.isTrue(element.rule!.value!.modified);
       assert.isFalse(removeStub.called);
 
       // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
     });
   });
 
@@ -478,7 +477,7 @@
     let setDefaultRuleValuesSpy: sinon.SinonSpy;
 
     setup(async () => {
-      setDefaultRuleValuesSpy = sinon.spy(element, '_setDefaultRuleValues');
+      setDefaultRuleValuesSpy = sinon.spy(element, 'setDefaultRuleValues');
       element.label = {
         values: [
           {value: -2, text: 'This shall not be merged'},
@@ -492,14 +491,14 @@
       element.permission = 'label-Code-Review' as AccessPermissionId;
       element.rule = {};
       element.section = 'refs/*';
-      element._setupValues(element.rule!);
-      await flush();
+      element.setupValues();
+      await element.updateComplete;
       element.rule!.value!.added = true;
-      await flush();
+      await element.updateComplete;
       element.connectedCallback();
     });
 
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
+    test('_ruleValues and originalRuleValues are set correctly', () => {
       // Since the element does not already have default values, they should
       // be set. The original values should be set to those too.
       assert.isNotOk(element.rule!.value!.modified);
@@ -528,15 +527,15 @@
       });
     });
 
-    test('modify value', () => {
+    test('modify value', async () => {
       assert.isNotOk(element.rule!.value!.modified);
       const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
       labelMinBindValue.bindValue = 1;
-      flush();
+      await element.updateComplete;
       assert.isTrue(element.rule!.value!.modified);
 
       // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
     });
   });
 
@@ -551,108 +550,13 @@
         },
       };
       element.section = 'refs/*';
-      element._setupValues(element.rule!);
-      await flush();
+      element.setupValues();
+      await element.updateComplete;
       element.connectedCallback();
     });
 
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule!.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.isTrue(
-        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
-      );
-      assert.equal(
-        queryAndAssert<GrSelect>(element, '#action').bindValue,
-        element.rule!.value!.action
-      );
-      assert.equal(
-        queryAndAssert<GrSelect>(element, '#force').bindValue,
-        element.rule!.value!.force
-      );
-      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
-      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule!.value!.modified);
-      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
-      actionBindValue.bindValue = false;
-      flush();
-      assert.isTrue(element.rule!.value!.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
-    });
-  });
-
-  suite('new push rule', () => {
-    setup(async () => {
-      element.groupName = 'Group Name';
-      element.permission = 'push' as AccessPermissionId;
-      element.rule = {};
-      element.section = 'refs/*';
-      element._setupValues(element.rule!);
-      await flush();
-      element.rule!.value!.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule!.value!.modified);
-      const expectedRuleValue = {
-        action: 'ALLOW',
-        force: false,
-        added: true,
-      };
-      assert.deepEqual(element.rule!.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(
-          queryAndAssert<GrSelect>(element, '#action').bindValue,
-          expectedRuleValue.action
-        );
-        assert.equal(
-          queryAndAssert<GrSelect>(element, '#force').bindValue,
-          expectedRuleValue.action
-        );
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule!.value!.modified);
-      const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
-      forceBindValue.bindValue = true;
-      flush();
-      assert.isTrue(element.rule!.value!.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
-    });
-  });
-
-  suite('already existing edit rule', () => {
-    setup(async () => {
-      element.groupName = 'Group Name';
-      element.permission = 'editTopicName' as AccessPermissionId;
-      element.rule = {
-        value: {
-          action: 'ALLOW',
-          force: true,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
     });
 
     test('values are set correctly', () => {
@@ -675,11 +579,106 @@
       assert.isNotOk(element.rule!.value!.modified);
       const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
       actionBindValue.bindValue = false;
-      await flush();
+      await element.updateComplete;
       assert.isTrue(element.rule!.value!.modified);
 
       // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('new push rule', async () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'push' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.rule!.value!.added = true;
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      const expectedRuleValue = {
+        action: 'ALLOW',
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#action').bindValue,
+          expectedRuleValue.action
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#force').bindValue,
+          expectedRuleValue.action
+        );
+      });
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+      forceBindValue.bindValue = true;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('already existing edit rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'editTopicName' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: 'ALLOW',
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        element.rule!.value!.force
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = false;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
     });
   });
 });