Merge "PolyGerrit: Implement /admin/groups page"
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
new file mode 100644
index 0000000..730ff74
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
@@ -0,0 +1,115 @@
+<!--
+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="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<link rel="import" href="../../../styles/gr-form-styles.html">
+
+<dom-module id="gr-admin-group-list">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: flex;
+        flex-direction: column;
+      }
+      tr.group-table {
+        border-bottom: 1px solid #eee;
+      }
+      #groupList {
+        border-collapse: collapse;
+        width: 100%;
+      }
+      #filterContainer {
+        margin: 1em;
+      }
+      #groupFilter {
+        font-size: 1em;
+        max-width: 25em;
+      }
+      td {
+        flex-shrink: 0;
+        padding: .3em .5em;
+      }
+      th {
+        background-color: #ddd;
+        border-bottom: 1px solid #eee;
+        font-weight: bold;
+        padding: .3em .5em;
+        text-align: left;
+      }
+      a {
+        color: var(--default-text-color);
+        text-decoration: none;
+      }
+      a:hover {
+        text-decoration: underline;
+      }
+      nav {
+        padding: .5em 0;
+        text-align: center;
+      }
+      nav a {
+        display: inline-block;
+      }
+      nav a:first-of-type {
+        margin-right: .5em;
+      }
+      .description {
+        width: 70%;
+      }
+    </style>
+    <div id="filterContainer">
+      <label>Filter:</label>
+      <input is="iron-input"
+          type="text"
+          id="groupFilter"
+          bind-value="{{_filter}}"
+          on-input="_onValueChange">
+    </div>
+    <table id="groupList">
+      <tr class="headerRow">
+        <th class="name topHeader">Group Name</th>
+        <th class="description topHeader">Group Description</th>
+        <th class="visibleToAll topHeader">Visible To All</th>
+      </tr>
+      <template is="dom-repeat" items="[[_shownGroups]]">
+        <tr class="group-table">
+          <td class="name">
+            <a href$="[[_getUrl(item.group_id)]]">[[item.name]]</a>
+          </td>
+          <td class="description">[[item.description]]</td>
+          <td class="visibleToAll">[[_visibleToAll(item)]]</td>
+        </tr>
+      </template>
+    </table>
+    <nav>
+      <a id="prevArrow"
+          href$="[[_computeNavLink(_offset, -1, _groupsPerPage)]]"
+          hidden$="[[_hidePrevArrow(_offset)]]" hidden>&larr; Prev</a>
+      <a id="nextArrow"
+          href$="[[_computeNavLink(_offset, 1, _groupsPerPage)]]"
+          hidden$="[[_hideNextArrow(_loading, _groups)]]" hidden>
+        Next &rarr;</a>
+    </nav>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-admin-group-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
new file mode 100644
index 0000000..2586b0c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
@@ -0,0 +1,151 @@
+// 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 REQUEST_DEBOUNCE_INTERVAL_MS = 200;
+
+  Polymer({
+    is: 'gr-admin-group-list',
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: Number,
+
+      _groups: Array,
+
+      /**
+       * Because  we request one more than the groupsPerPage, _shownGroups
+       * may be one less than _groups.
+       * */
+      _shownGroups: {
+        type: Array,
+        computed: '_computeShownGroups(_groups)',
+      },
+
+      _groupsPerPage: {
+        type: Number,
+        value: 25,
+      },
+
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: String,
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    listeners: {
+      'next-page': '_handleNextPage',
+      'previous-page': '_handlePreviousPage',
+    },
+
+    _onValueChange(e) {
+      this.debounce('reload', () => {
+        if (e.target.value) {
+          return page.show('/admin/groups/q/filter:' +
+              this.encodeURL(e.target.value, false));
+        }
+        page.show('/admin/groups');
+      }, REQUEST_DEBOUNCE_INTERVAL_MS);
+    },
+
+    _paramsChanged(value) {
+      this._loading = true;
+
+      if (value) {
+        this._filter = value.filter || null;
+      }
+
+      if (value && value.offset) {
+        this._offset = value.offset;
+      } else {
+        this._offset = 0;
+      }
+      return this._getGroups(this._filter, this._groupsPerPage,
+          this._offset);
+    },
+
+    _getGroups(filter, groupsPerPage, offset) {
+      return this.$.restAPI.getGroups(filter, groupsPerPage, offset)
+          .then(groups => {
+            if (!groups) {
+              this._groups = [];
+              return;
+            }
+            this._groups = Object.keys(groups)
+             .map(key => {
+               const group = groups[key];
+               group.name = key;
+               return group;
+             });
+            this._loading = false;
+          });
+    },
+
+    _visibleToAll(item) {
+      return item.options.visible_to_all === true ? 'Y' : 'N';
+    },
+
+    _getUrl(item) {
+      return this.getBaseUrl() + '/admin/groups/' +
+          this.encodeURL(item, true);
+    },
+
+    _computeNavLink(offset, direction, groupsPerPage, filter) {
+      // Offset could be a string when passed from the router.
+      offset = +(offset || 0);
+      const newOffset = Math.max(0, offset + (groupsPerPage * direction));
+      let href = this.getBaseUrl() + '/admin/groups';
+      if (filter) {
+        href += '/q/filter:' + filter;
+      }
+      if (newOffset > 0) {
+        href += ',' + newOffset;
+      }
+      return href;
+    },
+
+    _computeShownGroups(groups) {
+      return groups.slice(0, 25);
+    },
+
+    _hidePrevArrow(offset) {
+      return offset === 0;
+    },
+
+    _hideNextArrow(loading, groups) {
+      let lastPage = false;
+      if (groups.length < this._groupsPerPage + 1) {
+        lastPage = true;
+      }
+      return loading || lastPage || !groups || !groups.length;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
new file mode 100644
index 0000000..5bb6906
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
@@ -0,0 +1,200 @@
+<!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-admin-group-list</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="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+
+<link rel="import" href="gr-admin-group-list.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-admin-group-list></gr-admin-group-list>
+  </template>
+</test-fixture>
+
+<script>
+  let counter = 0;
+  const groupGenerator = () => {
+    return {
+      name: `test${++counter}`,
+      id: '59b92f35489e62c80d1ab1bf0c2d17843038df8b',
+      url: '#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b',
+      options: {
+        visible_to_all: false,
+      },
+      description: 'Gerrit Site Administrators',
+      group_id: 1,
+      owner: 'Administrators',
+      owner_id: '7ca042f4d5847936fcb90ca91057673157fd06fc',
+    };
+  };
+
+  suite('gr-admin-group-list tests', () => {
+    let element;
+    let groups;
+    let sandbox;
+    let value;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('list with groups', () => {
+      setup(done => {
+        groups = _.times(26, groupGenerator);
+
+        stub('gr-rest-api-interface', {
+          getGroups(num, offset) {
+            return Promise.resolve(groups);
+          },
+        });
+
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('test for test group in the list', done => {
+        flush(() => {
+          assert.equal(element._groups[1].name, 'test2');
+          assert.equal(element._groups[1].options.visible_to_all, false);
+          done();
+        });
+      });
+
+      test('test next button', done => {
+        flush(() => {
+          let loading;
+          assert.isFalse(element._hideNextArrow(loading, groups));
+          loading = true;
+          assert.isTrue(element._hideNextArrow(loading, groups));
+          loading = false;
+          assert.isFalse(element._hideNextArrow(loading, groups));
+          element._groups = [];
+          assert.isTrue(element._hideNextArrow(loading, element._groups));
+          groups = _.times(4, groupGenerator);
+          assert.isTrue(element._hideNextArrow(loading, groups));
+          done();
+        });
+      });
+
+      test('test for prev button', () => {
+        flush(() => {
+          let offset = 0;
+          assert.isTrue(element._hidePrevArrow(offset));
+          offset = 5;
+          assert.isFalse(element._hidePrevArrow(offset));
+        });
+      });
+
+      test('_shownGroups', () => {
+        assert.equal(element._shownGroups.length, 25);
+      });
+    });
+
+    suite('test with less then 25 groups', () => {
+      setup(done => {
+        groups = _.times(25, groupGenerator);
+
+        stub('gr-rest-api-interface', {
+          getGroups(num, offset) {
+            return Promise.resolve(groups);
+          },
+        });
+
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('test next button', done => {
+        flush(() => {
+          let loading;
+          assert.isTrue(element._hideNextArrow(loading, groups));
+          groups = _.times(1, groupGenerator);
+          assert.isTrue(element._hideNextArrow(loading, groups));
+          groups = _.times(26, groupGenerator);
+          assert.isFalse(element._hideNextArrow(loading, groups));
+          done();
+        });
+      });
+
+      test('_shownGroups', () => {
+        assert.equal(element._shownGroups.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_onValueChange', done => {
+        sandbox.stub(page, 'show', url => {
+          assert.equal(url, '/admin/groups/q/filter:test');
+          done();
+        });
+        const e = {target: {value: 'test'}};
+        element._onValueChange(e);
+      });
+
+      test('_paramsChanged', done => {
+        sandbox.stub(element.$.restAPI, 'getGroups', () => {
+          return Promise.resolve(groups);
+        });
+        const value = {
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(value).then(() => {
+          assert.isTrue(element.$.restAPI.getGroups.lastCall
+              .calledWithExactly('test', 25, 25));
+          done();
+        });
+      });
+
+      test('_computeNavLink', () => {
+        const offset = 25;
+        const groupsPerPage = 25;
+        const filter = 'test';
+
+        sandbox.stub(element, 'getBaseUrl', () => '');
+
+        assert.equal(
+            element._computeNavLink(offset, 1, groupsPerPage, filter),
+            '/admin/groups/q/filter:test,50');
+
+        assert.equal(
+            element._computeNavLink(offset, -1, groupsPerPage, filter),
+            '/admin/groups/q/filter:test');
+
+        assert.equal(
+            element._computeNavLink(offset, 1, groupsPerPage, null),
+            '/admin/groups,50');
+
+        assert.equal(
+            element._computeNavLink(offset, -1, groupsPerPage, null),
+            '/admin/groups');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index c6b3ccf..4087e41 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -108,6 +108,48 @@
       });
     });
 
+    // Matches /admin/groups[,<offset>][/].
+    page(/^\/admin\/groups(,(\d+))?(\/)?$/, loadUser, data => {
+      restAPI.getLoggedIn().then(loggedIn => {
+        if (loggedIn) {
+          app.params = {
+            view: 'gr-admin-group-list',
+            offset: data.params[1] || 0,
+            filter: null,
+          };
+        } else {
+          page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
+        }
+      });
+    });
+
+    page('/admin/groups/q/filter::filter,:offset', loadUser, data => {
+      restAPI.getLoggedIn().then(loggedIn => {
+        if (loggedIn) {
+          app.params = {
+            view: 'gr-admin-group-list',
+            offset: data.params.offset,
+            filter: data.params.filter,
+          };
+        } else {
+          page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
+        }
+      });
+    });
+
+    page('/admin/groups/q/filter::filter', loadUser, data => {
+      restAPI.getLoggedIn().then(loggedIn => {
+        if (loggedIn) {
+          app.params = {
+            view: 'gr-admin-group-list',
+            filter: data.params.filter || null,
+          };
+        } else {
+          page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
+        }
+      });
+    });
+
     // Matches /admin/projects[,<offset>][/].
     page(/^\/admin\/projects(,(\d+))?(\/)?$/, loadUser, data => {
       app.params = {
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index e0938a5..caf83bb 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -18,6 +18,7 @@
 <link rel="import" href="../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../styles/app-theme.html">
+<link rel="import" href="./admin/gr-admin-group-list/gr-admin-group-list.html">
 <link rel="import" href="./admin/gr-admin-project-list/gr-admin-project-list.html">
 <link rel="import" href="./admin/gr-admin-project/gr-admin-project.html">
 <link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
@@ -141,6 +142,11 @@
             on-account-detail-update="_handleAccountDetailUpdate">
         </gr-settings-view>
       </template>
+      <template is="dom-if" if="[[_showGroupListView]]" restamp="true">
+        <gr-admin-group-list
+            params="[[params]]"
+            id="groupList"></gr-admin-group-list>
+      </template>
       <template is="dom-if" if="[[_showProjectListView]]" restamp="true">
         <gr-admin-project-list
             params="[[params]]"
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index c852aae..7f63330 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -138,6 +138,7 @@
       this.set('_showChangeView', view === 'gr-change-view');
       this.set('_showDiffView', view === 'gr-diff-view');
       this.set('_showSettingsView', view === 'gr-settings-view');
+      this.set('_showGroupListView', view === 'gr-admin-group-list');
       this.set('_showProjectListView', view === 'gr-admin-project-list');
       this.set('_showAdminProject', view === 'gr-admin-project');
       this.set('_showAdminView', view === 'gr-admin-view');
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index c2352c5d..4f89535 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -497,6 +497,15 @@
       });
     },
 
+    getGroups(filter, groupsPerPage, opt_offset) {
+      const offset = opt_offset || 0;
+      filter = filter ? '&m=' + filter : '';
+
+      return this._fetchSharedCacheURL(
+          `/groups/?n=${groupsPerPage + 1}&S=${offset}${filter}`
+      );
+    },
+
     getProjects(filter, projectsPerPage, opt_offset) {
       const offset = opt_offset || 0;
       filter = filter ? '&m=' + filter : '';