Commit 5b6ce062 authored by Nathan Friend's avatar Nathan Friend

Add issue stats to releases

This commit adds issue statistics to release blocks on the Releases
page.  These stats are computed using the release's associated
milestone(s).
parent 808b8e82
...@@ -12,6 +12,7 @@ import { scrollToElement } from '~/lib/utils/common_utils'; ...@@ -12,6 +12,7 @@ import { scrollToElement } from '~/lib/utils/common_utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ReleaseBlockFooter from './release_block_footer.vue'; import ReleaseBlockFooter from './release_block_footer.vue';
import EvidenceBlock from './evidence_block.vue'; import EvidenceBlock from './evidence_block.vue';
import ReleaseBlockMilestoneInfo from './release_block_milestone_info.vue';
export default { export default {
name: 'ReleaseBlock', name: 'ReleaseBlock',
...@@ -23,6 +24,7 @@ export default { ...@@ -23,6 +24,7 @@ export default {
Icon, Icon,
UserAvatarLink, UserAvatarLink,
ReleaseBlockFooter, ReleaseBlockFooter,
ReleaseBlockMilestoneInfo,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -90,6 +92,12 @@ export default { ...@@ -90,6 +92,12 @@ export default {
shouldShowFooter() { shouldShowFooter() {
return this.glFeatures.releaseIssueSummary; return this.glFeatures.releaseIssueSummary;
}, },
shouldRenderReleaseMetaData() {
return !this.glFeatures.releaseIssueSummary;
},
shouldRenderMilestoneInfo() {
return Boolean(this.glFeatures.releaseIssueSummary && !_.isEmpty(this.release.milestones));
},
}, },
mounted() { mounted() {
const hash = getLocationHash(); const hash = getLocationHash();
...@@ -106,26 +114,30 @@ export default { ...@@ -106,26 +114,30 @@ export default {
</script> </script>
<template> <template>
<div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block"> <div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block">
<div class="card-header d-flex align-items-center bg-white pr-0">
<h2 class="card-title my-2 mr-auto gl-font-size-20">
{{ release.name }}
<gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
__('Upcoming Release')
}}</gl-badge>
</h2>
<gl-link
v-if="shouldShowEditButton"
v-gl-tooltip
class="btn btn-default append-right-10 js-edit-button ml-2"
:title="__('Edit this release')"
:href="release._links.edit_url"
>
<icon name="pencil" />
</gl-link>
</div>
<div class="card-body"> <div class="card-body">
<div class="d-flex align-items-start"> <div v-if="shouldRenderMilestoneInfo">
<h2 class="card-title mt-0 mr-auto"> <release-block-milestone-info :milestones="release.milestones" />
{{ release.name }} <hr class="mb-3 mt-0" />
<gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
__('Upcoming Release')
}}</gl-badge>
</h2>
<gl-link
v-if="shouldShowEditButton"
v-gl-tooltip
class="btn btn-default js-edit-button ml-2"
:title="__('Edit this release')"
:href="release._links.edit_url"
>
<icon name="pencil" />
</gl-link>
</div> </div>
<div class="card-subtitle d-flex flex-wrap text-secondary"> <div v-if="shouldRenderReleaseMetaData" class="card-subtitle d-flex flex-wrap text-secondary">
<div class="append-right-8"> <div class="append-right-8">
<icon name="commit" class="align-middle" /> <icon name="commit" class="align-middle" />
<gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl"> <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl">
......
<script>
import { GlProgressBar, GlLink, GlBadge, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
import { MAX_MILESTONES_TO_DISPLAY } from '../constants';
/** Sums the values of an array. For use with Array.reduce. */
const sumReducer = (acc, curr) => acc + curr;
export default {
name: 'ReleaseBlockMilestoneInfo',
components: {
GlProgressBar,
GlLink,
GlBadge,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
milestones: {
type: Array,
required: true,
},
},
data() {
return {
showAllMilestones: false,
};
},
computed: {
percentCompleteText() {
return sprintf(__('%{percent}%{percentSymbol} complete'), {
percent: this.percentComplete,
percentSymbol: '%',
});
},
percentComplete() {
const percent = Math.round((this.closedIssuesCount / this.totalIssuesCount) * 100);
return Number.isNaN(percent) ? 0 : percent;
},
allIssueStats() {
return this.milestones.map(m => m.issue_stats || {});
},
openIssuesCount() {
return this.allIssueStats.map(stats => stats.opened || 0).reduce(sumReducer);
},
closedIssuesCount() {
return this.allIssueStats.map(stats => stats.closed || 0).reduce(sumReducer);
},
totalIssuesCount() {
return this.openIssuesCount + this.closedIssuesCount;
},
milestoneLabelText() {
return n__('Milestone', 'Milestones', this.milestones.length);
},
issueCountsText() {
return sprintf(__('Open: %{open} • Closed: %{closed}'), {
open: this.openIssuesCount,
closed: this.closedIssuesCount,
});
},
milestonesToDisplay() {
return this.showAllMilestones
? this.milestones
: this.milestones.slice(0, MAX_MILESTONES_TO_DISPLAY);
},
showMoreLink() {
return this.milestones.length > MAX_MILESTONES_TO_DISPLAY;
},
moreText() {
return this.showAllMilestones
? __('show fewer')
: sprintf(__('show %{count} more'), {
count: this.milestones.length - MAX_MILESTONES_TO_DISPLAY,
});
},
},
methods: {
toggleShowAll() {
this.showAllMilestones = !this.showAllMilestones;
},
shouldRenderBullet(milestoneIndex) {
return Boolean(milestoneIndex !== this.milestonesToDisplay.length - 1 || this.showMoreLink);
},
shouldRenderShowMoreLink(milestoneIndex) {
return Boolean(milestoneIndex === this.milestonesToDisplay.length - 1 && this.showMoreLink);
},
},
};
</script>
<template>
<div class="release-block-milestone-info d-flex align-items-start flex-wrap">
<div
v-gl-tooltip
class="milestone-progress-bar-container js-milestone-progress-bar-container d-flex flex-column align-items-start flex-shrink-1 mr-4 mb-3"
:title="__('Closed issues')"
>
<span class="mb-2">{{ percentCompleteText }}</span>
<span class="w-100">
<gl-progress-bar :value="closedIssuesCount" :max="totalIssuesCount" variant="success" />
</span>
</div>
<div class="d-flex flex-column align-items-start mr-4 mb-3 js-milestone-list-container">
<span class="mb-1">{{ milestoneLabelText }}</span>
<div class="d-flex flex-wrap align-items-end">
<template v-for="(milestone, index) in milestonesToDisplay">
<gl-link
:key="milestone.id"
v-gl-tooltip
:title="milestone.description"
:href="milestone.web_url"
class="append-right-4"
>
{{ milestone.title }}
</gl-link>
<template v-if="shouldRenderBullet(index)">
<span :key="'bullet-' + milestone.id" class="append-right-4">&bull;</span>
</template>
<template v-if="shouldRenderShowMoreLink(index)">
<gl-button :key="'more-button-' + milestone.id" variant="link" @click="toggleShowAll">
{{ moreText }}
</gl-button>
</template>
</template>
</div>
</div>
<div class="d-flex flex-column align-items-start flex-shrink-0 mr-4 mb-3 js-issues-container">
<span class="mb-1">
{{ __('Issues') }}
<gl-badge pill variant="light" class="font-weight-bold">{{ totalIssuesCount }}</gl-badge>
</span>
{{ issueCountsText }}
</div>
</div>
</template>
/* eslint-disable import/prefer-default-export */
// This eslint-disable ^^^ can be removed when at least
// one more constant is added to this file. Currently
// constants.js files with only a single constant
// are flagged by this rule.
export const MAX_MILESTONES_TO_DISPLAY = 5;
.release-block-milestone-info {
.milestone-progress-bar-container {
width: 300px;
min-height: 46px;
}
}
---
title: Add issue statistics to releases on the Releases page
merge_request: 19448
author:
type: added
...@@ -324,6 +324,9 @@ msgstr "" ...@@ -324,6 +324,9 @@ msgstr ""
msgid "%{percent}%% complete" msgid "%{percent}%% complete"
msgstr "" msgstr ""
msgid "%{percent}%{percentSymbol} complete"
msgstr ""
msgid "%{primary} (%{secondary})" msgid "%{primary} (%{secondary})"
msgstr "" msgstr ""
...@@ -12015,6 +12018,9 @@ msgstr "" ...@@ -12015,6 +12018,9 @@ msgstr ""
msgid "Open source software to collaborate on code" msgid "Open source software to collaborate on code"
msgstr "" msgstr ""
msgid "Open: %{open} • Closed: %{closed}"
msgstr ""
msgid "Opened" msgid "Opened"
msgstr "" msgstr ""
...@@ -21511,6 +21517,12 @@ msgstr "" ...@@ -21511,6 +21517,12 @@ msgstr ""
msgid "should be greater than or equal to %{access} inherited membership from group %{group_name}" msgid "should be greater than or equal to %{access} inherited membership from group %{group_name}"
msgstr "" msgstr ""
msgid "show %{count} more"
msgstr ""
msgid "show fewer"
msgstr ""
msgid "show less" msgid "show less"
msgstr "" msgstr ""
......
import { mount } from '@vue/test-utils';
import { GlProgressBar, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import ReleaseBlockMilestoneInfo from '~/releases/list/components/release_block_milestone_info.vue';
import { milestones } from '../../mock_data';
import { trimText } from 'helpers/text_helper';
import { MAX_MILESTONES_TO_DISPLAY } from '~/releases/list/constants';
describe('Release block milestone info', () => {
let wrapper;
let milestonesClone;
const factory = milestonesProp => {
wrapper = mount(ReleaseBlockMilestoneInfo, {
propsData: {
milestones: milestonesProp,
},
sync: false,
});
return wrapper.vm.$nextTick();
};
beforeEach(() => {
milestonesClone = JSON.parse(JSON.stringify(milestones));
});
afterEach(() => {
wrapper.destroy();
});
const milestoneProgressBarContainer = () => wrapper.find('.js-milestone-progress-bar-container');
const milestoneListContainer = () => wrapper.find('.js-milestone-list-container');
const issuesContainer = () => wrapper.find('.js-issues-container');
describe('with default props', () => {
beforeEach(() => factory(milestonesClone));
it('renders the correct percentage', () => {
expect(milestoneProgressBarContainer().text()).toContain('41% complete');
});
it('renders a progress bar that displays the correct percentage', () => {
const progressBar = milestoneProgressBarContainer().find(GlProgressBar);
expect(progressBar.exists()).toBe(true);
expect(progressBar.attributes()).toEqual(
expect.objectContaining({
value: '22',
max: '54',
}),
);
});
it('renders a list of links to all associated milestones', () => {
expect(trimText(milestoneListContainer().text())).toContain('Milestones 13.6 • 13.5');
milestonesClone.forEach((m, i) => {
const milestoneLink = milestoneListContainer()
.findAll(GlLink)
.at(i);
expect(milestoneLink.text()).toBe(m.title);
expect(milestoneLink.attributes('href')).toBe(m.web_url);
expect(milestoneLink.attributes('data-original-title')).toBe(m.description);
});
});
it('renders the "Issues" section with a total count of issues associated to the milestone(s)', () => {
const totalIssueCount = 54;
const issuesContainerText = trimText(issuesContainer().text());
expect(issuesContainerText).toContain(`Issues ${totalIssueCount}`);
const badge = issuesContainer().find(GlBadge);
expect(badge.text()).toBe(totalIssueCount.toString());
expect(issuesContainerText).toContain('Open: 32 • Closed: 22');
});
});
describe('with lots of milestones', () => {
let lotsOfMilestones;
let fullListString;
let abbreviatedListString;
beforeEach(() => {
lotsOfMilestones = [];
const template = milestonesClone[0];
for (let i = 0; i < MAX_MILESTONES_TO_DISPLAY + 10; i += 1) {
lotsOfMilestones.push({
...template,
id: template.id + i,
iid: template.iid + i,
title: `m-${i}`,
});
}
fullListString = lotsOfMilestones.map(m => m.title).join('');
abbreviatedListString = lotsOfMilestones
.slice(0, MAX_MILESTONES_TO_DISPLAY)
.map(m => m.title)
.join('');
return factory(lotsOfMilestones);
});
const clickShowMoreFewerButton = () => {
milestoneListContainer()
.find(GlButton)
.trigger('click');
return wrapper.vm.$nextTick();
};
const milestoneListText = () => trimText(milestoneListContainer().text());
it('only renders a subset of the milestones', () => {
expect(milestoneListText()).toContain(`Milestones ${abbreviatedListString} • show 10 more`);
});
it('renders all milestones when "show more" is clicked', () =>
clickShowMoreFewerButton().then(() => {
expect(milestoneListText()).toContain(`Milestones ${fullListString} • show fewer`);
}));
it('returns to the original view when "show fewer" is clicked', () =>
clickShowMoreFewerButton()
.then(clickShowMoreFewerButton)
.then(() => {
expect(milestoneListText()).toContain(
`Milestones ${abbreviatedListString} • show 10 more`,
);
}));
});
const expectAllZeros = () => {
it('displays percentage as 0%', () => {
expect(milestoneProgressBarContainer().text()).toContain('0% complete');
});
it('shows 0 for all issue counts', () => {
const issuesContainerText = trimText(issuesContainer().text());
expect(issuesContainerText).toContain('Issues 0 Open: 0 • Closed: 0');
});
};
/** Ensures we don't have any issues with dividing by zero when computing percentages */
describe('when all issue counts are zero', () => {
beforeEach(() => {
milestonesClone = milestonesClone.map(m => ({
...m,
issue_stats: {
...m.issue_stats,
opened: 0,
closed: 0,
},
}));
return factory(milestonesClone);
});
expectAllZeros();
});
describe('if the API response is missing the "issue_stats" property', () => {
beforeEach(() => {
milestonesClone = milestonesClone.map(m => ({
...m,
issue_stats: undefined,
}));
return factory(milestonesClone);
});
expectAllZeros();
});
});
...@@ -117,35 +117,6 @@ describe('Release block', () => { ...@@ -117,35 +117,6 @@ describe('Release block', () => {
}); });
}); });
it('renders the milestone icon', () => {
expect(
milestoneListLabel()
.find(Icon)
.exists(),
).toBe(true);
});
it('renders the label as "Milestones" if more than one milestone is passed in', () => {
expect(
milestoneListLabel()
.find('.js-label-text')
.text(),
).toEqual('Milestones');
});
it('renders a link to the milestone with a tooltip', () => {
const milestone = first(release.milestones);
const milestoneLink = wrapper.find('.js-milestone-link');
expect(milestoneLink.exists()).toBe(true);
expect(milestoneLink.text()).toBe(milestone.title);
expect(milestoneLink.attributes('href')).toBe(milestone.web_url);
expect(milestoneLink.attributes('data-original-title')).toBe(milestone.description);
});
it('renders the footer', () => { it('renders the footer', () => {
expect(wrapper.find(ReleaseBlockFooter).exists()).toBe(true); expect(wrapper.find(ReleaseBlockFooter).exists()).toBe(true);
}); });
...@@ -187,18 +158,6 @@ describe('Release block', () => { ...@@ -187,18 +158,6 @@ describe('Release block', () => {
}); });
}); });
it('renders the label as "Milestone" if only a single milestone is passed in', () => {
releaseClone.milestones = releaseClone.milestones.slice(0, 1);
return factory(releaseClone).then(() => {
expect(
milestoneListLabel()
.find('.js-label-text')
.text(),
).toEqual('Milestone');
});
});
it('renders upcoming release badge', () => { it('renders upcoming release badge', () => {
releaseClone.upcoming_release = true; releaseClone.upcoming_release = true;
...@@ -281,4 +240,51 @@ describe('Release block', () => { ...@@ -281,4 +240,51 @@ describe('Release block', () => {
}); });
}); });
}); });
describe('when the releaseIssueSummary feature flag is disabled', () => {
describe('with default props', () => {
beforeEach(() => factory(release, { releaseIssueSummary: false }));
it('renders the milestone icon', () => {
expect(
milestoneListLabel()
.find(Icon)
.exists(),
).toBe(true);
});
it('renders the label as "Milestones" if more than one milestone is passed in', () => {
expect(
milestoneListLabel()
.find('.js-label-text')
.text(),
).toEqual('Milestones');
});
it('renders a link to the milestone with a tooltip', () => {
const milestone = first(release.milestones);
const milestoneLink = wrapper.find('.js-milestone-link');
expect(milestoneLink.exists()).toBe(true);
expect(milestoneLink.text()).toBe(milestone.title);
expect(milestoneLink.attributes('href')).toBe(milestone.web_url);
expect(milestoneLink.attributes('data-original-title')).toBe(milestone.description);
});
});
it('renders the label as "Milestone" if only a single milestone is passed in', () => {
releaseClone.milestones = releaseClone.milestones.slice(0, 1);
return factory(releaseClone, { releaseIssueSummary: false }).then(() => {
expect(
milestoneListLabel()
.find('.js-label-text')
.text(),
).toEqual('Milestone');
});
});
});
}); });
...@@ -11,6 +11,10 @@ export const milestones = [ ...@@ -11,6 +11,10 @@ export const milestones = [
due_date: '2019-09-19', due_date: '2019-09-19',
start_date: '2019-08-31', start_date: '2019-08-31',
web_url: 'http://0.0.0.0:3001/root/release-test/-/milestones/2', web_url: 'http://0.0.0.0:3001/root/release-test/-/milestones/2',
issue_stats: {
opened: 14,
closed: 19,
},
}, },
{ {
id: 49, id: 49,
...@@ -24,6 +28,10 @@ export const milestones = [ ...@@ -24,6 +28,10 @@ export const milestones = [
due_date: '2019-10-11', due_date: '2019-10-11',
start_date: '2019-08-19', start_date: '2019-08-19',
web_url: 'http://0.0.0.0:3001/root/release-test/-/milestones/1', web_url: 'http://0.0.0.0:3001/root/release-test/-/milestones/1',
issue_stats: {
opened: 18,
closed: 3,
},
}, },
]; ];
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment