UI: Move tasks to a primary tab

Tasks are now rendered in a primary tab. This makes the UI compact and
consistent. The API to fetch tasks has been moved from GrTaskPlugin to
GrTaskSummary because the summary is registered to the commit-container
which renders by default when the change page is loaded. The tasks tab
content is attached to 'change-view-tab-content' which only renders when
the tab is clicked or focus is changed to it.

Screenshots: https://imgur.com/a/Qr2i1pQ

Change-Id: I14a21fc83ef04a909576dfd796624044cbf72858
diff --git a/gr-task-plugin/gr-task-chip.js b/gr-task-plugin/gr-task-chip.js
index 8e46bd0..a0c5b94 100644
--- a/gr-task-plugin/gr-task-chip.js
+++ b/gr-task-plugin/gr-task-chip.js
@@ -18,7 +18,7 @@
 import './gr-task-plugin.js';
 import {htmlTemplate} from './gr-task-chip_html.js';
 
-class GrTaskChip extends Polymer.Element {
+export class GrTaskChip extends Polymer.Element {
   static get is() {
     return 'gr-task-chip';
   }
@@ -37,23 +37,24 @@
     };
   }
 
-  _setTasksTabActive() {
-    // TODO: Identify a better way as current implementation is fragile
-    const endPointDecorators = document.querySelector('gr-app')
+  static getPrimaryTabs() {
+    return document.querySelector('gr-app')
         .shadowRoot.querySelector('gr-app-element')
         .shadowRoot.querySelector('main')
         .querySelector('gr-change-view')
         .shadowRoot.querySelector('#mainContent')
-        .getElementsByTagName('gr-endpoint-decorator');
-    if (endPointDecorators) {
-      for (let i = 0; i <= endPointDecorators.length; i++) {
-        const el = endPointDecorators[i]
-            ?.shadowRoot?.querySelector('gr-task-plugin');
-        if (el) {
-          el.shadowRoot.querySelector('paper-tabs')
-              .querySelector('paper-tab').scrollIntoView();
-          break;
-        }
+        .querySelector('#primaryTabs');
+  }
+
+  _setTasksTabActive() {
+    // TODO: Identify a better way as current implementation is fragile
+    const paperTabs = GrTaskChip.getPrimaryTabs();
+    const tabs = paperTabs.querySelectorAll('paper-tab');
+    for (let i=0; i <= tabs.length; i++) {
+      if (tabs[i].dataset['name'] === 'change-view-tab-header-task') {
+        paperTabs.selected = i;
+        tabs[i].scrollIntoView({block: 'center'});
+        break;
       }
     }
   }
diff --git a/gr-task-plugin/gr-task-plugin.js b/gr-task-plugin/gr-task-plugin.js
index 2ac355d..2bff130 100644
--- a/gr-task-plugin/gr-task-plugin.js
+++ b/gr-task-plugin/gr-task-plugin.js
@@ -82,30 +82,16 @@
         notify: true,
         value: 0,
       },
-      _invalid_count: {
-        type: Number,
-        notify: true,
-        value: 0,
-      },
-      _waiting_count: {
-        type: Number,
-        notify: true,
-        value: 0,
-      },
-      _duplicate_count: {
-        type: Number,
-        notify: true,
-        value: 0,
-      },
-      _pass_count: {
-        type: Number,
-        notify: true,
-        value: 0,
-      },
+
       _isPending: {
         type: Boolean,
         value: true,
       },
+
+      _tasks_info: {
+        type: Object,
+        observer: '_tasksInfoChanged',
+      },
     };
   }
 
@@ -113,52 +99,36 @@
     return show_all === 'true';
   }
 
-  connectedCallback() {
-    super.connectedCallback();
-
+  ready() {
+    super.ready();
+    if (!this.change) {
+      return;
+    }
+    document.addEventListener(`response-tasks-${this.change._number}`, e => {
+      this._tasks_info = e.detail.tasks_info;
+      this._isPending = e.detail.is_loading;
+    });
     this._getTasks();
   }
 
+  _tasksInfoChanged(newValue, oldValue) {
+    if (this._tasks_info) {
+      this._tasks = this._addTasks(this._tasks_info.roots);
+    }
+  }
+
   _is_hidden(_isPending, _tasks) {
     return (!_isPending && !_tasks.length);
   }
 
-  _getTasks() {
-    if (!this.change) {
-      return;
+  async _getTasks() {
+    while (this._isPending) {
+      document.dispatchEvent(
+          new CustomEvent(`request-tasks-${this.change._number}`, {
+            composed: true, bubbles: true,
+          }));
+      await new Promise(r => setTimeout(r, 100));
     }
-
-    this._isPending = true;
-    const endpoint =
-        `/changes/?q=change:${this.change._number}&--task--applicable`;
-
-    return this.plugin.restApi().get(endpoint).then(response => {
-      this._isPending = false;
-      if (response && response.length === 1) {
-        const cinfo = response[0];
-        if (cinfo.plugins) {
-          const taskPluginInfo = cinfo.plugins.find(
-              pluginInfo => pluginInfo.name === 'task');
-
-          if (taskPluginInfo) {
-            this._tasks = this._addTasks(taskPluginInfo.roots);
-          }
-        }
-        document.dispatchEvent(new CustomEvent('tasks-loaded', {
-          detail: {
-            ready_count: this._ready_count,
-            fail_count: this._fail_count,
-            invalid_count: this._invalid_count,
-            waiting_count: this._waiting_count,
-            duplicate_count: this._duplicate_count,
-            pass_count: this._pass_count,
-          },
-          composed: true, bubbles: true,
-        }));
-      }
-    }).catch(e => {
-      this._isPending = false;
-    });
   }
 
   _computeIcon(task) {
@@ -223,18 +193,6 @@
       case 'READY':
         this._ready_count++;
         break;
-      case 'INVALID':
-        this._invalid_count++;
-        break;
-      case 'WAITING':
-        this._waiting_count++;
-        break;
-      case 'DUPLICATE':
-        this._duplicate_count++;
-        break;
-      case 'PASS':
-        this._pass_count++;
-        break;
     }
   }
 
diff --git a/gr-task-plugin/gr-task-plugin_html.js b/gr-task-plugin/gr-task-plugin_html.js
index 6ab5fb9..e0ea9cd 100644
--- a/gr-task-plugin/gr-task-plugin_html.js
+++ b/gr-task-plugin/gr-task-plugin_html.js
@@ -80,14 +80,6 @@
 </style>
 
 <div id="tasks" hidden$="[[_is_hidden(_isPending, _tasks)]]">
-  <paper-tabs id="secondaryTabs" selected="0">
-    <paper-tab
-      data-name$="Tasks"
-      class="Tasks"
-    >
-      Tasks
-    </paper-tab>
-  </paper-tabs>
   <section class="TasksList">
     <div hidden$="[[!_isPending]]" class="task-list-item">Loading...</div>
     <div hidden$="[[_isPending]]" class="task-list-item">
@@ -119,6 +111,7 @@
   <div hidden$="[[!_expand_all]]" style="padding-bottom: 12px">
     <ul style="list-style-type:none;">
       <gr-task-plugin-tasks
+          hidden$="[[_isPending]]"
           tasks="[[_tasks]]"
           show_all$="[[_show_all]]"> </gr-task-plugin-tasks>
     </ul>
diff --git a/gr-task-plugin/gr-task-summary.js b/gr-task-plugin/gr-task-summary.js
index 49c858d..95ad3f3 100644
--- a/gr-task-plugin/gr-task-summary.js
+++ b/gr-task-plugin/gr-task-summary.js
@@ -16,7 +16,7 @@
  */
 
 import {htmlTemplate} from './gr-task-summary_html.js';
-import './gr-task-chip.js';
+import {GrTaskChip} from './gr-task-chip.js';
 
 class GrTaskSummary extends Polymer.Element {
   static get is() {
@@ -29,6 +29,10 @@
 
   static get properties() {
     return {
+      change: {
+        type: Object,
+      },
+
       ready_count: {
         type: Number,
         notify: true,
@@ -69,23 +73,93 @@
         type: Boolean,
         value: true,
       },
+
+      tasks_info: {
+        type: Object,
+      },
     };
   }
 
   /** @override */
   ready() {
     super.ready();
-    document.addEventListener('tasks-loaded', e => {
-      this.ready_count = e.detail.ready_count;
-      this.fail_count = e.detail.fail_count;
-      this.invalid_count = e.detail.invalid_count;
-      this.waiting_count = e.detail.waiting_count;
-      this.duplicate_count = e.detail.duplicate_count;
-      this.pass_count = e.detail.pass_count;
-      this.is_loading = false;
+    this._fetch_tasks();
+
+    document.addEventListener(`request-tasks-${this.change._number}`, e => {
+      document.dispatchEvent(
+          new CustomEvent(`response-tasks-${this.change._number}`, {
+            detail: {
+              tasks_info: this.tasks_info,
+              is_loading: this.is_loading,
+            },
+            composed: true, bubbles: true,
+          }));
     });
   }
 
+  _fetch_tasks() {
+    const endpoint =
+        `/changes/?q=change:${this.change._number}&--task--applicable`;
+    return this.plugin.restApi().get(endpoint).then(response => {
+      if (response && response.length === 1) {
+        const cinfo = response[0];
+        if (cinfo.plugins) {
+          this.tasks_info = cinfo.plugins.find(
+              pluginInfo => pluginInfo.name === 'task');
+          this._compute_counts(this.tasks_info.roots);
+        }
+      }
+    }).finally(e => {
+      this.is_loading = false;
+      if (!this._can_show_summary(
+          this.is_loading, this.ready_count,
+          this.fail_count, this.invalid_count,
+          this.waiting_count, this.duplicate_count,
+          this.pass_count)) {
+        this._hide_tasks_tab();
+      }
+    });
+  }
+
+  _compute_counts(tasks) {
+    if (!tasks) return [];
+    tasks.forEach(task => {
+      switch (task.status) {
+        case 'FAIL':
+          this.fail_count++;
+          break;
+        case 'READY':
+          this.ready_count++;
+          break;
+        case 'INVALID':
+          this.invalid_count++;
+          break;
+        case 'WAITING':
+          this.waiting_count++;
+          break;
+        case 'DUPLICATE':
+          this.duplicate_count++;
+          break;
+        case 'PASS':
+          this.pass_count++;
+          break;
+      }
+      this._compute_counts(task.sub_tasks);
+    });
+  }
+
+  _hide_tasks_tab() {
+    const paperTabs = GrTaskChip.getPrimaryTabs();
+    const tabs = paperTabs.querySelectorAll('paper-tab');
+    for (let i=0; i <= tabs.length; i++) {
+      if (tabs[i].dataset['name'] === 'change-view-tab-header-task') {
+        tabs[i].setAttribute('hidden', true);
+        paperTabs.selected = 0;
+        break;
+      }
+    }
+  }
+
   _can_show_summary(is_loading, ready_count,
       fail_count, invalid_count,
       waiting_count, duplicate_count,
@@ -98,4 +172,4 @@
   }
 }
 
-customElements.define(GrTaskSummary.is, GrTaskSummary);
\ No newline at end of file
+customElements.define(GrTaskSummary.is, GrTaskSummary);
diff --git a/gr-task-plugin/gr-task-tab-header.js b/gr-task-plugin/gr-task-tab-header.js
new file mode 100644
index 0000000..1bc6bbe
--- /dev/null
+++ b/gr-task-plugin/gr-task-tab-header.js
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright (C) 2024 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 {htmlTemplate} from './gr-task-tab-header_html.js';
+import './gr-task-plugin.js';
+
+class GrTaskTabHeader extends Polymer.Element {
+  static get is() {
+    return 'gr-task-tab-header';
+  }
+
+  static get template() {
+    return htmlTemplate;
+  }
+}
+
+customElements.define(GrTaskTabHeader.is, GrTaskTabHeader);
\ No newline at end of file
diff --git a/gr-task-plugin/gr-task-tab-header_html.js b/gr-task-plugin/gr-task-tab-header_html.js
new file mode 100644
index 0000000..66be4a6
--- /dev/null
+++ b/gr-task-plugin/gr-task-tab-header_html.js
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright (C) 2024 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.
+ */
+
+export const htmlTemplate = Polymer.html`
+  <div class="gr-task-tab-header">Tasks</div>
+`;
\ No newline at end of file
diff --git a/gr-task-plugin/plugin.js b/gr-task-plugin/plugin.js
index 2219f12..2448ea1 100644
--- a/gr-task-plugin/plugin.js
+++ b/gr-task-plugin/plugin.js
@@ -17,10 +17,18 @@
 
 import './gr-task-plugin.js';
 import './gr-task-summary.js';
+import './gr-task-tab-header.js';
 
 Gerrit.install(plugin => {
+  plugin.registerDynamicCustomComponent(
+      'change-view-tab-header',
+      'gr-task-tab-header'
+  );
+  plugin.registerDynamicCustomComponent(
+      'change-view-tab-content',
+      'gr-task-plugin'
+  );
   plugin.registerCustomComponent(
-      'change-view-integration', 'gr-task-plugin');
-  plugin.registerCustomComponent(
-      'commit-container', 'gr-task-summary');
+      'commit-container',
+      'gr-task-summary');
 });