Commit b1e32e23 authored by Felipe Artur's avatar Felipe Artur Committed by Fatih Acet

Chart showing issues created per month

parent 2ec20f6a
......@@ -5,6 +5,7 @@ import 'core-js/fn/array/find-index';
import 'core-js/fn/array/from';
import 'core-js/fn/array/includes';
import 'core-js/fn/object/assign';
import 'core-js/fn/object/values';
import 'core-js/fn/promise';
import 'core-js/fn/string/code-point-at';
import 'core-js/fn/string/from-code-point';
......
......@@ -14,7 +14,7 @@ window.timeago = timeago;
*
* @param {Boolean} abbreviated
*/
const getMonthNames = abbreviated => {
export const getMonthNames = abbreviated => {
if (abbreviated) {
return [
s__('Jan'),
......
......@@ -670,3 +670,8 @@ $modal-body-height: 134px;
$modal-border-color: #e9ecef;
$priority-label-empty-state-width: 114px;
/*
Issues Analytics
*/
$issues-analytics-popover-boarder-color: rgba(0, 0, 0, 0.15);
- issues_count = group_issues_count(state: 'opened')
- merge_requests_count = group_merge_requests_count(state: 'opened')
- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index', 'boards#index', 'boards#show']
- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index', 'boards#index', 'boards#show', 'issues_analytics#show']
.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
.nav-sidebar-inner-scroll
......@@ -74,6 +74,8 @@
%span
= boards_link_text
= render_if_exists 'layouts/nav/issues_analytics_link'
- if group_sidebar_link?(:labels)
= nav_link(path: 'labels#index') do
= link_to group_labels_path(@group), title: _('Labels') do
......
......@@ -10,7 +10,7 @@
-# haml-lint:disable InlineJavaScript
%script#js-board-template{ type: "text/x-template" }= render "shared/boards/components/board"
%script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal
%script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal, show_sorting_dropdown: false
%script#js-board-promotion{ type: "text/x-template" }= render_if_exists "shared/promotions/promote_issue_board"
#board-app.boards-app{ "v-cloak" => true, data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" }
......
......@@ -3,6 +3,7 @@
- block_css_class = type != :boards_modal ? 'row-content-block second-block' : ''
- full_path = @project.present? ? @project.full_path : @group.full_path
- user_can_admin_list = board && can?(current_user, :admin_list, board.parent)
- show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true)
.issues-filters
.issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal }
......@@ -140,5 +141,5 @@
- if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
#js-toggle-focus-btn
- elsif type != :boards_modal
- elsif show_sorting_dropdown
= render 'shared/sort_dropdown'
......@@ -8,7 +8,8 @@ Gitlab::Seeder.quiet do
description: FFaker::Lorem.sentence,
state: ['opened', 'closed'].sample,
milestone: project.milestones.sample,
assignees: [project.team.users.sample]
assignees: [project.team.users.sample],
created_at: rand(12).months.ago
}
Issues::CreateService.new(project, project.team.users.sample, issue_params).execute
......
......@@ -86,6 +86,7 @@ on projects and code.
- [Epics](user/group/epics/index.md) **[ULTIMATE]**
- [Roadmap](user/group/roadmap/index.md) **[ULTIMATE]**
- [Contribution Analytics](user/group/contribution_analytics/index.md): See detailed statistics of group contributors. **[STARTER]**
- [Issues Analytics](user/group/issues_analytics/index.md): Check how many issues were created per month. **[PREMIUM]**
- [Search through GitLab](user/search/index.md): Search for issues, merge requests, projects, groups, todos, and issues in Issue Boards.
- [Advanced Global Search](user/search/advanced_global_search.md): Leverage Elasticsearch for faster, more advanced code search across your entire GitLab instance. **[STARTER]**
- [Advanced Syntax Search](user/search/advanced_search_syntax.md): Use advanced queries for more targeted search results. **[STARTER]**
......
......@@ -333,5 +333,10 @@ With [GitLab Contribution Analytics](contribution_analytics/index.md)
you have an overview of the contributions (pushes, merge requests,
and issues) performed my your group members.
## Issues analytics **[PREMIUM]**
With [GitLab Issues Analytics](issues_analytics/index.md), in groups, you can see a bar chart of the number of issues created each month.
[ee]: https://about.gitlab.com/pricing/
[ee-2534]: https://gitlab.com/gitlab-org/gitlab-ee/issues/2534
# Issues created per month
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/7478) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.5.
GitLab can display a bar chart of the number of issues created each month. By default, GitLab displays the number of all issues created in the last 12 months but this is configurable.
The **Search or filter results...** field can be used for filtering the issues by any attribute. For example, labels, assignee, milestone, and author.
To access the chart, navigate to a group's sidebar and select **Issues > Analytics**.
![Issues created per month](img/issues_created_per_month.png)
<script>
import { imagePath } from '~/lib/utils/common_utils';
export default {
props: {
title: {
type: String,
required: true,
},
summary: {
type: String,
required: true,
},
image: {
type: String,
required: true,
},
},
computed: {
imagePath() {
return imagePath(this.image);
},
},
};
</script>
<template>
<div class="row empty-state">
<div class="col-12">
<div class="svg-content">
<img
class="content-image"
:src="imagePath"
/>
</div>
</div>
<div class="col-12">
<div class="text-content">
<h4 class="content-title text-center">{{ title }}</h4>
<p class="content-summary">{{ summary }}</p>
</div>
</div>
</div>
</template>
<script>
import { mapGetters, mapActions, mapState } from 'vuex';
import Chart from 'chart.js';
import bp from '~/breakpoints';
import { getMonthNames } from '~/lib/utils/datetime_utility';
import EmptyState from './empty_state.vue';
import { CHART_OPTNS, CHART_COLORS } from '../constants';
export default {
components: {
EmptyState,
},
props: {
endpoint: {
type: String,
required: true,
},
filterBlockEl: {
type: HTMLDivElement,
required: true,
},
},
data() {
return {
drawChart: true,
chartOptions: {
...CHART_OPTNS,
customTooltips: this.generateCustomTooltip,
},
showPopover: false,
popoverTitle: '',
popoverContent: '',
popoverPositionLeft: true,
};
},
computed: {
...mapState('issueAnalytics', ['chartData', 'loading']),
...mapGetters('issueAnalytics', ['hasFilters', 'appliedFilters']),
chartLabels() {
const { chartData, chartHasData } = this;
const labels = [];
if (chartHasData()) {
Object.keys(chartData).forEach(label => {
const date = new Date(label);
labels.push(`${getMonthNames(true)[date.getUTCMonth()]} ${date.getUTCFullYear()}`);
});
}
return labels;
},
chartDateRange() {
return `${this.chartLabels[0]} - ${this.chartLabels[this.chartLabels.length - 1]}`;
},
showChart() {
return !this.loading && this.chartHasData();
},
showNoDataEmptyState() {
return !this.loading && !this.showChart && !this.hasFilters;
},
showFiltersEmptyState() {
return !this.loading && !this.showChart && this.hasFilters;
},
},
watch: {
chartData() {
// If chart data changes we need to redraw chart
if (this.chartHasData()) {
this.drawChart = true;
}
},
appliedFilters() {
this.fetchChartData(this.endpoint);
},
showNoDataEmptyState(showEmptyState) {
if (showEmptyState) {
this.$nextTick(() => this.filterBlockEl.classList.add('hide'));
}
},
},
mounted() {
this.fetchChartData(this.endpoint);
},
updated() {
// Only render chart when DOM is ready
if (this.showChart && this.drawChart) {
this.$nextTick(() => {
this.createChart();
});
}
},
methods: {
...mapActions('issueAnalytics', ['fetchChartData']),
createChart() {
const { chartData, chartOptions, chartLabels } = this;
const largeBreakpoints = ['md', 'lg'];
// Reset spacing of chart item on large screens
if (largeBreakpoints.includes(bp.getBreakpointSize())) {
chartOptions.barValueSpacing = 12;
}
// Render chart when DOM has been updated
this.$nextTick(() => {
const ctx = this.$refs.issuesChart.getContext('2d');
new Chart(ctx).Bar(
{
labels: chartLabels,
datasets: [
{
...CHART_COLORS,
data: Object.values(chartData),
},
],
},
chartOptions,
);
this.drawChart = false;
});
},
generateCustomTooltip(tooltip) {
if (!tooltip) {
this.showPopover = false;
return;
}
let top; // Find Y Location on page
if (tooltip.yAlign === 'above') {
top = tooltip.y - tooltip.caretHeight - tooltip.caretPadding;
} else {
top = tooltip.y + tooltip.caretHeight + tooltip.caretPadding;
}
[this.popoverTitle, this.popoverContent] = tooltip.text.split(':');
this.showPopover = true;
this.$nextTick(() => {
const tooltipEl = this.$refs.chartTooltip;
const tooltipWidth = tooltipEl.getBoundingClientRect().width;
const tooltipLeftOffest = window.innerWidth - tooltipWidth;
const tooltipLeftPosition = tooltip.chart.canvas.offsetLeft + tooltip.x;
this.popoverPositionLeft = tooltipLeftPosition < tooltipLeftOffest;
tooltipEl.style.top = `${tooltip.chart.canvas.offsetTop + top}px`;
// Move tooltip to the right if too close to the left
if (tooltipLeftPosition > tooltipLeftOffest) {
tooltipEl.style.left = `${tooltipLeftPosition - tooltipWidth}px`;
} else {
tooltipEl.style.left = `${tooltipLeftPosition}px`;
}
});
},
chartHasData() {
if (!this.chartData) {
return false;
}
return Object.values(this.chartData).reduce((acc, value) => acc + parseInt(value, 10), 0) > 0;
},
},
};
</script>
<template>
<div class="issues-analytics-wrapper">
<div
v-if="loading"
class="issues-analytics-loading text-center"
>
<gl-loading-icon
:inline="true"
:size="4"
/>
</div>
<div
v-if="showChart"
class="issues-analytics-chart">
<h4 class="chart-title">{{ s__('IssuesAnalytics|Issues created per month') }}</h4>
<div class="d-flex">
<div class="chart-legend d-none d-sm-block bold align-self-center">{{ s__('IssuesAnalytics|Issues Created') }}</div>
<div class="chart-canvas-wrapper">
<canvas
ref="issuesChart"
height="300"
class="append-bottom-15"
></canvas>
</div>
</div>
<p class="bold text-center">{{ s__('IssuesAnalytics|Last 12 months') }} ({{ chartDateRange }})</p>
<div
ref="chartTooltip"
:class="[showPopover ? 'show' : 'hide', popoverPositionLeft ? 'bs-popover-right' : 'bs-popover-left']"
class="popover no-pointer-events"
role="tooltip"
>
<div class="arrow"></div>
<h3 class="popover-header">{{ popoverTitle }}</h3>
<div class="popover-body">
<span class="popover-label">{{ s__('IssuesAnalytics|Issues Created') }}</span>
{{ popoverContent }}
</div>
</div>
</div>
<empty-state
v-if="showFiltersEmptyState"
image="illustrations/issues.svg"
:title="s__('IssuesAnalytics|Sorry, your filter produced no results')"
:summary="s__('IssuesAnalytics|To widen your search, change or remove filters in the filter bar above')"
/>
<empty-state
v-if="showNoDataEmptyState"
image="illustrations/monitoring/getting_started.svg"
:title="s__('IssuesAnalytics|There are no issues for the projects in your group')"
:summary="s__(
'IssuesAnalytics|After you begin creating issues for your projects, we can start tracking and displaying metrics for them'
)"
/>
</div>
</template>
export const CHART_OPTNS = {
scaleOverlay: true,
responsive: true,
pointHitDetectionRadius: 2,
maintainAspectRatio: false,
scaleShowVerticalLines: false,
scaleBeginAtZero: true,
barStrokeWidth: 1,
barValueSpacing: 2,
scaleGridLineColor: '#DFDFDF',
scaleLineColor: 'transparent',
};
export const CHART_COLORS = {
fillColor: 'rgba(31,120,209,0.1)',
strokeColor: 'rgba(31,120,209,1)',
highlightFill: 'rgba(31,120,209,0.3)',
};
import IssuesFilteredSearchTokenKeysEE from 'ee/filtered_search/issues_filtered_search_token_keys';
import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
import { historyPushState } from '~/lib/utils/common_utils';
import issueAnalyticsStore from './stores';
export default class FilteredSearchIssueAnalytics extends FilteredSearchManager {
constructor() {
super({
page: 'issues_analytics',
isGroupDecendent: true,
stateFiltersSelector: '.issues-state-filters',
isGroup: true,
filteredSearchTokenKeys: IssuesFilteredSearchTokenKeysEE,
});
this.isHandledAsync = true;
}
/**
* Updates issues analytics store and window history
* with filter path
*/
updateObject = path => {
historyPushState(path);
issueAnalyticsStore.dispatch('issueAnalytics/setFilters', path);
};
}
import Vue from 'vue';
import IssuesAnalytics from './components/issues_analytics.vue';
import store from './stores';
import FilteredSearchIssueAnalytics from './filtered_search_issues_analytics';
export default () => {
const el = document.querySelector('#js-issues-analytics');
const filterBlockEl = document.querySelector('.issues-filters');
if (!el) return null;
// Set default filters from URL
store.dispatch('issueAnalytics/setFilters', window.location.search);
return new Vue({
el,
store,
components: {
IssuesAnalytics,
},
mounted() {
this.filterManager = new FilteredSearchIssueAnalytics(store.state.issueAnalytics.filters);
this.filterManager.setup();
},
render(createElement) {
return createElement('issues-analytics', {
props: {
endpoint: el.dataset.endpoint,
filterBlockEl,
},
});
},
});
};
import axios from '~/lib/utils/axios_utils';
export default {
fetchChartData(endpoint, filters) {
return axios.get(`${endpoint}${filters}`);
},
};
import Vue from 'vue';
import Vuex from 'vuex';
import issueAnalytics from './modules/issue_analytics';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
modules: {
issueAnalytics: issueAnalytics(),
},
});
export default createStore();
import flash from '~/flash';
import { __ } from '~/locale';
import service from '../../../services/issue_analytics_service';
import * as types from './mutation_types';
export const setFilters = ({ commit }, value) => {
commit(types.SET_FILTERS, value);
};
export const setLoadingState = ({ commit }, value) => {
commit(types.SET_LOADING_STATE, value);
};
export const fetchChartData = ({ commit, dispatch, getters }, endpoint) => {
dispatch('setLoadingState', true);
return service
.fetchChartData(endpoint, getters.appliedFilters)
.then(res => res.data)
.then(data => commit(types.SET_CHART_DATA, data))
.then(() => dispatch('setLoadingState', false))
.catch(() => flash(__('An error occurred while loading chart data')));
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const hasFilters = state => Object.keys(state.filters).length > 0;
export const appliedFilters = state => state.filters;
\ No newline at end of file
import state from './state';
import mutations from './mutations';
import * as actions from './actions';
import * as getters from './getters';
export default () => ({
namespaced: true,
state: state(),
mutations,
actions,
getters,
});
export const SET_FILTERS = 'SET_FILTERS';
export const SET_CHART_DATA = 'SET_CHART_DATA';
export const SET_LOADING_STATE = 'SET_LOADING_STATE';
import * as types from './mutation_types';
export default {
[types.SET_LOADING_STATE](state, value) {
state.loading = value;
},
[types.SET_CHART_DATA](state, chartData) {
Object.assign(state, {
chartData,
});
},
[types.SET_FILTERS](state, value) {
state.filters = value;
},
};
export default () => ({
loading: false,
filters: '',
chartData: null,
});
import initIssuesAnalytics from 'ee/issues_analytics';
document.addEventListener('DOMContentLoaded', () => {
initIssuesAnalytics();
});
.issues-analytics-wrapper {
@include media-breakpoint-up(sm) {
padding-left: $gl-sidebar-padding;
}
}
.issues-analytics-chart {
.chart-title {
margin: $gl-padding-24 0 $gl-padding-32;
}
.chart-legend {
width: $gl-padding-32;
transform: rotate(-90deg);
white-space: nowrap;
margin-right: $gl-col-padding;
}
.chart-canvas-wrapper {
width: 100%;
@include media-breakpoint-up(lg) {
width: 90%;
}
}
.popover {
border: 0;
border-radius: $border-radius-small;
box-shadow: 0 1px 4px 0 $black-transparent;
.arrow {
top: $gl-padding-8;
&::before {
border-right-color: $issues-analytics-popover-boarder-color;
}
&::after {
border-right-color: $gray-light;
}
}
.popover-header {
background: $gray-light;
}
.popover-body,
.popover-header {
font-weight: normal;
padding: $gl-padding-8 $gl-padding-8 $gl-padding-top;
}
.popover-label {
margin-right: $gl-padding-32;
}
}
}
.issues-analytics-loading {
padding-top: $header-height * 2;
}
# frozen_string_literal: true
class Groups::IssuesAnalyticsController < Groups::ApplicationController
include IssuableCollections
before_action :authorize_read_group!
before_action :authorize_read_issue_analytics!
def show
respond_to do |format|
format.html
format.json do
@chart_data =
IssuablesAnalytics.new(issuables: issuables_collection, months_back: params[:months_back]).data
render json: @chart_data
end
end
end
private
def authorize_read_issue_analytics!
render_404 unless group.feature_available?(:issues_analytics)
end
def authorize_read_group!
render_404 unless can?(current_user, :read_group, group)
end
def finder_type
IssuesFinder
end
def set_default_state
params[:state] = 'all' if params[:state].blank?
end
def preload_for_collection
nil
end
end
......@@ -35,6 +35,10 @@ module EE
links << :epics
end
if @group.feature_available?(:issues_analytics)
links << :analytics
end
links
end
end
......
# frozen_string_literal: true
# Gather the number of issuables created by month returning
# a hash with the format: {"2017-12"=>2, "2018-01"=>2, "2018-03"=>1}
#
# By default it creates the hash only for the last 12 months including the current month, but it accepts
# a parameter to get issuables for n months back.
class IssuablesAnalytics
include Gitlab::Utils::StrongMemoize
attr_reader :issuables, :start_date, :end_date, :months_back
DATE_FORMAT = "%Y-%m".freeze
def initialize(issuables:, months_back: nil)
@issuables = issuables
@months_back = months_back.to_i - 1 if months_back.present?
@months_back ||= 12
@start_date = @months_back.months.ago.beginning_of_month.to_date
@end_date = Date.today
end
def data
start_date_to_end_date = start_date.upto(end_date).to_a
start_date_to_end_date.each_with_object({}) do |date, data_hash|
date = date.strftime(DATE_FORMAT)
data_hash[date] = issues_created_at_dates.count(date) || 0
end
end
private
def issues_created_at_dates
strong_memoize(:issues_created_at_dates) do
issuables
.where('issues.created_at >= ?', months_back.months.ago.beginning_of_month)
.pluck('issues.created_at')
.map { |date| date.strftime(DATE_FORMAT) }
end
end
end
......@@ -70,6 +70,7 @@ class License < ActiveRecord::Base
code_owner_as_approver_suggestion
feature_flags
batch_comments
issues_analytics
].freeze
EEU_FEATURES = EEP_FEATURES + %i[
......
= render 'shared/issuable/search_bar', type: :issues_analytics, show_sorting_dropdown: false
#js-issues-analytics{ data: { endpoint: group_issues_analytics_path(@group) } }
- if group_sidebar_link?(:analytics)
= nav_link(path: 'issues_analytics#show') do
= link_to group_issues_analytics_path(@group) do
%span
= _('Analytics')
- type = local_assigns.fetch(:type)
- return unless type == :issues || type == :boards || type == :boards_modal
- return unless type == :issues || type == :boards || type == :boards_modal || type == :issues_analytics
#js-dropdown-weight.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ 'data-dropdown' => true }
......
---
title: Add chart of issues created per month
merge_request:
author:
type: added
......@@ -24,6 +24,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end
end
resource :issues_analytics, only: [:show]
resource :notification_setting, only: [:update]
resources :ldap_group_links, only: [:index, :create, :destroy]
......
# frozen_string_literal: true
require 'spec_helper'
describe Groups::IssuesAnalyticsController do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project1) { create(:project, :empty_repo, namespace: group) }
let(:project2) { create(:project, :empty_repo, namespace: group) }
before do
group.add_owner(user)
sign_in(user)
end
describe 'GET #show' do
context 'when issues analytics is not available for license' do
it 'renders 404' do
get :show, group_id: group.to_param
expect(response).to have_gitlab_http_status(404)
end
end
context 'when user does not have permission to read group' do
let(:user) { create(:user) }
before do
sign_in(user)
end
it 'renders 404' do
get :show, group_id: group.to_param
expect(response).to have_gitlab_http_status(404)
end
end
context 'when issues analytics is available for license' do
before do
stub_licensed_features(issues_analytics: true)
end
context 'as HTML' do
it 'renders show template' do
get :show, group_id: group.to_param, months_back: 2
expect(response).to render_template(:show)
end
end
context 'as JSON' do
let!(:issue1) { create(:issue, project: project1, confidential: true) }
let!(:issue2) { create(:issue, project: project2, state: :closed) }
it 'renders chart data as JSON' do
expected_result = { issue1.created_at.strftime(IssuablesAnalytics::DATE_FORMAT) => 2 }
get :show, group_id: group.to_param, format: :json
expect(JSON.parse(response.body)).to include(expected_result)
end
context 'when user cannot view issues' do
let(:guest) { create(:user) }
before do
group.add_guest(guest)
sign_in(guest)
end
it 'does not count issues which user cannot view' do
expected_result = { issue1.created_at.strftime(IssuablesAnalytics::DATE_FORMAT) => 1 }
get :show, group_id: group.to_param, format: :json
expect(JSON.parse(response.body)).to include(expected_result)
end
end
end
end
end
end
import Vue from 'vue';
import EmptyState from 'ee/issues_analytics/components/empty_state.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Empty state component', () => {
let vm;
const Component = Vue.extend(EmptyState);
const props = {
image: 'illustrations/issues.svg',
title: 'Hello World',
summary: 'Lorem, ipsum dolor sit amet consectetur adipisicing elit.',
};
beforeEach(() => {
vm = mountComponent(Component, props);
});
it('renders the image', () => {
expect(vm.$el.querySelector('.content-image').src).toContain(props.image);
});
it('renders the title', () => {
expect(vm.$el.querySelector('.content-title').textContent.trim()).toEqual(props.title);
});
it('renders the summary', () => {
expect(vm.$el.querySelector('.content-summary').textContent.trim()).toEqual(props.summary);
});
});
import Vue from 'vue';
import IssuesAnalytics from 'ee/issues_analytics/components/issues_analytics.vue';
import { createStore } from 'ee/issues_analytics/stores';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
describe('Issues Analytics component', () => {
let vm;
let store;
let mountComponent;
const Component = Vue.extend(IssuesAnalytics);
const mockChartData = { '2017-11': 0, '2017-12': 2 };
const mockTooltipData = {
y: 1,
x: 1,
text: 'Jul 2018:1',
caretHeight: 1,
caretPadding: 1,
chart: {
canvas: {
offsetLeft: 1,
offsetTop: 1,
},
},
};
beforeEach(() => {
store = createStore();
spyOn(store, 'dispatch').and.stub();
mountComponent = data => {
setFixtures('<div id="mock-filter"></div>');
const props = data || {
endpoint: gl.TEST_HOST,
filterBlockEl: document.querySelector('#mock-filter'),
};
return mountComponentWithStore(Component, { store, props });
};
vm = mountComponent();
spyOn(vm, 'createChart').and.stub();
});
afterEach(() => {
vm.$destroy();
});
it('fetches chart data when mounted', () => {
expect(store.dispatch).toHaveBeenCalledWith('issueAnalytics/fetchChartData', gl.TEST_HOST);
});
it('renders loading state when loading', done => {
vm.$store.state.issueAnalytics.loading = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.issues-analytics-loading')).not.toBe(null);
expect(vm.$el.querySelector('.issues-analytics-chart')).toBe(null);
done();
});
});
it('renders chart when data is present', done => {
vm.$store.state.issueAnalytics.chartData = mockChartData;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.issues-analytics-chart')).not.toBe(null);
done();
});
});
it('renders chart tooltip with the correct details', done => {
const [popoverTitle, popoverContent] = mockTooltipData.text.split(':');
vm.$store.state.issueAnalytics.chartData = mockChartData;
vm.generateCustomTooltip(mockTooltipData);
vm.$nextTick(() => {
expect(vm.showPopover).toBe(true);
expect(vm.popoverTitle).toEqual(popoverTitle);
expect(vm.popoverContent).toEqual(popoverContent);
done();
});
});
it('fetches data when filters are applied', done => {
vm.$store.state.issueAnalytics.filters = '?hello=world';
vm.$nextTick(() => {
expect(store.dispatch).toHaveBeenCalledTimes(2);
expect(store.dispatch.calls.argsFor(1)).toEqual([
'issueAnalytics/fetchChartData',
gl.TEST_HOST,
]);
done();
});
});
it('renders empty state when chart data is empty', done => {
vm.$store.state.issueAnalytics.chartData = {};
vm.$nextTick(() => {
expect(vm.$el.querySelector('.empty-state')).not.toBe(null);
expect(vm.showNoDataEmptyState).toBe(true);
done();
});
});
it('renders filters empty state when filters are applied and chart data is empty', done => {
vm.$store.state.issueAnalytics.chartData = {};
vm.$store.state.issueAnalytics.filters = '?hello=world';
vm.$nextTick(() => {
expect(vm.$el.querySelector('.empty-state')).not.toBe(null);
expect(vm.showFiltersEmptyState).toBe(true);
done();
});
});
});
import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'spec/helpers/vuex_action_helper';
import * as actions from 'ee/issues_analytics/stores/modules/issue_analytics/actions';
describe('Issue analytics store actions', () => {
describe('setFilters', () => {
it('commits SET_FILTERS', done => {
testAction(
actions.setFilters,
null,
null,
[{ type: 'SET_FILTERS', payload: null }],
[],
done,
);
});
});
describe('setLoadingState', () => {
it('commits SET_LOADING_STATE', done => {
testAction(
actions.setLoadingState,
true,
null,
[{ type: 'SET_LOADING_STATE', payload: true }],
[],
done,
);
});
});
describe('fetchChartData', () => {
let mock;
let commit;
let dispatch;
const chartData = { '2017-11': 0, '2017-12': 2 };
beforeEach(() => {
dispatch = jasmine.createSpy('dispatch');
commit = jasmine.createSpy('commit');
mock = new MockAdapter(axios);
mock.onGet().reply(200, chartData);
});
afterEach(() => {
mock.restore();
});
it('commits SET_CHART_DATA with chart data', done => {
const getters = { appliedFilters: '?hello=world' };
const context = {
dispatch,
getters,
commit,
};
actions
.fetchChartData(context, gl.TEST_HOST)
.then(() => {
expect(dispatch.calls.argsFor(0)).toEqual(['setLoadingState', true]);
expect(commit).toHaveBeenCalledWith('SET_CHART_DATA', chartData);
expect(dispatch.calls.argsFor(1)).toEqual(['setLoadingState', false]);
})
.then(done)
.catch(done.fail);
});
});
});
import createState from 'ee/issues_analytics/stores/modules/issue_analytics/state';
import mutations from 'ee/issues_analytics/stores/modules/issue_analytics/mutations';
import * as types from 'ee/issues_analytics/stores/modules/issue_analytics/mutation_types';
describe('Issues Analytics mutations', () => {
let state;
beforeEach(() => {
state = createState();
});
describe(types.SET_LOADING_STATE, () => {
it('sets loading state', () => {
mutations[types.SET_LOADING_STATE](state, true);
expect(state.loading).toBe(true);
});
});
describe(types.SET_CHART_DATA, () => {
it('adds chart data to state', () => {
const chartData = { '2017-11': 0, '2017-12': 2 };
mutations[types.SET_CHART_DATA](state, chartData);
expect(state.chartData).toEqual(chartData);
});
});
describe(types.SET_FILTERS, () => {
it('adds applied filters to state', () => {
const filter = '?state=opened&assignee_username=someone';
mutations[types.SET_FILTERS](state, filter);
expect(state.filters).toEqual(filter);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
describe IssuablesAnalytics do
describe '#data' do
let(:project) { create(:project, :empty_repo) }
# The hash key is the number of months back that the issue `created_at` will be.
# The hash value is the number of issues created for the month key.
let(:seed) do
{ 0 => 2, 1 => 0, 2 => 1, 3 => 2, 4 => 1, 5 => 1, 6 => 1, 7 => 1, 8 => 1, 9 => 2, 10 => 1, 11 => 0 }
end
before do
Timecop.freeze(Time.now) do
seed.each_pair do |months_back, issues_count|
create_list(:issue, issues_count, project: project, created_at: months_back.months.ago)
end
end
end
context 'when months_back parameter is nil' do
it 'returns a hash containing the issues count created in the past 12 months' do
data = described_class.new(issuables: project.issues).data
seed.each_pair do |months_back, issues_count|
date = months_back.months.ago.strftime(described_class::DATE_FORMAT)
expect(data[date]).to eq(issues_count)
end
end
end
context 'when months_back parameter is present' do
it 'returns a hash containing the issues count created in the past x months' do
data = described_class.new(issuables: project.issues, months_back: 3).data
filtered_seed = seed.keep_if do |months_back, _|
months_back < 3
end
filtered_seed.each_pair do |months_back, issues_count|
date = months_back.months.ago.strftime(described_class::DATE_FORMAT)
expect(data[date]).to eq(issues_count)
end
end
end
end
end
......@@ -683,6 +683,9 @@ msgstr ""
msgid "An error occurred while initializing path locks"
msgstr ""
msgid "An error occurred while loading chart data"
msgstr ""
msgid "An error occurred while loading commit signatures"
msgstr ""
......@@ -734,6 +737,9 @@ msgstr ""
msgid "An error occurred. Please try again."
msgstr ""
msgid "Analytics"
msgstr ""
msgid "Anonymous"
msgstr ""
......@@ -4454,6 +4460,27 @@ msgstr ""
msgid "Issues, merge requests, pushes and comments."
msgstr ""
msgid "IssuesAnalytics|After you begin creating issues for your projects, we can start tracking and displaying metrics for them"
msgstr ""
msgid "IssuesAnalytics|Issues Created"
msgstr ""
msgid "IssuesAnalytics|Issues created per month"
msgstr ""
msgid "IssuesAnalytics|Last 12 months"
msgstr ""
msgid "IssuesAnalytics|Sorry, your filter produced no results"
msgstr ""
msgid "IssuesAnalytics|There are no issues for the projects in your group"
msgstr ""
msgid "IssuesAnalytics|To widen your search, change or remove filters in the filter bar above"
msgstr ""
msgid "Jan"
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