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') }} • + <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') }} • + <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') }} • + <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