Commit 7fdfd9c1 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '33604-tasks-by-type-chart-filters' into 'master'

Cycle Analytics 'Tasks by Type' Chart Filters

See merge request gitlab-org/gitlab!22936
parents f0e5d1fc b7d9582c
......@@ -144,6 +144,7 @@ export default {
'setFeatureFlags',
'editCustomStage',
'updateStage',
'setTasksByTypeFilters',
]),
onGroupSelect(group) {
this.setSelectedGroup(group);
......@@ -319,6 +320,8 @@ export default {
<tasks-by-type-chart
:chart-data="tasksByTypeChartData"
:filters="selectedTasksByTypeFilters"
:labels="labels"
@updateFilter="setTasksByTypeFilters"
/>
</div>
<gl-loading-icon v-else size="md" class="my-4 py-4" />
......
......@@ -96,7 +96,9 @@ export default {
{{ selectedStagesLabel }}
<icon name="chevron-down" />
</gl-button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width">
<div
class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width dropdown-menu-right"
>
<div class="dropdown-title text-left">{{ s__('CycleAnalytics|Stages') }}</div>
<div class="dropdown-content"></div>
</div>
......
......@@ -2,11 +2,14 @@
import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import { s__, sprintf } from '~/locale';
import { formattedDate } from '../../shared/utils';
import { TASKS_BY_TYPE_SUBJECT_ISSUE } from '../constants';
import TasksByTypeFilters from './tasks_by_type_filters.vue';
export default {
name: 'TasksByTypeChart',
components: {
GlStackedColumnChart,
TasksByTypeFilters,
},
props: {
filters: {
......@@ -17,18 +20,15 @@ export default {
type: Object,
required: true,
},
labels: {
type: Array,
required: true,
},
},
computed: {
hasData() {
return Boolean(this.chartData?.data?.length);
},
selectedFiltersText() {
const { subject, selectedLabelIds } = this.filters;
return sprintf(s__('CycleAnalytics|Showing %{subject} and %{selectedLabelsCount} labels'), {
subject,
selectedLabelsCount: selectedLabelIds.length,
});
},
summaryDescription() {
const {
startDate,
......@@ -53,9 +53,12 @@ export default {
selectedProjectCount,
});
},
},
chartOptions: {
legend: false,
selectedSubjectFilter() {
const {
filters: { subject },
} = this;
return subject || TASKS_BY_TYPE_SUBJECT_ISSUE;
},
},
};
</script>
......@@ -65,13 +68,17 @@ export default {
<h3>{{ s__('CycleAnalytics|Type of work') }}</h3>
<div v-if="hasData">
<p>{{ summaryDescription }}</p>
<h4>{{ s__('CycleAnalytics|Tasks by type') }}</h4>
<p>{{ selectedFiltersText }}</p>
<tasks-by-type-filters
:labels="labels"
:selected-label-ids="filters.selectedLabelIds"
:subject-filter="selectedSubjectFilter"
@updateFilter="$emit('updateFilter', $event)"
/>
<gl-stacked-column-chart
:option="$options.chartOptions"
:data="chartData.data"
:group-by="chartData.groupBy"
x-axis-type="category"
y-axis-type="value"
:x-axis-title="__('Date')"
:y-axis-title="s__('CycleAnalytics|Number of tasks')"
:series-names="chartData.seriesNames"
......
<script>
import $ from 'jquery';
import _ from 'underscore';
import { GlButton, GlDropdownDivider, GlSegmentedControl } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import {
TASKS_BY_TYPE_FILTERS,
TASKS_BY_TYPE_SUBJECT_ISSUE,
TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS,
} from '../constants';
export default {
name: 'TasksByTypeFilters',
components: {
GlButton,
GlDropdownDivider,
GlSegmentedControl,
Icon,
},
props: {
labels: {
type: Array,
required: true,
},
selectedLabelIds: {
type: Array,
required: false,
default: () => [],
},
subjectFilter: {
type: String,
required: true,
},
},
data() {
const { subjectFilter: selectedSubjectFilter } = this;
return {
selectedSubjectFilter,
};
},
computed: {
subjectFilterOptions() {
return Object.entries(TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS).map(([value, text]) => ({
text,
value,
}));
},
selectedFiltersText() {
const { subjectFilter, selectedLabelIds } = this;
const subjectFilterText =
subjectFilter === TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS[subjectFilter]
? TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS[subjectFilter]
: TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS[TASKS_BY_TYPE_SUBJECT_ISSUE];
return sprintf(
s__('CycleAnalytics|Showing %{subjectFilterText} and %{selectedLabelsCount} labels'),
{
subjectFilterText,
selectedLabelsCount: selectedLabelIds.length,
},
);
},
},
mounted() {
$(this.$refs.labelsDropdown).glDropdown({
selectable: true,
multiSelect: true,
filterable: true,
search: {
fields: ['title'],
},
clicked: this.onClick.bind(this),
data: this.formatData.bind(this),
renderRow: group => this.rowTemplate(group),
text: label => label.title,
});
},
methods: {
onClick({ e, selectedObj }) {
e.preventDefault();
const { id: value } = selectedObj;
this.$emit('updateFilter', { filter: TASKS_BY_TYPE_FILTERS.LABEL, value });
},
formatData(term, callback) {
callback(this.labels);
},
rowTemplate(label) {
return `
<li>
<a href='#' class='dropdown-menu-link is-active'>
<span style="background-color: ${
label.color
};" class="d-inline-block dropdown-label-box">
</span>
${_.escape(label.title)}
</a>
</li>
`;
},
},
TASKS_BY_TYPE_FILTERS,
};
</script>
<template>
<div
class="js-tasks-by-type-chart-filters d-flex flex-row justify-content-between align-items-center"
>
<div class="flex-column">
<h4>{{ s__('CycleAnalytics|Tasks by type') }}</h4>
<p>{{ selectedFiltersText }}</p>
</div>
<div class="flex-column">
<div ref="labelsDropdown" class="dropdown dropdown-labels">
<gl-button
class="shadow-none bg-white btn-svg"
type="button"
data-toggle="dropdown"
aria-expanded="false"
:aria-label="__('CycleAnalytics|Display chart filters')"
>
<icon :size="16" name="settings" />
<icon :size="16" name="chevron-down" />
</gl-button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-right">
<div class="js-tasks-by-type-chart-filters-subject mb-3 mx-3">
<p class="font-weight-bold text-left mb-2">{{ s__('CycleAnalytics|Show') }}</p>
<gl-segmented-control
v-model="selectedSubjectFilter"
:options="subjectFilterOptions"
@input="
value =>
$emit('updateFilter', { filter: $options.TASKS_BY_TYPE_FILTERS.SUBJECT, value })
"
/>
</div>
<gl-dropdown-divider />
<div class="js-tasks-by-type-chart-filters-labels mb-3 mx-3">
<p class="font-weight-bold text-left my-2">
{{ s__('CycleAnalytics|Select labels') }}
</p>
<div class="dropdown-input px-0">
<input class="dropdown-input-field" type="search" />
<icon name="search" class="dropdown-input-search" data-hidden="true" />
</div>
<div class="dropdown-content px-0"></div>
</div>
</div>
</div>
</div>
</div>
</template>
......@@ -33,6 +33,16 @@ export const EMPTY_STAGE_TEXT = {
export const TASKS_BY_TYPE_SUBJECT_ISSUE = 'Issue';
export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest';
export const TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS = {
[TASKS_BY_TYPE_SUBJECT_ISSUE]: __('Issues'),
[TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST]: __('Merge Requests'),
};
export const TASKS_BY_TYPE_FILTERS = {
SUBJECT: 'SUBJECT',
LABELS: 'LABELS',
};
export const STAGE_ACTIONS = {
SELECT: 'selectStage',
EDIT: 'editStage',
......
......@@ -494,3 +494,8 @@ export const updateSelectedDurationChartStages = ({ state, commit }, stages) =>
updatedDurationStageMedianData,
});
};
export const setTasksByTypeFilters = ({ dispatch, commit }, data) => {
commit(types.SET_TASKS_BY_TYPE_FILTERS, data);
dispatch('fetchTasksByTypeData');
};
......@@ -58,3 +58,5 @@ export const RECEIVE_DURATION_DATA_ERROR = 'RECEIVE_DURATION_DATA_ERROR';
export const REQUEST_DURATION_MEDIAN_DATA = 'REQUEST_DURATION_MEDIAN_DATA';
export const RECEIVE_DURATION_MEDIAN_DATA_SUCCESS = 'RECEIVE_DURATION_MEDIAN_DATA_SUCCESS';
export const RECEIVE_DURATION_MEDIAN_DATA_ERROR = 'RECEIVE_DURATION_MEDIAN_DATA_ERROR';
export const SET_TASKS_BY_TYPE_FILTERS = 'SET_TASKS_BY_TYPE_FILTERS';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
import { transformRawStages, transformRawTasksByTypeData } from '../utils';
import { TASKS_BY_TYPE_FILTERS } from '../constants';
export default {
[types.SET_FEATURE_FLAGS](state, featureFlags) {
......@@ -190,4 +191,25 @@ export default {
state.durationMedianData = [];
state.isLoadingDurationChartMedianData = false;
},
[types.SET_TASKS_BY_TYPE_FILTERS](state, { filter, value }) {
const {
tasksByType: { labelIds, ...tasksByTypeRest },
} = state;
let updatedFilter = {};
switch (filter) {
case TASKS_BY_TYPE_FILTERS.LABEL:
updatedFilter = {
labelIds: labelIds.includes(value)
? labelIds.filter(v => v !== value)
: [...labelIds, value],
};
break;
case TASKS_BY_TYPE_FILTERS.SUBJECT:
updatedFilter = { subject: value };
break;
default:
break;
}
state.tasksByType = { ...tasksByTypeRest, labelIds, ...updatedFilter };
},
};
......@@ -246,7 +246,11 @@ describe 'Group Cycle Analytics', :js do
end
it 'has 2 labels selected' do
expect(page).to have_text('Showing Issue and 2 labels')
expect(page).to have_text('Showing Issues and 2 labels')
end
it 'has chart filters' do
expect(page).to have_css('.js-tasks-by-type-chart-filters')
end
end
......
......@@ -11,15 +11,55 @@ exports[`TasksByTypeChart no data available should render the no data available
</div>"
`;
exports[`TasksByTypeChart with data available filters labels has label filters 1`] = `
"<div class=\\"js-tasks-by-type-chart-filters-labels mb-3 mx-3\\">
<p class=\\"font-weight-bold text-left my-2\\">
Select labels
</p>
<div class=\\"dropdown-input px-0\\"><input type=\\"search\\" class=\\"dropdown-input-field\\"> <svg aria-hidden=\\"true\\" class=\\"dropdown-input-search s16 ic-search\\" data-hidden=\\"true\\">
<use xlink:href=\\"#search\\"></use>
</svg></div>
<div class=\\"dropdown-content px-0\\"></div>
</div>"
`;
exports[`TasksByTypeChart with data available filters labels with label dropdown open renders the group labels as dropdown items 1`] = `
"<div class=\\"dropdown-content px-0\\">
<ul>
<li>
<a href=\\"#\\" class=\\"dropdown-menu-link is-active\\">
<span style=\\"background-color: #BADA55;\\" class=\\"d-inline-block dropdown-label-box\\">
</span>
</a>
</li>
<li>
<a href=\\"#\\" class=\\"dropdown-menu-link is-active\\">
<span style=\\"background-color: #0033CC;\\" class=\\"d-inline-block dropdown-label-box\\">
</span>
</a>
</li>
</ul>
</div>"
`;
exports[`TasksByTypeChart with data available filters subject has subject filters 1`] = `
"<div class=\\"js-tasks-by-type-chart-filters-subject mb-3 mx-3\\">
<p class=\\"font-weight-bold text-left mb-2\\">Show</p>
<div role=\\"radiogroup\\" tabindex=\\"-1\\" class=\\"gl-segmented-control btn-group-toggle btn-group\\" id=\\"__BVID__74\\"><label class=\\"btn btn-gl-segmented-button active\\"><input id=\\"__BVID__74__BV_option_0_\\" type=\\"radio\\" name=\\"__BVID__74\\" autocomplete=\\"off\\" class=\\"\\" value=\\"Issue\\"><span>Issues</span></label><label class=\\"btn btn-gl-segmented-button\\"><input id=\\"__BVID__74__BV_option_1_\\" type=\\"radio\\" name=\\"__BVID__74\\" autocomplete=\\"off\\" class=\\"\\" value=\\"MergeRequest\\"><span>Merge Requests</span></label></div>
</div>"
`;
exports[`TasksByTypeChart with data available should render the loading chart 1`] = `
"<div class=\\"row\\">
<div class=\\"col-12\\">
<h3>Type of work</h3>
<div>
<p>Showing data for group 'Gitlab Org' from Dec 11, 2019 to Jan 10, 2020</p>
<h4>Tasks by type</h4>
<p>Showing Issue and 3 labels</p>
<gl-stacked-column-chart-stub data=\\"0,1,2,5,2,3,2,4,1\\" option=\\"[object Object]\\" presentation=\\"stacked\\" groupby=\\"Group 1,Group 2,Group 3\\" xaxistype=\\"category\\" xaxistitle=\\"Date\\" yaxistitle=\\"Number of tasks\\" seriesnames=\\"Cool label,Normal label\\" legendaveragetext=\\"Avg\\" legendmaxtext=\\"Max\\"></gl-stacked-column-chart-stub>
<tasks-by-type-filters-stub labels=\\"[object Object],[object Object]\\" selectedlabelids=\\"1,2,3\\" subjectfilter=\\"Issue\\"></tasks-by-type-filters-stub>
<gl-stacked-column-chart-stub data=\\"0,1,2,5,2,3,2,4,1\\" option=\\"[object Object]\\" presentation=\\"stacked\\" groupby=\\"Group 1,Group 2,Group 3\\" xaxistype=\\"category\\" xaxistitle=\\"Date\\" yaxistitle=\\"Number of tasks\\" seriesnames=\\"Cool label,Normal label\\" legendaveragetext=\\"Avg\\" legendmaxtext=\\"Max\\" y-axis-type=\\"value\\"></gl-stacked-column-chart-stub>
</div>
</div>
</div>"
......
import { shallowMount } from '@vue/test-utils';
import { mount, shallowMount } from '@vue/test-utils';
import $ from 'jquery';
import 'bootstrap';
import '~/gl_dropdown';
import TasksByTypeChart from 'ee/analytics/cycle_analytics/components/tasks_by_type_chart.vue';
import { TASKS_BY_TYPE_SUBJECT_ISSUE } from 'ee/analytics/cycle_analytics/constants';
import {
TASKS_BY_TYPE_SUBJECT_ISSUE,
TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST,
TASKS_BY_TYPE_FILTERS,
} from 'ee/analytics/cycle_analytics/constants';
import { groupLabels } from '../mock_data';
const seriesNames = ['Cool label', 'Normal label'];
const data = [[0, 1, 2], [5, 2, 3], [2, 4, 1]];
......@@ -19,21 +27,28 @@ const filters = {
selectedLabelIds: [1, 2, 3],
};
describe('TasksByTypeChart', () => {
function createComponent(props) {
return shallowMount(TasksByTypeChart, {
propsData: {
filters,
chartData: {
groupBy,
data,
seriesNames,
},
...props,
function createComponent({ props = {}, shallow = true, stubs = {} }) {
const fn = shallow ? shallowMount : mount;
return fn(TasksByTypeChart, {
propsData: {
filters,
chartData: {
groupBy,
data,
seriesNames,
},
});
}
labels: groupLabels,
...props,
},
stubs: {
'gl-stacked-column-chart': true,
'tasks-by-type-filters': true,
...stubs,
},
});
}
describe('TasksByTypeChart', () => {
let wrapper = null;
afterEach(() => {
......@@ -48,15 +63,102 @@ describe('TasksByTypeChart', () => {
it('should render the loading chart', () => {
expect(wrapper.html()).toMatchSnapshot();
});
describe('filters', () => {
const findSubjectFilters = ctx => ctx.find('.js-tasks-by-type-chart-filters-subject');
const findSelectedSubjectFilters = ctx =>
ctx.find('.js-tasks-by-type-chart-filters-subject .active');
const findLabelFilters = ctx => ctx.find('.js-tasks-by-type-chart-filters-labels');
const findDropdown = ctx => ctx.find('.dropdown');
const findDropdownContent = ctx => ctx.find('.dropdown-content');
const openDropdown = ctx => {
$(findDropdown(ctx).element)
.parent()
.trigger('shown.bs.dropdown');
};
beforeEach(() => {
wrapper = createComponent({
shallow: false,
stubs: {
'tasks-by-type-filters': false,
},
});
});
describe('labels', () => {
it('has label filters', () => {
expect(findLabelFilters(wrapper).html()).toMatchSnapshot();
});
describe('with label dropdown open', () => {
beforeEach(() => {
openDropdown(wrapper);
return wrapper.vm.$nextTick();
});
it('renders the group labels as dropdown items', () => {
expect(findDropdownContent(wrapper).html()).toMatchSnapshot();
});
it('emits the `updateFilter` event when a subject label is clicked', done => {
expect(wrapper.emitted().updateFilter).toBeUndefined();
findLabelFilters(wrapper)
.findAll('.dropdown-menu-link')
.at(0)
.trigger('click');
wrapper.vm.$nextTick(() => {
expect(wrapper.emitted().updateFilter).toBeDefined();
expect(wrapper.emitted().updateFilter[0]).toEqual([
{ filter: TASKS_BY_TYPE_FILTERS.LABEL, value: groupLabels[0].id },
]);
done();
});
});
});
});
describe('subject', () => {
it('has subject filters', () => {
expect(findSubjectFilters(wrapper).html()).toMatchSnapshot();
});
it('has the issue subject set by default', () => {
expect(findSelectedSubjectFilters(wrapper).text()).toBe('Issues');
});
it('emits the `updateFilter` event when a subject filter is clicked', done => {
expect(wrapper.emitted().updateFilter).toBeUndefined();
findSubjectFilters(wrapper)
.findAll('label:not(.active)')
.at(0)
.trigger('click');
wrapper.vm.$nextTick(() => {
expect(wrapper.emitted().updateFilter).toBeDefined();
expect(wrapper.emitted().updateFilter[0]).toEqual([
{ filter: TASKS_BY_TYPE_FILTERS.SUBJECT, value: TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST },
]);
done();
});
});
});
});
});
describe('no data available', () => {
beforeEach(() => {
wrapper = createComponent({
chartData: {
groupBy: [],
data: [],
seriesNames: [],
props: {
chartData: {
groupBy: [],
data: [],
seriesNames: [],
},
},
});
});
......
......@@ -4,6 +4,7 @@ import testAction from 'helpers/vuex_action_helper';
import * as getters from 'ee/analytics/cycle_analytics/store/getters';
import * as actions from 'ee/analytics/cycle_analytics/store/actions';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { TASKS_BY_TYPE_FILTERS } from 'ee/analytics/cycle_analytics/constants';
import createFlash from '~/flash';
import {
group,
......@@ -1244,4 +1245,29 @@ describe('Cycle analytics actions', () => {
);
});
});
describe('setTasksByTypeFilters', () => {
const filter = TASKS_BY_TYPE_FILTERS.SUBJECT;
const value = 'issue';
it(`commits the ${types.SET_TASKS_BY_TYPE_FILTERS} mutation and dispatches 'fetchTasksByTypeData'`, done => {
testAction(
actions.setTasksByTypeFilters,
{ filter, value },
{},
[
{
type: types.SET_TASKS_BY_TYPE_FILTERS,
payload: { filter, value },
},
],
[
{
type: 'fetchTasksByTypeData',
},
],
done,
);
});
});
});
import mutations from 'ee/analytics/cycle_analytics/store/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { TASKS_BY_TYPE_FILTERS } from 'ee/analytics/cycle_analytics/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
......@@ -265,4 +266,27 @@ describe('Cycle analytics mutations', () => {
expect(stateWithData.medians).toEqual({ '1': 20, '2': 10 });
});
});
describe(`${types.SET_TASKS_BY_TYPE_FILTERS}`, () => {
it('will update the tasksByType state key', () => {
state = { tasksByType: {} };
const subjectFilter = { filter: TASKS_BY_TYPE_FILTERS.SUBJECT, value: 'cool-subject' };
mutations[types.SET_TASKS_BY_TYPE_FILTERS](state, subjectFilter);
expect(state.tasksByType).toEqual({ subject: 'cool-subject' });
});
it('will toggle the specified label id in the tasksByType.labelIds state key', () => {
state = {
tasksByType: { labelIds: [10, 20, 30] },
};
const labelFilter = { filter: TASKS_BY_TYPE_FILTERS.LABEL, value: 20 };
mutations[types.SET_TASKS_BY_TYPE_FILTERS](state, labelFilter);
expect(state.tasksByType).toEqual({ labelIds: [10, 30] });
mutations[types.SET_TASKS_BY_TYPE_FILTERS](state, labelFilter);
expect(state.tasksByType).toEqual({ labelIds: [10, 30, 20] });
});
});
});
......@@ -5759,13 +5759,22 @@ msgstr ""
msgid "CycleAnalytics|Days to completion"
msgstr ""
msgid "CycleAnalytics|Display chart filters"
msgstr ""
msgid "CycleAnalytics|No stages selected"
msgstr ""
msgid "CycleAnalytics|Number of tasks"
msgstr ""
msgid "CycleAnalytics|Showing %{subject} and %{selectedLabelsCount} labels"
msgid "CycleAnalytics|Select labels"
msgstr ""
msgid "CycleAnalytics|Show"
msgstr ""
msgid "CycleAnalytics|Showing %{subjectFilterText} and %{selectedLabelsCount} labels"
msgstr ""
msgid "CycleAnalytics|Showing data for group '%{groupName}' and %{selectedProjectCount} projects from %{startDate} to %{endDate}"
......
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