Commit 9a3b2b9c authored by Phil Hughes's avatar Phil Hughes

Merge branch...

Merge branch '197337-value-stream-analytics-move-type-of-work-charts-to-separate-module' into 'master'

Move tasks by type components into separate directory

Closes #197337

See merge request gitlab-org/gitlab!28522
parents 15a5a2ad e2ff9db4
......@@ -10,7 +10,7 @@ import { LAST_ACTIVITY_AT, DATE_RANGE_LIMIT } from '../../shared/constants';
import DateRange from '../../shared/components/daterange.vue';
import StageTable from './stage_table.vue';
import DurationChart from './duration_chart.vue';
import TasksByTypeChart from './tasks_by_type_chart.vue';
import TypeOfWorkCharts from './type_of_work_charts.vue';
import UrlSyncMixin from '../../shared/mixins/url_sync_mixin';
import { toYmd } from '../../shared/utils';
import RecentActivityCard from './recent_activity_card.vue';
......@@ -25,7 +25,7 @@ export default {
GroupsDropdownFilter,
ProjectsDropdownFilter,
StageTable,
TasksByTypeChart,
TypeOfWorkCharts,
RecentActivityCard,
},
mixins: [glFeatureFlagsMixin(), UrlSyncMixin],
......@@ -53,6 +53,7 @@ export default {
'isLoading',
'isLoadingStage',
'isLoadingTasksByTypeChart',
'isLoadingTasksByTypeChartTopLabels',
'isEmptyStage',
'isSavingCustomStage',
'isCreatingCustomStage',
......@@ -93,11 +94,13 @@ export default {
shouldDisplayDurationChart() {
return this.featureFlags.hasDurationChart && !this.hasNoAccessError && !this.isLoading;
},
shouldDisplayTasksByTypeChart() {
shouldDisplayTypeOfWorkCharts() {
return this.featureFlags.hasTasksByTypeChart && !this.hasNoAccessError;
},
isTasksByTypeChartLoaded() {
return !this.isLoading && !this.isLoadingTasksByTypeChart;
isLoadingTypeOfWork() {
return (
this.isLoading || this.isLoadingTasksByTypeChartTopLabels || this.isLoadingTasksByTypeChart
);
},
hasDateRangeSet() {
return this.startDate && this.endDate;
......@@ -310,21 +313,14 @@ export default {
/>
</div>
</div>
<div v-if="shouldDisplayDurationChart" class="mt-3">
<duration-chart :stages="activeStages" />
</div>
<template v-if="shouldDisplayTasksByTypeChart">
<div class="js-tasks-by-type-chart">
<div v-if="isTasksByTypeChartLoaded">
<tasks-by-type-chart
:chart-data="tasksByTypeChartData"
:filters="selectedTasksByTypeFilters"
<duration-chart v-if="shouldDisplayDurationChart" class="mt-3" :stages="activeStages" />
<type-of-work-charts
v-if="shouldDisplayTypeOfWorkCharts"
:is-loading="isLoadingTypeOfWork"
:tasks-by-type-chart-data="tasksByTypeChartData"
:selected-tasks-by-type-filters="selectedTasksByTypeFilters"
@updateFilter="setTasksByTypeFilters"
/>
</div>
<gl-loading-icon v-else size="md" class="my-4 py-4" />
</div>
</template>
</div>
</div>
</template>
<script>
import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
export default {
name: 'TasksByTypeChart',
components: {
GlStackedColumnChart,
},
props: {
data: {
type: Array,
required: true,
},
groupBy: {
type: Array,
required: true,
},
seriesNames: {
type: Array,
required: true,
},
},
computed: {
hasData() {
return Boolean(this.data.length);
},
},
};
</script>
<template>
<gl-stacked-column-chart
v-if="hasData"
:data="data"
:group-by="groupBy"
x-axis-type="category"
y-axis-type="value"
:x-axis-title="__('Date')"
:y-axis-title="s__('CycleAnalytics|Number of tasks')"
:series-names="seriesNames"
/>
<div v-else class="bs-callout bs-callout-info">
<p>{{ __('There is no data available. Please change your selection.') }}</p>
</div>
</template>
......@@ -2,14 +2,14 @@
import { GlDropdownDivider, GlSegmentedControl, GlIcon } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import createFlash from '~/flash';
import { removeFlash } from '../utils';
import { removeFlash } from '../../utils';
import {
TASKS_BY_TYPE_FILTERS,
TASKS_BY_TYPE_SUBJECT_ISSUE,
TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS,
TASKS_BY_TYPE_MAX_LABELS,
} from '../constants';
import LabelsSelector from './labels_selector.vue';
} from '../../constants';
import LabelsSelector from '../labels_selector.vue';
export default {
name: 'TasksByTypeFilters',
......
<script>
import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import { GlLoadingIcon } from '@gitlab/ui';
import TasksByTypeChart from './tasks_by_type/tasks_by_type_chart.vue';
import TasksByTypeFilters from './tasks_by_type/tasks_by_type_filters.vue';
import { s__, sprintf } from '~/locale';
import { formattedDate } from '../../shared/utils';
import { TASKS_BY_TYPE_SUBJECT_ISSUE } from '../constants';
import TasksByTypeFilters from './tasks_by_type_filters.vue';
export default {
name: 'TasksByTypeChart',
components: {
GlStackedColumnChart,
TasksByTypeFilters,
},
name: 'TypeOfWorkCharts',
components: { GlLoadingIcon, TasksByTypeChart, TasksByTypeFilters },
props: {
filters: {
isLoading: {
type: Boolean,
required: true,
},
tasksByTypeChartData: {
type: Object,
required: true,
},
chartData: {
selectedTasksByTypeFilters: {
type: Object,
required: true,
},
},
computed: {
hasData() {
return Boolean(this.chartData?.data?.length);
},
summaryDescription() {
const {
startDate,
endDate,
selectedProjectIds,
selectedGroup: { name: groupName },
} = this.filters;
} = this.selectedTasksByTypeFilters;
const selectedProjectCount = selectedProjectIds.length;
const str =
......@@ -51,7 +50,7 @@ export default {
},
selectedSubjectFilter() {
const {
filters: { subject },
selectedTasksByTypeFilters: { subject },
} = this;
return subject || TASKS_BY_TYPE_SUBJECT_ISSUE;
},
......@@ -59,29 +58,21 @@ export default {
};
</script>
<template>
<div class="row">
<div class="col-12">
<div class="js-tasks-by-type-chart row">
<gl-loading-icon v-if="isLoading" size="md" class="col-12 my-4 py-4" />
<div v-else class="col-12">
<h3>{{ s__('CycleAnalytics|Type of work') }}</h3>
<div v-if="hasData">
<p>{{ summaryDescription }}</p>
<tasks-by-type-filters
:selected-label-ids="filters.selectedLabelIds"
:selected-label-ids="selectedTasksByTypeFilters.selectedLabelIds"
:subject-filter="selectedSubjectFilter"
@updateFilter="$emit('updateFilter', $event)"
/>
<gl-stacked-column-chart
:data="chartData.data"
:group-by="chartData.groupBy"
x-axis-type="category"
y-axis-type="value"
:x-axis-title="__('Date')"
:y-axis-title="s__('CycleAnalytics|Number of tasks')"
:series-names="chartData.seriesNames"
<tasks-by-type-chart
:data="tasksByTypeChartData.data"
:group-by="tasksByTypeChartData.groupBy"
:series-names="tasksByTypeChartData.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>
......@@ -295,18 +295,12 @@ export const createCustomStage = ({ dispatch, state }, data) => {
});
};
export const receiveTasksByTypeDataSuccess = ({ commit }, data) => {
commit(types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS, data);
};
export const receiveTasksByTypeDataError = ({ commit }, error) => {
commit(types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR, error);
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 fetchTasksByTypeData = ({ dispatch, state, getters }) => {
export const fetchTasksByTypeData = ({ dispatch, commit, state, getters }) => {
const {
currentGroupPath,
cycleAnalyticsRequestParams: { created_after, created_before, project_ids },
......@@ -316,6 +310,9 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
tasksByType: { subject, selectedLabelIds },
} = state;
// ensure we clear any chart data currently in state
commit(types.REQUEST_TASKS_BY_TYPE_DATA);
// dont request if we have no labels selected...for now
if (selectedLabelIds.length) {
const params = {
......@@ -326,13 +323,11 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
label_ids: selectedLabelIds,
};
dispatch('requestTasksByTypeData');
return Api.cycleAnalyticsTasksByType(currentGroupPath, params)
.then(({ data }) => dispatch('receiveTasksByTypeDataSuccess', data))
.then(({ data }) => commit(types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS, data))
.catch(error => dispatch('receiveTasksByTypeDataError', error));
}
return Promise.resolve();
return commit(types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS, []);
};
export const requestUpdateStage = ({ commit }) => commit(types.REQUEST_UPDATE_STAGE);
......@@ -403,7 +398,7 @@ export const removeStage = ({ dispatch, state }, stageId) => {
export const setTasksByTypeFilters = ({ dispatch, commit }, data) => {
commit(types.SET_TASKS_BY_TYPE_FILTERS, data);
dispatch('fetchTasksByTypeData');
dispatch('fetchTopRankedGroupLabels');
};
export const initializeCycleAnalyticsSuccess = ({ commit }) =>
......
......@@ -50,6 +50,7 @@ export default {
state.isLoadingStage = false;
},
[types.REQUEST_TOP_RANKED_GROUP_LABELS](state) {
state.isLoadingTasksByTypeChartTopLabels = true;
state.topRankedLabels = [];
state.tasksByType = {
...state.tasksByType,
......@@ -58,6 +59,7 @@ export default {
},
[types.RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS](state, data = []) {
const { tasksByType } = state;
state.isLoadingTasksByTypeChartTopLabels = false;
state.topRankedLabels = data.map(convertObjectPropsToCamelCase);
state.tasksByType = {
...tasksByType,
......@@ -66,6 +68,7 @@ export default {
},
[types.RECEIVE_TOP_RANKED_GROUP_LABELS_ERROR](state) {
const { tasksByType } = state;
state.isLoadingTasksByTypeChartTopLabels = false;
state.topRankedLabels = [];
state.tasksByType = {
...tasksByType,
......@@ -130,7 +133,7 @@ export default {
[types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR](state) {
state.isLoadingTasksByTypeChart = false;
},
[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, data) {
[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, data = []) {
state.isLoadingTasksByTypeChart = false;
state.tasksByType = {
...state.tasksByType,
......
......@@ -9,6 +9,7 @@ export default () => ({
isLoading: false,
isLoadingStage: false,
isLoadingTasksByTypeChart: false,
isLoadingTasksByTypeChartTopLabels: false,
isEmptyStage: false,
errorCode: null,
......
// 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>
<tasks-by-type-filters-stub selectedlabelids=\\"1,2,3\\" maxlabels=\\"15\\" subjectfilter=\\"Issue\\"></tasks-by-type-filters-stub>
<gl-stacked-column-chart-stub data=\\"0,1,2,5,2,3,2,4,1\\" option=\\"[object Object]\\" presentation=\\"stacked\\" groupby=\\"Group 1,Group 2,Group 3\\" xaxistype=\\"category\\" xaxistitle=\\"Date\\" yaxistitle=\\"Number of tasks\\" seriesnames=\\"Cool label,Normal label\\" legendaveragetext=\\"Avg\\" legendmaxtext=\\"Max\\" y-axis-type=\\"value\\"></gl-stacked-column-chart-stub>
</div>
</div>
</div>"
`;
......@@ -14,7 +14,7 @@ import 'bootstrap';
import '~/gl_dropdown';
import DurationChart from 'ee/analytics/cycle_analytics/components/duration_chart.vue';
import Daterange from 'ee/analytics/shared/components/daterange.vue';
import TasksByTypeChart from 'ee/analytics/cycle_analytics/components/tasks_by_type_chart.vue';
import TypeOfWorkCharts from 'ee/analytics/cycle_analytics/components/type_of_work_charts.vue';
import waitForPromises from 'helpers/wait_for_promises';
import httpStatusCodes from '~/lib/utils/http_status';
import * as commonUtils from '~/lib/utils/common_utils';
......@@ -132,8 +132,8 @@ describe('Cycle Analytics component', () => {
expect(wrapper.find(DurationChart).exists()).toBe(flag);
};
const displaysTasksByType = flag => {
expect(wrapper.find(TasksByTypeChart).exists()).toBe(flag);
const displaysTypeOfWork = flag => {
expect(wrapper.find(TypeOfWorkCharts).exists()).toBe(flag);
};
beforeEach(() => {
......@@ -343,7 +343,7 @@ describe('Cycle Analytics component', () => {
});
it('does not display the tasks by type chart', () => {
displaysTasksByType(false);
displaysTypeOfWork(false);
});
it('does not display the duration chart', () => {
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TasksByTypeChart no data available should render the no data available message 1`] = `
"<div class=\\"bs-callout bs-callout-info\\">
<p>There is no data available. Please change your selection.</p>
</div>"
`;
exports[`TasksByTypeChart with data available should render the loading chart 1`] = `"<gl-stacked-column-chart-stub data=\\"0,1,2,5,2,3,2,4,1\\" option=\\"[object Object]\\" presentation=\\"stacked\\" groupby=\\"Group 1,Group 2,Group 3\\" xaxistype=\\"category\\" xaxistitle=\\"Date\\" yaxistitle=\\"Number of tasks\\" seriesnames=\\"Cool label,Normal label\\" legendaveragetext=\\"Avg\\" legendmaxtext=\\"Max\\" y-axis-type=\\"value\\"></gl-stacked-column-chart-stub>"`;
import { mount, 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';
import TasksByTypeChart from 'ee/analytics/cycle_analytics/components/tasks_by_type/tasks_by_type_chart.vue';
import { tasksByTypeData } from '../../mock_data';
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],
};
const { groupBy, data, seriesNames } = tasksByTypeData;
function createComponent({ props = {}, shallow = true, stubs = {} }) {
const fn = shallow ? shallowMount : mount;
return fn(TasksByTypeChart, {
propsData: {
filters,
chartData: {
groupBy,
data,
seriesNames,
},
...props,
},
stubs: {
......@@ -60,12 +42,10 @@ describe('TasksByTypeChart', () => {
beforeEach(() => {
wrapper = createComponent({
props: {
chartData: {
groupBy: [],
data: [],
seriesNames: [],
},
},
});
});
......
......@@ -3,7 +3,7 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
import { GlDropdownItem, GlSegmentedControl } from '@gitlab/ui';
import TasksByTypeFilters from 'ee/analytics/cycle_analytics/components/tasks_by_type_filters.vue';
import TasksByTypeFilters from 'ee/analytics/cycle_analytics/components/tasks_by_type/tasks_by_type_filters.vue';
import LabelsSelector from 'ee/analytics/cycle_analytics/components/labels_selector.vue';
import {
TASKS_BY_TYPE_SUBJECT_ISSUE,
......@@ -11,8 +11,8 @@ import {
TASKS_BY_TYPE_FILTERS,
} from 'ee/analytics/cycle_analytics/constants';
import waitForPromises from 'helpers/wait_for_promises';
import { shouldFlashAMessage } from '../helpers';
import { groupLabels } from '../mock_data';
import { shouldFlashAMessage } from '../../helpers';
import { groupLabels } from '../../mock_data';
import createStore from 'ee/analytics/cycle_analytics/store';
import * as getters from 'ee/analytics/cycle_analytics/store/getters';
......
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import TypeOfWorkCharts from 'ee/analytics/cycle_analytics/components/type_of_work_charts.vue';
import TasksByTypeChart from 'ee/analytics/cycle_analytics/components/tasks_by_type/tasks_by_type_chart.vue';
import TasksByTypeFilters from 'ee/analytics/cycle_analytics/components/tasks_by_type/tasks_by_type_filters.vue';
import { tasksByTypeData, taskByTypeFilters } from '../mock_data';
import {
TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST,
TASKS_BY_TYPE_FILTERS,
} from 'ee/analytics/cycle_analytics/constants';
describe('TypeOfWorkCharts', () => {
function createComponent({ props = {}, stubs = {} } = {}) {
return shallowMount(TypeOfWorkCharts, {
propsData: {
isLoading: false,
tasksByTypeChartData: tasksByTypeData,
selectedTasksByTypeFilters: taskByTypeFilters,
...props,
},
stubs: {
TasksByTypeChart: false,
TasksByTypeFilters: false,
...stubs,
},
});
}
let wrapper = null;
const findSubjectFilters = _wrapper => _wrapper.find(TasksByTypeFilters);
const findTasksByTypeChart = _wrapper => _wrapper.find(TasksByTypeChart);
const findLoader = _wrapper => _wrapper.find(GlLoadingIcon);
afterEach(() => {
wrapper.destroy();
});
describe('with data', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the task by type chart', () => {
expect(findTasksByTypeChart(wrapper).exists()).toBe(true);
});
it('does not render the loading icon', () => {
expect(findLoader(wrapper).exists()).toBe(false);
});
});
describe('when a filter is selected', () => {
const payload = {
filter: TASKS_BY_TYPE_FILTERS.SUBJECT,
value: TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST,
};
beforeEach(() => {
wrapper = createComponent();
findSubjectFilters(wrapper).vm.$emit('updateFilter', payload);
return wrapper.vm.$nextTick();
});
it('emits the `updateFilter` event', () => {
expect(wrapper.emitted('updateFilter')).toBeDefined();
expect(wrapper.emitted('updateFilter')[0]).toEqual([payload]);
});
});
describe('while loading', () => {
beforeEach(() => {
wrapper = createComponent({ props: { isLoading: true } });
});
it('renders loading icon', () => {
expect(findLoader(wrapper).exists()).toBe(true);
});
});
});
......@@ -3,7 +3,10 @@ import { TEST_HOST } from 'helpers/test_constants';
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 { DEFAULT_DAYS_IN_PAST } from 'ee/analytics/cycle_analytics/constants';
import {
DEFAULT_DAYS_IN_PAST,
TASKS_BY_TYPE_SUBJECT_ISSUE,
} from 'ee/analytics/cycle_analytics/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { getDateInPast, getDatesInRange } from '~/lib/utils/datetime_utility';
import { toYmd } from 'ee/analytics/shared/utils';
......@@ -142,7 +145,7 @@ export const customStageFormErrors = convertObjectPropsToCamelCase(rawCustomStag
const dateRange = getDatesInRange(startDate, endDate, toYmd);
export const tasksByTypeData = getJSONFixture('analytics/type_of_work/tasks_by_type.json').map(
export const rawTasksByTypeData = getJSONFixture('analytics/type_of_work/tasks_by_type.json').map(
labelData => {
// add data points for our mock date range
const maxValue = 10;
......@@ -154,7 +157,27 @@ export const tasksByTypeData = getJSONFixture('analytics/type_of_work/tasks_by_t
},
);
export const transformedTasksByTypeData = transformRawTasksByTypeData(tasksByTypeData);
export const transformedTasksByTypeData = transformRawTasksByTypeData(rawTasksByTypeData);
export const tasksByTypeData = {
seriesNames: ['Cool label', 'Normal label'],
data: [[0, 1, 2], [5, 2, 3], [2, 4, 1]],
groupBy: ['Group 1', 'Group 2', 'Group 3'],
};
export const taskByTypeFilters = {
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],
};
export const rawDurationData = [
{
......
......@@ -849,7 +849,7 @@ describe('Cycle analytics actions', () => {
const filter = TASKS_BY_TYPE_FILTERS.SUBJECT;
const value = 'issue';
it(`commits the ${types.SET_TASKS_BY_TYPE_FILTERS} mutation and dispatches 'fetchTasksByTypeData'`, done => {
it(`commits the ${types.SET_TASKS_BY_TYPE_FILTERS} mutation and dispatches 'fetchTopRankedGroupLabels'`, done => {
testAction(
actions.setTasksByTypeFilters,
{ filter, value },
......@@ -862,7 +862,7 @@ describe('Cycle analytics actions', () => {
],
[
{
type: 'fetchTasksByTypeData',
type: 'fetchTopRankedGroupLabels',
},
],
done,
......
......@@ -15,7 +15,7 @@ import {
startDate,
endDate,
customizableStagesAndEvents,
tasksByTypeData,
rawTasksByTypeData,
transformedTasksByTypeData,
selectedProjects,
} from '../mock_data';
......@@ -185,7 +185,7 @@ describe('Cycle analytics mutations', () => {
it('sets tasksByType.data to the raw returned chart data', () => {
state = { tasksByType: { data: null } };
mutations[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, tasksByTypeData);
mutations[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, rawTasksByTypeData);
expect(state.tasksByType.data).toEqual(transformedTasksByTypeData);
});
......
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