Commit 79a3bea4 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '33604-fe-visualize-tasks-by-type-in-customizable-cycle-analytics' into 'master'

[FE] Visualize Tasks by Type in Customizable Cycle Analytics

See merge request gitlab-org/gitlab!19602
parents 44d6830b 2099e33b
...@@ -12,6 +12,7 @@ import StageDropdownFilter from './stage_dropdown_filter.vue'; ...@@ -12,6 +12,7 @@ import StageDropdownFilter from './stage_dropdown_filter.vue';
import SummaryTable from './summary_table.vue'; import SummaryTable from './summary_table.vue';
import StageTable from './stage_table.vue'; import StageTable from './stage_table.vue';
import { LAST_ACTIVITY_AT } from '../../shared/constants'; import { LAST_ACTIVITY_AT } from '../../shared/constants';
import TasksByTypeChart from './tasks_by_type_chart.vue';
export default { export default {
name: 'CycleAnalytics', name: 'CycleAnalytics',
...@@ -25,6 +26,7 @@ export default { ...@@ -25,6 +26,7 @@ export default {
GlDaterangePicker, GlDaterangePicker,
StageDropdownFilter, StageDropdownFilter,
Scatterplot, Scatterplot,
TasksByTypeChart,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
props: { props: {
...@@ -41,24 +43,19 @@ export default { ...@@ -41,24 +43,19 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
multiProjectSelect: true,
dateOptions: [7, 30, 90],
};
},
computed: { computed: {
...mapState([ ...mapState([
'featureFlags', 'featureFlags',
'isLoading', 'isLoading',
'isLoadingStage', 'isLoadingStage',
'isLoadingChartData', 'isLoadingTasksByTypeChart',
'isLoadingDurationChart', 'isLoadingDurationChart',
'isEmptyStage', 'isEmptyStage',
'isSavingCustomStage', 'isSavingCustomStage',
'isCreatingCustomStage', 'isCreatingCustomStage',
'isEditingCustomStage', 'isEditingCustomStage',
'selectedGroup', 'selectedGroup',
'selectedProjectIds',
'selectedStage', 'selectedStage',
'stages', 'stages',
'summary', 'summary',
...@@ -71,7 +68,12 @@ export default { ...@@ -71,7 +68,12 @@ export default {
'tasksByType', 'tasksByType',
'medians', 'medians',
]), ]),
...mapGetters(['hasNoAccessError', 'currentGroupPath', 'durationChartPlottableData']), ...mapGetters([
'hasNoAccessError',
'currentGroupPath',
'durationChartPlottableData',
'tasksByTypeChartData',
]),
shouldRenderEmptyState() { shouldRenderEmptyState() {
return !this.selectedGroup; return !this.selectedGroup;
}, },
...@@ -84,6 +86,10 @@ export default { ...@@ -84,6 +86,10 @@ export default {
shouldDisplayDurationChart() { shouldDisplayDurationChart() {
return !this.isLoadingDurationChart && !this.isLoading; return !this.isLoadingDurationChart && !this.isLoading;
}, },
shouldDisplayTasksByTypeChart() {
return !this.isLoading && !this.isLoadingTasksByTypeChart;
},
dateRange: { dateRange: {
get() { get() {
return { startDate: this.startDate, endDate: this.endDate }; return { startDate: this.startDate, endDate: this.endDate };
...@@ -95,6 +101,26 @@ export default { ...@@ -95,6 +101,26 @@ export default {
}); });
}, },
}, },
hasDateRangeSet() {
return this.startDate && this.endDate;
},
selectedTasksByTypeFilters() {
const {
selectedGroup,
startDate,
endDate,
selectedProjectIds,
tasksByType: { subject, labelIds: selectedLabelIds },
} = this;
return {
selectedGroup,
selectedProjectIds,
startDate,
endDate,
subject,
selectedLabelIds,
};
},
}, },
mounted() { mounted() {
this.initDateRange(); this.initDateRange();
...@@ -160,6 +186,8 @@ export default { ...@@ -160,6 +186,8 @@ export default {
this.updateSelectedDurationChartStages(stages); this.updateSelectedDurationChartStages(stages);
}, },
}, },
multiProjectSelect: true,
dateOptions: [7, 30, 90],
groupsQueryParams: { groupsQueryParams: {
min_access_level: featureAccessLevel.EVERYONE, min_access_level: featureAccessLevel.EVERYONE,
}, },
...@@ -191,7 +219,7 @@ export default { ...@@ -191,7 +219,7 @@ export default {
class="js-projects-dropdown-filter ml-md-1 mt-1 mt-md-0 dropdown-select" class="js-projects-dropdown-filter ml-md-1 mt-1 mt-md-0 dropdown-select"
:group-id="selectedGroup.id" :group-id="selectedGroup.id"
:query-params="$options.projectsQueryParams" :query-params="$options.projectsQueryParams"
:multi-select="multiProjectSelect" :multi-select="$options.multiProjectSelect"
@selected="onProjectsSelect" @selected="onProjectsSelect"
/> />
<div <div
...@@ -288,6 +316,17 @@ export default { ...@@ -288,6 +316,17 @@ export default {
</template> </template>
<gl-loading-icon v-else-if="!isLoading" size="md" class="my-4 py-4" /> <gl-loading-icon v-else-if="!isLoading" size="md" class="my-4 py-4" />
</template> </template>
<template v-if="featureFlags.hasTasksByTypeChart">
<div class="js-tasks-by-type-chart">
<div v-if="shouldDisplayTasksByTypeChart">
<tasks-by-type-chart
:chart-data="tasksByTypeChartData"
:filters="selectedTasksByTypeFilters"
/>
</div>
<gl-loading-icon v-else size="md" class="my-4 py-4" />
</div>
</template>
</div> </div>
</div> </div>
</template> </template>
<script>
import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import { s__, sprintf } from '~/locale';
import { formattedDate } from '../../shared/utils';
export default {
name: 'TasksByTypeChart',
components: {
GlStackedColumnChart,
},
props: {
filters: {
type: Object,
required: true,
},
chartData: {
type: Object,
required: true,
},
},
computed: {
hasData() {
return Boolean(this.chartData?.data?.length);
},
selectedFiltersText() {
const { subject, selectedLabelIds } = this.filters;
return sprintf(s__('CycleAnalytics|Showing %{subject} and %{selectedLabelsCount} labels'), {
subject,
selectedLabelsCount: selectedLabelIds.length,
});
},
summaryDescription() {
const {
startDate,
endDate,
selectedProjectIds,
selectedGroup: { name: groupName },
} = this.filters;
const selectedProjectCount = selectedProjectIds.length;
const str =
selectedProjectCount > 0
? s__(
"CycleAnalytics|Showing data for group '%{groupName}' and %{selectedProjectCount} projects from %{startDate} to %{endDate}",
)
: s__(
"CycleAnalytics|Showing data for group '%{groupName}' from %{startDate} to %{endDate}",
);
return sprintf(str, {
startDate: formattedDate(startDate),
endDate: formattedDate(endDate),
groupName,
selectedProjectCount,
});
},
},
chartOptions: {
legend: false,
},
};
</script>
<template>
<div class="row">
<div class="col-12">
<h3>{{ s__('CycleAnalytics|Type of work') }}</h3>
<div v-if="hasData">
<p>{{ summaryDescription }}</p>
<h4>{{ s__('CycleAnalytics|Tasks by type') }}</h4>
<p>{{ selectedFiltersText }}</p>
<gl-stacked-column-chart
:option="$options.chartOptions"
:data="chartData.data"
:group-by="chartData.groupBy"
x-axis-type="category"
:x-axis-title="__('Date')"
:y-axis-title="s__('CycleAnalytics|Number of tasks')"
:series-names="chartData.seriesNames"
/>
</div>
<div v-else class="bs-callout bs-callout-info">
<p>{{ __('There is no data available. Please change your selection.') }}</p>
</div>
</div>
</div>
</template>
...@@ -246,13 +246,15 @@ export const createCustomStage = ({ dispatch, state }, data) => { ...@@ -246,13 +246,15 @@ export const createCustomStage = ({ dispatch, state }, data) => {
.catch(error => dispatch('receiveCreateCustomStageError', { error, data })); .catch(error => dispatch('receiveCreateCustomStageError', { error, data }));
}; };
export const receiveTasksByTypeDataSuccess = ({ commit }, data) => export const receiveTasksByTypeDataSuccess = ({ commit }, data) => {
commit(types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS, data); commit(types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS, data);
};
export const receiveTasksByTypeDataError = ({ commit }, error) => { export const receiveTasksByTypeDataError = ({ commit }, error) => {
commit(types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR, error); commit(types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR, error);
createFlash(__('There was an error fetching data for the tasks by type chart')); createFlash(__('There was an error fetching data for the tasks by type chart'));
}; };
export const requestTasksByTypeData = ({ commit }) => commit(types.REQUEST_TASKS_BY_TYPE_DATA); export const requestTasksByTypeData = ({ commit }) => commit(types.REQUEST_TASKS_BY_TYPE_DATA);
export const fetchTasksByTypeData = ({ dispatch, state, getters }) => { export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
...@@ -279,7 +281,7 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => { ...@@ -279,7 +281,7 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
dispatch('requestTasksByTypeData'); dispatch('requestTasksByTypeData');
return Api.cycleAnalyticsTasksByType(params) return Api.cycleAnalyticsTasksByType(params)
.then(data => dispatch('receiveTasksByTypeDataSuccess', data)) .then(({ data }) => dispatch('receiveTasksByTypeDataSuccess', data))
.catch(error => dispatch('receiveTasksByTypeDataError', error)); .catch(error => dispatch('receiveTasksByTypeDataError', error));
} }
return Promise.resolve(); return Promise.resolve();
......
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import { dateFormats } from '../../shared/constants'; import { dateFormats } from '../../shared/constants';
import { getDurationChartData } from '../utils'; import { getDurationChartData, getTasksByTypeData } from '../utils';
export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN; export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN;
...@@ -25,3 +25,14 @@ export const durationChartPlottableData = state => { ...@@ -25,3 +25,14 @@ export const durationChartPlottableData = state => {
return plottableData.length ? plottableData : null; return plottableData.length ? plottableData : null;
}; };
export const tasksByTypeChartData = ({ tasksByType, startDate, endDate }) => {
if (tasksByType && tasksByType.data.length) {
return getTasksByTypeData({
data: tasksByType.data,
startDate,
endDate,
});
}
return { groupBy: [], data: [], seriesNames: [] };
};
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 } from '../utils'; import { transformRawStages, transformRawTasksByTypeData } from '../utils';
export default { export default {
[types.SET_FEATURE_FLAGS](state, featureFlags) { [types.SET_FEATURE_FLAGS](state, featureFlags) {
...@@ -133,16 +133,16 @@ export default { ...@@ -133,16 +133,16 @@ export default {
); );
}, },
[types.REQUEST_TASKS_BY_TYPE_DATA](state) { [types.REQUEST_TASKS_BY_TYPE_DATA](state) {
state.isLoadingChartData = true; state.isLoadingTasksByTypeChart = true;
}, },
[types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR](state) { [types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR](state) {
state.isLoadingChartData = false; state.isLoadingTasksByTypeChart = false;
}, },
[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, data) { [types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, data) {
state.isLoadingChartData = false; state.isLoadingTasksByTypeChart = false;
state.tasksByType = { state.tasksByType = {
...state.tasksByType, ...state.tasksByType,
data, data: transformRawTasksByTypeData(data),
}; };
}, },
[types.REQUEST_CREATE_CUSTOM_STAGE](state) { [types.REQUEST_CREATE_CUSTOM_STAGE](state) {
......
...@@ -8,7 +8,7 @@ export default () => ({ ...@@ -8,7 +8,7 @@ export default () => ({
isLoading: false, isLoading: false,
isLoadingStage: false, isLoadingStage: false,
isLoadingChartData: false, isLoadingTasksByTypeChart: false,
isLoadingDurationChart: false, isLoadingDurationChart: false,
isEmptyStage: false, isEmptyStage: false,
......
...@@ -2,9 +2,10 @@ import { isString, isNumber } from 'underscore'; ...@@ -2,9 +2,10 @@ import { isString, 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 { newDate, dayAfter, secondsToDays } from '~/lib/utils/datetime_utility'; import { newDate, dayAfter, secondsToDays, getDatesInRange } from '~/lib/utils/datetime_utility';
import { dateFormats } from '../shared/constants'; import { dateFormats } from '../shared/constants';
import { STAGE_NAME } from './constants'; import { STAGE_NAME } from './constants';
import { toYmd } from '../shared/utils';
const EVENT_TYPE_LABEL = 'label'; const EVENT_TYPE_LABEL = 'label';
...@@ -77,6 +78,11 @@ export const transformRawStages = (stages = []) => ...@@ -77,6 +78,11 @@ export const transformRawStages = (stages = []) =>
name: name.length ? name : title, name: name.length ? name : title,
})); }));
export const transformRawTasksByTypeData = (data = []) => {
if (!data.length) return [];
return data.map(d => convertObjectPropsToCamelCase(d, { deep: true }));
};
export const nestQueryStringKeys = (obj = null, targetKey = '') => { export const nestQueryStringKeys = (obj = null, targetKey = '') => {
if (!obj || !isString(targetKey) || !targetKey.length) return {}; if (!obj || !isString(targetKey) || !targetKey.length) return {};
return Object.entries(obj).reduce((prev, [key, value]) => { return Object.entries(obj).reduce((prev, [key, value]) => {
...@@ -189,3 +195,86 @@ export const getDurationChartData = (data, startDate, endDate) => { ...@@ -189,3 +195,86 @@ export const getDurationChartData = (data, startDate, endDate) => {
return eventData; return eventData;
}; };
export const orderByDate = (a, b, dateFmt = datetime => new Date(datetime).getTime()) =>
dateFmt(a) - dateFmt(b);
/**
* Takes a dictionary of dates and the associated value, sorts them and returns just the value
*
* @param {Object.<Date, number>} series - Key value pair of dates and the value for that date
* @returns {number[]} The values of each key value pair
*/
export const flattenTaskByTypeSeries = (series = {}) =>
Object.entries(series)
.sort((a, b) => orderByDate(a[0], b[0]))
.map(dataSet => dataSet[1]);
/**
* @typedef {Object} RawTasksByTypeData
* @property {Object} label - Raw data for a group label
* @property {Array} series - Array of arrays with date and associated value ie [ ['2020-01-01', 10],['2020-01-02', 10] ]
* @typedef {Object} TransformedTasksByTypeData
* @property {Array} groupBy - The list of dates for the range of data in each data series
* @property {Array} data - An array of the data values for each series
* @property {Array} seriesNames - Names of the series to be charted ie label names
*/
/**
* Takes the raw tasks by type data and generates an array of data points,
* an array of data series and an array of data labels for the given time period.
*
* Currently the data is transformed to support use in a stacked column chart:
* https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/charts-stacked-column-chart--stacked
*
* @param {Object} obj
* @param {RawTasksByTypeData[]} obj.data - array of raw data, each element contains a label and series
* @param {Date} obj.startDate - start date in ISO date format
* @param {Date} obj.endDate - end date in ISO date format
*
* @returns {TransformedTasksByTypeData} The transformed data ready for use in charts
*/
export const getTasksByTypeData = ({ data = [], startDate = null, endDate = null }) => {
if (!startDate || !endDate || !data.length) {
return {
groupBy: [],
data: [],
seriesNames: [],
};
}
const groupBy = getDatesInRange(startDate, endDate, toYmd).sort(orderByDate);
const zeroValuesForEachDataPoint = groupBy.reduce(
(acc, date) => ({
...acc,
[date]: 0,
}),
{},
);
const transformed = data.reduce(
(acc, curr) => {
const {
label: { title },
series,
} = curr;
acc.seriesNames = [...acc.seriesNames, title];
acc.data = [
...acc.data,
// adds 0 values for each data point and overrides with data from the series
flattenTaskByTypeSeries({ ...zeroValuesForEachDataPoint, ...Object.fromEntries(series) }),
];
return acc;
},
{
data: [],
seriesNames: [],
},
);
return {
...transformed,
groupBy,
};
};
import dateFormat from 'dateformat';
import { dateFormats } from './constants';
export const toYmd = date => dateFormat(date, dateFormats.isoDate);
export default {
toYmd,
};
export const formattedDate = d => dateFormat(d, dateFormats.defaultDate);
...@@ -9,6 +9,8 @@ describe 'Group Cycle Analytics', :js do ...@@ -9,6 +9,8 @@ describe 'Group Cycle Analytics', :js do
let(:milestone) { create(:milestone, project: project) } let(:milestone) { create(:milestone, project: project) }
let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") } let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") }
let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) } let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) }
let(:label) { create(:group_label, group: group) }
let(:label2) { create(:group_label, group: group) }
stage_nav_selector = '.stage-nav' stage_nav_selector = '.stage-nav'
...@@ -18,6 +20,7 @@ describe 'Group Cycle Analytics', :js do ...@@ -18,6 +20,7 @@ describe 'Group Cycle Analytics', :js do
before do before do
stub_licensed_features(cycle_analytics_for_groups: true) stub_licensed_features(cycle_analytics_for_groups: true)
group.add_owner(user) group.add_owner(user)
project.add_maintainer(user) project.add_maintainer(user)
...@@ -217,6 +220,67 @@ describe 'Group Cycle Analytics', :js do ...@@ -217,6 +220,67 @@ describe 'Group Cycle Analytics', :js do
end end
end end
describe 'Tasks by type chart', :js do
context 'enabled' do
before do
stub_licensed_features(cycle_analytics_for_groups: true, type_of_work_analytics: true)
sign_in(user)
end
context 'with data available' do
before do
3.times do |i|
create(:labeled_issue, created_at: i.days.ago, project: create(:project, group: group), labels: [label])
create(:labeled_issue, created_at: i.days.ago, project: create(:project, group: group), labels: [label2])
end
visit analytics_cycle_analytics_path
select_group
end
it 'displays the chart' do
expect(page).to have_text('Type of work')
expect(page).to have_text('Tasks by type')
end
it 'has 2 labels selected' do
expect(page).to have_text('Showing Issue and 2 labels')
end
end
context 'no data available' do
before do
visit analytics_cycle_analytics_path
select_group
end
it 'shows the no data available message' do
expect(page).to have_text('Type of work')
expect(page).to have_text('There is no data available. Please change your selection.')
end
end
end
context 'not enabled' do
before do
stub_feature_flags(Gitlab::Analytics::TASKS_BY_TYPE_CHART_FEATURE_FLAG => false)
visit analytics_cycle_analytics_path
select_group
end
it 'will not display the tasks by type chart' do
expect(page).not_to have_selector('.js-tasks-by-type-chart')
expect(page).not_to have_text('Tasks by type')
end
end
end
describe 'Customizable cycle analytics', :js do describe 'Customizable cycle analytics', :js do
custom_stage_name = "Cool beans" custom_stage_name = "Cool beans"
start_event_identifier = :merge_request_created start_event_identifier = :merge_request_created
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TasksByTypeChart no data available should render the no data available message 1`] = `
"<div class=\\"row\\">
<div class=\\"col-12\\">
<h3>Type of work</h3>
<div class=\\"bs-callout bs-callout-info\\">
<p>There is no data available. Please change your selection.</p>
</div>
</div>
</div>"
`;
exports[`TasksByTypeChart with data available should render the loading chart 1`] = `
"<div class=\\"row\\">
<div class=\\"col-12\\">
<h3>Type of work</h3>
<div>
<p>Showing data for group 'Gitlab Org' from Dec 11, 2019 to Jan 10, 2020</p>
<h4>Tasks by type</h4>
<p>Showing Issue and 3 labels</p>
<gl-stacked-column-chart-stub data=\\"0,1,2,5,2,3,2,4,1\\" option=\\"[object Object]\\" presentation=\\"stacked\\" groupby=\\"Group 1,Group 2,Group 3\\" xaxistype=\\"category\\" xaxistitle=\\"Date\\" yaxistitle=\\"Number of tasks\\" seriesnames=\\"Cool label,Normal label\\" legendaveragetext=\\"Avg\\" legendmaxtext=\\"Max\\"></gl-stacked-column-chart-stub>
</div>
</div>
</div>"
`;
...@@ -25,12 +25,20 @@ const baseStagesEndpoint = '/-/analytics/cycle_analytics/stages'; ...@@ -25,12 +25,20 @@ const baseStagesEndpoint = '/-/analytics/cycle_analytics/stages';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
const defaultStubs = {
'summary-table': true,
'stage-event-list': true,
'stage-nav-item': true,
'tasks-by-type-chart': true,
};
function createComponent({ function createComponent({
opts = {}, opts = {},
shallow = true, shallow = true,
withStageSelected = false, withStageSelected = false,
scatterplotEnabled = true, scatterplotEnabled = true,
tasksByTypeChartEnabled = true, tasksByTypeChartEnabled = true,
customizableCycleAnalyticsEnabled = false,
} = {}) { } = {}) {
const func = shallow ? shallowMount : mount; const func = shallow ? shallowMount : mount;
const comp = func(Component, { const comp = func(Component, {
...@@ -46,6 +54,7 @@ function createComponent({ ...@@ -46,6 +54,7 @@ function createComponent({
glFeatures: { glFeatures: {
cycleAnalyticsScatterplotEnabled: scatterplotEnabled, cycleAnalyticsScatterplotEnabled: scatterplotEnabled,
tasksByTypeChart: tasksByTypeChartEnabled, tasksByTypeChart: tasksByTypeChartEnabled,
customizableCycleAnalytics: customizableCycleAnalyticsEnabled,
}, },
}, },
...opts, ...opts,
...@@ -166,7 +175,7 @@ describe('Cycle Analytics component', () => { ...@@ -166,7 +175,7 @@ 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 }); wrapper = createComponent({ withStageSelected: true, tasksByTypeChartEnabled: false });
}); });
it('hides the empty state', () => { it('hides the empty state', () => {
...@@ -180,7 +189,7 @@ describe('Cycle Analytics component', () => { ...@@ -180,7 +189,7 @@ describe('Cycle Analytics component', () => {
expect.objectContaining({ expect.objectContaining({
queryParams: wrapper.vm.$options.projectsQueryParams, queryParams: wrapper.vm.$options.projectsQueryParams,
groupId: mockData.group.id, groupId: mockData.group.id,
multiSelect: wrapper.vm.multiProjectSelect, multiSelect: wrapper.vm.$options.multiProjectSelect,
}), }),
); );
}); });
...@@ -218,6 +227,7 @@ describe('Cycle Analytics component', () => { ...@@ -218,6 +227,7 @@ describe('Cycle Analytics component', () => {
describe('with durationData', () => { describe('with durationData', () => {
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios);
wrapper.vm.$store.dispatch('setDateRange', { wrapper.vm.$store.dispatch('setDateRange', {
skipFetch: true, skipFetch: true,
startDate: mockData.startDate, startDate: mockData.startDate,
...@@ -248,14 +258,10 @@ describe('Cycle Analytics component', () => { ...@@ -248,14 +258,10 @@ describe('Cycle Analytics component', () => {
}, },
shallow: false, shallow: false,
withStageSelected: true, withStageSelected: true,
tasksByTypeChartEnabled: false,
}); });
}); });
afterEach(() => {
wrapper.destroy();
mock.restore();
});
it('has the first stage selected by default', () => { it('has the first stage selected by default', () => {
const first = selectStageNavItem(0); const first = selectStageNavItem(0);
const second = selectStageNavItem(1); const second = selectStageNavItem(1);
...@@ -281,6 +287,7 @@ describe('Cycle Analytics component', () => { ...@@ -281,6 +287,7 @@ describe('Cycle Analytics component', () => {
describe('the user does not have access to the group', () => { describe('the user does not have access to the group', () => {
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios);
wrapper.vm.$store.dispatch('setSelectedGroup', { wrapper.vm.$store.dispatch('setSelectedGroup', {
...mockData.group, ...mockData.group,
}); });
...@@ -321,19 +328,12 @@ describe('Cycle Analytics component', () => { ...@@ -321,19 +328,12 @@ describe('Cycle Analytics component', () => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
wrapper = createComponent({ wrapper = createComponent({
opts: { opts: {
stubs: { stubs: defaultStubs,
'summary-table': true,
'stage-event-list': true,
'stage-nav-item': true,
},
provide: {
glFeatures: {
customizableCycleAnalytics: true,
},
},
}, },
shallow: false, shallow: false,
withStageSelected: true, withStageSelected: true,
customizableCycleAnalyticsEnabled: true,
tasksByTypeChartEnabled: false,
}); });
}); });
...@@ -346,6 +346,53 @@ describe('Cycle Analytics component', () => { ...@@ -346,6 +346,53 @@ describe('Cycle Analytics component', () => {
expect(wrapper.find('.js-add-stage-button').exists()).toBe(true); expect(wrapper.find('.js-add-stage-button').exists()).toBe(true);
}); });
}); });
describe('with tasksByTypeChart=true', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent({
opts: {
stubs: defaultStubs,
},
shallow: false,
withStageSelected: true,
customizableCycleAnalyticsEnabled: false,
tasksByTypeChartEnabled: true,
});
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
it('displays the tasks by type chart', () => {
expect(wrapper.find('.js-tasks-by-type-chart').exists()).toBe(true);
});
});
describe('with tasksByTypeChart=false', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent({
opts: {
stubs: defaultStubs,
},
shallow: false,
withStageSelected: true,
customizableCycleAnalyticsEnabled: false,
tasksByTypeChartEnabled: false,
});
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
it('does not render the tasks by type chart', () => {
expect(wrapper.find('.js-tasks-by-type-chart').exists()).toBe(false);
});
});
}); });
}); });
...@@ -357,6 +404,7 @@ describe('Cycle Analytics component', () => { ...@@ -357,6 +404,7 @@ describe('Cycle Analytics component', () => {
mockFetchStageData = true, mockFetchStageData = true,
mockFetchStageMedian = true, mockFetchStageMedian = true,
mockFetchDurationData = true, mockFetchDurationData = true,
mockFetchTasksByTypeData = true,
}) { }) {
const defaultStatus = 200; const defaultStatus = 200;
const defaultRequests = { const defaultRequests = {
...@@ -375,14 +423,15 @@ describe('Cycle Analytics component', () => { ...@@ -375,14 +423,15 @@ describe('Cycle Analytics component', () => {
endpoint: `/groups/${groupId}/-/labels`, endpoint: `/groups/${groupId}/-/labels`,
response: [...mockData.groupLabels], response: [...mockData.groupLabels],
}, },
fetchTasksByTypeData: {
status: defaultStatus,
endpoint: '/-/analytics/type_of_work/tasks_by_type',
response: { ...mockData.tasksByTypeData },
},
...overrides, ...overrides,
}; };
if (mockFetchTasksByTypeData) {
mock
.onGet(/analytics\/type_of_work\/tasks_by_type/)
.reply(defaultStatus, { ...mockData.tasksByTypeData });
}
if (mockFetchDurationData) { if (mockFetchDurationData) {
mock mock
.onGet(/analytics\/cycle_analytics\/stages\/\d+\/duration_chart/) .onGet(/analytics\/cycle_analytics\/stages\/\d+\/duration_chart/)
...@@ -491,15 +540,7 @@ describe('Cycle Analytics component', () => { ...@@ -491,15 +540,7 @@ describe('Cycle Analytics component', () => {
it('will display an error if the fetchTasksByTypeData request fails', () => { it('will display an error if the fetchTasksByTypeData request fails', () => {
expect(findFlashError()).toBeNull(); expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({ mockRequestCycleAnalyticsData({ mockFetchTasksByTypeData: false });
overrides: {
fetchTasksByTypeData: {
endPoint: '/-/analytics/type_of_work/tasks_by_type',
status: httpStatusCodes.BAD_REQUEST,
response: { response: { status: httpStatusCodes.BAD_REQUEST } },
},
},
});
return selectGroupAndFindError( return selectGroupAndFindError(
'There was an error fetching data for the tasks by type chart', 'There was an error fetching data for the tasks by type chart',
......
import { shallowMount } from '@vue/test-utils';
import TasksByTypeChart from 'ee/analytics/cycle_analytics/components/tasks_by_type_chart.vue';
import { TASKS_BY_TYPE_SUBJECT_ISSUE } from 'ee/analytics/cycle_analytics/constants';
const seriesNames = ['Cool label', 'Normal label'];
const data = [[0, 1, 2], [5, 2, 3], [2, 4, 1]];
const groupBy = ['Group 1', 'Group 2', 'Group 3'];
const filters = {
selectedGroup: {
id: 22,
name: 'Gitlab Org',
fullName: 'Gitlab Org',
fullPath: 'gitlab-org',
},
selectedProjectIds: [],
startDate: new Date('2019-12-11'),
endDate: new Date('2020-01-10'),
subject: TASKS_BY_TYPE_SUBJECT_ISSUE,
selectedLabelIds: [1, 2, 3],
};
describe('TasksByTypeChart', () => {
function createComponent(props) {
return shallowMount(TasksByTypeChart, {
propsData: {
filters,
chartData: {
groupBy,
data,
seriesNames,
},
...props,
},
});
}
let wrapper = null;
afterEach(() => {
wrapper.destroy();
});
describe('with data available', () => {
beforeEach(() => {
wrapper = createComponent({});
});
it('should render the loading chart', () => {
expect(wrapper.html()).toMatchSnapshot();
});
});
describe('no data available', () => {
beforeEach(() => {
wrapper = createComponent({
chartData: {
groupBy: [],
data: [],
seriesNames: [],
},
});
});
it('should render the no data available message', () => {
expect(wrapper.html()).toMatchSnapshot();
});
});
});
...@@ -4,8 +4,10 @@ import mutations from 'ee/analytics/cycle_analytics/store/mutations'; ...@@ -4,8 +4,10 @@ import mutations from 'ee/analytics/cycle_analytics/store/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types'; import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { DEFAULT_DAYS_IN_PAST } from 'ee/analytics/cycle_analytics/constants'; import { DEFAULT_DAYS_IN_PAST } from 'ee/analytics/cycle_analytics/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime_utility'; import { getDateInPast, getDatesInRange } from '~/lib/utils/datetime_utility';
import { mockLabels } from '../../../../../spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data'; import { mockLabels } from '../../../../../spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data';
import { toYmd } from 'ee/analytics/shared/utils';
import { transformRawTasksByTypeData } from 'ee/analytics/cycle_analytics/utils';
const endpoints = { const endpoints = {
customizableCycleAnalyticsStagesAndEvents: 'analytics/cycle_analytics/stages.json', // customizable stages and events endpoint customizableCycleAnalyticsStagesAndEvents: 'analytics/cycle_analytics/stages.json', // customizable stages and events endpoint
...@@ -121,7 +123,21 @@ export const customStageEvents = [ ...@@ -121,7 +123,21 @@ export const customStageEvents = [
labelStopEvent, labelStopEvent,
]; ];
export const tasksByTypeData = getJSONFixture('analytics/type_of_work/tasks_by_type.json'); const dateRange = getDatesInRange(startDate, endDate, toYmd);
export const tasksByTypeData = getJSONFixture('analytics/type_of_work/tasks_by_type.json').map(
labelData => {
// add data points for our mock date range
const maxValue = 10;
const series = dateRange.map(date => [date, Math.floor(Math.random() * Math.floor(maxValue))]);
return {
...labelData,
series,
};
},
);
export const transformedTasksByTypeData = transformRawTasksByTypeData(tasksByTypeData);
export const rawDurationData = [ export const rawDurationData = [
{ {
......
...@@ -18,6 +18,7 @@ import { ...@@ -18,6 +18,7 @@ import {
customizableStagesAndEvents, customizableStagesAndEvents,
tasksByTypeData, tasksByTypeData,
transformedDurationData, transformedDurationData,
transformedTasksByTypeData,
} from '../mock_data'; } from '../mock_data';
let state = null; let state = null;
...@@ -32,34 +33,34 @@ describe('Cycle analytics mutations', () => { ...@@ -32,34 +33,34 @@ describe('Cycle analytics mutations', () => {
}); });
it.each` it.each`
mutation | stateKey | value mutation | stateKey | value
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${false} ${types.HIDE_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${true} ${types.SHOW_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${true}
${types.EDIT_CUSTOM_STAGE} | ${'isEditingCustomStage'} | ${true} ${types.EDIT_CUSTOM_STAGE} | ${'isEditingCustomStage'} | ${true}
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true} ${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
${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.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'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'stages'} | ${[]} ${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'stages'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'customStageFormEvents'} | ${[]} ${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'customStageFormEvents'} | ${[]} ${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_CREATE_CUSTOM_STAGE} | ${'isSavingCustomStage'} | ${true} ${types.REQUEST_CREATE_CUSTOM_STAGE} | ${'isSavingCustomStage'} | ${true}
${types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE} | ${'isSavingCustomStage'} | ${false} ${types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE} | ${'isSavingCustomStage'} | ${false}
${types.REQUEST_TASKS_BY_TYPE_DATA} | ${'isLoadingChartData'} | ${true} ${types.REQUEST_TASKS_BY_TYPE_DATA} | ${'isLoadingTasksByTypeChart'} | ${true}
${types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR} | ${'isLoadingChartData'} | ${false} ${types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR} | ${'isLoadingTasksByTypeChart'} | ${false}
${types.REQUEST_UPDATE_STAGE} | ${'isLoading'} | ${true} ${types.REQUEST_UPDATE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_UPDATE_STAGE_RESPONSE} | ${'isLoading'} | ${false} ${types.RECEIVE_UPDATE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_REMOVE_STAGE} | ${'isLoading'} | ${true} ${types.REQUEST_REMOVE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false} ${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_DURATION_DATA} | ${'isLoadingDurationChart'} | ${true} ${types.REQUEST_DURATION_DATA} | ${'isLoadingDurationChart'} | ${true}
${types.RECEIVE_DURATION_DATA_ERROR} | ${'isLoadingDurationChart'} | ${false} ${types.RECEIVE_DURATION_DATA_ERROR} | ${'isLoadingDurationChart'} | ${false}
${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}} ${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}} ${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => { `('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state); mutations[mutation](state);
...@@ -189,17 +190,17 @@ describe('Cycle analytics mutations', () => { ...@@ -189,17 +190,17 @@ describe('Cycle analytics mutations', () => {
}); });
describe(`${types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS}`, () => { describe(`${types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS}`, () => {
it('sets isLoadingChartData to false', () => { it('sets isLoadingTasksByTypeChart to false', () => {
mutations[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, {}); mutations[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, {});
expect(state.isLoadingChartData).toEqual(false); expect(state.isLoadingTasksByTypeChart).toEqual(false);
}); });
it('sets tasksByType.data to the raw returned chart data', () => { it('sets tasksByType.data to the raw returned chart data', () => {
state = { tasksByType: { data: null } }; state = { tasksByType: { data: null } };
mutations[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, tasksByTypeData); mutations[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, tasksByTypeData);
expect(state.tasksByType.data).toEqual(tasksByTypeData); expect(state.tasksByType.data).toEqual(transformedTasksByTypeData);
}); });
}); });
......
import { isNumber } from 'underscore';
import { getDatesInRange } from '~/lib/utils/datetime_utility';
import { import {
isStartEvent, isStartEvent,
isLabelEvent, isLabelEvent,
...@@ -10,7 +12,11 @@ import { ...@@ -10,7 +12,11 @@ import {
getDurationChartData, getDurationChartData,
transformRawStages, transformRawStages,
isPersistedStage, isPersistedStage,
getTasksByTypeData,
flattenTaskByTypeSeries,
orderByDate,
} from 'ee/analytics/cycle_analytics/utils'; } from 'ee/analytics/cycle_analytics/utils';
import { toYmd } from 'ee/analytics/shared/utils';
import { import {
customStageEvents as events, customStageEvents as events,
labelStartEvent, labelStartEvent,
...@@ -23,6 +29,7 @@ import { ...@@ -23,6 +29,7 @@ import {
endDate, endDate,
issueStage, issueStage,
rawCustomStage, rawCustomStage,
transformedTasksByTypeData,
} from './mock_data'; } from './mock_data';
const labelEvents = [labelStartEvent, labelStopEvent].map(i => i.identifier); const labelEvents = [labelStartEvent, labelStopEvent].map(i => i.identifier);
...@@ -199,4 +206,112 @@ describe('Cycle analytics utils', () => { ...@@ -199,4 +206,112 @@ describe('Cycle analytics utils', () => {
expect(isPersistedStage({ custom, id })).toEqual(expected); expect(isPersistedStage({ custom, id })).toEqual(expected);
}); });
}); });
describe('flattenTaskByTypeSeries', () => {
const dummySeries = Object.fromEntries([
['2019-01-16', 40],
['2019-01-14', 20],
['2019-01-12', 10],
['2019-01-15', 30],
]);
let transformedDummySeries = [];
beforeEach(() => {
transformedDummySeries = flattenTaskByTypeSeries(dummySeries);
});
it('extracts the value from an array of datetime / value pairs', () => {
expect(transformedDummySeries.every(isNumber)).toEqual(true);
Object.values(dummySeries).forEach(v => {
expect(transformedDummySeries.includes(v)).toBeTruthy();
});
});
it('sorts the items by the datetime parameter', () => {
expect(transformedDummySeries).toEqual([10, 20, 30, 40]);
});
});
describe('orderByDate', () => {
it('sorts dates from the earliest to latest', () => {
expect(['2019-01-14', '2019-01-12', '2019-01-16', '2019-01-15'].sort(orderByDate)).toEqual([
'2019-01-12',
'2019-01-14',
'2019-01-15',
'2019-01-16',
]);
});
});
describe('getTasksByTypeData', () => {
let transformed = {};
const groupBy = getDatesInRange(startDate, endDate, toYmd);
// only return the values, drop the date which is the first paramater
const extractSeriesValues = ({ series }) => series.map(kv => kv[1]);
const data = transformedTasksByTypeData.map(extractSeriesValues);
const labels = transformedTasksByTypeData.map(d => {
const { label } = d;
return label.title;
});
it('will return blank arrays if given no data', () => {
[{ data: [], startDate, endDate }, [], {}].forEach(chartData => {
transformed = getTasksByTypeData(chartData);
['seriesNames', 'data', 'groupBy'].forEach(key => {
expect(transformed[key]).toEqual([]);
});
});
});
describe('with data', () => {
beforeEach(() => {
transformed = getTasksByTypeData({ data: transformedTasksByTypeData, startDate, endDate });
});
it('will return an object with the properties needed for the chart', () => {
['seriesNames', 'data', 'groupBy'].forEach(key => {
expect(transformed).toHaveProperty(key);
});
});
describe('seriesNames', () => {
it('returns the names of all the labels in the dataset', () => {
expect(transformed.seriesNames).toEqual(labels);
});
});
describe('groupBy', () => {
it('returns the date groupBy as an array', () => {
expect(transformed.groupBy).toEqual(groupBy);
});
it('the start date is the first element', () => {
expect(transformed.groupBy[0]).toEqual(toYmd(startDate));
});
it('the end date is the last element', () => {
expect(transformed.groupBy[transformed.groupBy.length - 1]).toEqual(toYmd(endDate));
});
});
describe('data', () => {
it('returns an array of data points', () => {
expect(transformed.data).toEqual(data);
});
it('contains an array of data for each label', () => {
expect(transformed.data.length).toEqual(labels.length);
});
it('contains a value for each day in the groupBy', () => {
transformed.data.forEach(d => {
expect(d.length).toEqual(transformed.groupBy.length);
});
});
});
});
});
}); });
...@@ -5639,12 +5639,30 @@ msgstr "" ...@@ -5639,12 +5639,30 @@ msgstr ""
msgid "CycleAnalytics|No stages selected" msgid "CycleAnalytics|No stages selected"
msgstr "" msgstr ""
msgid "CycleAnalytics|Number of tasks"
msgstr ""
msgid "CycleAnalytics|Showing %{subject} and %{selectedLabelsCount} labels"
msgstr ""
msgid "CycleAnalytics|Showing data for group '%{groupName}' and %{selectedProjectCount} projects from %{startDate} to %{endDate}"
msgstr ""
msgid "CycleAnalytics|Showing data for group '%{groupName}' from %{startDate} to %{endDate}"
msgstr ""
msgid "CycleAnalytics|Stages" msgid "CycleAnalytics|Stages"
msgstr "" msgstr ""
msgid "CycleAnalytics|Tasks by type"
msgstr ""
msgid "CycleAnalytics|Total days to completion" msgid "CycleAnalytics|Total days to completion"
msgstr "" msgstr ""
msgid "CycleAnalytics|Type of work"
msgstr ""
msgid "CycleAnalytics|group dropdown filter" msgid "CycleAnalytics|group dropdown filter"
msgstr "" msgstr ""
...@@ -5687,6 +5705,9 @@ msgstr "" ...@@ -5687,6 +5705,9 @@ msgstr ""
msgid "Data is still calculating..." msgid "Data is still calculating..."
msgstr "" msgstr ""
msgid "Date"
msgstr ""
msgid "Date picker" msgid "Date picker"
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