Merge "Introduce gr-rule-editor"
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
new file mode 100644
index 0000000..60b9ddf
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
@@ -0,0 +1,127 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+
+<dom-module id="gr-rule-editor">
+  <template>
+    <style include="shared-styles">
+      :host {
+        border-bottom: 1px solid #d1d2d3;
+        padding: .7em;
+        display: block;
+      }
+      .buttons {
+        display: flex;
+      }
+      .buttons gr-button {
+        margin-left: .3em;
+      }
+      #mainContainer {
+        align-items: baseline;
+        display: flex;
+        flex-wrap: nowrap;
+        justify-content: space-between;
+      }
+      .buttons gr-button {
+        float: left;
+        margin-left: .3em;
+      }
+      #undoBtn,
+      #force,
+      #deletedContainer,
+      #mainContainer.deleted {
+        display: none;
+      }
+      #undoBtn.modified,
+      #force.force,
+      #deletedContainer.deleted {
+        display: block;
+      }
+    </style>
+    <style include="gr-form-styles"></style>
+    <div id="mainContainer"
+        class$="gr-form-styles [[_computeDeletedClass(_deleted)]]">
+      <div id="options">
+        <gr-select id="action"
+            bind-value="{{rule.value.action}}"
+            on-change="_handleValueChange">
+          <select>
+            <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>
+              <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>
+              <template is="dom-repeat" items="[[label.values]]">
+                <option value="[[item.value]]">[[item.value]]</option>
+              </template>
+            </select>
+          </gr-select>
+        </template>
+        [[group]]
+        <gr-select
+            id="force"
+            class$="[[_computeForceClass(permission)]]"
+            bind-value="{{rule.value.force}}"
+            on-change="_handleValueChange">
+          <select>
+            <template
+                is="dom-repeat"
+                items="[[_computeForceOptions(permission)]]">
+              <option value="[[item.value]]">[[item.name]]</option>
+            </template>
+          </select>
+        </gr-select>
+      </div>
+      <div class="buttons">
+        <gr-button
+            id="undoBtn"
+            on-tap="_handleUndoChange"
+            class$="[[_computeModifiedClass(_modified)]]">Undo</gr-button>
+        <gr-button id="removeBtn" on-tap="_handleRemoveRule">Remove</gr-button>
+      </div>
+    </div>
+    <div
+        id="deletedContainer"
+        class$="gr-form-styles [[_computeDeletedClass(_deleted)]]">
+      [[group]] was deleted
+      <gr-button id="undoRemoveBtn" on-tap="_handleUndoRemove">Undo</gr-button>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-rule-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
new file mode 100644
index 0000000..da026db
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -0,0 +1,169 @@
+// 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.
+(function() {
+  'use strict';
+
+  const PRIORITY_OPTIONS = [
+    'BATCH',
+    'INTERACTIVE',
+  ];
+
+  const DROPDOWN_OPTIONS = [
+    'ALLOW',
+    'DENY',
+    'BLOCK',
+  ];
+
+  const FORCE_PUSH_OPTIONS = [
+    {
+      name: 'No Force Push',
+      value: false,
+    },
+    {
+      name: 'Force Push',
+      value: true,
+    },
+  ];
+
+  const FORCE_EDIT_OPTIONS = [
+    {
+      name: 'No Force Edit',
+      value: false,
+    },
+    {
+      name: 'Force Edit',
+      value: true,
+    },
+  ];
+
+  Polymer({
+    is: 'gr-rule-editor',
+
+    properties: {
+      /** @type {?} */
+      label: Object,
+      group: String,
+      permission: String,
+      /** @type {?} */
+      rule: {
+        type: Object,
+        notify: true,
+      },
+      section: String,
+      _modified: {
+        type: Boolean,
+        value: false,
+      },
+      _originalRuleValues: Object,
+      _deleted: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    observers: [
+      '_handleValueChange(rule.value.*)',
+    ],
+
+    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);
+    },
+
+    _setupValues(rule) {
+      if (!rule.value) {
+        this._setDefaultRuleValues();
+      }
+      this._setOriginalRuleValues(rule.value);
+    },
+
+    _computeForce(permission) {
+      return 'push' === permission || 'editTopicName' === permission;
+    },
+
+    _computeForceClass(permission) {
+      return this._computeForce(permission) ? 'force' : '';
+    },
+
+    _computeDeletedClass(deleted) {
+      return deleted ? 'deleted' : '';
+    },
+
+    _computeForceOptions(permission) {
+      if (permission === 'push') {
+        return FORCE_PUSH_OPTIONS;
+      } else if (permission === 'editTopicName') {
+        return FORCE_EDIT_OPTIONS;
+      }
+      return [];
+    },
+
+    _getDefaultRuleValues(permission, label) {
+      const value = {};
+      if (permission === '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)) {
+        value.force = this._computeForceOptions(permission)[0].value;
+      }
+      value.action = DROPDOWN_OPTIONS[0];
+      return value;
+    },
+
+    _setDefaultRuleValues() {
+      this.set('rule.value',
+          this._getDefaultRuleValues(this.permission, this.label));
+    },
+
+    _computeOptions(permission) {
+      if (permission === 'priority') {
+        return PRIORITY_OPTIONS;
+      }
+      return DROPDOWN_OPTIONS;
+    },
+
+    _handleRemoveRule() {
+      this._deleted = true;
+      this.rule.deleted = true;
+    },
+
+    _handleUndoRemove() {
+      this._deleted = false;
+      delete this.rule.deleted;
+    },
+
+    _handleUndoChange() {
+      this.set('rule.value', Object.assign({}, this._originalRuleValues));
+      this._modified = false;
+    },
+
+    _handleValueChange() {
+      if (!this._originalRuleValues) { return; }
+      this._modified = true;
+    },
+
+    _setOriginalRuleValues(value) {
+      this._originalRuleValues = Object.assign({}, value);
+    },
+
+    _computeModifiedClass(modified) {
+      return modified ? 'modified' : '';
+    },
+  });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
new file mode 100644
index 0000000..376fb79
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
@@ -0,0 +1,571 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-rule-editor</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-rule-editor.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-rule-editor></gr-rule-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-rule-editor tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('unit tests', () => {
+      test('_computeForce, _computeForceClass, and _computeForceOptions',
+          () => {
+            const FORCE_PUSH_OPTIONS = [
+              {
+                name: 'No Force Push',
+                value: false,
+              },
+              {
+                name: 'Force Push',
+                value: true,
+              },
+            ];
+
+            const FORCE_EDIT_OPTIONS = [
+              {
+                name: 'No Force Edit',
+                value: false,
+              },
+              {
+                name: 'Force Edit',
+                value: true,
+              },
+            ];
+            let permission = 'push';
+            assert.isTrue(element._computeForce(permission));
+            assert.equal(element._computeForceClass(permission), 'force');
+            assert.deepEqual(element._computeForceOptions(permission),
+                FORCE_PUSH_OPTIONS);
+            permission = 'editTopicName';
+            assert.isTrue(element._computeForce(permission));
+            assert.equal(element._computeForceClass(permission), 'force');
+            assert.deepEqual(element._computeForceOptions(permission),
+                FORCE_EDIT_OPTIONS);
+            permission = 'submit';
+            assert.isFalse(element._computeForce(permission));
+            assert.equal(element._computeForceClass(permission), '');
+            assert.deepEqual(element._computeForceOptions(permission), []);
+          });
+
+      test('_computeDeletedClass', () => {
+        assert.equal(element._computeDeletedClass(true), 'deleted');
+        assert.equal(element._computeDeletedClass(false), '');
+      });
+
+      test('_getDefaultRuleValues', () => {
+        let permission = 'priority';
+        let label;
+        assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'BATCH'});
+        permission = 'label-Code-Review';
+        label = {values: [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ]};
+        assert.deepEqual(element._getDefaultRuleValues(permission, label),
+            {action: 'ALLOW', max: 2, min: -2});
+        permission = 'push';
+        label = undefined;
+        assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'ALLOW', force: false});
+        permission = 'submit';
+        assert.deepEqual(element._getDefaultRuleValues(permission, label),
+            {action: 'ALLOW'});
+      });
+
+      test('_setDefaultRuleValues', () => {
+        element.rule = {id: 123};
+        const defaultValue = {action: 'ALLOW'};
+        sandbox.stub(element, '_getDefaultRuleValues').returns(defaultValue);
+        element._setDefaultRuleValues();
+        assert.isTrue(element._getDefaultRuleValues.called);
+        assert.equal(element.rule.value, defaultValue);
+      });
+
+      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);
+      });
+
+      test('_handleValueChange', () => {
+        element._handleValueChange();
+        assert.isFalse(element._modified);
+        element._originalRuleValues = {};
+        element._handleValueChange();
+        assert.isTrue(element._modified);
+      });
+
+      test('_setOriginalRuleValues', () => {
+        const value = {
+          action: 'ALLOW',
+          force: false,
+        };
+        element._setOriginalRuleValues(value);
+        assert.deepEqual(element._originalRuleValues, value);
+      });
+    });
+
+    suite('already existing generic rule', () => {
+      setup(() => {
+        element.group = 'Group Name';
+        element.permission = 'submit';
+        element.rule = {
+          id: '123',
+          value: {
+            action: 'ALLOW',
+            force: false,
+          },
+        };
+        element.section = 'refs/*';
+
+        // Typically called on ready since elements will have properies defined
+        // by the parent element.
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      test('_ruleValues and _originalRuleValues are set correctly', () => {
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+
+      test('values are set correctly', () => {
+        assert.equal(element.$.action.bindValue, element.rule.value.action);
+        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMin'));
+        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
+        assert.isFalse(element.$.force.classList.contains('force'));
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        element.$.action.bindValue = 'DENY';
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+        assert.equal(element.$.action.bindValue, 'ALLOW');
+        assert.isFalse(element._modified);
+      });
+
+      test('remove rule and undo remove', () => {
+        element.rule = {id: 123};
+        assert.isFalse(
+            element.$.deletedContainer.classList.contains('deleted'));
+        MockInteractions.tap(element.$.removeBtn);
+        assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
+        assert.isTrue(element.rule.deleted);
+
+        MockInteractions.tap(element.$.undoRemoveBtn);
+        assert.isNotOk(element.rule.deleted);
+      });
+    });
+
+    suite('new edit rule', () => {
+      setup(() => {
+        element.group = 'Group Name';
+        element.permission = 'editTopicName';
+        element.rule = {
+          id: '123',
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      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.isFalse(element._modified);
+        const expectedRuleValue = {
+          action: 'ALLOW',
+          force: false,
+        };
+        assert.deepEqual(element.rule.value, expectedRuleValue);
+        assert.deepEqual(element._originalRuleValues, expectedRuleValue);
+        test('values are set correctly', () => {
+          assert.equal(element.$.action.bindValue, expectedRuleValue.action);
+          assert.equal(element.$.force.bindValue, expectedRuleValue.action);
+        });
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        element.$.force.bindValue = true;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+
+    suite('already existing rule with labels', () => {
+      setup(() => {
+        element.label = {values: [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ]};
+        element.group = 'Group Name';
+        element.permission = 'label-Code-Review';
+        element.rule = {
+          id: '123',
+          value: {
+            action: 'ALLOW',
+            force: false,
+            max: 2,
+            min: -2,
+          },
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      test('_ruleValues and _originalRuleValues are set correctly', () => {
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+
+      test('values are set correctly', () => {
+        assert.equal(element.$.action.bindValue, element.rule.value.action);
+        assert.equal(
+            Polymer.dom(element.root).querySelector('#labelMin').bindValue,
+            element.rule.value.min);
+        assert.equal(
+            Polymer.dom(element.root).querySelector('#labelMax').bindValue,
+            element.rule.value.max);
+        assert.isFalse(element.$.force.classList.contains('force'));
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        Polymer.dom(element.root).querySelector('#labelMin').bindValue = 1;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+
+    suite('new rule with labels', () => {
+      setup(() => {
+        sandbox.spy(element, '_setDefaultRuleValues');
+        element.label = {values: [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ]};
+        element.group = 'Group Name';
+        element.permission = 'label-Code-Review';
+        element.rule = {
+          id: '123',
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      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.isFalse(element._modified);
+        assert.isTrue(element._setDefaultRuleValues.called);
+
+        const expectedRuleValue = {
+          max: element.label.values[element.label.values.length - 1].value,
+          min: element.label.values[0].value,
+          action: 'ALLOW',
+        };
+        assert.deepEqual(element.rule.value, expectedRuleValue);
+        assert.deepEqual(element._originalRuleValues, expectedRuleValue);
+        test('values are set correctly', () => {
+          assert.equal(
+              element.$.action.bindValue,
+              expectedRuleValue.action);
+          assert.equal(
+              Polymer.dom(element.root).querySelector('#labelMin').bindValue,
+              expectedRuleValue.min);
+          assert.equal(
+              Polymer.dom(element.root).querySelector('#labelMax').bindValue,
+              expectedRuleValue.max);
+        });
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        Polymer.dom(element.root).querySelector('#labelMin').bindValue = 1;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+
+    suite('already existing push rule', () => {
+      setup(() => {
+        element.group = 'Group Name';
+        element.permission = 'push';
+        element.rule = {
+          id: '123',
+          value: {
+            action: 'ALLOW',
+            force: true,
+          },
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      test('_ruleValues and _originalRuleValues are set correctly', () => {
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+
+      test('values are set correctly', () => {
+        assert.isTrue(element.$.force.classList.contains('force'));
+        assert.equal(element.$.action.bindValue, element.rule.value.action);
+        assert.equal(
+            Polymer.dom(element.root).querySelector('#force').bindValue,
+            element.rule.value.force);
+        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMin'));
+        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        element.$.action.bindValue = false;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+
+    suite('new push rule', () => {
+      setup(() => {
+        element.group = 'Group Name';
+        element.permission = 'push';
+        element.rule = {
+          id: '123',
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      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.isFalse(element._modified);
+        const expectedRuleValue = {
+          action: 'ALLOW',
+          force: false,
+        };
+        assert.deepEqual(element.rule.value, expectedRuleValue);
+        assert.deepEqual(element._originalRuleValues, expectedRuleValue);
+        test('values are set correctly', () => {
+          assert.equal(element.$.action.bindValue, expectedRuleValue.action);
+          assert.equal(element.$.force.bindValue, expectedRuleValue.action);
+        });
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        element.$.force.bindValue = true;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+
+    suite('already existing edit rule', () => {
+      setup(() => {
+        element.group = 'Group Name';
+        element.permission = 'editTopicName';
+        element.rule = {
+          id: '123',
+          value: {
+            action: 'ALLOW',
+            force: true,
+          },
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      test('_ruleValues and _originalRuleValues are set correctly', () => {
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+
+      test('values are set correctly', () => {
+        assert.isTrue(element.$.force.classList.contains('force'));
+        assert.equal(element.$.action.bindValue, element.rule.value.action);
+        assert.equal(
+            Polymer.dom(element.root).querySelector('#force').bindValue,
+            element.rule.value.force);
+        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMin'));
+        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        element.$.action.bindValue = false;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+
+    suite('new edit rule', () => {
+      setup(() => {
+        element.group = 'Group Name';
+        element.permission = 'editTopicName';
+        element.rule = {
+          id: '123',
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      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.isFalse(element._modified);
+        const expectedRuleValue = {
+          action: 'ALLOW',
+          force: false,
+        };
+        assert.deepEqual(element.rule.value, expectedRuleValue);
+        assert.deepEqual(element._originalRuleValues, expectedRuleValue);
+        test('values are set correctly', () => {
+          assert.equal(element.$.action.bindValue, expectedRuleValue.action);
+          assert.equal(element.$.force.bindValue, expectedRuleValue.action);
+        });
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        element.$.force.bindValue = true;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 634dfd6..b171e9c 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -43,6 +43,7 @@
     'admin/gr-plugin-list/gr-plugin-list_test.html',
     'admin/gr-project/gr-project_test.html',
     'admin/gr-project-detail-list/gr-project-detail-list_test.html',
+    'admin/gr-rule-editor/gr-rule-editor_test.html',
     'change-list/gr-change-list-item/gr-change-list-item_test.html',
     'change-list/gr-change-list-view/gr-change-list-view_test.html',
     'change-list/gr-change-list/gr-change-list_test.html',