Commit 895cb36f authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '197336-value-stream-analytics-move-duration-chart-to-separate-module' into 'master'

Move duration chart to separate component

See merge request gitlab-org/gitlab!28624
parents c3e7e5c4 8d8555f1
...@@ -6,11 +6,10 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; ...@@ -6,11 +6,10 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PROJECTS_PER_PAGE } from '../constants'; import { PROJECTS_PER_PAGE } from '../constants';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue'; import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue'; import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import Scatterplot from '../../shared/components/scatterplot.vue'; import { LAST_ACTIVITY_AT, DATE_RANGE_LIMIT } from '../../shared/constants';
import { LAST_ACTIVITY_AT, dateFormats, DATE_RANGE_LIMIT } from '../../shared/constants';
import DateRange from '../../shared/components/daterange.vue'; import DateRange from '../../shared/components/daterange.vue';
import StageDropdownFilter from './stage_dropdown_filter.vue';
import StageTable from './stage_table.vue'; import StageTable from './stage_table.vue';
import DurationChart from './duration_chart.vue';
import TasksByTypeChart from './tasks_by_type_chart.vue'; import TasksByTypeChart from './tasks_by_type_chart.vue';
import UrlSyncMixin from '../../shared/mixins/url_sync_mixin'; import UrlSyncMixin from '../../shared/mixins/url_sync_mixin';
import { toYmd } from '../../shared/utils'; import { toYmd } from '../../shared/utils';
...@@ -20,13 +19,12 @@ export default { ...@@ -20,13 +19,12 @@ export default {
name: 'CycleAnalytics', name: 'CycleAnalytics',
components: { components: {
DateRange, DateRange,
DurationChart,
GlLoadingIcon, GlLoadingIcon,
GlEmptyState, GlEmptyState,
GroupsDropdownFilter, GroupsDropdownFilter,
ProjectsDropdownFilter, ProjectsDropdownFilter,
StageTable, StageTable,
StageDropdownFilter,
Scatterplot,
TasksByTypeChart, TasksByTypeChart,
RecentActivityCard, RecentActivityCard,
}, },
...@@ -214,7 +212,6 @@ export default { ...@@ -214,7 +212,6 @@ export default {
order_by: LAST_ACTIVITY_AT, order_by: LAST_ACTIVITY_AT,
include_subgroups: true, include_subgroups: true,
}, },
durationChartTooltipDateFormat: dateFormats.defaultDate,
maxDateRange: DATE_RANGE_LIMIT, maxDateRange: DATE_RANGE_LIMIT,
}; };
</script> </script>
...@@ -323,31 +320,15 @@ export default { ...@@ -323,31 +320,15 @@ export default {
/> />
</div> </div>
</div> </div>
<template v-if="shouldDisplayDurationChart"> <div v-if="shouldDisplayDurationChart" class="mt-3">
<template v-if="isDurationChartLoaded"> <duration-chart
<div class="mt-3 d-flex"> :is-loading="isLoading"
<h4 class="mt-0">{{ s__('CycleAnalytics|Days to completion') }}</h4> :stages="activeStages"
<stage-dropdown-filter :scatter-data="durationChartPlottableData"
v-if="activeStages.length" :median-line-data="durationChartMedianData"
class="ml-auto" @stageSelected="onDurationStageSelect"
:stages="activeStages" />
@selected="onDurationStageSelect" </div>
/>
</div>
<scatterplot
v-if="durationChartPlottableData"
:x-axis-title="s__('CycleAnalytics|Date')"
:y-axis-title="s__('CycleAnalytics|Total days to completion')"
:tooltip-date-format="$options.durationChartTooltipDateFormat"
:scatter-data="durationChartPlottableData"
:median-line-data="durationChartMedianData"
/>
<div v-else ref="duration-chart-no-data" class="bs-callout bs-callout-info">
{{ __('There is no data available. Please change your selection.') }}
</div>
</template>
<gl-loading-icon v-else-if="!isLoading" size="md" class="my-4 py-4" />
</template>
<template v-if="shouldDisplayTasksByTypeChart"> <template v-if="shouldDisplayTasksByTypeChart">
<div class="js-tasks-by-type-chart"> <div class="js-tasks-by-type-chart">
<div v-if="isTasksByTypeChartLoaded"> <div v-if="isTasksByTypeChartLoaded">
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { dateFormats } from '../../shared/constants';
import Scatterplot from '../../shared/components/scatterplot.vue';
import StageDropdownFilter from './stage_dropdown_filter.vue';
export default {
name: 'DurationChart',
components: {
GlLoadingIcon,
Scatterplot,
StageDropdownFilter,
},
props: {
isLoading: {
type: Boolean,
required: false,
default: false,
},
stages: {
type: Array,
required: true,
},
scatterData: {
type: Array,
required: true,
},
medianLineData: {
type: Array,
required: true,
},
},
computed: {
hasData() {
return Boolean(this.scatterData.length);
},
},
methods: {
onSelectStage(selectedStages) {
this.$emit('stageSelected', selectedStages);
},
},
durationChartTooltipDateFormat: dateFormats.defaultDate,
};
</script>
<template>
<gl-loading-icon v-if="isLoading" size="md" class="my-4 py-4" />
<div v-else>
<div class="d-flex">
<h4 class="mt-0">{{ s__('CycleAnalytics|Days to completion') }}</h4>
<stage-dropdown-filter
v-if="stages.length"
class="ml-auto"
:stages="stages"
@selected="onSelectStage"
/>
</div>
<scatterplot
v-if="hasData"
:x-axis-title="s__('CycleAnalytics|Date')"
:y-axis-title="s__('CycleAnalytics|Total days to completion')"
:tooltip-date-format="$options.durationChartTooltipDateFormat"
:scatter-data="scatterData"
:median-line-data="medianLineData"
/>
<div v-else ref="duration-chart-no-data" class="bs-callout bs-callout-info">
{{ __('There is no data available. Please change your selection.') }}
</div>
</div>
</template>
...@@ -23,7 +23,7 @@ export const durationChartPlottableData = state => { ...@@ -23,7 +23,7 @@ export const durationChartPlottableData = state => {
const selectedStagesDurationData = durationData.filter(stage => stage.selected); const selectedStagesDurationData = durationData.filter(stage => stage.selected);
const plottableData = getDurationChartData(selectedStagesDurationData, startDate, endDate); const plottableData = getDurationChartData(selectedStagesDurationData, startDate, endDate);
return plottableData.length ? plottableData : null; return plottableData.length ? plottableData : [];
}; };
export const durationChartMedianData = state => { export const durationChartMedianData = state => {
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DurationChart renders the duration chart 1`] = `
"<div>
<div class=\\"d-flex\\">
<h4 class=\\"mt-0\\">Days to completion</h4>
<stagedropdownfilter-stub stages=\\"[object Object],[object Object],[object Object]\\" label=\\"stage dropdown\\" class=\\"ml-auto\\"></stagedropdownfilter-stub>
</div>
<scatterplot-stub xaxistitle=\\"Date\\" yaxistitle=\\"Total days to completion\\" scatterdata=\\"2019-01-01,29,2019-01-01,2019-01-02,100,2019-01-02\\" medianlinedata=\\"2018-12-31,29,2019-01-01,100\\" tooltipdateformat=\\"mmm d, yyyy\\"></scatterplot-stub>
</div>"
`;
...@@ -12,7 +12,7 @@ import RecentActivityCard from 'ee/analytics/cycle_analytics/components/recent_a ...@@ -12,7 +12,7 @@ import RecentActivityCard from 'ee/analytics/cycle_analytics/components/recent_a
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue'; import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import 'bootstrap'; import 'bootstrap';
import '~/gl_dropdown'; import '~/gl_dropdown';
import Scatterplot from 'ee/analytics/shared/components/scatterplot.vue'; import DurationChart from 'ee/analytics/cycle_analytics/components/duration_chart.vue';
import Daterange from 'ee/analytics/shared/components/daterange.vue'; import Daterange from 'ee/analytics/shared/components/daterange.vue';
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 waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
...@@ -39,6 +39,7 @@ const defaultStubs = { ...@@ -39,6 +39,7 @@ const defaultStubs = {
'stage-nav-item': true, 'stage-nav-item': true,
'tasks-by-type-chart': true, 'tasks-by-type-chart': true,
'labels-selector': true, 'labels-selector': true,
DurationChart: true,
}; };
function createComponent({ function createComponent({
...@@ -127,8 +128,8 @@ describe('Cycle Analytics component', () => { ...@@ -127,8 +128,8 @@ describe('Cycle Analytics component', () => {
expect(wrapper.find(StageTable).exists()).toBe(flag); expect(wrapper.find(StageTable).exists()).toBe(flag);
}; };
const displaysDurationScatterPlot = flag => { const displaysDurationChart = flag => {
expect(wrapper.find(Scatterplot).exists()).toBe(flag); expect(wrapper.find(DurationChart).exists()).toBe(flag);
}; };
const displaysTasksByType = flag => { const displaysTasksByType = flag => {
...@@ -183,8 +184,8 @@ describe('Cycle Analytics component', () => { ...@@ -183,8 +184,8 @@ describe('Cycle Analytics component', () => {
displaysStageTable(false); displaysStageTable(false);
}); });
it('does not display the duration scatter plot', () => { it('does not display the duration chart', () => {
displaysDurationScatterPlot(false); displaysDurationChart(false);
}); });
describe('hideGroupDropDown = true', () => { describe('hideGroupDropDown = true', () => {
...@@ -206,7 +207,10 @@ describe('Cycle Analytics component', () => { ...@@ -206,7 +207,10 @@ describe('Cycle Analytics component', () => {
describe('after a filter has been selected', () => { describe('after a filter has been selected', () => {
describe('the user has access to the group', () => { describe('the user has access to the group', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ withStageSelected: true, tasksByTypeChartEnabled: false }); wrapper = createComponent({
withStageSelected: true,
tasksByTypeChartEnabled: false,
});
}); });
it('hides the empty state', () => { it('hides the empty state', () => {
...@@ -243,16 +247,7 @@ describe('Cycle Analytics component', () => { ...@@ -243,16 +247,7 @@ describe('Cycle Analytics component', () => {
describe('with no durationData', () => { describe('with no durationData', () => {
it('displays the duration chart', () => { it('displays the duration chart', () => {
expect(wrapper.find(Scatterplot).exists()).toBe(false); displaysDurationChart(true);
});
it('displays the no data message', () => {
const element = wrapper.find({ ref: 'duration-chart-no-data' });
expect(element.exists()).toBe(true);
expect(element.text()).toBe(
'There is no data available. Please change your selection.',
);
}); });
}); });
...@@ -271,7 +266,7 @@ describe('Cycle Analytics component', () => { ...@@ -271,7 +266,7 @@ describe('Cycle Analytics component', () => {
}); });
it('displays the duration chart', () => { it('displays the duration chart', () => {
expect(wrapper.find(Scatterplot).exists()).toBe(true); expect(wrapper.find(DurationChart).exists()).toBe(true);
}); });
}); });
...@@ -357,7 +352,7 @@ describe('Cycle Analytics component', () => { ...@@ -357,7 +352,7 @@ describe('Cycle Analytics component', () => {
}); });
it('does not display the duration chart', () => { it('does not display the duration chart', () => {
displaysDurationScatterPlot(false); displaysDurationChart(false);
}); });
}); });
......
import { shallowMount, mount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import $ from 'jquery';
import 'bootstrap';
import '~/gl_dropdown';
import Scatterplot from 'ee/analytics/shared/components/scatterplot.vue';
import DurationChart from 'ee/analytics/cycle_analytics/components/duration_chart.vue';
import StageDropdownFilter from 'ee/analytics/cycle_analytics/components/stage_dropdown_filter.vue';
import {
allowedStages as stages,
durationChartPlottableData as scatterData,
durationChartPlottableMedianData as medianLineData,
} from '../mock_data';
function createComponent({ mountFn = shallowMount, props = {}, stubs = {} } = {}) {
return mountFn(DurationChart, {
propsData: {
isLoading: false,
stages,
scatterData,
medianLineData,
...props,
},
stubs: {
GlLoadingIcon: true,
Scatterplot: true,
StageDropdownFilter: true,
...stubs,
},
});
}
describe('DurationChart', () => {
let wrapper;
const findNoDataContainer = _wrapper => _wrapper.find({ ref: 'duration-chart-no-data' });
const findScatterPlot = _wrapper => _wrapper.find(Scatterplot);
const findStageDropdown = _wrapper => _wrapper.find(StageDropdownFilter);
const findLoader = _wrapper => _wrapper.find(GlLoadingIcon);
const openStageDropdown = _wrapper => {
$(findStageDropdown(_wrapper).element).trigger('shown.bs.dropdown');
return _wrapper.vm.$nextTick();
};
const selectStage = (_wrapper, index = 0) => {
findStageDropdown(_wrapper)
.findAll('a')
.at(index)
.trigger('click');
return _wrapper.vm.$nextTick();
};
beforeEach(() => {
wrapper = createComponent({});
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders the duration chart', () => {
expect(wrapper.html()).toMatchSnapshot();
});
it('renders the scatter plot', () => {
expect(findScatterPlot(wrapper).exists()).toBe(true);
});
it('renders the stage dropdown', () => {
expect(findStageDropdown(wrapper).exists()).toBe(true);
});
describe('when a stage is selected', () => {
const selectedIndex = 1;
const selectedStages = stages.filter((_, index) => index !== selectedIndex);
beforeEach(() => {
wrapper = createComponent({ mountFn: mount, stubs: { StageDropdownFilter: false } });
return openStageDropdown(wrapper).then(() => selectStage(wrapper, selectedIndex));
});
it('emits the stageSelected event', () => {
expect(wrapper.emitted().stageSelected).toBeTruthy();
});
it('toggles the selected stage', () => {
expect(wrapper.emitted('stageSelected')[0]).toEqual([selectedStages]);
return selectStage(wrapper, selectedIndex).then(() => {
const [updatedStages] = wrapper.emitted('stageSelected')[1];
stages.forEach(stage => {
expect(updatedStages).toContain(stage);
});
});
});
});
describe('with no chart data', () => {
beforeEach(() => {
wrapper = createComponent({ props: { scatterData: [], medianLineData: [] } });
});
it('renders the no data available message', () => {
expect(findNoDataContainer(wrapper).text()).toEqual(
'There is no data available. Please change your selection.',
);
});
});
describe('while loading', () => {
beforeEach(() => {
wrapper = createComponent({ props: { isLoading: true } });
});
it('renders loading icon', () => {
expect(findLoader(wrapper).exists()).toBe(true);
});
});
});
...@@ -111,14 +111,14 @@ describe('Cycle analytics getters', () => { ...@@ -111,14 +111,14 @@ describe('Cycle analytics getters', () => {
); );
}); });
it('returns null if there is no plottable data for the selected stages', () => { it('returns an empty array if there is no plottable data for the selected stages', () => {
const stateWithDurationData = { const stateWithDurationData = {
startDate, startDate,
endDate, endDate,
durationData: [], durationData: [],
}; };
expect(getters.durationChartPlottableData(stateWithDurationData)).toBeNull(); expect(getters.durationChartPlottableData(stateWithDurationData)).toEqual([]);
}); });
}); });
......
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