From 6935c9c8b3f310bc58b66713892f027fad937072 Mon Sep 17 00:00:00 2001
From: "Andrew Smith (EspadaV8)" <espadav8@gmail.com>
Date: Mon, 21 Jun 2021 14:19:00 +0000
Subject: [PATCH] Show open issues and remaining weight on epics

---
 .../boards/components/board_card_inner.vue    |  95 +++++++++++++---
 doc/user/group/epics/epic_boards.md           |   6 +
 .../boards/graphql/lists_epics.query.graphql  |  11 ++
 locale/gitlab.pot                             |   3 +
 spec/frontend/boards/board_card_inner_spec.js | 106 ++++++++++++++++--
 5 files changed, 199 insertions(+), 22 deletions(-)

diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 2f4e9044b9e..fef6f8653ab 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -1,5 +1,5 @@
 <script>
-import { GlLabel, GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui';
+import { GlLabel, GlTooltip, GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui';
 import { sortBy } from 'lodash';
 import { mapActions, mapGetters, mapState } from 'vuex';
 import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
@@ -16,6 +16,7 @@ import IssueTimeEstimate from './issue_time_estimate.vue';
 
 export default {
   components: {
+    GlTooltip,
     GlLabel,
     GlLoadingIcon,
     GlIcon,
@@ -55,7 +56,7 @@ export default {
     };
   },
   computed: {
-    ...mapState(['isShowingLabels', 'issuableType']),
+    ...mapState(['isShowingLabels', 'issuableType', 'allowSubEpics']),
     ...mapGetters(['isEpicBoard']),
     cappedAssignees() {
       // e.g. maxRender is 4,
@@ -99,6 +100,9 @@ export default {
       }
       return false;
     },
+    shouldRenderEpicCountables() {
+      return this.isEpicBoard && this.item.hasIssues;
+    },
     showLabelFooter() {
       return this.isShowingLabels && this.item.labels.find(this.showLabel);
     },
@@ -115,6 +119,17 @@ export default {
       }
       return __('Blocked issue');
     },
+    totalEpicsCount() {
+      return this.item.descendantCounts.openedEpics + this.item.descendantCounts.closedEpics;
+    },
+    totalIssuesCount() {
+      return this.item.descendantCounts.openedIssues + this.item.descendantCounts.closedIssues;
+    },
+    totalWeight() {
+      return (
+        this.item.descendantWeightSum.openedIssues + this.item.descendantWeightSum.closedIssues
+      );
+    },
   },
   methods: {
     ...mapActions(['performSearch', 'setError']),
@@ -227,17 +242,71 @@ export default {
           {{ itemId }}
         </span>
         <span class="board-info-items gl-mt-3 gl-display-inline-block">
-          <issue-due-date
-            v-if="item.dueDate"
-            :date="item.dueDate"
-            :closed="item.closed || Boolean(item.closedAt)"
-          />
-          <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" />
-          <issue-card-weight
-            v-if="validIssueWeight(item)"
-            :weight="item.weight"
-            @click="filterByWeight(item.weight)"
-          />
+          <span v-if="shouldRenderEpicCountables" data-testid="epic-countables">
+            <gl-tooltip :target="() => $refs.countBadge" data-testid="epic-countables-tooltip">
+              <p v-if="allowSubEpics" class="gl-font-weight-bold gl-m-0">
+                {{ __('Epics') }} &#8226;
+                <span class="gl-font-weight-normal"
+                  >{{
+                    sprintf(__('%{openedEpics} open, %{closedEpics} closed'), {
+                      openedEpics: item.descendantCounts.openedEpics,
+                      closedEpics: item.descendantCounts.closedEpics,
+                    })
+                  }}
+                </span>
+              </p>
+              <p class="gl-font-weight-bold gl-m-0">
+                {{ __('Issues') }} &#8226;
+                <span class="gl-font-weight-normal"
+                  >{{
+                    sprintf(__('%{openedIssues} open, %{closedIssues} closed'), {
+                      openedIssues: item.descendantCounts.openedIssues,
+                      closedIssues: item.descendantCounts.closedIssues,
+                    })
+                  }}
+                </span>
+              </p>
+              <p class="gl-font-weight-bold gl-m-0">
+                {{ __('Weight') }} &#8226;
+                <span class="gl-font-weight-normal" data-testid="epic-countables-total-weight"
+                  >{{
+                    sprintf(__('%{closedWeight} complete, %{openWeight} incomplete'), {
+                      openWeight: item.descendantWeightSum.openedIssues,
+                      closedWeight: item.descendantWeightSum.closedIssues,
+                    })
+                  }}
+                </span>
+              </p>
+            </gl-tooltip>
+
+            <span ref="countBadge" class="issue-count-badge board-card-info">
+              <span v-if="allowSubEpics" class="gl-mr-3">
+                <gl-icon name="epic" />
+                {{ totalEpicsCount }}
+              </span>
+              <span class="gl-mr-3" data-testid="epic-countables-counts-issues">
+                <gl-icon name="issues" />
+                {{ totalIssuesCount }}
+              </span>
+              <span class="gl-mr-3" data-testid="epic-countables-weight-issues">
+                <gl-icon name="weight" />
+                {{ totalWeight }}
+              </span>
+            </span>
+          </span>
+          <span v-if="!isEpicBoard">
+            <issue-due-date
+              v-if="item.dueDate"
+              :date="item.dueDate"
+              :closed="item.closed || Boolean(item.closedAt)"
+            />
+            <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" />
+            <issue-card-weight
+              v-if="validIssueWeight(item)"
+              :weight="item.weight"
+              @click="filterByWeight(item.weight)"
+            />
+          </span>
         </span>
       </div>
       <div class="board-card-assignee gl-display-flex">
diff --git a/doc/user/group/epics/epic_boards.md b/doc/user/group/epics/epic_boards.md
index c31b0c7f78a..17801bea03a 100644
--- a/doc/user/group/epics/epic_boards.md
+++ b/doc/user/group/epics/epic_boards.md
@@ -112,6 +112,12 @@ You can filter by the following:
 - Author
 - Label
 
+### View count of issues and weight in an epic
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/331330) in GitLab 14.1.
+
+Epics on the **Epic Boards** show a summary of their issues and weight. Hovering over the total counts will show the number of open and closed issues, as well as the completed and incomplete weight.
+
 ### Move epics and lists
 
 > [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/5079) in GitLab 14.0.
diff --git a/ee/app/assets/javascripts/boards/graphql/lists_epics.query.graphql b/ee/app/assets/javascripts/boards/graphql/lists_epics.query.graphql
index 7d9bfb02e52..8f016a0f5a5 100644
--- a/ee/app/assets/javascripts/boards/graphql/lists_epics.query.graphql
+++ b/ee/app/assets/javascripts/boards/graphql/lists_epics.query.graphql
@@ -27,6 +27,17 @@ query ListEpics(
                     ...Label
                   }
                 }
+                hasIssues
+                descendantCounts {
+                  closedEpics
+                  closedIssues
+                  openedEpics
+                  openedIssues
+                }
+                descendantWeightSum {
+                  closedIssues
+                  openedIssues
+                }
               }
             }
             pageInfo {
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index bc0e31dbbcc..fb092dfae2e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -436,6 +436,9 @@ msgid_plural "%{bold_start}%{count}%{bold_end} opened merge requests"
 msgstr[0] ""
 msgstr[1] ""
 
+msgid "%{closedWeight} complete, %{openWeight} incomplete"
+msgstr ""
+
 msgid "%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements."
 msgstr ""
 
diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js
index 15ea5d4eec4..ca6b46a164d 100644
--- a/spec/frontend/boards/board_card_inner_spec.js
+++ b/spec/frontend/boards/board_card_inner_spec.js
@@ -1,7 +1,7 @@
-import { GlLabel, GlLoadingIcon } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
+import { GlLabel, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
 import { range } from 'lodash';
 import Vuex from 'vuex';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
 import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue';
 import BoardCardInner from '~/boards/components/board_card_inner.vue';
 import { issuableTypes } from '~/boards/constants';
@@ -35,8 +35,14 @@ describe('Board card component', () => {
   let store;
 
   const findBoardBlockedIcon = () => wrapper.find(BoardBlockedIcon);
-
-  const createStore = () => {
+  const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+  const findEpicCountablesTotalTooltip = () => wrapper.findComponent(GlTooltip);
+  const findEpicCountables = () => wrapper.findByTestId('epic-countables');
+  const findEpicCountablesBadgeIssues = () => wrapper.findByTestId('epic-countables-counts-issues');
+  const findEpicCountablesBadgeWeight = () => wrapper.findByTestId('epic-countables-weight-issues');
+  const findEpicCountablesTotalWeight = () => wrapper.findByTestId('epic-countables-total-weight');
+
+  const createStore = ({ isEpicBoard = false } = {}) => {
     store = new Vuex.Store({
       ...defaultStore,
       state: {
@@ -45,16 +51,14 @@ describe('Board card component', () => {
       },
       getters: {
         isGroupBoard: () => true,
-        isEpicBoard: () => false,
+        isEpicBoard: () => isEpicBoard,
         isProjectBoard: () => false,
       },
     });
   };
 
   const createWrapper = (props = {}) => {
-    createStore();
-
-    wrapper = mount(BoardCardInner, {
+    wrapper = mountExtended(BoardCardInner, {
       store,
       propsData: {
         list,
@@ -88,6 +92,7 @@ describe('Board card component', () => {
       weight: 1,
     };
 
+    createStore();
     createWrapper({ item: issue, list });
   });
 
@@ -414,7 +419,90 @@ describe('Board card component', () => {
         },
       });
 
-      expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+      expect(findLoadingIcon().exists()).toBe(true);
+    });
+  });
+
+  describe('is an epic board', () => {
+    const descendantCounts = {
+      closedEpics: 0,
+      closedIssues: 0,
+      openedEpics: 0,
+      openedIssues: 0,
+    };
+
+    const descendantWeightSum = {
+      closedIssues: 0,
+      openedIssues: 0,
+    };
+
+    beforeEach(() => {
+      createStore({ isEpicBoard: true });
+    });
+
+    it('should render if the item has issues', () => {
+      createWrapper({
+        item: {
+          ...issue,
+          descendantCounts,
+          descendantWeightSum,
+          hasIssues: true,
+        },
+      });
+
+      expect(findEpicCountables().exists()).toBe(true);
+    });
+
+    it('should not render if the item does not have issues', () => {
+      createWrapper({
+        item: {
+          ...issue,
+          descendantCounts,
+          descendantWeightSum,
+          hasIssues: false,
+        },
+      });
+
+      expect(findEpicCountablesBadgeIssues().exists()).toBe(false);
+    });
+
+    it('shows render item countBadge and weights correctly', () => {
+      createWrapper({
+        item: {
+          ...issue,
+          descendantCounts: {
+            ...descendantCounts,
+            openedIssues: 1,
+          },
+          descendantWeightSum: {
+            ...descendantWeightSum,
+            openedIssues: 2,
+          },
+          hasIssues: true,
+        },
+      });
+
+      expect(findEpicCountablesBadgeIssues().text()).toBe('1');
+      expect(findEpicCountablesBadgeWeight().text()).toBe('2');
+    });
+
+    it('renders the tooltip with the correct data', () => {
+      createWrapper({
+        item: {
+          ...issue,
+          descendantCounts,
+          descendantWeightSum: {
+            closedIssues: 10,
+            openedIssues: 5,
+          },
+          hasIssues: true,
+        },
+      });
+
+      const tooltip = findEpicCountablesTotalTooltip();
+      expect(tooltip).toBeDefined();
+
+      expect(findEpicCountablesTotalWeight().text()).toBe('10 complete, 5 incomplete');
     });
   });
 });
-- 
2.30.9