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 { ...@@ -524,6 +524,8 @@ img.emoji {
cursor: pointer; cursor: pointer;
} }
.cursor-not-allowed { cursor: not-allowed; }
// this needs to use "!important" due to some very specific styles // this needs to use "!important" due to some very specific styles
// around buttons // around buttons
.cursor-default { .cursor-default {
......
...@@ -64,6 +64,7 @@ export default { ...@@ -64,6 +64,7 @@ export default {
'stages', 'stages',
'summary', 'summary',
'labels', 'labels',
'topRankedLabels',
'currentStageEvents', 'currentStageEvents',
'customStageFormEvents', 'customStageFormEvents',
'errorCode', 'errorCode',
...@@ -112,7 +113,7 @@ export default { ...@@ -112,7 +113,7 @@ export default {
startDate, startDate,
endDate, endDate,
selectedProjectIds, selectedProjectIds,
tasksByType: { subject, labelIds: selectedLabelIds }, tasksByType: { subject, selectedLabelIds },
} = this; } = this;
return { return {
selectedGroup, selectedGroup,
......
<script> <script>
import $ from 'jquery'; import {
import _ from 'underscore'; GlDropdownDivider,
import { GlButton, GlDropdownDivider, GlSegmentedControl } from '@gitlab/ui'; GlSegmentedControl,
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlIcon,
} from '@gitlab/ui';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue'; import createFlash from '~/flash';
import { removeFlash } from '../utils';
import { import {
TASKS_BY_TYPE_FILTERS, TASKS_BY_TYPE_FILTERS,
TASKS_BY_TYPE_SUBJECT_ISSUE, TASKS_BY_TYPE_SUBJECT_ISSUE,
TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS, TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS,
TASKS_BY_TYPE_MAX_LABELS,
} from '../constants'; } from '../constants';
export default { export default {
name: 'TasksByTypeFilters', name: 'TasksByTypeFilters',
components: { components: {
GlButton,
GlDropdownDivider,
GlSegmentedControl, GlSegmentedControl,
Icon, GlDropdownDivider,
GlIcon,
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
}, },
props: { props: {
maxLabels: {
type: Number,
required: false,
default: TASKS_BY_TYPE_MAX_LABELS,
},
labels: { labels: {
type: Array, type: Array,
required: true, required: true,
...@@ -37,6 +51,7 @@ export default { ...@@ -37,6 +51,7 @@ export default {
const { subjectFilter: selectedSubjectFilter } = this; const { subjectFilter: selectedSubjectFilter } = this;
return { return {
selectedSubjectFilter, selectedSubjectFilter,
labelsSearchTerm: '',
}; };
}, },
computed: { computed: {
...@@ -60,42 +75,48 @@ export default { ...@@ -60,42 +75,48 @@ export default {
}, },
); );
}, },
availableLabels() {
return this.labels.filter(({ name }) =>
name.toLowerCase().includes(this.labelsSearchTerm.toLowerCase()),
);
}, },
mounted() { selectedLabelLimitText() {
$(this.$refs.labelsDropdown).glDropdown({ const { selectedLabelIds, maxLabels } = this;
selectable: true, return sprintf(s__('CycleAnalytics|%{selectedLabelsCount} selected (%{maxLabels} max)'), {
multiSelect: true, selectedLabelsCount: selectedLabelIds.length,
filterable: true, maxLabels,
search: {
fields: ['title'],
},
clicked: this.onClick.bind(this),
data: this.formatData.bind(this),
renderRow: group => this.rowTemplate(group),
text: label => label.title,
}); });
}, },
maxLabelsSelected() {
return this.selectedLabelIds.length >= this.maxLabels;
},
hasMatchingLabels() {
return this.availableLabels.length;
},
},
methods: { methods: {
onClick({ e, selectedObj }) { canUpdateLabelFilters(value) {
e.preventDefault(); // we can always remove a filter
const { id: value } = selectedObj; return this.selectedLabelIds.includes(value) || !this.maxLabelsSelected;
this.$emit('updateFilter', { filter: TASKS_BY_TYPE_FILTERS.LABEL, value });
}, },
formatData(term, callback) { isLabelSelected(id) {
callback(this.labels); return this.selectedLabelIds.includes(id);
}, },
rowTemplate(label) { isLabelDisabled(id) {
return ` return this.maxLabelsSelected && !this.isLabelSelected(id);
<li> },
<a href='#' class='dropdown-menu-link is-active'> handleLabelSelected(value) {
<span style="background-color: ${ removeFlash('notice');
label.color if (this.canUpdateLabelFilters(value)) {
};" class="d-inline-block dropdown-label-box"> this.$emit('updateFilter', { filter: TASKS_BY_TYPE_FILTERS.LABEL, value });
</span> } else {
${_.escape(label.title)} const { maxLabels } = this;
</a> const message = sprintf(
</li> s__('CycleAnalytics|Only %{maxLabels} labels can be selected at this time'),
`; { maxLabels },
);
createFlash(message, 'notice');
}
}, },
}, },
TASKS_BY_TYPE_FILTERS, TASKS_BY_TYPE_FILTERS,
...@@ -111,19 +132,16 @@ export default { ...@@ -111,19 +132,16 @@ export default {
<p>{{ selectedFiltersText }}</p> <p>{{ selectedFiltersText }}</p>
</div> </div>
<div class="flex-column"> <div class="flex-column">
<div ref="labelsDropdown" class="dropdown dropdown-labels"> <gl-dropdown
<gl-button
class="shadow-none bg-white btn-svg"
type="button"
data-toggle="dropdown"
aria-expanded="false" aria-expanded="false"
:aria-label="__('CycleAnalytics|Display chart filters')" :aria-label="__('CycleAnalytics|Display chart filters')"
right
> >
<icon :size="16" name="settings" /> <template #button-content>
<icon :size="16" name="chevron-down" /> <gl-icon class="vertical-align-top" name="settings" />
</gl-button> <gl-icon name="chevron-down" />
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-right"> </template>
<div class="js-tasks-by-type-chart-filters-subject mb-3 mx-3"> <div class="mb-3 px-3">
<p class="font-weight-bold text-left mb-2">{{ s__('CycleAnalytics|Show') }}</p> <p class="font-weight-bold text-left mb-2">{{ s__('CycleAnalytics|Show') }}</p>
<gl-segmented-control <gl-segmented-control
v-model="selectedSubjectFilter" v-model="selectedSubjectFilter"
...@@ -135,18 +153,38 @@ export default { ...@@ -135,18 +153,38 @@ export default {
/> />
</div> </div>
<gl-dropdown-divider /> <gl-dropdown-divider />
<div class="js-tasks-by-type-chart-filters-labels mb-3 mx-3"> <div class="mb-3 px-3">
<p class="font-weight-bold text-left my-2"> <p class="font-weight-bold text-left my-2">
{{ s__('CycleAnalytics|Select labels') }} {{ s__('CycleAnalytics|Select labels') }}
<br /><small>{{ selectedLabelLimitText }}</small>
</p> </p>
<div class="dropdown-input px-0"> <gl-search-box-by-type v-model.trim="labelsSearchTerm" class="mb-2" />
<input class="dropdown-input-field" type="search" /> <gl-dropdown-item
<icon name="search" class="dropdown-input-search" data-hidden="true" /> v-for="label in availableLabels"
</div> :key="label.id"
<div class="dropdown-content px-0"></div> :disabled="isLabelDisabled(label.id)"
</div> :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"
/>
<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> </div>
</gl-dropdown>
</div> </div>
</div> </div>
</template> </template>
...@@ -34,6 +34,7 @@ export const DEFAULT_STAGE_NAMES = [...Object.keys(EMPTY_STAGE_TEXT), 'total']; ...@@ -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_ISSUE = 'Issue';
export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest'; 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 = { export const TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS = {
[TASKS_BY_TYPE_SUBJECT_ISSUE]: __('Issues'), [TASKS_BY_TYPE_SUBJECT_ISSUE]: __('Issues'),
...@@ -42,7 +43,7 @@ export const TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS = { ...@@ -42,7 +43,7 @@ export const TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS = {
export const TASKS_BY_TYPE_FILTERS = { export const TASKS_BY_TYPE_FILTERS = {
SUBJECT: 'SUBJECT', SUBJECT: 'SUBJECT',
LABELS: 'LABELS', LABEL: 'LABEL',
}; };
export const STAGE_ACTIONS = { export const STAGE_ACTIONS = {
......
...@@ -3,19 +3,13 @@ import Api from 'ee/api'; ...@@ -3,19 +3,13 @@ import Api from 'ee/api';
import { getDayDifference, getDateInPast } from '~/lib/utils/datetime_utility'; import { getDayDifference, getDateInPast } from '~/lib/utils/datetime_utility';
import { historyPushState } from '~/lib/utils/common_utils'; import { historyPushState } from '~/lib/utils/common_utils';
import { setUrlParams } from '~/lib/utils/url_utility'; import { setUrlParams } from '~/lib/utils/url_utility';
import createFlash, { hideFlash } from '~/flash'; import createFlash from '~/flash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { dateFormats } from '../../shared/constants'; import { dateFormats } from '../../shared/constants';
import { toYmd } from '../../shared/utils'; import { toYmd } from '../../shared/utils';
import { removeFlash } from '../utils';
const removeError = () => {
const flashEl = document.querySelector('.flash-alert');
if (flashEl) {
hideFlash(flashEl);
}
};
const handleErrorOrRethrow = ({ action, error }) => { const handleErrorOrRethrow = ({ action, error }) => {
if (error?.response?.status === httpStatus.FORBIDDEN) { if (error?.response?.status === httpStatus.FORBIDDEN) {
...@@ -167,11 +161,12 @@ export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => { ...@@ -167,11 +161,12 @@ export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
}; };
export const fetchCycleAnalyticsData = ({ dispatch }) => { export const fetchCycleAnalyticsData = ({ dispatch }) => {
removeError(); removeFlash();
dispatch('requestCycleAnalyticsData'); dispatch('requestCycleAnalyticsData');
return Promise.resolve() return Promise.resolve()
.then(() => dispatch('fetchGroupLabels')) .then(() => dispatch('fetchGroupLabels'))
.then(() => dispatch('fetchTopRankedGroupLabels'))
.then(() => dispatch('fetchGroupStagesAndEvents')) .then(() => dispatch('fetchGroupStagesAndEvents'))
.then(() => dispatch('fetchStageMedianValues')) .then(() => dispatch('fetchStageMedianValues'))
.then(() => dispatch('fetchSummaryData')) .then(() => dispatch('fetchSummaryData'))
...@@ -181,12 +176,12 @@ export const fetchCycleAnalyticsData = ({ dispatch }) => { ...@@ -181,12 +176,12 @@ export const fetchCycleAnalyticsData = ({ dispatch }) => {
export const hideCustomStageForm = ({ commit }) => { export const hideCustomStageForm = ({ commit }) => {
commit(types.HIDE_CUSTOM_STAGE_FORM); commit(types.HIDE_CUSTOM_STAGE_FORM);
removeError(); removeFlash();
}; };
export const showCustomStageForm = ({ commit }) => { export const showCustomStageForm = ({ commit }) => {
commit(types.SHOW_CUSTOM_STAGE_FORM); commit(types.SHOW_CUSTOM_STAGE_FORM);
removeError(); removeFlash();
}; };
export const showEditCustomStageForm = ({ commit, dispatch }, selectedStage = {}) => { export const showEditCustomStageForm = ({ commit, dispatch }, selectedStage = {}) => {
...@@ -208,7 +203,7 @@ export const showEditCustomStageForm = ({ commit, dispatch }, selectedStage = {} ...@@ -208,7 +203,7 @@ export const showEditCustomStageForm = ({ commit, dispatch }, selectedStage = {}
endEventLabelId, endEventLabelId,
}); });
dispatch('setSelectedStage', selectedStage); dispatch('setSelectedStage', selectedStage);
removeError(); removeFlash();
}; };
export const requestSummaryData = ({ commit }) => commit(types.REQUEST_SUMMARY_DATA); export const requestSummaryData = ({ commit }) => commit(types.REQUEST_SUMMARY_DATA);
...@@ -264,6 +259,43 @@ export const fetchGroupLabels = ({ dispatch, state }) => { ...@@ -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) => { export const receiveGroupStagesAndEventsError = ({ commit }, error) => {
commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR, error); commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR, error);
createFlash(__('There was an error fetching value stream analytics stages.')); createFlash(__('There was an error fetching value stream analytics stages.'));
...@@ -306,7 +338,7 @@ export const fetchGroupStagesAndEvents = ({ state, dispatch, getters }) => { ...@@ -306,7 +338,7 @@ export const fetchGroupStagesAndEvents = ({ state, dispatch, getters }) => {
export const clearCustomStageFormErrors = ({ commit }) => { export const clearCustomStageFormErrors = ({ commit }) => {
commit(types.CLEAR_CUSTOM_STAGE_FORM_ERRORS); commit(types.CLEAR_CUSTOM_STAGE_FORM_ERRORS);
removeError(); removeFlash();
}; };
export const requestCreateCustomStage = ({ commit }) => commit(types.REQUEST_CREATE_CUSTOM_STAGE); export const requestCreateCustomStage = ({ commit }) => commit(types.REQUEST_CREATE_CUSTOM_STAGE);
...@@ -372,18 +404,18 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => { ...@@ -372,18 +404,18 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
} = getters; } = getters;
const { const {
tasksByType: { labelIds, subject }, tasksByType: { subject, selectedLabelIds },
} = state; } = state;
// dont request if we have no labels selected...for now // dont request if we have no labels selected...for now
if (labelIds.length) { if (selectedLabelIds.length) {
const params = { const params = {
group_id: currentGroupPath, group_id: currentGroupPath,
created_after, created_after,
created_before, created_before,
project_ids, project_ids,
subject, subject,
label_ids: labelIds, label_ids: selectedLabelIds,
}; };
dispatch('requestTasksByTypeData'); dispatch('requestTasksByTypeData');
......
...@@ -28,6 +28,10 @@ export const REQUEST_GROUP_LABELS = 'REQUEST_GROUP_LABELS'; ...@@ -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_SUCCESS = 'RECEIVE_GROUP_LABELS_SUCCESS';
export const RECEIVE_GROUP_LABELS_ERROR = 'RECEIVE_GROUP_LABELS_ERROR'; 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 REQUEST_SUMMARY_DATA = 'REQUEST_SUMMARY_DATA';
export const RECEIVE_SUMMARY_DATA_SUCCESS = 'RECEIVE_SUMMARY_DATA_SUCCESS'; export const RECEIVE_SUMMARY_DATA_SUCCESS = 'RECEIVE_SUMMARY_DATA_SUCCESS';
export const RECEIVE_SUMMARY_DATA_ERROR = 'RECEIVE_SUMMARY_DATA_ERROR'; export const RECEIVE_SUMMARY_DATA_ERROR = 'RECEIVE_SUMMARY_DATA_ERROR';
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { transformRawStages, transformRawTasksByTypeData } from '../utils'; import { transformRawStages, transformRawTasksByTypeData, toggleSelectedLabel } from '../utils';
import { TASKS_BY_TYPE_FILTERS } from '../constants'; import { TASKS_BY_TYPE_FILTERS } from '../constants';
export default { export default {
...@@ -58,9 +58,34 @@ export default { ...@@ -58,9 +58,34 @@ export default {
}, },
[types.REQUEST_GROUP_LABELS](state) { [types.REQUEST_GROUP_LABELS](state) {
state.labels = []; 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 = {
...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) { [types.REQUEST_STAGE_MEDIANS](state) {
...@@ -78,22 +103,6 @@ export default { ...@@ -78,22 +103,6 @@ export default {
[types.RECEIVE_STAGE_MEDIANS_ERROR](state) { [types.RECEIVE_STAGE_MEDIANS_ERROR](state) {
state.medians = {}; 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) { [types.SHOW_CUSTOM_STAGE_FORM](state) {
state.isCreatingCustomStage = true; state.isCreatingCustomStage = true;
state.isEditingCustomStage = false; state.isEditingCustomStage = false;
...@@ -217,15 +226,13 @@ export default { ...@@ -217,15 +226,13 @@ export default {
}, },
[types.SET_TASKS_BY_TYPE_FILTERS](state, { filter, value }) { [types.SET_TASKS_BY_TYPE_FILTERS](state, { filter, value }) {
const { const {
tasksByType: { labelIds, ...tasksByTypeRest }, tasksByType: { selectedLabelIds, ...tasksByTypeRest },
} = state; } = state;
let updatedFilter = {}; let updatedFilter = {};
switch (filter) { switch (filter) {
case TASKS_BY_TYPE_FILTERS.LABEL: case TASKS_BY_TYPE_FILTERS.LABEL:
updatedFilter = { updatedFilter = {
labelIds: labelIds.includes(value) selectedLabelIds: toggleSelectedLabel({ selectedLabelIds, value }),
? labelIds.filter(v => v !== value)
: [...labelIds, value],
}; };
break; break;
case TASKS_BY_TYPE_FILTERS.SUBJECT: case TASKS_BY_TYPE_FILTERS.SUBJECT:
...@@ -234,7 +241,7 @@ export default { ...@@ -234,7 +241,7 @@ export default {
default: default:
break; break;
} }
state.tasksByType = { ...tasksByTypeRest, labelIds, ...updatedFilter }; state.tasksByType = { ...tasksByTypeRest, selectedLabelIds, ...updatedFilter };
}, },
[types.INITIALIZE_CYCLE_ANALYTICS]( [types.INITIALIZE_CYCLE_ANALYTICS](
state, state,
......
...@@ -28,6 +28,7 @@ export default () => ({ ...@@ -28,6 +28,7 @@ export default () => ({
stages: [], stages: [],
summary: [], summary: [],
labels: [], labels: [],
topRankedLabels: [],
medians: {}, medians: {},
customStageFormEvents: [], customStageFormEvents: [],
...@@ -36,7 +37,7 @@ export default () => ({ ...@@ -36,7 +37,7 @@ export default () => ({
tasksByType: { tasksByType: {
subject: TASKS_BY_TYPE_SUBJECT_ISSUE, subject: TASKS_BY_TYPE_SUBJECT_ISSUE,
labelIds: [], selectedLabelIds: [],
data: [], data: [],
}, },
......
...@@ -2,6 +2,7 @@ import { isNumber } from 'underscore'; ...@@ -2,6 +2,7 @@ import { isNumber } from 'underscore';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { hideFlash } from '~/flash';
import { import {
newDate, newDate,
dayAfter, dayAfter,
...@@ -17,6 +18,20 @@ import { toYmd } from '../shared/utils'; ...@@ -17,6 +18,20 @@ import { toYmd } from '../shared/utils';
const EVENT_TYPE_LABEL = 'label'; 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 isStartEvent = ev => Boolean(ev) && Boolean(ev.canBeStartEvent) && ev.canBeStartEvent;
export const eventToOption = (obj = null) => { export const eventToOption = (obj = null) => {
......
...@@ -15,6 +15,7 @@ export default { ...@@ -15,6 +15,7 @@ export default {
projectPackagesPath: '/api/:version/projects/:id/packages', projectPackagesPath: '/api/:version/projects/:id/packages',
projectPackagePath: '/api/:version/projects/:id/packages/:package_id', projectPackagePath: '/api/:version/projects/:id/packages/:package_id',
cycleAnalyticsTasksByTypePath: '/-/analytics/type_of_work/tasks_by_type', cycleAnalyticsTasksByTypePath: '/-/analytics/type_of_work/tasks_by_type',
cycleAnalyticsTopLabelsPath: '/-/analytics/type_of_work/tasks_by_type/top_labels',
cycleAnalyticsSummaryDataPath: '/-/analytics/value_stream_analytics/summary', cycleAnalyticsSummaryDataPath: '/-/analytics/value_stream_analytics/summary',
cycleAnalyticsGroupStagesAndEventsPath: '/-/analytics/value_stream_analytics/stages', cycleAnalyticsGroupStagesAndEventsPath: '/-/analytics/value_stream_analytics/stages',
cycleAnalyticsStageEventsPath: '/-/analytics/value_stream_analytics/stages/:stage_id/records', cycleAnalyticsStageEventsPath: '/-/analytics/value_stream_analytics/stages/:stage_id/records',
...@@ -121,6 +122,11 @@ export default { ...@@ -121,6 +122,11 @@ export default {
return axios.get(url, { params }); return axios.get(url, { params });
}, },
cycleAnalyticsTopLabels(params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsTopLabelsPath);
return axios.get(url, { params });
},
cycleAnalyticsSummaryData(params = {}) { cycleAnalyticsSummaryData(params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsSummaryDataPath); const url = Api.buildUrl(this.cycleAnalyticsSummaryDataPath);
return axios.get(url, { params }); return axios.get(url, { params });
......
...@@ -11,62 +11,13 @@ exports[`TasksByTypeChart no data available should render the no data available ...@@ -11,62 +11,13 @@ exports[`TasksByTypeChart no data available should render the no data available
</div>" </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`] = ` exports[`TasksByTypeChart with data available should render the loading chart 1`] = `
"<div class=\\"row\\"> "<div class=\\"row\\">
<div class=\\"col-12\\"> <div class=\\"col-12\\">
<h3>Type of work</h3> <h3>Type of work</h3>
<div> <div>
<p>Showing data for group 'Gitlab Org' from Dec 11, 2019 to Jan 10, 2020</p> <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> <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> </div>
......
...@@ -419,13 +419,13 @@ describe('Cycle Analytics component', () => { ...@@ -419,13 +419,13 @@ describe('Cycle Analytics component', () => {
}); });
describe('with failed requests while loading', () => { describe('with failed requests while loading', () => {
function mockRequestCycleAnalyticsData({ const mockRequestCycleAnalyticsData = ({
overrides = {}, overrides = {},
mockFetchStageData = true, mockFetchStageData = true,
mockFetchStageMedian = true, mockFetchStageMedian = true,
mockFetchDurationData = true, mockFetchDurationData = true,
mockFetchTasksByTypeData = true, mockFetchTasksByTypeData = true,
}) { }) => {
const defaultStatus = 200; const defaultStatus = 200;
const defaultRequests = { const defaultRequests = {
fetchSummaryData: { fetchSummaryData: {
...@@ -446,6 +446,10 @@ describe('Cycle Analytics component', () => { ...@@ -446,6 +446,10 @@ describe('Cycle Analytics component', () => {
...overrides, ...overrides,
}; };
mock
.onGet(mockData.endpoints.tasksByTypeTopLabelsData)
.reply(defaultStatus, mockData.groupLabels);
if (mockFetchTasksByTypeData) { if (mockFetchTasksByTypeData) {
mock mock
.onGet(mockData.endpoints.tasksByTypeData) .onGet(mockData.endpoints.tasksByTypeData)
...@@ -469,7 +473,7 @@ describe('Cycle Analytics component', () => { ...@@ -469,7 +473,7 @@ describe('Cycle Analytics component', () => {
Object.values(defaultRequests).forEach(({ endpoint, status, response }) => { Object.values(defaultRequests).forEach(({ endpoint, status, response }) => {
mock.onGet(endpoint).replyOnce(status, response); mock.onGet(endpoint).replyOnce(status, response);
}); });
} };
beforeEach(() => { beforeEach(() => {
setFixtures('<div class="flash-container"></div>'); setFixtures('<div class="flash-container"></div>');
......
import { mount, 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 TasksByTypeChart from 'ee/analytics/cycle_analytics/components/tasks_by_type_chart.vue';
import { import { TASKS_BY_TYPE_SUBJECT_ISSUE } from 'ee/analytics/cycle_analytics/constants';
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'; import { groupLabels } from '../mock_data';
const seriesNames = ['Cool label', 'Normal label']; const seriesNames = ['Cool label', 'Normal label'];
...@@ -63,91 +56,6 @@ describe('TasksByTypeChart', () => { ...@@ -63,91 +56,6 @@ describe('TasksByTypeChart', () => {
it('should render the loading chart', () => { it('should render the loading chart', () => {
expect(wrapper.html()).toMatchSnapshot(); 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', () => { 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 = {}) { ...@@ -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 { export default {
renderTotalTime, renderTotalTime,
shouldFlashAMessage,
}; };
...@@ -25,6 +25,7 @@ export const endpoints = { ...@@ -25,6 +25,7 @@ export const endpoints = {
stageMedian: /analytics\/value_stream_analytics\/stages\/\d+\/median/, stageMedian: /analytics\/value_stream_analytics\/stages\/\d+\/median/,
baseStagesEndpoint: /analytics\/value_stream_analytics\/stages$/, baseStagesEndpoint: /analytics\/value_stream_analytics\/stages$/,
tasksByTypeData: /analytics\/type_of_work\/tasks_by_type/, 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( export const groupLabels = getJSONFixture(fixtureEndpoints.groupLabels).map(
......
...@@ -6,7 +6,10 @@ import testAction from 'helpers/vuex_action_helper'; ...@@ -6,7 +6,10 @@ import testAction from 'helpers/vuex_action_helper';
import * as getters from 'ee/analytics/cycle_analytics/store/getters'; import * as getters from 'ee/analytics/cycle_analytics/store/getters';
import * as actions from 'ee/analytics/cycle_analytics/store/actions'; import * as actions from 'ee/analytics/cycle_analytics/store/actions';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types'; 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 createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import { toYmd } from 'ee/analytics/shared/utils'; import { toYmd } from 'ee/analytics/shared/utils';
...@@ -24,6 +27,7 @@ import { ...@@ -24,6 +27,7 @@ import {
transformedDurationMedianData, transformedDurationMedianData,
endpoints, endpoints,
} from '../mock_data'; } from '../mock_data';
import { shouldFlashAMessage } from '../helpers';
const stageData = { events: [] }; const stageData = { events: [] };
const error = new Error(`Request failed with status code ${httpStatusCodes.NOT_FOUND}`); const error = new Error(`Request failed with status code ${httpStatusCodes.NOT_FOUND}`);
...@@ -38,10 +42,6 @@ describe('Cycle analytics actions', () => { ...@@ -38,10 +42,6 @@ describe('Cycle analytics actions', () => {
let state; let state;
let mock; let mock;
function shouldFlashAMessage(msg = flashErrorMessage) {
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg);
}
function shouldSetUrlParams({ action, payload, result }) { function shouldSetUrlParams({ action, payload, result }) {
const store = { const store = {
state, state,
...@@ -332,6 +332,65 @@ describe('Cycle analytics actions', () => { ...@@ -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', () => { describe('fetchCycleAnalyticsData', () => {
function mockFetchCycleAnalyticsAction(overrides = {}) { function mockFetchCycleAnalyticsAction(overrides = {}) {
const mocks = { const mocks = {
...@@ -373,6 +432,7 @@ describe('Cycle analytics actions', () => { ...@@ -373,6 +432,7 @@ describe('Cycle analytics actions', () => {
[ [
{ type: 'requestCycleAnalyticsData' }, { type: 'requestCycleAnalyticsData' },
{ type: 'fetchGroupLabels' }, { type: 'fetchGroupLabels' },
{ type: 'fetchTopRankedGroupLabels' },
{ type: 'fetchGroupStagesAndEvents' }, { type: 'fetchGroupStagesAndEvents' },
{ type: 'fetchStageMedianValues' }, { type: 'fetchStageMedianValues' },
{ type: 'fetchSummaryData' }, { type: 'fetchSummaryData' },
...@@ -558,7 +618,7 @@ describe('Cycle analytics actions', () => { ...@@ -558,7 +618,7 @@ describe('Cycle analytics actions', () => {
{}, {},
); );
shouldFlashAMessage(); shouldFlashAMessage(flashErrorMessage);
}); });
}); });
}); });
...@@ -611,7 +671,7 @@ describe('Cycle analytics actions', () => { ...@@ -611,7 +671,7 @@ describe('Cycle analytics actions', () => {
{ response }, { response },
); );
shouldFlashAMessage(); shouldFlashAMessage(flashErrorMessage);
}); });
}); });
...@@ -671,7 +731,7 @@ describe('Cycle analytics actions', () => { ...@@ -671,7 +731,7 @@ describe('Cycle analytics actions', () => {
); );
}); });
shouldFlashAMessage(); shouldFlashAMessage(flashErrorMessage);
}); });
}); });
......
...@@ -30,6 +30,25 @@ describe('Cycle analytics getters', () => { ...@@ -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('currentGroupPath', () => {
describe('with selectedGroup set', () => { describe('with selectedGroup set', () => {
it('returns the `fullPath` value of the group', () => { it('returns the `fullPath` value of the group', () => {
......
...@@ -53,6 +53,8 @@ describe('Cycle analytics mutations', () => { ...@@ -53,6 +53,8 @@ describe('Cycle analytics mutations', () => {
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true} ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true}
${types.REQUEST_GROUP_LABELS} | ${'labels'} | ${[]} ${types.REQUEST_GROUP_LABELS} | ${'labels'} | ${[]}
${types.RECEIVE_GROUP_LABELS_ERROR} | ${'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.RECEIVE_SUMMARY_DATA_ERROR} | ${'summary'} | ${[]}
${types.REQUEST_SUMMARY_DATA} | ${'summary'} | ${[]} ${types.REQUEST_SUMMARY_DATA} | ${'summary'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'stages'} | ${[]} ${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'stages'} | ${[]}
...@@ -146,20 +148,15 @@ describe('Cycle analytics mutations', () => { ...@@ -146,20 +148,15 @@ describe('Cycle analytics mutations', () => {
}); });
}); });
describe.each` describe(`${types.RECEIVE_GROUP_LABELS_SUCCESS}`, () => {
mutation | value it('will set the labels state item with the camelCased group labels', () => {
${types.REQUEST_GROUP_LABELS} | ${[]} mutations[types.RECEIVE_GROUP_LABELS_SUCCESS](state, groupLabels);
${types.RECEIVE_GROUP_LABELS_ERROR} | ${[]}
`('$mutation', ({ mutation, value }) => {
it(`will set tasksByType.labelIds to ${value}`, () => {
state = { tasksByType: {} };
mutations[mutation](state);
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', () => { it('will set the labels state item with the camelCased group labels', () => {
mutations[types.RECEIVE_GROUP_LABELS_SUCCESS](state, groupLabels); mutations[types.RECEIVE_GROUP_LABELS_SUCCESS](state, groupLabels);
...@@ -306,17 +303,17 @@ describe('Cycle analytics mutations', () => { ...@@ -306,17 +303,17 @@ describe('Cycle analytics mutations', () => {
expect(state.tasksByType).toEqual({ subject: 'cool-subject' }); 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 = { state = {
tasksByType: { labelIds: [10, 20, 30] }, tasksByType: { selectedLabelIds: [10, 20, 30] },
}; };
const labelFilter = { filter: TASKS_BY_TYPE_FILTERS.LABEL, value: 20 }; const labelFilter = { filter: TASKS_BY_TYPE_FILTERS.LABEL, value: 20 };
mutations[types.SET_TASKS_BY_TYPE_FILTERS](state, labelFilter); 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); 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 { ...@@ -15,6 +15,7 @@ import {
getTasksByTypeData, getTasksByTypeData,
flattenTaskByTypeSeries, flattenTaskByTypeSeries,
orderByDate, orderByDate,
toggleSelectedLabel,
} from 'ee/analytics/cycle_analytics/utils'; } from 'ee/analytics/cycle_analytics/utils';
import { toYmd } from 'ee/analytics/shared/utils'; import { toYmd } from 'ee/analytics/shared/utils';
import { import {
...@@ -302,4 +303,19 @@ describe('Cycle analytics utils', () => { ...@@ -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'; ...@@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import Api from 'ee/api'; import Api from 'ee/api';
import * as cycleAnalyticsConstants from 'ee/analytics/cycle_analytics/constants'; import * as cycleAnalyticsConstants from 'ee/analytics/cycle_analytics/constants';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import * as analyticsMockData from 'ee_jest/analytics/cycle_analytics/mock_data';
describe('Api', () => { describe('Api', () => {
const dummyApiVersion = 'v3000'; const dummyApiVersion = 'v3000';
...@@ -353,13 +354,38 @@ describe('Api', () => { ...@@ -353,13 +354,38 @@ describe('Api', () => {
subject: cycleAnalyticsConstants.TASKS_BY_TYPE_SUBJECT_ISSUE, subject: cycleAnalyticsConstants.TASKS_BY_TYPE_SUBJECT_ISSUE,
label_ids: labelIds, label_ids: labelIds,
}; };
const expectedUrl = `${dummyUrlRoot}/-/analytics/type_of_work/tasks_by_type`; const expectedUrl = analyticsMockData.endpoints.tasksByTypeData;
mock.onGet(expectedUrl).reply(200, tasksByTypeResponse); mock.onGet(expectedUrl).reply(200, tasksByTypeResponse);
Api.cycleAnalyticsTasksByType({ params }) Api.cycleAnalyticsTasksByType(params)
.then(({ data, config: { params: reqParams } }) => { .then(({ data, config: { params: reqParams } }) => {
expect(data).toEqual(tasksByTypeResponse); 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) .then(done)
.catch(done.fail); .catch(done.fail);
......
...@@ -6110,6 +6110,9 @@ msgstr "" ...@@ -6110,6 +6110,9 @@ msgstr ""
msgid "CycleAnalyticsStage|should be under a group" msgid "CycleAnalyticsStage|should be under a group"
msgstr "" msgstr ""
msgid "CycleAnalytics|%{selectedLabelsCount} selected (%{maxLabels} max)"
msgstr ""
msgid "CycleAnalytics|%{stageCount} stages selected" msgid "CycleAnalytics|%{stageCount} stages selected"
msgstr "" msgstr ""
...@@ -6131,6 +6134,9 @@ msgstr "" ...@@ -6131,6 +6134,9 @@ msgstr ""
msgid "CycleAnalytics|Number of tasks" msgid "CycleAnalytics|Number of tasks"
msgstr "" msgstr ""
msgid "CycleAnalytics|Only %{maxLabels} labels can be selected at this time"
msgstr ""
msgid "CycleAnalytics|Project selected" msgid "CycleAnalytics|Project selected"
msgid_plural "CycleAnalytics|%d projects selected" msgid_plural "CycleAnalytics|%d projects selected"
msgstr[0] "" msgstr[0] ""
...@@ -13170,6 +13176,9 @@ msgstr "" ...@@ -13170,6 +13176,9 @@ msgstr ""
msgid "No licenses found." msgid "No licenses found."
msgstr "" msgstr ""
msgid "No matching labels"
msgstr ""
msgid "No matching results" msgid "No matching results"
msgstr "" msgstr ""
...@@ -19906,6 +19915,9 @@ msgstr "" ...@@ -19906,6 +19915,9 @@ msgstr ""
msgid "There was an error fetching the environments information." msgid "There was an error fetching the environments information."
msgstr "" msgstr ""
msgid "There was an error fetching the top labels for the selected group"
msgstr ""
msgid "There was an error fetching the variables." msgid "There was an error fetching the variables."
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