Commit 0b5c8759 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '32019-follow-up-add-vuex-to-customizable-cycle-analytics' into 'master'

Resolve "Follow up - Add Vuex to customizable cycle analytics"

Closes #32019

See merge request gitlab-org/gitlab!17208
parents 974fa05f fe561d59
export default {
data() {
return {
isCustomStageForm: false,
};
},
methods: {
showAddStageForm: () => {},
hideAddStageForm: () => {},
},
};
......@@ -3,7 +3,6 @@ import Vue from 'vue';
import Cookies from 'js-cookie';
import { GlEmptyState } from '@gitlab/ui';
import filterMixins from 'ee_else_ce/analytics/cycle_analytics/mixins/filter_mixins';
import addStageMixin from 'ee_else_ce/analytics/cycle_analytics/mixins/add_stage_mixin';
import Flash from '../flash';
import { __ } from '~/locale';
import Translate from '../vue_shared/translate';
......@@ -44,14 +43,8 @@ export default () => {
DateRangeDropdown: () =>
import('ee_component/analytics/shared/components/date_range_dropdown.vue'),
'stage-nav-item': stageNavItem,
CustomStageForm: () =>
import('ee_component/analytics/cycle_analytics/components/custom_stage_form.vue'),
AddStageButton: () =>
import('ee_component/analytics/cycle_analytics/components/add_stage_button.vue'),
CustomStageFormContainer: () =>
import('ee_component/analytics/cycle_analytics/components/custom_stage_form_container.vue'),
},
mixins: [filterMixins, addStageMixin],
mixins: [filterMixins],
data() {
return {
store: CycleAnalyticsStore,
......@@ -131,7 +124,6 @@ export default () => {
return;
}
this.hideAddStageForm();
this.isLoadingStage = true;
this.store.setStageEvents([], stage);
this.store.setActiveStage(stage);
......
......@@ -2,6 +2,7 @@
import { GlEmptyState } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import DateRangeDropdown from '../../shared/components/date_range_dropdown.vue';
......@@ -18,6 +19,7 @@ export default {
SummaryTable,
StageTable,
},
mixins: [glFeatureFlagsMixin()],
props: {
emptyStateSvgPath: {
type: String,
......@@ -45,35 +47,38 @@ export default {
...mapState([
'isLoading',
'isLoadingStage',
'isLoadingStageForm',
'isEmptyStage',
'isAddingCustomStage',
'selectedGroup',
'selectedProjectIds',
'selectedStageName',
'events',
'stages',
'summary',
'dataTimeframe',
'labels',
'currentStageEvents',
'customStageFormEvents',
]),
...mapGetters(['currentStage', 'defaultStage', 'hasNoAccessError', 'currentGroupPath']),
shouldRenderEmptyState() {
return !this.selectedGroup;
},
hasCustomizableCycleAnalytics() {
return gon && gon.features ? gon.features.customizableCycleAnalytics : false;
return Boolean(this.glFeatures.customizableCycleAnalytics);
},
},
methods: {
...mapActions([
'fetchCustomStageFormData',
'fetchCycleAnalyticsData',
'fetchStageData',
'setCycleAnalyticsDataEndpoint',
'setStageDataEndpoint',
'setSelectedGroup',
'fetchCycleAnalyticsData',
'setSelectedProjects',
'setSelectedTimeframe',
'fetchStageData',
'setSelectedStageName',
'showCustomStageForm',
'hideCustomStageForm',
]),
onGroupSelect(group) {
......@@ -97,7 +102,7 @@ export default {
this.fetchStageData(this.currentStage.name);
},
onShowAddStageForm() {
this.showCustomStageForm();
this.fetchCustomStageFormData(this.currentGroupPath);
},
},
};
......@@ -161,22 +166,26 @@ export default {
)
"
/>
<summary-table class="js-summary-table" :items="summary" />
<stage-table
v-if="currentStage"
class="js-stage-table"
:current-stage="currentStage"
:stages="stages"
:is-loading-stage="isLoadingStage"
:is-empty-stage="isEmptyStage"
:is-adding-custom-stage="isAddingCustomStage"
:events="events"
:no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect"
@showAddStageForm="onShowAddStageForm"
/>
<div v-else>
<summary-table class="js-summary-table" :items="summary" />
<stage-table
v-if="currentStage"
class="js-stage-table"
:current-stage="currentStage"
:stages="stages"
:is-loading="isLoadingStage || isLoadingStageForm"
:is-empty-stage="isEmptyStage"
:is-adding-custom-stage="isAddingCustomStage"
:current-stage-events="currentStageEvents"
:custom-stage-form-events="customStageFormEvents"
:labels="labels"
:no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect"
@showAddStageForm="onShowAddStageForm"
/>
</div>
</div>
</div>
</template>
......@@ -2,9 +2,7 @@
import { isEqual } from 'underscore';
import { GlButton, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui';
import { s__ } from '~/locale';
import LabelsSelector from './labels_selector.vue';
import {
isStartEvent,
isLabelEvent,
......
<script>
// NOTE: this is a temporary component while cycle-analytics is being refactored
// post refactor we will have a vuex store and functionality to fetch data
// https://gitlab.com/gitlab-org/gitlab/issues/32019
import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
import Api from '~/api';
import CustomStageForm from './custom_stage_form.vue';
export default {
name: 'CustomStageFormContainer',
components: {
CustomStageForm,
GlLoadingIcon,
},
props: {
namespace: {
type: String,
required: true,
},
},
data() {
return {
// NOTE: events will be part of the response from the new cycle analytics backend
// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/31535
events: [],
labels: [],
isLoading: false,
};
},
created() {
this.isLoading = true;
Api.groupLabels(this.namespace)
.then(labels => {
this.labels = labels.map(({ title, ...rest }) => ({ ...rest, name: title }));
})
.catch(() => {
createFlash(__('There was an error fetching the form data'));
})
.finally(() => {
this.isLoading = false;
});
},
};
</script>
<template>
<gl-loading-icon v-if="isLoading" size="md" class="my-3" />
<custom-stage-form v-else :labels="labels" :events="events" />
</template>
......@@ -19,13 +19,11 @@ export default {
required: false,
},
},
computed: {
activeClass() {
return 'active font-weight-bold border-style-solid border-color-blue-300';
},
inactiveClass() {
return 'bg-transparent border-style-dashed border-color-default';
},
data() {
return {
activeClass: 'active font-weight-bold border-color-blue-300',
inactiveClass: 'bg-transparent border-color-default',
};
},
};
</script>
......@@ -33,7 +31,7 @@ export default {
<template>
<div
:class="[isActive ? activeClass : inactiveClass]"
class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-width-1px"
class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-width-1px border-style-solid"
>
<slot></slot>
<div v-if="canEdit" class="dropdown">
......
......@@ -6,7 +6,7 @@ import StageNavItem from './stage_nav_item.vue';
import StageEventList from './stage_event_list.vue';
import StageTableHeader from './stage_table_header.vue';
import AddStageButton from './add_stage_button.vue';
import CustomStageFormContainer from './custom_stage_form_container.vue';
import CustomStageForm from './custom_stage_form.vue';
export default {
name: 'StageTable',
......@@ -18,7 +18,7 @@ export default {
StageNavItem,
StageTableHeader,
AddStageButton,
CustomStageFormContainer,
CustomStageForm,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -32,7 +32,7 @@ export default {
type: Object,
required: true,
},
isLoadingStage: {
isLoading: {
type: Boolean,
required: true,
},
......@@ -44,7 +44,15 @@ export default {
type: Boolean,
required: true,
},
events: {
currentStageEvents: {
type: Array,
required: true,
},
customStageFormEvents: {
type: Array,
required: true,
},
labels: {
type: Array,
required: true,
},
......@@ -60,19 +68,14 @@ export default {
type: Boolean,
required: true,
},
groupPath: {
type: String,
required: false,
default: null,
},
},
computed: {
stageName() {
return this.currentStage ? this.currentStage.legend : __('Related Issues');
},
shouldDisplayStage() {
const { events = [], isLoadingStage, isEmptyStage } = this;
return events.length && !isLoadingStage && !isEmptyStage;
const { currentStageEvents = [], isLoading, isEmptyStage } = this;
return currentStageEvents.length && !isLoading && !isEmptyStage;
},
stageHeaders() {
return [
......@@ -147,20 +150,24 @@ export default {
</ul>
</nav>
<div class="section stage-events">
<gl-loading-icon v-if="isLoadingStage" class="mt-4" size="md" />
<gl-loading-icon v-if="isLoading" class="mt-4" size="md" />
<gl-empty-state
v-else-if="currentStage && !currentStage.isUserAllowed"
:title="__('You need permission.')"
:description="__('Want to see the data? Please ask an administrator for access.')"
:svg-path="noAccessSvgPath"
/>
<custom-stage-form-container
<custom-stage-form
v-else-if="isAddingCustomStage"
:events="events"
:namespace="groupPath"
:events="customStageFormEvents"
:labels="labels"
/>
<template v-else>
<stage-event-list v-if="shouldDisplayStage" :stage="currentStage" :events="events" />
<stage-event-list
v-if="shouldDisplayStage"
:stage="currentStage"
:events="currentStageEvents"
/>
<gl-empty-state
v-if="isEmptyStage"
:title="__('We don\'t have enough data to show this stage.')"
......
export default {
data() {
return {
isCustomStageForm: false,
};
},
methods: {
showAddStageForm() {
if (this.store) {
this.store.deactivateAllStages();
}
this.isCustomStageForm = true;
},
hideAddStageForm() {
this.isCustomStageForm = false;
},
},
};
......@@ -2,9 +2,11 @@ import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
import Api from '~/api';
export const setCycleAnalyticsDataEndpoint = ({ commit }, groupPath) =>
commit(types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT, groupPath);
export const setStageDataEndpoint = ({ commit }, stageSlug) =>
commit(types.SET_STAGE_DATA_ENDPOINT, stageSlug);
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
......@@ -50,6 +52,7 @@ export const receiveCycleAnalyticsDataSuccess = ({ state, commit, dispatch }, da
createFlash(__('There was an error while fetching cycle analytics data.'));
}
};
export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
const { status } = response;
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR, status);
......@@ -70,10 +73,21 @@ export const fetchCycleAnalyticsData = ({ state, dispatch }) => {
.catch(error => dispatch('receiveCycleAnalyticsDataError', error));
};
export const showCustomStageForm = ({ commit }) => {
commit(types.SHOW_CUSTOM_STAGE_FORM);
export const hideCustomStageForm = ({ commit }) => commit(types.HIDE_CUSTOM_STAGE_FORM);
export const receiveCustomStageFormDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_CUSTOM_STAGE_FORM_DATA_SUCCESS, data);
export const receiveCustomStageFormDataError = ({ commit }, error) => {
commit(types.RECEIVE_CUSTOM_STAGE_FORM_DATA_ERROR, error);
createFlash(__('There was an error fetching data for the form'));
};
export const requestCustomStageFormData = ({ commit }) =>
commit(types.REQUEST_CUSTOM_STAGE_FORM_DATA);
export const fetchCustomStageFormData = ({ dispatch }, groupPath) => {
dispatch('requestCustomStageFormData');
export const hideCustomStageForm = ({ commit }) => {
commit(types.HIDE_CUSTOM_STAGE_FORM);
return Api.groupLabels(groupPath)
.then(data => dispatch('receiveCustomStageFormDataSuccess', data))
.catch(error => dispatch('receiveCustomStageFormDataError', error));
};
......@@ -9,4 +9,4 @@ export const defaultStage = state => (state.stages.length ? state.stages[0] : nu
export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN;
export const currentGroupPath = state =>
state.selectedGroup ? state.selectedGroup.full_path : null;
state.selectedGroup && state.selectedGroup.full_path ? state.selectedGroup.full_path : null;
......@@ -14,5 +14,8 @@ export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA';
export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS';
export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR';
export const SHOW_CUSTOM_STAGE_FORM = 'SHOW_CUSTOM_STAGE_FORM';
export const HIDE_CUSTOM_STAGE_FORM = 'HIDE_CUSTOM_STAGE_FORM';
export const REQUEST_CUSTOM_STAGE_FORM_DATA = 'REQUEST_CUSTOM_STAGE_FORM_DATA';
export const RECEIVE_CUSTOM_STAGE_FORM_DATA_SUCCESS = 'RECEIVE_CUSTOM_STAGE_FORM_DATA_SUCCESS';
export const RECEIVE_CUSTOM_STAGE_FORM_DATA_ERROR = 'RECEIVE_CUSTOM_STAGE_FORM_DATA_ERROR';
......@@ -58,25 +58,32 @@ export default {
[types.REQUEST_STAGE_DATA](state) {
state.isLoadingStage = true;
},
[types.RECEIVE_STAGE_DATA_SUCCESS](state, data) {
state.events = data.events.map(({ name = '', ...rest }) =>
[types.RECEIVE_STAGE_DATA_SUCCESS](state, data = {}) {
const { events = [] } = data;
state.currentStageEvents = events.map(({ name = '', ...rest }) =>
convertObjectPropsToCamelCase({ title: name, ...rest }, { deep: true }),
);
state.isEmptyStage = state.events.length === 0;
state.isEmptyStage = state.currentStageEvents.length === 0;
state.isLoadingStage = false;
},
[types.RECEIVE_STAGE_DATA_ERROR](state) {
state.isEmptyStage = true;
state.isLoadingStage = false;
},
[types.SHOW_CUSTOM_STAGE_FORM](state) {
[types.REQUEST_CUSTOM_STAGE_FORM_DATA](state) {
state.isAddingCustomStage = true;
state.isEmptyStage = false;
state.isLoadingStage = false;
state.isLoadingStageForm = true;
},
[types.HIDE_CUSTOM_STAGE_FORM](state) {
state.isAddingCustomStage = false;
state.isEmptyStage = false;
state.isLoadingStage = false;
},
[types.RECEIVE_CUSTOM_STAGE_FORM_DATA_SUCCESS](state, data = []) {
state.labels = data.map(convertObjectPropsToCamelCase);
state.isLoadingStageForm = false;
},
[types.RECEIVE_CUSTOM_STAGE_FORM_DATA_ERROR](state) {
state.isLoadingStageForm = false;
state.labels = [];
},
};
......@@ -10,6 +10,7 @@ export default () => ({
isLoading: false,
isLoadingStage: false,
isLoadingStageForm: false,
isEmptyStage: false,
errorCode: null,
......@@ -20,7 +21,11 @@ export default () => ({
selectedProjectIds: [],
selectedStageName: null,
events: [],
currentStageEvents: [],
stages: [],
summary: [],
labels: [],
customStageFormEvents: [],
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import Vue from 'vue';
import store from 'ee/analytics/cycle_analytics/store';
import Component from 'ee/analytics/cycle_analytics/components/base.vue';
import { GlEmptyState } from '@gitlab/ui';
......@@ -21,22 +22,49 @@ const emptyStateSvgPath = 'path/to/empty/state';
const localVue = createLocalVue();
localVue.use(Vuex);
function createComponent({ opts = {}, shallow = true, withStageSelected = false } = {}) {
const func = shallow ? shallowMount : mount;
const comp = func(Component, {
localVue,
store,
sync: false,
propsData: {
emptyStateSvgPath,
noDataSvgPath,
noAccessSvgPath,
},
...opts,
});
if (withStageSelected) {
comp.vm.$store.dispatch('setSelectedGroup', {
...mockData.group,
});
comp.vm.$store.dispatch('receiveCycleAnalyticsDataSuccess', {
...mockData.cycleAnalyticsData,
});
comp.vm.$store.dispatch('receiveStageDataSuccess', {
events: mockData.issueEvents,
});
}
return comp;
}
describe('Cycle Analytics component', () => {
let wrapper;
let mock;
const selectStageNavItem = index =>
wrapper
.find(StageTable)
.findAll('.stage-nav-item')
.at(index);
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = shallowMount(localVue.extend(Component), {
localVue,
store,
sync: false,
propsData: {
emptyStateSvgPath,
noDataSvgPath,
noAccessSvgPath,
},
});
wrapper = createComponent();
});
afterEach(() => {
......@@ -66,17 +94,7 @@ describe('Cycle Analytics component', () => {
describe('after a filter has been selected', () => {
describe('the user has access to the group', () => {
beforeEach(() => {
wrapper.vm.$store.dispatch('setSelectedGroup', {
...mockData.group,
});
wrapper.vm.$store.dispatch('receiveCycleAnalyticsDataSuccess', {
...mockData.cycleAnalyticsData,
});
wrapper.vm.$store.dispatch('receiveStageDataSuccess', {
events: mockData.issueEvents,
});
wrapper = createComponent({ withStageSelected: true });
});
it('hides the empty state', () => {
......@@ -92,8 +110,56 @@ describe('Cycle Analytics component', () => {
expect(wrapper.find(SummaryTable).exists()).toBe(true);
});
it('displays the stage table', () => {
expect(wrapper.find(StageTable).exists()).toBe(true);
it('does not display the add stage button', () => {
expect(wrapper.find('.js-add-stage-button').exists()).toBe(false);
});
describe('StageTable', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent({
opts: {
stubs: {
'stage-event-list': true,
'summary-table': true,
'add-stage-button': true,
'stage-table-header': true,
},
},
shallow: false,
withStageSelected: true,
});
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
it('displays the stage table', () => {
expect(wrapper.find(StageTable).exists()).toBe(true);
});
it('has the first stage selected by default', () => {
const first = selectStageNavItem(0);
const second = selectStageNavItem(1);
expect(first.classes('active')).toBe(true);
expect(second.classes('active')).toBe(false);
});
it('can navigate to different stages', done => {
selectStageNavItem(2).trigger('click');
Vue.nextTick(() => {
const first = selectStageNavItem(0);
const third = selectStageNavItem(2);
expect(third.classes('active')).toBe(true);
expect(first.classes('active')).toBe(false);
done();
});
});
});
});
......@@ -112,6 +178,49 @@ describe('Cycle Analytics component', () => {
expect(emptyState.exists()).toBe(true);
expect(emptyState.props('svgPath')).toBe(noAccessSvgPath);
});
it('will not render the summary table', () => {
expect(wrapper.find('.js-summary-table').exists()).toBe(false);
});
it('will not render the stage table', () => {
expect(wrapper.find('.js-stage-table').exists()).toBe(false);
});
it('does not display the add stage button', () => {
expect(wrapper.find('.js-add-stage-button').exists()).toBe(false);
});
});
describe('with customizableCycleAnalytics=true', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent({
opts: {
stubs: {
'summary-table': true,
'stage-event-list': true,
'stage-nav-item': true,
},
provide: {
glFeatures: {
customizableCycleAnalytics: true,
},
},
},
shallow: false,
withStageSelected: true,
});
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
it('will display the add stage button', () => {
expect(wrapper.find('.js-add-stage-button').exists()).toBe(true);
});
});
});
});
......
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import CustomStageForm from 'ee/analytics/cycle_analytics/components/custom_stage_form.vue';
import { mockLabels } from '../../../../../../spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data';
import { apiResponse } from '../mock_data';
const labels = mockLabels.map(({ title, ...rest }) => ({ ...rest, name: title }));
import { apiResponse, groupLabels } from '../mock_data';
const { events } = apiResponse;
......@@ -14,9 +11,9 @@ const stopEvents = events.filter(ev => !ev.canBeStartEvent);
const initData = {
name: 'Cool stage pre',
startEvent: 'issue_label_added',
startEventLabel: labels[0].id,
startEventLabel: groupLabels[0].id,
stopEvent: 'issue_label_removed',
stopEventLabel: labels[1].id,
stopEventLabel: groupLabels[1].id,
};
describe('CustomStageForm', () => {
......@@ -24,7 +21,7 @@ describe('CustomStageForm', () => {
return mount(CustomStageForm, {
propsData: {
events,
labels,
labels: groupLabels,
...props,
},
sync: false,
......@@ -85,16 +82,20 @@ describe('CustomStageForm', () => {
beforeEach(() => {
wrapper = createComponent({}, false);
});
afterEach(() => {
wrapper.destroy();
});
it('selects events with canBeStartEvent=true for the start events dropdown', () => {
const select = wrapper.find(sel.startEvent);
startEvents.forEach(ev => {
expect(select.html()).toHaveHtml(
`<option value="${ev.identifier}">${ev.name}</option>`,
);
});
stopEvents.forEach(ev => {
expect(select.html()).not.toHaveHtml(
`<option value="${ev.identifier}">${ev.name}</option>`,
......@@ -120,11 +121,12 @@ describe('CustomStageForm', () => {
afterEach(() => {
wrapper.destroy();
});
it('is hidden by default', () => {
expect(wrapper.find(sel.startEventLabel).exists()).toEqual(false);
});
it('will display the start event label field if a label event is selected', () => {
it('will display the start event label field if a label event is selected', done => {
wrapper.setData({
fields: {
startEvent: 'issue_label_added',
......@@ -133,11 +135,12 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
expect(wrapper.find(sel.startEventLabel).exists()).toEqual(true);
done();
});
});
it('will set the "startEventLabel" field when selected', () => {
const selectedLabelId = labels[0].id;
it('will set the "startEventLabel" field when selected', done => {
const selectedLabelId = groupLabels[0].id;
expect(wrapper.vm.fields.startEventLabel).toEqual(null);
wrapper.find(sel.startEvent).setValue('issue_label_added');
......@@ -150,11 +153,13 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
expect(wrapper.vm.fields.startEventLabel).toEqual(selectedLabelId);
done();
});
});
});
});
});
describe('Stop event', () => {
beforeEach(() => {
wrapper = createComponent(
......@@ -169,22 +174,26 @@ describe('CustomStageForm', () => {
expect(wrapper.text()).toContain('Please select a start event first');
});
it('clears notification when a start event is selected', () => {
it('clears notification when a start event is selected', done => {
selectDropdownOption(wrapper, sel.startEvent, 1);
Vue.nextTick(() =>
expect(wrapper.text()).not.toContain('Please select a start event first'),
);
Vue.nextTick(() => {
expect(wrapper.text()).not.toContain('Please select a start event first');
done();
});
});
it('is enabled when a start event is selected', () => {
it('is enabled when a start event is selected', done => {
const el = wrapper.find(sel.stopEvent);
expect(el.attributes('disabled')).toEqual('disabled');
selectDropdownOption(wrapper, sel.startEvent, 1);
Vue.nextTick(() => expect(el.attributes('disabled')).toBeUndefined());
Vue.nextTick(() => {
expect(el.attributes('disabled')).toBeUndefined();
done();
});
});
it('will update the list of stop events when a start event is changed', () => {
it('will update the list of stop events when a start event is changed', done => {
let stopOptions = wrapper.find(sel.stopEvent).findAll('option');
expect(stopOptions.length).toEqual(1);
......@@ -193,10 +202,11 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
stopOptions = wrapper.find(sel.stopEvent).findAll('option');
expect(stopOptions.length).toEqual(2);
done();
});
});
it('will only display valid stop events allowed for the selected start event', () => {
it('will only display valid stop events allowed for the selected start event', done => {
let stopOptions = wrapper.find(sel.stopEvent).findAll('option');
expect(stopOptions.at(0).html()).toEqual('<option value="">Select stop event</option>');
......@@ -224,6 +234,7 @@ describe('CustomStageForm', () => {
`<option value="${identifier}">${name}</option>`,
);
});
done();
});
});
......@@ -246,7 +257,7 @@ describe('CustomStageForm', () => {
wrapper.destroy();
});
it('will notify if the current start and stop event pair is not valid', () => {
it('will notify if the current start and stop event pair is not valid', done => {
expect(wrapper.find(sel.invalidFeedback).exists()).toEqual(false);
selectDropdownOption(wrapper, sel.startEvent, 2);
......@@ -256,19 +267,24 @@ describe('CustomStageForm', () => {
expect(wrapper.find(sel.invalidFeedback).text()).toContain(
'Start event changed, please select a valid stop event',
);
done();
});
});
it('will update the list of stop events', () => {
it('will update the list of stop events', done => {
const se = wrapper.vm.stopEventOptions;
selectDropdownOption(wrapper, sel.startEvent, 2);
Vue.nextTick(() => {
expect(se[1].value).not.toEqual(wrapper.vm.stopEventOptions[1].value);
done();
});
});
it('will disable the submit button until a valid stopEvent is selected', () => {
it('will disable the submit button until a valid stopEvent is selected', done => {
selectDropdownOption(wrapper, sel.startEvent, 2);
Vue.nextTick(() => {
expect(wrapper.find(sel.submit).attributes('disabled')).toEqual('disabled');
done();
});
});
});
......@@ -281,10 +297,12 @@ describe('CustomStageForm', () => {
afterEach(() => {
wrapper.destroy();
});
it('is hidden by default', () => {
expect(wrapper.find(sel.startEventLabel).exists()).toEqual(false);
});
it('will display the stop event label field if a label event is selected', () => {
it('will display the stop event label field if a label event is selected', done => {
expect(wrapper.find(sel.stopEventLabel).exists()).toEqual(false);
wrapper.setData({
......@@ -296,11 +314,12 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
expect(wrapper.find(sel.stopEventLabel).exists()).toEqual(true);
done();
});
});
it('will set the "stopEventLabel" field when selected', () => {
const selectedLabelId = labels[1].id;
it('will set the "stopEventLabel" field when selected', done => {
const selectedLabelId = groupLabels[1].id;
expect(wrapper.vm.fields.stopEventLabel).toEqual(null);
wrapper.setData({
......@@ -319,6 +338,7 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
expect(wrapper.vm.fields.stopEventLabel).toEqual(selectedLabelId);
done();
});
});
});
......@@ -331,7 +351,7 @@ describe('CustomStageForm', () => {
selectDropdownOption(wrapper, sel.startEvent, 1);
Vue.nextTick(() => {
return Vue.nextTick(() => {
selectDropdownOption(wrapper, sel.stopEvent, 1);
});
});
......@@ -340,7 +360,7 @@ describe('CustomStageForm', () => {
wrapper.destroy();
});
it('is enabled when all required fields are filled', () => {
it('is enabled when all required fields are filled', done => {
const btn = wrapper.find(sel.submit);
expect(btn.attributes('disabled')).toEqual('disabled');
......@@ -348,6 +368,7 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
expect(btn.attributes('disabled')).toBeUndefined();
done();
});
});
......@@ -357,7 +378,7 @@ describe('CustomStageForm', () => {
selectDropdownOption(wrapper, sel.startEvent, 1);
Vue.nextTick(() => {
return Vue.nextTick(() => {
selectDropdownOption(wrapper, sel.stopEvent, 1);
wrapper.find(sel.name).setValue('Cool stage');
});
......@@ -366,6 +387,7 @@ describe('CustomStageForm', () => {
afterEach(() => {
wrapper.destroy();
});
it('emits a `submit` event when clicked', () => {
expect(wrapper.emitted().submit).toBeUndefined();
......@@ -373,6 +395,7 @@ describe('CustomStageForm', () => {
expect(wrapper.emitted().submit).toBeTruthy();
expect(wrapper.emitted().submit.length).toEqual(1);
});
it('`submit` event receives the latest data', () => {
expect(wrapper.emitted().submit).toBeUndefined();
......@@ -401,7 +424,7 @@ describe('CustomStageForm', () => {
wrapper.destroy();
});
it('is enabled when the form is dirty', () => {
it('is enabled when the form is dirty', done => {
const btn = wrapper.find(sel.cancel);
expect(btn.attributes('disabled')).toEqual('disabled');
......@@ -409,9 +432,11 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
expect(btn.attributes('disabled')).toBeUndefined();
done();
});
});
it('will reset the fields when clicked', () => {
it('will reset the fields when clicked', done => {
wrapper.setData({
fields: {
name: 'Cool stage pre',
......@@ -431,10 +456,12 @@ describe('CustomStageForm', () => {
stopEvent: '',
stopEventLabel: null,
});
done();
});
});
});
it('will emit the `cancel` event when clicked', () => {
it('will emit the `cancel` event when clicked', done => {
expect(wrapper.emitted().cancel).toBeUndefined();
wrapper.setData({
......@@ -449,6 +476,7 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
expect(wrapper.emitted().cancel).toBeTruthy();
expect(wrapper.emitted().cancel.length).toEqual(1);
done();
});
});
});
......@@ -472,7 +500,7 @@ describe('CustomStageForm', () => {
},
});
Vue.nextTick();
return Vue.nextTick();
});
afterEach(() => {
......@@ -480,7 +508,7 @@ describe('CustomStageForm', () => {
});
describe('Cancel button', () => {
it('will reset the fields to initial state when clicked', () => {
it('will reset the fields to initial state when clicked', done => {
wrapper.setData({
fields: {
name: 'Cool stage pre',
......@@ -496,6 +524,7 @@ describe('CustomStageForm', () => {
expect(wrapper.vm.fields).toEqual({
...initData,
});
done();
});
});
});
......@@ -505,7 +534,8 @@ describe('CustomStageForm', () => {
it('is disabled by default', () => {
expect(wrapper.find(sel.submit).attributes('disabled')).toEqual('disabled');
});
it('is enabled when a field is changed and fields are valid', () => {
it('is enabled when a field is changed and fields are valid', done => {
wrapper.setData({
fields: {
name: 'Cool updated form',
......@@ -514,9 +544,11 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
expect(wrapper.find(sel.submit).attributes('disabled')).toBeUndefined();
done();
});
});
it('is disabled when a field is changed but fields are incomplete', () => {
it('is disabled when a field is changed but fields are incomplete', done => {
wrapper.setData({
fields: {
name: '',
......@@ -525,9 +557,11 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
expect(wrapper.find(sel.submit).attributes('disabled')).toEqual('disabled');
done();
});
});
it('emits a `submit` event when clicked', () => {
it('emits a `submit` event when clicked', done => {
expect(wrapper.emitted().submit).toBeUndefined();
wrapper.setData({
......@@ -542,10 +576,12 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
expect(wrapper.emitted().submit).toBeTruthy();
expect(wrapper.emitted().submit.length).toEqual(1);
done();
});
});
});
it('`submit` event receives the latest data', () => {
it('`submit` event receives the latest data', done => {
wrapper.setData({
fields: {
name: 'Cool updated form',
......@@ -559,6 +595,7 @@ describe('CustomStageForm', () => {
const submitted = wrapper.emitted().submit[0];
expect(submitted).not.toEqual([initData]);
expect(submitted).toEqual([{ ...initData, name: 'Cool updated form' }]);
done();
});
});
});
......
import { mount, shallowMount } from '@vue/test-utils';
import LabelsSelector from 'ee/analytics/cycle_analytics/components/labels_selector.vue';
import { mockLabels } from '../../../../../../spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data';
import { groupLabels } from '../mock_data';
const labels = mockLabels.map(({ title, ...rest }) => ({ ...rest, name: title }));
const selectedLabel = labels[labels.length - 1];
const selectedLabel = groupLabels[groupLabels.length - 1];
describe('Cycle Analytics LabelsSelector', () => {
function createComponent({ props = {}, shallow = true } = {}) {
const func = shallow ? shallowMount : mount;
return func(LabelsSelector, {
propsData: {
labels,
labels: groupLabels,
selectedLabelId: props.selectedLabelId || null,
},
sync: false,
......@@ -18,6 +17,7 @@ describe('Cycle Analytics LabelsSelector', () => {
}
let wrapper = null;
const labelNames = groupLabels.map(({ name }) => name);
describe('with no item selected', () => {
beforeEach(() => {
......@@ -27,13 +27,9 @@ describe('Cycle Analytics LabelsSelector', () => {
afterEach(() => {
wrapper.destroy();
});
it('will generate the list of labels', () => {
// includes the blank option 'Select a label'
expect(wrapper.findAll('gldropdownitem-stub').length).toEqual(labels.length + 1);
labels.forEach(({ name }) => {
expect(wrapper.text()).toContain(name);
});
it.each(labelNames)('generate a label item for the label %s', name => {
expect(wrapper.text()).toContain(name);
});
it('will render with the default option selected', () => {
......@@ -55,7 +51,7 @@ describe('Cycle Analytics LabelsSelector', () => {
elem.trigger('click');
expect(wrapper.emitted('selectLabel').length > 0).toBe(true);
expect(wrapper.emitted('selectLabel')[0]).toContain(mockLabels[1].id);
expect(wrapper.emitted('selectLabel')[0]).toContain(groupLabels[1].id);
});
it('will emit the "clearLabel" event if it is the default item', () => {
......@@ -77,6 +73,7 @@ describe('Cycle Analytics LabelsSelector', () => {
afterEach(() => {
wrapper.destroy();
});
it('will set the active class', () => {
const activeItem = wrapper.find('[active="true"]');
......
......@@ -23,6 +23,8 @@ const generateEvents = n =>
.fill(issueEvents[0])
.map((ev, k) => ({ ...ev, title: `event-${k}`, id: k }));
const bulkEvents = generateEvents(50);
const mockStubs = {
'stage-event-item': true,
'stage-build-item': true,
......@@ -54,8 +56,9 @@ describe('Stage', () => {
beforeEach(() => {
wrapper = createComponent({
props: {
events: generateEvents(50),
events: bulkEvents,
},
stubs: mockStubs,
});
});
......
import Vue from 'vue';
import { shallowMount, mount } from '@vue/test-utils';
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import { issueEvents, issueStage, allowedStages } from '../mock_data';
import { issueEvents, issueStage, allowedStages, groupLabels } from '../mock_data';
let wrapper = null;
const $sel = {
......@@ -25,14 +25,16 @@ function createComponent(props = {}, shallow = false) {
propsData: {
stages: allowedStages,
currentStage: issueStage,
events: issueEvents,
isLoadingStage: false,
currentStageEvents: issueEvents,
labels: groupLabels,
isLoading: false,
isEmptyStage: false,
isUserAllowed: true,
isAddingCustomStage: false,
noDataSvgPath,
noAccessSvgPath,
canEditStages: false,
customStageFormEvents: [],
...props,
},
stubs: {
......@@ -118,12 +120,10 @@ describe('StageTable', () => {
selectStage(1);
Vue.nextTick()
.then(() => {
expect(wrapper.emitted().selectStage.length).toEqual(1);
})
.then(done)
.catch(done.fail);
Vue.nextTick(() => {
expect(wrapper.emitted().selectStage.length).toEqual(1);
done();
});
});
it('will emit `selectStage` with the new stage title', done => {
......@@ -131,19 +131,17 @@ describe('StageTable', () => {
selectStage(1);
Vue.nextTick()
.then(() => {
const [params] = wrapper.emitted('selectStage')[0];
expect(params).toMatchObject({ title: secondStage.title });
})
.then(done)
.catch(done.fail);
Vue.nextTick(() => {
const [params] = wrapper.emitted('selectStage')[0];
expect(params).toMatchObject({ title: secondStage.title });
done();
});
});
});
});
it('isLoadingStage = true', () => {
wrapper = createComponent({ isLoadingStage: true }, true);
it('isLoading = true', () => {
wrapper = createComponent({ isLoading: true }, true);
expect(wrapper.find('gl-loading-icon-stub').exists()).toEqual(true);
});
......
......@@ -3,6 +3,9 @@ import { getJSONFixture } from 'helpers/fixtures';
import mutations from 'ee/analytics/cycle_analytics/store/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { mockLabels } from '../../../../../spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data';
export const groupLabels = mockLabels.map(({ title, ...rest }) => ({ ...rest, name: title }));
export const group = {
id: 1,
......
......@@ -4,10 +4,12 @@ import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as actions from 'ee/analytics/cycle_analytics/store/actions';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { group, cycleAnalyticsData, allowedStages as stages } from '../mock_data';
import { group, cycleAnalyticsData, allowedStages as stages, groupLabels } from '../mock_data';
const stageData = { events: [] };
const error = new Error('Request failed with status code 404');
const groupPath = 'cool-group';
const groupLabelsEndpoint = `/groups/${groupPath}/-/labels`;
describe('Cycle analytics actions', () => {
let state;
......@@ -143,6 +145,59 @@ describe('Cycle analytics actions', () => {
});
});
describe('fetchCustomStageFormData', () => {
beforeEach(() => {
mock.onGet(groupLabelsEndpoint).replyOnce(200, groupLabels);
});
it('dispatches receiveCustomStageFormData if the request succeeds', done => {
testAction(
actions.fetchCustomStageFormData,
groupPath,
state,
[],
[
{ type: 'requestCustomStageFormData' },
{
type: 'receiveCustomStageFormDataSuccess',
payload: groupLabels,
},
],
done,
);
});
it('dispatches receiveCustomStageFormDataError if the request fails', done => {
testAction(
actions.fetchCustomStageFormData,
'this-path-does-not-exist',
state,
[],
[
{ type: 'requestCustomStageFormData' },
{
type: 'receiveCustomStageFormDataError',
payload: error,
},
],
done,
);
});
describe('receiveCustomStageFormDataError', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('flashes an error message if the request fails', () => {
actions.receiveCustomStageFormDataError({
commit: () => {},
});
shouldFlashAnError('There was an error fetching data for the form');
});
});
});
describe('fetchCycleAnalyticsData', () => {
beforeEach(() => {
mock.onGet(state.endpoints.cycleAnalyticsData).replyOnce(200, cycleAnalyticsData);
......
......@@ -89,4 +89,26 @@ describe('Cycle analytics getters', () => {
expect(getters.hasNoAccessError(state)).toEqual(false);
});
});
describe('currentGroupPath', () => {
describe('with selectedGroup set', () => {
it('returns the `full_path` value of the group', () => {
const fullPath = 'cool-beans';
state = {
selectedGroup: {
full_path: fullPath,
},
};
expect(getters.currentGroupPath(state)).toEqual(fullPath);
});
});
describe('without a selectedGroup set', () => {
it.each([[''], [{}], [null]])('given %s will return null', value => {
state = { selectedGroup: value };
expect(getters.currentGroupPath(state)).toEqual(null);
});
});
});
});
import mutations from 'ee/analytics/cycle_analytics/store/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
cycleAnalyticsData,
rawEvents as events,
rawEvents,
issueEvents as transformedEvents,
issueStage,
planStage,
......@@ -10,22 +12,36 @@ import {
stagingStage,
reviewStage,
productionStage,
groupLabels,
} from '../mock_data';
let state = null;
describe('Cycle analytics mutations', () => {
beforeEach(() => {
state = {};
});
afterEach(() => {
state = null;
});
it.each`
mutation | stateKey | value
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${true}
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${false}
mutation | stateKey | value
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true}
${types.REQUEST_CUSTOM_STAGE_FORM_DATA} | ${'isAddingCustomStage'} | ${true}
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${false}
${types.REQUEST_CUSTOM_STAGE_FORM_DATA} | ${'isLoadingStageForm'} | ${true}
${types.RECEIVE_CUSTOM_STAGE_FORM_DATA_ERROR} | ${'isLoadingStageForm'} | ${false}
${types.RECEIVE_CUSTOM_STAGE_FORM_DATA_SUCCESS} | ${'isLoadingStageForm'} | ${false}
${types.RECEIVE_CUSTOM_STAGE_FORM_DATA_ERROR} | ${'labels'} | ${[]}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
const state = {};
mutations[mutation](state);
expect(state[stateKey]).toBe(value);
expect(state[stateKey]).toEqual(value);
});
it.each`
......@@ -39,7 +55,7 @@ describe('Cycle analytics mutations', () => {
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
const state = { endpoints: { cycleAnalyticsData: '/fake/api' } };
state = { endpoints: { cycleAnalyticsData: '/fake/api' } };
mutations[mutation](state, payload);
expect(state).toMatchObject(expectedState);
......@@ -47,21 +63,41 @@ describe('Cycle analytics mutations', () => {
);
describe(`${types.RECEIVE_STAGE_DATA_SUCCESS}`, () => {
it('will set the events state item with the camelCased events', () => {
const state = {};
it('will set the currentStageEvents state item with the camelCased events', () => {
mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state, { events: rawEvents });
expect(state.currentStageEvents).toEqual(transformedEvents);
});
it('will set isLoadingStage=false', () => {
mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state);
expect(state.isLoadingStage).toEqual(false);
});
mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state, { events });
it('will set isEmptyStage=false if currentStageEvents.length > 0', () => {
mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state, { events: rawEvents });
expect(state.events).toEqual(transformedEvents);
expect(state.isLoadingStage).toBe(false);
expect(state.isEmptyStage).toBe(false);
expect(state.isEmptyStage).toEqual(false);
});
it('will set isEmptyStage=true if currentStageEvents.length <= 0', () => {
mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state);
expect(state.isEmptyStage).toEqual(true);
});
});
describe(`${types.RECEIVE_CUSTOM_STAGE_FORM_DATA_SUCCESS}`, () => {
it('will set the labels state item with the camelCased custom stage events', () => {
mutations[types.RECEIVE_CUSTOM_STAGE_FORM_DATA_SUCCESS](state, groupLabels);
expect(state.labels).toEqual(groupLabels.map(convertObjectPropsToCamelCase));
});
});
describe(`${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS}`, () => {
it('will set isLoading=false and errorCode=null', () => {
const state = {};
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, {
stats: [],
summary: [],
......@@ -74,8 +110,6 @@ describe('Cycle analytics mutations', () => {
describe('with data', () => {
it('will convert the stats object to stages', () => {
const state = {};
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, cycleAnalyticsData);
[issueStage, planStage, codeStage, stagingStage, reviewStage, productionStage].forEach(
......@@ -86,8 +120,6 @@ describe('Cycle analytics mutations', () => {
});
it('will set the selectedStageName to the name of the first stage', () => {
const state = {};
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, cycleAnalyticsData);
expect(state.selectedStageName).toEqual('issue');
......@@ -96,8 +128,6 @@ describe('Cycle analytics mutations', () => {
it('will set each summary item with a value of 0 to "-"', () => {
// { value: '-', title: 'New Issues' }, { value: '-', title: 'Deploys' }
const state = {};
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, {
...cycleAnalyticsData,
summary: [{ value: 0, title: 'New Issues' }, { value: 0, title: 'Deploys' }],
......@@ -113,7 +143,6 @@ describe('Cycle analytics mutations', () => {
describe(`${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR}`, () => {
it('sets errorCode correctly', () => {
const state = {};
const errorCode = 403;
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state, errorCode);
......
......@@ -15839,7 +15839,7 @@ msgstr ""
msgid "There was an error fetching configuration for charts"
msgstr ""
msgid "There was an error fetching the form data"
msgid "There was an error fetching data for the form"
msgstr ""
msgid "There was an error gathering the chart data"
......
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