Commit b46573ad authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Phil Hughes

Add issues analytics table

This MR adds a table to the issues analaytics page.
The table contains a list of issues that have been used for
the counts in the chart.
parent 19b78d28
...@@ -6,6 +6,7 @@ import { GlColumnChart, GlChartLegend } from '@gitlab/ui/dist/charts'; ...@@ -6,6 +6,7 @@ import { GlColumnChart, GlChartLegend } from '@gitlab/ui/dist/charts';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { getMonthNames } from '~/lib/utils/datetime_utility'; import { getMonthNames } from '~/lib/utils/datetime_utility';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import IssuesAnalyticsTable from './issues_analytics_table.vue';
export default { export default {
components: { components: {
...@@ -13,12 +14,21 @@ export default { ...@@ -13,12 +14,21 @@ export default {
GlEmptyState, GlEmptyState,
GlColumnChart, GlColumnChart,
GlChartLegend, GlChartLegend,
IssuesAnalyticsTable,
}, },
props: { props: {
endpoint: { endpoint: {
type: String, type: String,
required: true, required: true,
}, },
issuesApiEndpoint: {
type: String,
required: true,
},
issuesPageEndpoint: {
type: String,
required: true,
},
filterBlockEl: { filterBlockEl: {
type: HTMLDivElement, type: HTMLDivElement,
required: true, required: true,
...@@ -99,6 +109,12 @@ export default { ...@@ -99,6 +109,12 @@ export default {
seriesTotal() { seriesTotal() {
return engineeringNotation(sum(...this.series)); return engineeringNotation(sum(...this.series));
}, },
issuesTableEndpoints() {
return {
api: `${this.issuesApiEndpoint}${this.appliedFilters}`,
issuesPage: this.issuesPageEndpoint,
};
},
}, },
watch: { watch: {
appliedFilters() { appliedFilters() {
...@@ -142,7 +158,7 @@ export default { ...@@ -142,7 +158,7 @@ export default {
</script> </script>
<template> <template>
<div class="issues-analytics-wrapper" data-qa-selector="issues_analytics_wrapper"> <div class="issues-analytics-wrapper" data-qa-selector="issues_analytics_wrapper">
<gl-loading-icon v-if="loading" size="xl" class="issues-analytics-loading" /> <gl-loading-icon v-if="loading" size="md" class="mt-8" />
<div v-if="showChart" class="issues-analytics-chart"> <div v-if="showChart" class="issues-analytics-chart">
<h4 class="chart-title">{{ s__('IssuesAnalytics|Issues opened per month') }}</h4> <h4 class="chart-title">{{ s__('IssuesAnalytics|Issues opened per month') }}</h4>
...@@ -166,6 +182,8 @@ export default { ...@@ -166,6 +182,8 @@ export default {
</div> </div>
</div> </div>
<issues-analytics-table :key="appliedFilters" class="mt-8" :endpoints="issuesTableEndpoints" />
<gl-empty-state <gl-empty-state
v-if="showFiltersEmptyState" v-if="showFiltersEmptyState"
:title="s__('IssuesAnalytics|Sorry, your filter produced no results')" :title="s__('IssuesAnalytics|Sorry, your filter produced no results')"
......
<script>
import {
GlTable,
GlLoadingIcon,
GlLink,
GlIcon,
GlAvatarLink,
GlAvatar,
GlAvatarsInline,
GlTooltipDirective,
GlPopover,
GlLabel,
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { s__, n__ } from '~/locale';
import createFlash from '~/flash';
import { getDayDifference } from '~/lib/utils/datetime_utility';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { mergeUrlParams } from '~/lib/utils/url_utility';
const DEFAULT_API_URL_PARAMS = { with_labels_details: true, per_page: 100 };
const SYMBOL = {
ISSUE: '#',
EPIC: '&',
};
const TH_TEST_ID = { 'data-testid': 'header' };
export default {
name: 'IssuesAnalyticsTable',
components: {
GlTable,
GlLoadingIcon,
GlLink,
GlIcon,
GlAvatarLink,
GlAvatar,
GlAvatarsInline,
GlPopover,
GlLabel,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
endpoints: {
type: Object,
required: true,
},
},
tableHeaderFields: [
{
key: 'issue_details',
label: s__('IssueAnalytics|Issue'),
tdClass: 'issues-analytics-td',
thAttr: TH_TEST_ID,
},
{
key: 'created_at',
label: s__('IssueAnalytics|Age'),
class: 'gl-text-left',
thAttr: TH_TEST_ID,
},
{
key: 'state',
label: s__('IssueAnalytics|Status'),
thAttr: TH_TEST_ID,
},
{
key: 'milestone',
label: s__('IssueAnalytics|Milestone'),
thAttr: TH_TEST_ID,
},
{
key: 'weight',
label: s__('IssueAnalytics|Weight'),
class: 'gl-text-right',
thAttr: TH_TEST_ID,
},
{
key: 'due_date',
label: s__('IssueAnalytics|Due date'),
class: 'gl-text-left',
thAttr: TH_TEST_ID,
},
{
key: 'assignees',
label: s__('IssueAnalytics|Assignees'),
class: 'gl-text-left',
thAttr: TH_TEST_ID,
},
{
key: 'author',
label: s__('IssueAnalytics|Opened by'),
class: 'gl-text-left',
thAttr: TH_TEST_ID,
},
],
data() {
return {
issues: [],
isLoading: true,
};
},
created() {
this.fetchIssues();
},
methods: {
fetchIssues() {
return axios
.get(mergeUrlParams(DEFAULT_API_URL_PARAMS, this.endpoints.api))
.then(({ data }) => {
this.issues = data;
this.isLoading = false;
})
.catch(() => {
createFlash(s__('IssueAnalytics|Failed to load issues. Please try again.'));
this.isLoading = false;
});
},
formatAge(date) {
return n__('%d day', '%d days', getDayDifference(new Date(date), new Date(Date.now())));
},
formatStatus(status) {
return capitalizeFirstCharacter(status);
},
formatIssueId(id) {
return `${SYMBOL.ISSUE}${id}`;
},
formatEpicId(id) {
return `${SYMBOL.EPIC}${id}`;
},
labelTarget(name) {
return mergeUrlParams({ 'label_name[]': name }, this.endpoints.issuesPage);
},
},
avatarSize: 24,
};
</script>
<template>
<gl-loading-icon v-if="isLoading" size="md" />
<gl-table
v-else
:fields="$options.tableHeaderFields"
:items="issues"
stacked="sm"
thead-class="thead-white border-bottom"
striped
>
<template #cell(issue_details)="{ item }">
<div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1" data-testid="detailsCol">
<div class="issue-title str-truncated">
<gl-link :href="item.web_url" target="_blank" class="gl-font-weight-bold text-plain">{{
item.title
}}</gl-link>
</div>
<ul class="horizontal-list list-items-separated gl-mb-0">
<li>{{ formatIssueId(item.iid) }}</li>
<li v-if="item.epic">{{ formatEpicId(item.epic.iid) }}</li>
<li v-if="item.labels.length">
<span :id="`${item.id}-labels`" class="gl-display-flex gl-align-items-center">
<gl-icon name="label" class="gl-mr-1" />
{{ item.labels.length }}
</span>
<gl-popover
:target="`${item.id}-labels`"
placement="top"
triggers="hover"
:css-classes="['issue-labels-popover']"
>
<div class="gl-display-flex gl-justify-content-start gl-flex-wrap gl-mr-1">
<gl-label
v-for="label in item.labels"
:key="label.id"
:title="label.name"
:background-color="label.color"
:description="label.description"
:scoped="label.name.includes('::')"
class="gl-ml-1 gl-mt-1"
:target="labelTarget(label.name)"
/>
</div>
</gl-popover>
</li>
</ul>
</div>
</template>
<template #cell(created_at)="{ value }">
<div data-testid="ageCol">{{ formatAge(value) }}</div>
</template>
<template #cell(state)="{ value }">
<div data-testid="statusCol">{{ formatStatus(value) }}</div>
</template>
<template #cell(milestone)="{ value }">
<template v-if="value">
<div class="milestone-title str-truncated">
{{ value.title }}
</div>
</template>
</template>
<template #cell(assignees)="{ value }">
<gl-avatars-inline
:avatars="value"
:avatar-size="$options.avatarSize"
:max-visible="2"
collapsed
>
<template #avatar="{ avatar }">
<gl-avatar-link v-gl-tooltip target="_blank" :href="avatar.web_url" :title="avatar.name">
<gl-avatar :src="avatar.avatar_url" :size="$options.avatarSize" />
</gl-avatar-link>
</template>
</gl-avatars-inline>
</template>
<template #cell(author)="{ value }">
<gl-avatar-link v-gl-tooltip target="_blank" :href="value.web_url" :title="value.name">
<gl-avatar :size="$options.avatarSize" :src="value.avatar_url" :entity-name="value.name" />
</gl-avatar-link>
</template>
</gl-table>
</template>
...@@ -9,7 +9,13 @@ export default () => { ...@@ -9,7 +9,13 @@ export default () => {
if (!el) return null; if (!el) return null;
const { endpoint, noDataEmptyStateSvgPath, filtersEmptyStateSvgPath } = el.dataset; const {
endpoint,
noDataEmptyStateSvgPath,
filtersEmptyStateSvgPath,
issuesApiEndpoint,
issuesPageEndpoint,
} = el.dataset;
// Set default filters from URL // Set default filters from URL
store.dispatch('issueAnalytics/setFilters', window.location.search); store.dispatch('issueAnalytics/setFilters', window.location.search);
...@@ -31,6 +37,8 @@ export default () => { ...@@ -31,6 +37,8 @@ export default () => {
filterBlockEl, filterBlockEl,
noDataEmptyStateSvgPath, noDataEmptyStateSvgPath,
filtersEmptyStateSvgPath, filtersEmptyStateSvgPath,
issuesApiEndpoint,
issuesPageEndpoint,
}, },
}); });
}, },
......
...@@ -10,11 +10,15 @@ ...@@ -10,11 +10,15 @@
} }
} }
.issues-analytics-loading {
padding-top: $header-height * 2;
}
.issues-analytics-legend { .issues-analytics-legend {
font-size: $gl-font-size-small; font-size: $gl-font-size-small;
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
} }
.issue-title {
max-width: px-to-rem(350px);
}
.milestone-title {
max-width: px-to-rem(75px);
}
...@@ -4,4 +4,4 @@ ...@@ -4,4 +4,4 @@
%h3 %h3
= _('Issues Analytics') = _('Issues Analytics')
= render 'shared/issuable/search_bar', type: :issues_analytics, show_sorting_dropdown: false, placeholder: _('Filter results...') = render 'shared/issuable/search_bar', type: :issues_analytics, show_sorting_dropdown: false, placeholder: _('Filter results...')
#js-issues-analytics{ data: { endpoint: group_issues_analytics_path(@group), no_data_empty_state_svg_path: image_path('illustrations/monitoring/getting_started.svg'), filters_empty_state_svg_path: image_path('illustrations/issues.svg') } } #js-issues-analytics{ data: { endpoint: group_issues_analytics_path(@group), no_data_empty_state_svg_path: image_path('illustrations/monitoring/getting_started.svg'), filters_empty_state_svg_path: image_path('illustrations/issues.svg'), issues_api_endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)), issues_page_endpoint: issues_group_path(@group) } }
...@@ -4,4 +4,4 @@ ...@@ -4,4 +4,4 @@
%h3 %h3
= _('Issues Analytics') = _('Issues Analytics')
= render 'shared/issuable/search_bar', type: :issues_analytics, show_sorting_dropdown: false, placeholder: _('Filter results...') = render 'shared/issuable/search_bar', type: :issues_analytics, show_sorting_dropdown: false, placeholder: _('Filter results...')
#js-issues-analytics{ data: { endpoint: project_analytics_issues_analytics_path(@project), no_data_empty_state_svg_path: image_path('illustrations/monitoring/getting_started.svg'), filters_empty_state_svg_path: image_path('illustrations/issues.svg') } } #js-issues-analytics{ data: { endpoint: project_analytics_issues_analytics_path(@project), no_data_empty_state_svg_path: image_path('illustrations/monitoring/getting_started.svg'), filters_empty_state_svg_path: image_path('illustrations/issues.svg'), issues_api_endpoint: expose_url(api_v4_projects_issues_path(id: @project.id)), issues_page_endpoint: project_issues_path(@project) } }
---
title: Add table to Issues Analytics
merge_request: 30603
author:
type: added
...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import IssuesAnalytics from 'ee/issues_analytics/components/issues_analytics.vue'; import IssuesAnalytics from 'ee/issues_analytics/components/issues_analytics.vue';
import IssuesAnalyticsTable from 'ee/issues_analytics/components/issues_analytics_table.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { createStore } from 'ee/issues_analytics/stores'; import { createStore } from 'ee/issues_analytics/stores';
...@@ -26,6 +27,8 @@ describe('Issues Analytics component', () => { ...@@ -26,6 +27,8 @@ describe('Issues Analytics component', () => {
setFixtures('<div id="mock-filter"></div>'); setFixtures('<div id="mock-filter"></div>');
const propsData = data || { const propsData = data || {
endpoint: TEST_HOST, endpoint: TEST_HOST,
issuesApiEndpoint: `${TEST_HOST}/api/issues`,
issuesPageEndpoint: `${TEST_HOST}/issues`,
filterBlockEl: document.querySelector('#mock-filter'), filterBlockEl: document.querySelector('#mock-filter'),
noDataEmptyStateSvgPath: 'svg', noDataEmptyStateSvgPath: 'svg',
filtersEmptyStateSvgPath: 'svg', filtersEmptyStateSvgPath: 'svg',
...@@ -101,4 +104,8 @@ describe('Issues Analytics component', () => { ...@@ -101,4 +104,8 @@ describe('Issues Analytics component', () => {
expect(wrapper.vm.showFiltersEmptyState).toBe(true); expect(wrapper.vm.showFiltersEmptyState).toBe(true);
}); });
}); });
it('renders the issues table', () => {
expect(wrapper.find(IssuesAnalyticsTable).exists()).toBe(true);
});
}); });
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import IssuesAnalyticsTable from 'ee/issues_analytics/components/issues_analytics_table.vue';
import { mount } from '@vue/test-utils';
import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import httpStatusCodes from '~/lib/utils/http_status';
import createFlash from '~/flash';
import { mockIssuesApiResponse, tableHeaders, endpoints } from '../mock_data';
jest.mock('~/flash');
describe('IssuesAnalyticsTable', () => {
let wrapper;
let mock;
const createComponent = () => {
return mount(IssuesAnalyticsTable, {
propsData: {
endpoints,
},
});
};
const findTable = () => wrapper.find(GlTable);
const findIssueDetailsCol = rowIndex =>
findTable()
.findAll('[data-testid="detailsCol"]')
.at(rowIndex);
const findAgeCol = rowIndex =>
findTable()
.findAll('[data-testid="ageCol"]')
.at(rowIndex);
const findStatusCol = rowIndex =>
findTable()
.findAll('[data-testid="statusCol"]')
.at(rowIndex);
beforeEach(() => {
jest.spyOn(Date, 'now').mockImplementation(() => new Date('2020-01-08'));
mock = new MockAdapter(axios);
mock.onGet().reply(httpStatusCodes.OK, mockIssuesApiResponse);
wrapper = createComponent();
return waitForPromises();
});
afterEach(() => {
mock.restore();
wrapper.destroy();
wrapper = null;
});
describe('while fetching data', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('displays a loading icon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('does not display the table', () => {
expect(findTable().exists()).toBe(false);
});
});
describe('fetching data completed', () => {
it('hides the loading state', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('displays the table', () => {
expect(findTable().exists()).toBe(true);
});
describe('table data and formatting', () => {
it('displays the correct table headers', () => {
const headers = findTable().findAll('[data-testid="header"]');
expect(headers).toHaveLength(tableHeaders.length);
tableHeaders.forEach((headerText, i) => expect(headers.at(i).text()).toEqual(headerText));
});
it('displays the correct issue details', () => {
const { title, iid, epic } = mockIssuesApiResponse[0];
expect(findIssueDetailsCol(0).text()).toBe(`${title} #${iid} &${epic.iid}`);
});
it('displays the correct issue age', () => {
expect(findAgeCol(0).text()).toBe('0 days');
expect(findAgeCol(1).text()).toBe('1 day');
expect(findAgeCol(2).text()).toBe('2 days');
});
it('capitalizes the status', () => {
expect(findStatusCol(0).text()).toBe('Closed');
});
});
});
describe('error fetching data', () => {
beforeEach(() => {
mock.onGet().reply(httpStatusCodes.NOT_FOUND, mockIssuesApiResponse);
wrapper = createComponent();
return waitForPromises();
});
it('displays an error', () => {
expect(createFlash).toHaveBeenCalledWith('Failed to load issues. Please try again.');
});
});
});
import { TEST_HOST } from 'helpers/test_constants';
const createIssue = values => {
return {
state: 'closed',
epic: {
iid: 12345,
},
labels: [],
milestone: {
title: '11.1',
},
weight: '3',
due_date: '2020-10-08',
assignees: [],
author: {},
web_url: `issues/${values.id}`,
iid: values.id,
...values,
};
};
export const mockIssuesApiResponse = [
createIssue({ id: 12345, title: 'Issue 1', created_at: '2020-01-08' }),
createIssue({ id: 23456, title: 'Issue 2', created_at: '2020-01-07' }),
createIssue({ id: 34567, title: 'Issue 3', created_at: '2020-01-6' }),
];
export const tableHeaders = [
'Issue',
'Age',
'Status',
'Milestone',
'Weight',
'Due date',
'Assignees',
'Opened by',
];
export const endpoints = {
api: `${TEST_HOST}/api`,
issuesPage: `${TEST_HOST}/issues/page`,
};
...@@ -124,6 +124,11 @@ msgid_plural "%d contributions" ...@@ -124,6 +124,11 @@ msgid_plural "%d contributions"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "%d day"
msgid_plural "%d days"
msgstr[0] ""
msgstr[1] ""
msgid "%d error" msgid "%d error"
msgid_plural "%d errors" msgid_plural "%d errors"
msgstr[0] "" msgstr[0] ""
...@@ -12119,6 +12124,33 @@ msgstr "" ...@@ -12119,6 +12124,33 @@ msgstr ""
msgid "Issue weight" msgid "Issue weight"
msgstr "" msgstr ""
msgid "IssueAnalytics|Age"
msgstr ""
msgid "IssueAnalytics|Assignees"
msgstr ""
msgid "IssueAnalytics|Due date"
msgstr ""
msgid "IssueAnalytics|Failed to load issues. Please try again."
msgstr ""
msgid "IssueAnalytics|Issue"
msgstr ""
msgid "IssueAnalytics|Milestone"
msgstr ""
msgid "IssueAnalytics|Opened by"
msgstr ""
msgid "IssueAnalytics|Status"
msgstr ""
msgid "IssueAnalytics|Weight"
msgstr ""
msgid "IssueBoards|Board" msgid "IssueBoards|Board"
msgstr "" msgstr ""
......
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