Commit 3d45adfe authored by Martin Wortschack's avatar Martin Wortschack

Merge branch 'fe-top-n-labels-endpoint' into 'master'

Fe top n labels endpoint

Closes #196584

See merge request gitlab-org/gitlab!24853
parents e9997645 7e7dac36
......@@ -524,6 +524,8 @@ img.emoji {
cursor: pointer;
}
.cursor-not-allowed { cursor: not-allowed; }
// this needs to use "!important" due to some very specific styles
// around buttons
.cursor-default {
......
......@@ -64,6 +64,7 @@ export default {
'stages',
'summary',
'labels',
'topRankedLabels',
'currentStageEvents',
'customStageFormEvents',
'errorCode',
......@@ -112,7 +113,7 @@ export default {
startDate,
endDate,
selectedProjectIds,
tasksByType: { subject, labelIds: selectedLabelIds },
tasksByType: { subject, selectedLabelIds },
} = this;
return {
selectedGroup,
......
<script>
import $ from 'jquery';
import _ from 'underscore';
import { GlButton, GlDropdownDivider, GlSegmentedControl } from '@gitlab/ui';
import {
GlDropdownDivider,
GlSegmentedControl,
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlIcon,
} from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import createFlash from '~/flash';
import { removeFlash } from '../utils';
import {
TASKS_BY_TYPE_FILTERS,
TASKS_BY_TYPE_SUBJECT_ISSUE,
TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS,
TASKS_BY_TYPE_MAX_LABELS,
} from '../constants';
export default {
name: 'TasksByTypeFilters',
components: {
GlButton,
GlDropdownDivider,
GlSegmentedControl,
Icon,
GlDropdownDivider,
GlIcon,
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
},
props: {
maxLabels: {
type: Number,
required: false,
default: TASKS_BY_TYPE_MAX_LABELS,
},
labels: {
type: Array,
required: true,
......@@ -37,6 +51,7 @@ export default {
const { subjectFilter: selectedSubjectFilter } = this;
return {
selectedSubjectFilter,
labelsSearchTerm: '',
};
},
computed: {
......@@ -60,42 +75,48 @@ export default {
},
);
},
},
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,
});
availableLabels() {
return this.labels.filter(({ name }) =>
name.toLowerCase().includes(this.labelsSearchTerm.toLowerCase()),
);
},
selectedLabelLimitText() {
const { selectedLabelIds, maxLabels } = this;
return sprintf(s__('CycleAnalytics|%{selectedLabelsCount} selected (%{maxLabels} max)'), {
selectedLabelsCount: selectedLabelIds.length,
maxLabels,
});
},
maxLabelsSelected() {
return this.selectedLabelIds.length >= this.maxLabels;
},
hasMatchingLabels() {
return this.availableLabels.length;
},
},
methods: {
onClick({ e, selectedObj }) {
e.preventDefault();
const { id: value } = selectedObj;
this.$emit('updateFilter', { filter: TASKS_BY_TYPE_FILTERS.LABEL, value });
canUpdateLabelFilters(value) {
// we can always remove a filter
return this.selectedLabelIds.includes(value) || !this.maxLabelsSelected;
},
formatData(term, callback) {
callback(this.labels);
isLabelSelected(id) {
return this.selectedLabelIds.includes(id);
},
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>
`;
isLabelDisabled(id) {
return this.maxLabelsSelected && !this.isLabelSelected(id);
},
handleLabelSelected(value) {
removeFlash('notice');
if (this.canUpdateLabelFilters(value)) {
this.$emit('updateFilter', { filter: TASKS_BY_TYPE_FILTERS.LABEL, value });
} else {
const { maxLabels } = this;
const message = sprintf(
s__('CycleAnalytics|Only %{maxLabels} labels can be selected at this time'),
{ maxLabels },
);
createFlash(message, 'notice');
}
},
},
TASKS_BY_TYPE_FILTERS,
......@@ -111,42 +132,59 @@ export default {
<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 })
"
<gl-dropdown
aria-expanded="false"
:aria-label="__('CycleAnalytics|Display chart filters')"
right
>
<template #button-content>
<gl-icon class="vertical-align-top" name="settings" />
<gl-icon name="chevron-down" />
</template>
<div class="mb-3 px-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="mb-3 px-3">
<p class="font-weight-bold text-left my-2">
{{ s__('CycleAnalytics|Select labels') }}
<br /><small>{{ selectedLabelLimitText }}</small>
</p>
<gl-search-box-by-type v-model.trim="labelsSearchTerm" class="mb-2" />
<gl-dropdown-item
v-for="label in availableLabels"
:key="label.id"
:disabled="isLabelDisabled(label.id)"
:class="{
'pl-4': !isLabelSelected(label.id),
'cursor-not-allowed': isLabelDisabled(label.id),
}"
@click="() => handleLabelSelected(label.id)"
>
<gl-icon
v-if="isLabelSelected(label.id)"
class="text-gray-700 mr-1 vertical-align-middle"
name="mobile-issue-close"
/>
</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>
<span
:style="{ 'background-color': label.color }"
class="d-inline-block dropdown-label-box"
></span>
{{ label.name }}
</gl-dropdown-item>
<div v-show="!hasMatchingLabels" class="text-secondary">
{{ __('No matching labels') }}
</div>
</div>
</div>
</gl-dropdown>
</div>
</div>
</template>
......@@ -34,6 +34,7 @@ export const DEFAULT_STAGE_NAMES = [...Object.keys(EMPTY_STAGE_TEXT), 'total'];
export const TASKS_BY_TYPE_SUBJECT_ISSUE = 'Issue';
export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest';
export const TASKS_BY_TYPE_MAX_LABELS = 15;
export const TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS = {
[TASKS_BY_TYPE_SUBJECT_ISSUE]: __('Issues'),
......@@ -42,7 +43,7 @@ export const TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS = {
export const TASKS_BY_TYPE_FILTERS = {
SUBJECT: 'SUBJECT',
LABELS: 'LABELS',
LABEL: 'LABEL',
};
export const STAGE_ACTIONS = {
......
......@@ -3,19 +3,13 @@ import Api from 'ee/api';
import { getDayDifference, getDateInPast } from '~/lib/utils/datetime_utility';
import { historyPushState } from '~/lib/utils/common_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
import createFlash, { hideFlash } from '~/flash';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import httpStatus from '~/lib/utils/http_status';
import * as types from './mutation_types';
import { dateFormats } from '../../shared/constants';
import { toYmd } from '../../shared/utils';
const removeError = () => {
const flashEl = document.querySelector('.flash-alert');
if (flashEl) {
hideFlash(flashEl);
}
};
import { removeFlash } from '../utils';
const handleErrorOrRethrow = ({ action, error }) => {
if (error?.response?.status === httpStatus.FORBIDDEN) {
......@@ -167,11 +161,12 @@ export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
};
export const fetchCycleAnalyticsData = ({ dispatch }) => {
removeError();
removeFlash();
dispatch('requestCycleAnalyticsData');
return Promise.resolve()
.then(() => dispatch('fetchGroupLabels'))
.then(() => dispatch('fetchTopRankedGroupLabels'))
.then(() => dispatch('fetchGroupStagesAndEvents'))
.then(() => dispatch('fetchStageMedianValues'))
.then(() => dispatch('fetchSummaryData'))
......@@ -181,12 +176,12 @@ export const fetchCycleAnalyticsData = ({ dispatch }) => {
export const hideCustomStageForm = ({ commit }) => {
commit(types.HIDE_CUSTOM_STAGE_FORM);
removeError();
removeFlash();
};
export const showCustomStageForm = ({ commit }) => {
commit(types.SHOW_CUSTOM_STAGE_FORM);
removeError();
removeFlash();
};
export const showEditCustomStageForm = ({ commit, dispatch }, selectedStage = {}) => {
......@@ -208,7 +203,7 @@ export const showEditCustomStageForm = ({ commit, dispatch }, selectedStage = {}
endEventLabelId,
});
dispatch('setSelectedStage', selectedStage);
removeError();
removeFlash();
};
export const requestSummaryData = ({ commit }) => commit(types.REQUEST_SUMMARY_DATA);
......@@ -264,6 +259,43 @@ export const fetchGroupLabels = ({ dispatch, state }) => {
);
};
export const receiveTopRankedGroupLabelsSuccess = ({ commit }, data) =>
commit(types.RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS, data);
export const receiveTopRankedGroupLabelsError = ({ commit }, error) => {
commit(types.RECEIVE_TOP_RANKED_GROUP_LABELS_ERROR, error);
createFlash(__('There was an error fetching the top labels for the selected group'));
};
export const requestTopRankedGroupLabels = ({ commit }) =>
commit(types.REQUEST_TOP_RANKED_GROUP_LABELS);
export const fetchTopRankedGroupLabels = ({
dispatch,
state,
getters: {
currentGroupPath,
cycleAnalyticsRequestParams: { created_after, created_before },
},
}) => {
dispatch('requestTopRankedGroupLabels');
const { subject } = state.tasksByType;
return Api.cycleAnalyticsTopLabels({
subject,
created_after,
created_before,
group_id: currentGroupPath,
})
.then(({ data }) => dispatch('receiveTopRankedGroupLabelsSuccess', data))
.catch(error =>
handleErrorOrRethrow({
error,
action: () => dispatch('receiveTopRankedGroupLabelsError', error),
}),
);
};
export const receiveGroupStagesAndEventsError = ({ commit }, error) => {
commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR, error);
createFlash(__('There was an error fetching value stream analytics stages.'));
......@@ -306,7 +338,7 @@ export const fetchGroupStagesAndEvents = ({ state, dispatch, getters }) => {
export const clearCustomStageFormErrors = ({ commit }) => {
commit(types.CLEAR_CUSTOM_STAGE_FORM_ERRORS);
removeError();
removeFlash();
};
export const requestCreateCustomStage = ({ commit }) => commit(types.REQUEST_CREATE_CUSTOM_STAGE);
......@@ -372,18 +404,18 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
} = getters;
const {
tasksByType: { labelIds, subject },
tasksByType: { subject, selectedLabelIds },
} = state;
// dont request if we have no labels selected...for now
if (labelIds.length) {
if (selectedLabelIds.length) {
const params = {
group_id: currentGroupPath,
created_after,
created_before,
project_ids,
subject,
label_ids: labelIds,
label_ids: selectedLabelIds,
};
dispatch('requestTasksByTypeData');
......
......@@ -28,6 +28,10 @@ export const REQUEST_GROUP_LABELS = 'REQUEST_GROUP_LABELS';
export const RECEIVE_GROUP_LABELS_SUCCESS = 'RECEIVE_GROUP_LABELS_SUCCESS';
export const RECEIVE_GROUP_LABELS_ERROR = 'RECEIVE_GROUP_LABELS_ERROR';
export const REQUEST_TOP_RANKED_GROUP_LABELS = 'REQUEST_TOP_RANKED_GROUP_LABELS';
export const RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS = 'RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS';
export const RECEIVE_TOP_RANKED_GROUP_LABELS_ERROR = 'RECEIVE_TOP_RANKED_GROUP_LABELS_ERROR';
export const REQUEST_SUMMARY_DATA = 'REQUEST_SUMMARY_DATA';
export const RECEIVE_SUMMARY_DATA_SUCCESS = 'RECEIVE_SUMMARY_DATA_SUCCESS';
export const RECEIVE_SUMMARY_DATA_ERROR = 'RECEIVE_SUMMARY_DATA_ERROR';
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
import { transformRawStages, transformRawTasksByTypeData } from '../utils';
import { transformRawStages, transformRawTasksByTypeData, toggleSelectedLabel } from '../utils';
import { TASKS_BY_TYPE_FILTERS } from '../constants';
export default {
......@@ -58,9 +58,34 @@ export default {
},
[types.REQUEST_GROUP_LABELS](state) {
state.labels = [];
},
[types.RECEIVE_GROUP_LABELS_SUCCESS](state, data = []) {
state.labels = data.map(convertObjectPropsToCamelCase);
},
[types.RECEIVE_GROUP_LABELS_ERROR](state) {
state.labels = [];
},
[types.REQUEST_TOP_RANKED_GROUP_LABELS](state) {
state.topRankedLabels = [];
state.tasksByType = {
...state.tasksByType,
labelIds: [],
selectedLabelIds: [],
};
},
[types.RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS](state, data = []) {
const { tasksByType } = state;
state.topRankedLabels = data.map(convertObjectPropsToCamelCase);
state.tasksByType = {
...tasksByType,
selectedLabelIds: data.map(({ id }) => id),
};
},
[types.RECEIVE_TOP_RANKED_GROUP_LABELS_ERROR](state) {
const { tasksByType } = state;
state.topRankedLabels = [];
state.tasksByType = {
...tasksByType,
selectedLabelIds: [],
};
},
[types.REQUEST_STAGE_MEDIANS](state) {
......@@ -78,22 +103,6 @@ export default {
[types.RECEIVE_STAGE_MEDIANS_ERROR](state) {
state.medians = {};
},
[types.RECEIVE_GROUP_LABELS_SUCCESS](state, data = []) {
const { tasksByType } = state;
state.labels = data.map(convertObjectPropsToCamelCase);
state.tasksByType = {
...tasksByType,
labelIds: data.map(({ id }) => id),
};
},
[types.RECEIVE_GROUP_LABELS_ERROR](state) {
const { tasksByType } = state;
state.labels = [];
state.tasksByType = {
...tasksByType,
labelIds: [],
};
},
[types.SHOW_CUSTOM_STAGE_FORM](state) {
state.isCreatingCustomStage = true;
state.isEditingCustomStage = false;
......@@ -217,15 +226,13 @@ export default {
},
[types.SET_TASKS_BY_TYPE_FILTERS](state, { filter, value }) {
const {
tasksByType: { labelIds, ...tasksByTypeRest },
tasksByType: { selectedLabelIds, ...tasksByTypeRest },
} = state;
let updatedFilter = {};
switch (filter) {
case TASKS_BY_TYPE_FILTERS.LABEL:
updatedFilter = {
labelIds: labelIds.includes(value)
? labelIds.filter(v => v !== value)
: [...labelIds, value],
selectedLabelIds: toggleSelectedLabel({ selectedLabelIds, value }),
};
break;
case TASKS_BY_TYPE_FILTERS.SUBJECT:
......@@ -234,7 +241,7 @@ export default {
default:
break;
}
state.tasksByType = { ...tasksByTypeRest, labelIds, ...updatedFilter };
state.tasksByType = { ...tasksByTypeRest, selectedLabelIds, ...updatedFilter };
},
[types.INITIALIZE_CYCLE_ANALYTICS](
state,
......
......@@ -28,6 +28,7 @@ export default () => ({
stages: [],
summary: [],
labels: [],
topRankedLabels: [],
medians: {},
customStageFormEvents: [],
......@@ -36,7 +37,7 @@ export default () => ({
tasksByType: {
subject: TASKS_BY_TYPE_SUBJECT_ISSUE,
labelIds: [],
selectedLabelIds: [],
data: [],
},
......
......@@ -2,6 +2,7 @@ import { isNumber } from 'underscore';
import dateFormat from 'dateformat';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { hideFlash } from '~/flash';
import {
newDate,
dayAfter,
......@@ -17,6 +18,20 @@ import { toYmd } from '../shared/utils';
const EVENT_TYPE_LABEL = 'label';
export const removeFlash = (type = 'alert') => {
const flashEl = document.querySelector(`.flash-${type}`);
if (flashEl) {
hideFlash(flashEl);
}
};
export const toggleSelectedLabel = ({ selectedLabelIds = [], value = null }) => {
if (!value) return selectedLabelIds;
return selectedLabelIds.includes(value)
? selectedLabelIds.filter(v => v !== value)
: [...selectedLabelIds, value];
};
export const isStartEvent = ev => Boolean(ev) && Boolean(ev.canBeStartEvent) && ev.canBeStartEvent;
export const eventToOption = (obj = null) => {
......
......@@ -15,6 +15,7 @@ export default {
projectPackagesPath: '/api/:version/projects/:id/packages',
projectPackagePath: '/api/:version/projects/:id/packages/:package_id',
cycleAnalyticsTasksByTypePath: '/-/analytics/type_of_work/tasks_by_type',
cycleAnalyticsTopLabelsPath: '/-/analytics/type_of_work/tasks_by_type/top_labels',
cycleAnalyticsSummaryDataPath: '/-/analytics/value_stream_analytics/summary',
cycleAnalyticsGroupStagesAndEventsPath: '/-/analytics/value_stream_analytics/stages',
cycleAnalyticsStageEventsPath: '/-/analytics/value_stream_analytics/stages/:stage_id/records',
......@@ -121,6 +122,11 @@ export default {
return axios.get(url, { params });
},
cycleAnalyticsTopLabels(params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsTopLabelsPath);
return axios.get(url, { params });
},
cycleAnalyticsSummaryData(params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsSummaryDataPath);
return axios.get(url, { params });
......
......@@ -11,62 +11,13 @@ 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: #FF0000;\\" class=\\"d-inline-block dropdown-label-box\\">
</span>
</a>
</li>
<li>
<a href=\\"#\\" class=\\"dropdown-menu-link is-active\\">
<span style=\\"background-color: #FFFFFF;\\" class=\\"d-inline-block dropdown-label-box\\">
</span>
</a>
</li>
<li>
<a href=\\"#\\" class=\\"dropdown-menu-link is-active\\">
<span style=\\"background-color: #0000FF;\\" 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>
<tasks-by-type-filters-stub labels=\\"[object Object],[object Object],[object Object]\\" selectedlabelids=\\"1,2,3\\" subjectfilter=\\"Issue\\"></tasks-by-type-filters-stub>
<tasks-by-type-filters-stub maxlabels=\\"15\\" labels=\\"[object Object],[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>
......
......@@ -419,13 +419,13 @@ describe('Cycle Analytics component', () => {
});
describe('with failed requests while loading', () => {
function mockRequestCycleAnalyticsData({
const mockRequestCycleAnalyticsData = ({
overrides = {},
mockFetchStageData = true,
mockFetchStageMedian = true,
mockFetchDurationData = true,
mockFetchTasksByTypeData = true,
}) {
}) => {
const defaultStatus = 200;
const defaultRequests = {
fetchSummaryData: {
......@@ -446,6 +446,10 @@ describe('Cycle Analytics component', () => {
...overrides,
};
mock
.onGet(mockData.endpoints.tasksByTypeTopLabelsData)
.reply(defaultStatus, mockData.groupLabels);
if (mockFetchTasksByTypeData) {
mock
.onGet(mockData.endpoints.tasksByTypeData)
......@@ -469,7 +473,7 @@ describe('Cycle Analytics component', () => {
Object.values(defaultRequests).forEach(({ endpoint, status, response }) => {
mock.onGet(endpoint).replyOnce(status, response);
});
}
};
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
......
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,
TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST,
TASKS_BY_TYPE_FILTERS,
} from 'ee/analytics/cycle_analytics/constants';
import { TASKS_BY_TYPE_SUBJECT_ISSUE } from 'ee/analytics/cycle_analytics/constants';
import { groupLabels } from '../mock_data';
const seriesNames = ['Cool label', 'Normal label'];
......@@ -63,91 +56,6 @@ 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', () => {
......
import { shallowMount, mount } from '@vue/test-utils';
import { GlDropdownItem, GlSegmentedControl } from '@gitlab/ui';
import TasksByTypeFilters from 'ee/analytics/cycle_analytics/components/tasks_by_type_filters.vue';
import {
TASKS_BY_TYPE_SUBJECT_ISSUE,
TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST,
TASKS_BY_TYPE_FILTERS,
} from 'ee/analytics/cycle_analytics/constants';
import { shouldFlashAMessage } from '../helpers';
import { groupLabels } from '../mock_data';
const selectedLabelIds = [groupLabels[0].id];
const findSubjectFilters = ctx => ctx.find(GlSegmentedControl);
const findSelectedSubjectFilters = ctx => findSubjectFilters(ctx).attributes('checked');
const findDropdownLabels = ctx => ctx.findAll(GlDropdownItem);
const selectLabelAtIndex = (ctx, index) => {
findDropdownLabels(ctx)
.at(index)
.vm.$emit('click');
return ctx.vm.$nextTick();
};
function createComponent({ props = {}, mountFn = shallowMount }) {
return mountFn(TasksByTypeFilters, {
propsData: {
selectedLabelIds,
labels: groupLabels,
subjectFilter: TASKS_BY_TYPE_SUBJECT_ISSUE,
...props,
},
stubs: {
GlNewDropdown: true,
GlDropdownItem: true,
},
});
}
describe('TasksByTypeFilters', () => {
let wrapper = null;
beforeEach(() => {
wrapper = createComponent({});
});
afterEach(() => {
wrapper.destroy();
});
describe('labels', () => {
beforeEach(() => {
wrapper = createComponent({});
});
it('emits the `updateFilter` event when a subject label is clicked', () => {
expect(wrapper.emitted('updateFilter')).toBeUndefined();
return selectLabelAtIndex(wrapper, 0).then(() => {
expect(wrapper.emitted('updateFilter')).toBeDefined();
expect(wrapper.emitted('updateFilter')[0]).toEqual([
{ filter: TASKS_BY_TYPE_FILTERS.LABEL, value: groupLabels[0].id },
]);
});
});
describe('with the warningMessageThreshold label threshold reached', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
wrapper = createComponent({
props: {
maxLabels: 5,
selectedLabelIds: [groupLabels[0].id, groupLabels[1].id],
warningMessageThreshold: 2,
},
});
return selectLabelAtIndex(wrapper, 2);
});
it('should indicate how many labels are selected', () => {
expect(wrapper.text()).toContain('2 selected (5 max)');
});
});
describe('with maximum labels selected', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
wrapper = createComponent({
props: {
maxLabels: 2,
selectedLabelIds: [groupLabels[0].id, groupLabels[1].id],
warningMessageThreshold: 1,
},
});
return selectLabelAtIndex(wrapper, 2);
});
it('should indicate how many labels are selected', () => {
expect(wrapper.text()).toContain('2 selected (2 max)');
});
it('should not allow selecting another label', () => {
expect(wrapper.emitted('updateFilter')).toBeUndefined();
});
it('should display a message', () => {
shouldFlashAMessage('Only 2 labels can be selected at this time');
});
});
});
describe('subject', () => {
it('has the issue subject set by default', () => {
expect(findSelectedSubjectFilters(wrapper)).toBe(TASKS_BY_TYPE_SUBJECT_ISSUE);
});
it('emits the `updateFilter` event when a subject filter is clicked', () => {
wrapper = createComponent({ mountFn: mount });
expect(wrapper.emitted('updateFilter')).toBeUndefined();
findSubjectFilters(wrapper)
.findAll('label:not(.active)')
.at(0)
.trigger('click');
return 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,
},
]);
});
});
});
});
......@@ -14,6 +14,10 @@ export function renderTotalTime(selector, element, totalTime = {}) {
}
}
export const shouldFlashAMessage = (msg = '') =>
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg);
export default {
renderTotalTime,
shouldFlashAMessage,
};
......@@ -25,6 +25,7 @@ export const endpoints = {
stageMedian: /analytics\/value_stream_analytics\/stages\/\d+\/median/,
baseStagesEndpoint: /analytics\/value_stream_analytics\/stages$/,
tasksByTypeData: /analytics\/type_of_work\/tasks_by_type/,
tasksByTypeTopLabelsData: /analytics\/type_of_work\/tasks_by_type\/top_labels/,
};
export const groupLabels = getJSONFixture(fixtureEndpoints.groupLabels).map(
......
......@@ -6,7 +6,10 @@ 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 {
TASKS_BY_TYPE_FILTERS,
TASKS_BY_TYPE_SUBJECT_ISSUE,
} from 'ee/analytics/cycle_analytics/constants';
import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import { toYmd } from 'ee/analytics/shared/utils';
......@@ -24,6 +27,7 @@ import {
transformedDurationMedianData,
endpoints,
} from '../mock_data';
import { shouldFlashAMessage } from '../helpers';
const stageData = { events: [] };
const error = new Error(`Request failed with status code ${httpStatusCodes.NOT_FOUND}`);
......@@ -38,10 +42,6 @@ describe('Cycle analytics actions', () => {
let state;
let mock;
function shouldFlashAMessage(msg = flashErrorMessage) {
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg);
}
function shouldSetUrlParams({ action, payload, result }) {
const store = {
state,
......@@ -332,6 +332,65 @@ describe('Cycle analytics actions', () => {
});
});
describe('fetchTopRankedGroupLabels', () => {
beforeEach(() => {
gon.api_version = 'v4';
state = { selectedGroup, tasksByType: { subject: TASKS_BY_TYPE_SUBJECT_ISSUE }, ...getters };
});
describe('succeeds', () => {
beforeEach(() => {
mock.onGet(endpoints.tasksByTypeTopLabelsData).replyOnce(200, groupLabels);
});
it('dispatches receiveTopRankedGroupLabelsSuccess if the request succeeds', () => {
return testAction(
actions.fetchTopRankedGroupLabels,
null,
state,
[],
[
{ type: 'requestTopRankedGroupLabels' },
{ type: 'receiveTopRankedGroupLabelsSuccess', payload: groupLabels },
],
);
});
});
describe('with an error', () => {
beforeEach(() => {
mock.onGet(endpoints.fetchTopRankedGroupLabels).replyOnce(404);
});
it('dispatches receiveTopRankedGroupLabelsError if the request fails', () => {
return testAction(
actions.fetchTopRankedGroupLabels,
null,
state,
[],
[
{ type: 'requestTopRankedGroupLabels' },
{ type: 'receiveTopRankedGroupLabelsError', payload: error },
],
);
});
});
describe('receiveTopRankedGroupLabelsError', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('flashes an error message if the request fails', () => {
actions.receiveTopRankedGroupLabelsError({
commit: () => {},
});
shouldFlashAMessage('There was an error fetching the top labels for the selected group');
});
});
});
describe('fetchCycleAnalyticsData', () => {
function mockFetchCycleAnalyticsAction(overrides = {}) {
const mocks = {
......@@ -373,6 +432,7 @@ describe('Cycle analytics actions', () => {
[
{ type: 'requestCycleAnalyticsData' },
{ type: 'fetchGroupLabels' },
{ type: 'fetchTopRankedGroupLabels' },
{ type: 'fetchGroupStagesAndEvents' },
{ type: 'fetchStageMedianValues' },
{ type: 'fetchSummaryData' },
......@@ -558,7 +618,7 @@ describe('Cycle analytics actions', () => {
{},
);
shouldFlashAMessage();
shouldFlashAMessage(flashErrorMessage);
});
});
});
......@@ -611,7 +671,7 @@ describe('Cycle analytics actions', () => {
{ response },
);
shouldFlashAMessage();
shouldFlashAMessage(flashErrorMessage);
});
});
......@@ -671,7 +731,7 @@ describe('Cycle analytics actions', () => {
);
});
shouldFlashAMessage();
shouldFlashAMessage(flashErrorMessage);
});
});
......
......@@ -30,6 +30,25 @@ describe('Cycle analytics getters', () => {
});
});
describe('selectedProjectIds', () => {
describe('with selectedProjects set', () => {
it('returns the ids of each project', () => {
state = {
selectedProjects,
};
expect(getters.selectedProjectIds(state)).toEqual([1, 2]);
});
});
describe('without selectedProjects set', () => {
it('will return an empty array', () => {
state = { selectedProjects: [] };
expect(getters.selectedProjectIds(state)).toEqual([]);
});
});
});
describe('currentGroupPath', () => {
describe('with selectedGroup set', () => {
it('returns the `fullPath` value of the group', () => {
......
......@@ -53,6 +53,8 @@ describe('Cycle analytics mutations', () => {
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true}
${types.REQUEST_GROUP_LABELS} | ${'labels'} | ${[]}
${types.RECEIVE_GROUP_LABELS_ERROR} | ${'labels'} | ${[]}
${types.REQUEST_TOP_RANKED_GROUP_LABELS} | ${'topRankedLabels'} | ${[]}
${types.RECEIVE_TOP_RANKED_GROUP_LABELS_ERROR} | ${'topRankedLabels'} | ${[]}
${types.RECEIVE_SUMMARY_DATA_ERROR} | ${'summary'} | ${[]}
${types.REQUEST_SUMMARY_DATA} | ${'summary'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'stages'} | ${[]}
......@@ -146,20 +148,15 @@ describe('Cycle analytics mutations', () => {
});
});
describe.each`
mutation | value
${types.REQUEST_GROUP_LABELS} | ${[]}
${types.RECEIVE_GROUP_LABELS_ERROR} | ${[]}
`('$mutation', ({ mutation, value }) => {
it(`will set tasksByType.labelIds to ${value}`, () => {
state = { tasksByType: {} };
mutations[mutation](state);
describe(`${types.RECEIVE_GROUP_LABELS_SUCCESS}`, () => {
it('will set the labels state item with the camelCased group labels', () => {
mutations[types.RECEIVE_GROUP_LABELS_SUCCESS](state, groupLabels);
expect(state.tasksByType.labelIds).toEqual(value);
expect(state.labels).toEqual(groupLabels.map(convertObjectPropsToCamelCase));
});
});
describe(`${types.RECEIVE_GROUP_LABELS_SUCCESS}`, () => {
describe(`${types.RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS}`, () => {
it('will set the labels state item with the camelCased group labels', () => {
mutations[types.RECEIVE_GROUP_LABELS_SUCCESS](state, groupLabels);
......@@ -306,17 +303,17 @@ describe('Cycle analytics mutations', () => {
expect(state.tasksByType).toEqual({ subject: 'cool-subject' });
});
it('will toggle the specified label id in the tasksByType.labelIds state key', () => {
it('will toggle the specified label id in the tasksByType.selectedLabelIds state key', () => {
state = {
tasksByType: { labelIds: [10, 20, 30] },
tasksByType: { selectedLabelIds: [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] });
expect(state.tasksByType).toEqual({ selectedLabelIds: [10, 30] });
mutations[types.SET_TASKS_BY_TYPE_FILTERS](state, labelFilter);
expect(state.tasksByType).toEqual({ labelIds: [10, 30, 20] });
expect(state.tasksByType).toEqual({ selectedLabelIds: [10, 30, 20] });
});
});
......
......@@ -15,6 +15,7 @@ import {
getTasksByTypeData,
flattenTaskByTypeSeries,
orderByDate,
toggleSelectedLabel,
} from 'ee/analytics/cycle_analytics/utils';
import { toYmd } from 'ee/analytics/shared/utils';
import {
......@@ -302,4 +303,19 @@ describe('Cycle analytics utils', () => {
});
});
});
describe('toggleSelectedLabel', () => {
const selectedLabelIds = [1, 2, 3];
it('will return the array if theres no value given', () => {
expect(toggleSelectedLabel({ selectedLabelIds })).toEqual([1, 2, 3]);
});
it('will remove an id that exists', () => {
expect(toggleSelectedLabel({ selectedLabelIds, value: 2 })).toEqual([1, 3]);
});
it('will add an id that does not exist', () => {
expect(toggleSelectedLabel({ selectedLabelIds, value: 4 })).toEqual([1, 2, 3, 4]);
});
});
});
......@@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import Api from 'ee/api';
import * as cycleAnalyticsConstants from 'ee/analytics/cycle_analytics/constants';
import axios from '~/lib/utils/axios_utils';
import * as analyticsMockData from 'ee_jest/analytics/cycle_analytics/mock_data';
describe('Api', () => {
const dummyApiVersion = 'v3000';
......@@ -353,13 +354,38 @@ describe('Api', () => {
subject: cycleAnalyticsConstants.TASKS_BY_TYPE_SUBJECT_ISSUE,
label_ids: labelIds,
};
const expectedUrl = `${dummyUrlRoot}/-/analytics/type_of_work/tasks_by_type`;
const expectedUrl = analyticsMockData.endpoints.tasksByTypeData;
mock.onGet(expectedUrl).reply(200, tasksByTypeResponse);
Api.cycleAnalyticsTasksByType({ params })
Api.cycleAnalyticsTasksByType(params)
.then(({ data, config: { params: reqParams } }) => {
expect(data).toEqual(tasksByTypeResponse);
expect(reqParams.params).toEqual(params);
expect(reqParams).toEqual(params);
})
.then(done)
.catch(done.fail);
});
});
describe('cycleAnalyticsTopLabels', () => {
it('fetches top group level labels', done => {
const response = [];
const labelIds = [10, 9, 8, 7];
const params = {
...defaultParams,
project_ids: null,
subject: cycleAnalyticsConstants.TASKS_BY_TYPE_SUBJECT_ISSUE,
label_ids: labelIds,
};
const expectedUrl = analyticsMockData.endpoints.tasksByTypeTopLabelsData;
mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsTopLabels(params)
.then(({ data, config: { url, params: reqParams } }) => {
expect(data).toEqual(response);
expect(url).toMatch(expectedUrl);
expect(reqParams).toEqual(params);
})
.then(done)
.catch(done.fail);
......
......@@ -6110,6 +6110,9 @@ msgstr ""
msgid "CycleAnalyticsStage|should be under a group"
msgstr ""
msgid "CycleAnalytics|%{selectedLabelsCount} selected (%{maxLabels} max)"
msgstr ""
msgid "CycleAnalytics|%{stageCount} stages selected"
msgstr ""
......@@ -6131,6 +6134,9 @@ msgstr ""
msgid "CycleAnalytics|Number of tasks"
msgstr ""
msgid "CycleAnalytics|Only %{maxLabels} labels can be selected at this time"
msgstr ""
msgid "CycleAnalytics|Project selected"
msgid_plural "CycleAnalytics|%d projects selected"
msgstr[0] ""
......@@ -13170,6 +13176,9 @@ msgstr ""
msgid "No licenses found."
msgstr ""
msgid "No matching labels"
msgstr ""
msgid "No matching results"
msgstr ""
......@@ -19906,6 +19915,9 @@ msgstr ""
msgid "There was an error fetching the environments information."
msgstr ""
msgid "There was an error fetching the top labels for the selected group"
msgstr ""
msgid "There was an error fetching the variables."
msgstr ""
......
......@@ -215,4 +215,4 @@
"node": ">=10.13.0",
"yarn": "^1.10.0"
}
}
}
\ No newline at end of file
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