Add gr-plugin-config-array-editor

Component used for editing array-based plugin config options.

Uses/will use unidirectional data flow and Polymer's notifyPath
functionality to update the config at the gr-repo level.

Bug: Issue 8535
Change-Id: I7754c80dd4327fbd65e53b6194bbe1ce9d6cb169
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
new file mode 100644
index 0000000..ca98c50
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
@@ -0,0 +1,100 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.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">
+
+<dom-module id="gr-plugin-config-array-editor">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+      .wrapper {
+        width: 30em;
+      }
+      .existingItems {
+        background: var(--table-header-background-color);
+        border: 1px solid var(--border-color);
+        border-radius: 2px;
+      }
+      gr-button {
+        float: right;
+        margin-left: .5em;
+        width: 4.5em;
+      }
+      .row {
+        align-items: center;
+        display: flex;
+        justify-content: space-between;
+        padding: .5em 0;
+        width: 100%;
+      }
+      .existingItems .row {
+        padding: .5em;
+      }
+      .existingItems .row:not(:first-of-type) {
+        border-top: 1px solid var(--border-color);
+      }
+      input {
+        flex-grow: 1;
+      }
+      .hide {
+        display: none;
+      }
+      .placeholder {
+        color: var(--deemphasized-text-color);
+        padding-top: .75em;
+      }
+    </style>
+    <div class="wrapper gr-form-styles">
+      <template is="dom-if" if="[[pluginOption.info.values.length]]">
+        <div class="existingItems">
+          <template is="dom-repeat" items="[[pluginOption.info.values]]">
+            <div class="row">
+              <span>[[item]]</span>
+              <gr-button
+                  link
+                  disabled$="[[disabled]]"
+                  data-item="[[item]]"
+                  on-tap="_handleDelete">Delete</gr-button>
+            </div>
+          </template>
+        </div>
+      </template>
+      <template is="dom-if" if="[[!pluginOption.info.values.length]]">
+        <div class="row placeholder">None configured.</div>
+      </template>
+      <div class$="row [[_computeShowInputRow(disabled)]]">
+        <input
+            is="iron-input"
+            id="input"
+            on-keydown="_handleInputKeydown"
+            bind-value="{{_newValue}}"/>
+        <gr-button
+            id="addButton"
+            disabled$="[[!_newValue.length]]"
+            link
+            on-tap="_handleAddTap">Add</gr-button>
+      </div>
+    </div>
+  </template>
+  <script src="gr-plugin-config-array-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
new file mode 100644
index 0000000..ab4d286
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-plugin-config-array-editor',
+
+    /**
+     * Fired when the plugin config option changes.
+     *
+     * @event plugin-config-option-changed
+     */
+
+    properties: {
+      /** @type {?} */
+      pluginOption: Object,
+      /** @type {Boolean} */
+      disabled: {
+        type: Boolean,
+        computed: '_computeDisabled(pluginOption.*)',
+      },
+      /** @type {?} */
+      _newValue: {
+        type: String,
+        value: '',
+      },
+    },
+
+    _computeDisabled(record) {
+      return !(record && record.base && record.base.info &&
+          record.base.info.editable);
+    },
+
+    _handleAddTap(e) {
+      e.preventDefault();
+      this._handleAdd();
+    },
+
+    _handleInputKeydown(e) {
+      // Enter.
+      if (e.keyCode === 13) {
+        e.preventDefault();
+        this._handleAdd();
+      }
+    },
+
+    _handleAdd() {
+      if (!this._newValue.length) { return; }
+      this._dispatchChanged(
+          this.pluginOption.info.values.concat([this._newValue]));
+      this._newValue = '';
+    },
+
+    _handleDelete(e) {
+      const value = Polymer.dom(e).localTarget.dataItem;
+      this._dispatchChanged(
+          this.pluginOption.info.values.filter(str => str !== value));
+    },
+
+    _dispatchChanged(values) {
+      const {_key, info} = this.pluginOption;
+      const detail = {
+        _key,
+        info: Object.assign(info, {values}, {}),
+        notifyPath: `${_key}.values`,
+      };
+      this.dispatchEvent(
+          new CustomEvent('plugin-config-option-changed', {detail}));
+    },
+
+    _computeShowInputRow(disabled) {
+      return disabled ? 'hide' : '';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
new file mode 100644
index 0000000..dc3f67e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
@@ -0,0 +1,142 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-config-array-editor</title>
+
+<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-plugin-config-array-editor.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-plugin-config-array-editor></gr-plugin-config-array-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-plugin-config-array-editor tests', () => {
+    let element;
+    let sandbox;
+    let dispatchStub;
+
+    const getAll = str => Polymer.dom(element.root).querySelectorAll(str);
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.pluginOption = {
+        _key: 'test-key',
+        info: {
+          values: [],
+        },
+      };
+    });
+
+    teardown(() => sandbox.restore());
+
+    test('_computeShowInputRow', () => {
+      assert.equal(element._computeShowInputRow(true), 'hide');
+      assert.equal(element._computeShowInputRow(false), '');
+    });
+
+    test('_computeDisabled', () => {
+      assert.isTrue(element._computeDisabled({}));
+      assert.isTrue(element._computeDisabled({base: {}}));
+      assert.isTrue(element._computeDisabled({base: {info: {}}}));
+      assert.isTrue(
+          element._computeDisabled({base: {info: {editable: false}}}));
+      assert.isFalse(
+          element._computeDisabled({base: {info: {editable: true}}}));
+    });
+
+    suite('adding', () => {
+      setup(() => {
+        dispatchStub = sandbox.stub(element, '_dispatchChanged');
+      });
+
+      test('with enter', () => {
+        element._newValue = '';
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        flushAsynchronousOperations();
+
+        assert.isFalse(dispatchStub.called);
+        element._newValue = 'test';
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        flushAsynchronousOperations();
+
+        assert.isTrue(dispatchStub.called);
+        assert.equal(dispatchStub.lastCall.args[0], 'test');
+        assert.equal(element._newValue, '');
+      });
+
+      test('with add btn', () => {
+        element._newValue = '';
+        MockInteractions.tap(element.$.addButton);
+        flushAsynchronousOperations();
+
+        assert.isFalse(dispatchStub.called);
+        element._newValue = 'test';
+        MockInteractions.tap(element.$.addButton);
+        flushAsynchronousOperations();
+
+        assert.isTrue(dispatchStub.called);
+        assert.equal(dispatchStub.lastCall.args[0], 'test');
+        assert.equal(element._newValue, '');
+      });
+    });
+
+    test('deleting', () => {
+      dispatchStub = sandbox.stub(element, '_dispatchChanged');
+      element.pluginOption = {info: {values: ['test', 'test2']}};
+      flushAsynchronousOperations();
+
+      const rows = getAll('.existingItems .row');
+      assert.equal(rows.length, 2);
+      const button = rows[0].querySelector('gr-button');
+
+      MockInteractions.tap(button);
+      flushAsynchronousOperations();
+
+      assert.isFalse(dispatchStub.called);
+      element.pluginOption.info.editable = true;
+      element.notifyPath('pluginOption.info.editable');
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(button);
+      flushAsynchronousOperations();
+
+      assert.isTrue(dispatchStub.called);
+      assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
+    });
+
+    test('_dispatchChanged', () => {
+      const eventStub = sandbox.stub(element, 'dispatchEvent');
+      element._dispatchChanged(['new-test-value']);
+
+      assert.isTrue(eventStub.called);
+      const {detail} = eventStub.lastCall.args[0];
+      assert.equal(detail._key, 'test-key');
+      assert.deepEqual(detail.info, {values: ['new-test-value']});
+      assert.equal(detail.notifyPath, 'test-key.values');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index d9e1238..395df6d 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -44,6 +44,7 @@
     'admin/gr-group-members/gr-group-members_test.html',
     'admin/gr-group/gr-group_test.html',
     'admin/gr-permission/gr-permission_test.html',
+    'admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html',
     'admin/gr-plugin-list/gr-plugin-list_test.html',
     'admin/gr-repo-access/gr-repo-access_test.html',
     'admin/gr-repo-command/gr-repo-command_test.html',