Commit b85f9cc8 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'mw-cr-filter-store' into 'master'

Code Review Analytics: Add filters store

See merge request gitlab-org/gitlab!30361
parents f97b60b3 9fe94507
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import * as types from './mutation_types'; import * as types from './mutation_types';
// eslint-disable-next-line import/prefer-default-export export const setMilestonesEndpoint = ({ commit }, milestonesEndpoint) =>
commit(types.SET_MILESTONES_ENDPOINT, milestonesEndpoint);
export const setLabelsEndpoint = ({ commit }, labelsEndpoint) =>
commit(types.SET_LABELS_ENDPOINT, labelsEndpoint);
export const fetchMilestones = ({ commit, state }) => {
commit(types.REQUEST_MILESTONES);
return axios
.get(state.milestonesEndpoint)
.then(({ data }) => {
commit(types.RECEIVE_MILESTONES_SUCCESS, data);
})
.catch(({ response }) => {
const { status } = response;
commit(types.RECEIVE_MILESTONES_ERROR, status);
createFlash(__('Failed to load milestones. Please try again.'));
});
};
export const fetchLabels = ({ commit, state }) => {
commit(types.REQUEST_LABELS);
return axios
.get(state.labelsEndpoint)
.then(({ data }) => {
commit(types.RECEIVE_LABELS_SUCCESS, data);
})
.catch(({ response }) => {
const { status } = response;
commit(types.RECEIVE_LABELS_ERROR, status);
createFlash(__('Failed to load labels. Please try again.'));
});
};
export const setFilters = ({ commit, dispatch }, { label_name, milestone_title }) => { export const setFilters = ({ commit, dispatch }, { label_name, milestone_title }) => {
commit(types.SET_FILTERS, { labelName: label_name, milestoneTitle: milestone_title }); commit(types.SET_FILTERS, { selectedLabels: label_name, selectedMilestone: milestone_title });
dispatch('mergeRequests/setPage', 1, { root: true }); dispatch('mergeRequests/setPage', 1, { root: true });
dispatch('mergeRequests/fetchMergeRequests', null, { root: true }); dispatch('mergeRequests/fetchMergeRequests', null, { root: true });
......
// eslint-disable-next-line import/prefer-default-export export const SET_MILESTONES_ENDPOINT = 'SET_MILESTONES_ENDPOINT';
export const SET_LABELS_ENDPOINT = 'SET_LABELS_ENDPOINT';
export const REQUEST_MILESTONES = 'REQUEST_MILESTONES';
export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
export const RECEIVE_MILESTONES_ERROR = 'RECEIVE_MILESTONES_ERROR';
export const REQUEST_LABELS = 'REQUEST_LABELS';
export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
export const RECEIVE_LABELS_ERROR = 'RECEIVE_LABELS_ERROR';
export const SET_FILTERS = 'SET_FILTERS'; export const SET_FILTERS = 'SET_FILTERS';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.SET_FILTERS](state, { labelName, milestoneTitle }) { [types.SET_MILESTONES_ENDPOINT](state, milestonesEndpoint) {
state.labelName = labelName; state.milestonesEndpoint = milestonesEndpoint;
state.milestoneTitle = milestoneTitle; },
[types.SET_LABELS_ENDPOINT](state, labelsEndpoint) {
state.labelsEndpoint = labelsEndpoint;
},
[types.REQUEST_MILESTONES](state) {
state.milestones.isLoading = true;
},
[types.RECEIVE_MILESTONES_SUCCESS](state, data) {
state.milestones.isLoading = false;
state.milestones.data = data;
state.milestones.errorCode = null;
},
[types.RECEIVE_MILESTONES_ERROR](state, errorCode) {
state.milestones.isLoading = false;
state.milestones.errorCode = errorCode;
state.milestones.data = [];
},
[types.REQUEST_LABELS](state) {
state.labels.isLoading = true;
},
[types.RECEIVE_LABELS_SUCCESS](state, data) {
state.labels.isLoading = false;
state.labels.data = data;
state.labels.errorCode = null;
},
[types.RECEIVE_LABELS_ERROR](state, errorCode) {
state.labels.isLoading = false;
state.labels.errorCode = errorCode;
state.labels.data = [];
},
[types.SET_FILTERS](state, { selectedLabels, selectedMilestone }) {
state.labels.selected = selectedLabels;
state.milestones.selected = selectedMilestone;
}, },
}; };
export default () => ({ export default () => ({
labelName: [], milestonePath: '',
milestoneTitle: null, labelsPath: '',
milestones: {
isLoading: false,
data: [],
errorCode: null,
selected: null,
},
labels: {
isLoading: false,
data: [],
errorCode: null,
selected: [],
},
}); });
...@@ -11,7 +11,8 @@ export const fetchMergeRequests = ({ dispatch, state, rootState }) => { ...@@ -11,7 +11,8 @@ export const fetchMergeRequests = ({ dispatch, state, rootState }) => {
const { projectId, pageInfo } = state; const { projectId, pageInfo } = state;
const { milestoneTitle, labelName } = rootState.filters; const { selected: milestoneTitle } = rootState.filters.milestones;
const { selected: labelName } = rootState.filters.labels;
const params = { const params = {
project_id: projectId, project_id: projectId,
......
...@@ -2,6 +2,6 @@ export const SET_PROJECT_ID = 'SET_PROJECT_ID'; ...@@ -2,6 +2,6 @@ export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const REQUEST_MERGE_REQUESTS = 'REQUEST_MERGE_REQUESTS'; export const REQUEST_MERGE_REQUESTS = 'REQUEST_MERGE_REQUESTS';
export const RECEIVE_MERGE_REQUESTS_SUCCESS = 'RECEIVE_MERGE_REQUESTS_SUCCESS'; export const RECEIVE_MERGE_REQUESTS_SUCCESS = 'RECEIVE_MERGE_REQUESTS_SUCCESS';
export const RECEIVE_MERGE_REQUESTS_ERROR = 'RECEIVE_MERGE_REQUESTS_ERRORR'; export const RECEIVE_MERGE_REQUESTS_ERROR = 'RECEIVE_MERGE_REQUESTS_ERROR';
export const SET_PAGE = 'SET_PAGE'; export const SET_PAGE = 'SET_PAGE';
...@@ -3,7 +3,7 @@ import Vuex from 'vuex'; ...@@ -3,7 +3,7 @@ import Vuex from 'vuex';
import { GlTable } from '@gitlab/ui'; import { GlTable } from '@gitlab/ui';
import MergeRequestTable from 'ee/analytics/code_review_analytics/components/merge_request_table.vue'; import MergeRequestTable from 'ee/analytics/code_review_analytics/components/merge_request_table.vue';
import createState from 'ee/analytics/code_review_analytics/store/modules/merge_requests/state'; import createState from 'ee/analytics/code_review_analytics/store/modules/merge_requests/state';
import mergeRequests from '../mock_data'; import { mockMergeRequests } from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -51,7 +51,7 @@ describe('MergeRequestTable component', () => { ...@@ -51,7 +51,7 @@ describe('MergeRequestTable component', () => {
.at(1); .at(1);
const updateMergeRequests = (index, attrs) => const updateMergeRequests = (index, attrs) =>
mergeRequests.map((item, idx) => { mockMergeRequests.map((item, idx) => {
if (idx !== index) { if (idx !== index) {
return item; return item;
} }
...@@ -66,7 +66,7 @@ describe('MergeRequestTable component', () => { ...@@ -66,7 +66,7 @@ describe('MergeRequestTable component', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(global, 'Date').mockImplementationOnce(() => new Date('2020-03-09T11:01:58.135Z')); jest.spyOn(global, 'Date').mockImplementationOnce(() => new Date('2020-03-09T11:01:58.135Z'));
bootstrap({ mergeRequests }); bootstrap({ mergeRequests: mockMergeRequests });
}); });
it('renders the GlTable component', () => { it('renders the GlTable component', () => {
......
const mergeRequests = [ export const mockMergeRequests = [
{ {
title: title:
'This is just a super long merge request title that does not fit into one line so it needs to be truncated', 'This is just a super long merge request title that does not fit into one line so it needs to be truncated',
...@@ -34,4 +34,40 @@ const mergeRequests = [ ...@@ -34,4 +34,40 @@ const mergeRequests = [
}, },
]; ];
export default mergeRequests; export const mockMilestones = [
{
id: 41,
title: 'Sprint - Eligendi et aut pariatur ab rerum vel.',
project_id: 1,
description: 'Accusamus qui sapiente porro et in voluptates.',
due_date: '2020-01-14',
created_at: '2020-01-08T15:47:37.697Z',
updated_at: '2020-01-08T15:47:37.697Z',
state: 'active',
iid: 6,
start_date: '2020-01-08',
group_id: null,
name: 'Sprint - Eligendi et aut pariatur ab rerum vel.',
},
{
id: 5,
title: 'v4.0',
project_id: 1,
description: 'Atque laudantium reiciendis consequatur temporibus qui qui.',
due_date: null,
created_at: '2020-01-18T15:46:07.448Z',
updated_at: '2020-01-18T15:46:07.448Z',
state: 'active',
iid: 5,
start_date: null,
group_id: null,
name: 'v4.0',
},
];
export const mockLabels = [
[
{ id: 74, title: 'Alero', color: '#6235f2', text_color: '#FFFFFF' },
{ id: 9, title: 'Amsche', color: '#581cc8', text_color: '#FFFFFF' },
],
];
...@@ -4,6 +4,10 @@ import testAction from 'helpers/vuex_action_helper'; ...@@ -4,6 +4,10 @@ import testAction from 'helpers/vuex_action_helper';
import * as actions from 'ee/analytics/code_review_analytics/store/modules/filters/actions'; import * as actions from 'ee/analytics/code_review_analytics/store/modules/filters/actions';
import * as types from 'ee/analytics/code_review_analytics/store/modules/filters/mutation_types'; import * as types from 'ee/analytics/code_review_analytics/store/modules/filters/mutation_types';
import getInitialState from 'ee/analytics/code_review_analytics/store/modules/filters/state'; import getInitialState from 'ee/analytics/code_review_analytics/store/modules/filters/state';
import createFlash from '~/flash';
import { mockMilestones, mockLabels } from '../../../mock_data';
jest.mock('~/flash', () => jest.fn());
describe('Code review analytics filters actions', () => { describe('Code review analytics filters actions', () => {
let state; let state;
...@@ -18,19 +22,145 @@ describe('Code review analytics filters actions', () => { ...@@ -18,19 +22,145 @@ describe('Code review analytics filters actions', () => {
mock.restore(); mock.restore();
}); });
describe('setMilestonesEndpoint', () => {
it('commits the SET_MILESTONES_ENDPOINT mutation', () =>
testAction(
actions.setMilestonesEndpoint,
'milestone_path',
state,
[
{
type: types.SET_MILESTONES_ENDPOINT,
payload: 'milestone_path',
},
],
[],
));
});
describe('setLabelsEndpoint', () => {
it('commits the SET_LABELS_ENDPOINT mutation', () =>
testAction(
actions.setLabelsEndpoint,
'labels_path',
state,
[
{
type: types.SET_LABELS_ENDPOINT,
payload: 'labels_path',
},
],
[],
));
});
describe('fetchMilestones', () => {
describe('success', () => {
beforeEach(() => {
mock.onGet(state.milestonesEndpoint).replyOnce(200, mockMilestones);
});
it('dispatches success with received data', () => {
testAction(
actions.fetchMilestones,
null,
state,
[
{ type: types.REQUEST_MILESTONES },
{ type: types.RECEIVE_MILESTONES_SUCCESS, payload: mockMilestones },
],
[],
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onGet(state.milestonesEndpoint).replyOnce(500);
});
it('dispatches error', done => {
testAction(
actions.fetchMilestones,
null,
state,
[
{ type: types.REQUEST_MILESTONES },
{
type: types.RECEIVE_MILESTONES_ERROR,
payload: 500,
},
],
[],
() => {
expect(createFlash).toHaveBeenCalled();
done();
},
);
});
});
});
describe('fetchLabels', () => {
describe('success', () => {
beforeEach(() => {
mock.onGet(state.labelsEndpoint).replyOnce(200, mockLabels);
});
it('dispatches success with received data', () => {
testAction(
actions.fetchLabels,
null,
state,
[
{ type: types.REQUEST_LABELS },
{ type: types.RECEIVE_LABELS_SUCCESS, payload: mockLabels },
],
[],
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onGet(state.labelsEndpoint).replyOnce(500);
});
it('dispatches error', done => {
testAction(
actions.fetchLabels,
null,
state,
[
{ type: types.REQUEST_LABELS },
{
type: types.RECEIVE_LABELS_ERROR,
payload: 500,
},
],
[],
() => {
expect(createFlash).toHaveBeenCalled();
done();
},
);
});
});
});
describe('setFilters', () => { describe('setFilters', () => {
const milestoneTitle = 'my milestone'; const selectedMilestone = 'my milestone';
const labelName = ['first label', 'second label']; const selectedLabels = ['first label', 'second label'];
it('commits the SET_FILTERS mutation', () => { it('commits the SET_FILTERS mutation', () => {
testAction( testAction(
actions.setFilters, actions.setFilters,
{ milestone_title: milestoneTitle, label_name: labelName }, { milestone_title: selectedMilestone, label_name: selectedLabels },
state, state,
[ [
{ {
type: types.SET_FILTERS, type: types.SET_FILTERS,
payload: { milestoneTitle, labelName }, payload: { selectedMilestone, selectedLabels },
}, },
], ],
[ [
......
import * as types from 'ee/analytics/code_review_analytics/store/modules/filters/mutation_types'; import * as types from 'ee/analytics/code_review_analytics/store/modules/filters/mutation_types';
import mutations from 'ee/analytics/code_review_analytics/store/modules/filters/mutations'; import mutations from 'ee/analytics/code_review_analytics/store/modules/filters/mutations';
import getInitialState from 'ee/analytics/code_review_analytics/store/modules/filters/state'; import getInitialState from 'ee/analytics/code_review_analytics/store/modules/filters/state';
import { mockMilestones, mockLabels } from '../../../mock_data';
describe('Code review analytics filters mutations', () => { describe('Code review analytics filters mutations', () => {
let state; let state;
...@@ -12,12 +13,109 @@ describe('Code review analytics filters mutations', () => { ...@@ -12,12 +13,109 @@ describe('Code review analytics filters mutations', () => {
state = getInitialState(); state = getInitialState();
}); });
describe(types.SET_MILESTONES_ENDPOINT, () => {
it('sets the milestone path', () => {
mutations[types.SET_MILESTONES_ENDPOINT](state, 'milestone_path');
expect(state.milestonesEndpoint).toEqual('milestone_path');
});
});
describe(types.SET_LABELS_ENDPOINT, () => {
it('sets the labels path', () => {
mutations[types.SET_LABELS_ENDPOINT](state, 'labels_path');
expect(state.labelsEndpoint).toEqual('labels_path');
});
});
describe(types.REQUEST_MILESTONES, () => {
it('sets isLoading to true', () => {
mutations[types.REQUEST_MILESTONES](state);
expect(state.milestones.isLoading).toBe(true);
});
});
describe(types.RECEIVE_MILESTONES_SUCCESS, () => {
beforeEach(() => {
mutations[types.RECEIVE_MILESTONES_SUCCESS](state, mockMilestones);
});
it.each`
stateProp | value
${'isLoading'} | ${false}
${'errorCode'} | ${null}
${'data'} | ${mockMilestones}
`('sets $stateProp to $value', ({ stateProp, value }) => {
expect(state.milestones[stateProp]).toEqual(value);
});
});
describe(types.RECEIVE_MILESTONES_ERROR, () => {
const errorCode = 500;
beforeEach(() => {
mutations[types.RECEIVE_MILESTONES_ERROR](state, errorCode);
});
it.each`
stateProp | value
${'isLoading'} | ${false}
${'errorCode'} | ${errorCode}
${'data'} | ${[]}
`('sets $stateProp to $value', ({ stateProp, value }) => {
expect(state.milestones[stateProp]).toEqual(value);
});
});
describe(types.REQUEST_LABELS, () => {
it('sets isLoading to true', () => {
mutations[types.REQUEST_LABELS](state);
expect(state.labels.isLoading).toBe(true);
});
});
describe(types.RECEIVE_LABELS_SUCCESS, () => {
beforeEach(() => {
mutations[types.RECEIVE_LABELS_SUCCESS](state, mockLabels);
});
it.each`
stateProp | value
${'isLoading'} | ${false}
${'errorCode'} | ${null}
${'data'} | ${mockLabels}
`('sets $stateProp to $value', ({ stateProp, value }) => {
expect(state.labels[stateProp]).toEqual(value);
});
});
describe(types.RECEIVE_LABELS_ERROR, () => {
const errorCode = 500;
beforeEach(() => {
mutations[types.RECEIVE_LABELS_ERROR](state, errorCode);
});
it.each`
stateProp | value
${'isLoading'} | ${false}
${'errorCode'} | ${errorCode}
${'data'} | ${[]}
`('sets $stateProp to $value', ({ stateProp, value }) => {
expect(state.labels[stateProp]).toEqual(value);
});
});
describe(types.SET_FILTERS, () => { describe(types.SET_FILTERS, () => {
it('updates milestoneTitle and labelName', () => { it('updates selected milestone and labels', () => {
mutations[types.SET_FILTERS](state, { milestoneTitle, labelName }); mutations[types.SET_FILTERS](state, {
selectedMilestone: milestoneTitle,
selectedLabels: labelName,
});
expect(state.milestoneTitle).toBe(milestoneTitle); expect(state.milestones.selected).toBe(milestoneTitle);
expect(state.labelName).toBe(labelName); expect(state.labels.selected).toBe(labelName);
}); });
}); });
}); });
...@@ -5,7 +5,7 @@ import * as actions from 'ee/analytics/code_review_analytics/store/modules/merge ...@@ -5,7 +5,7 @@ import * as actions from 'ee/analytics/code_review_analytics/store/modules/merge
import * as types from 'ee/analytics/code_review_analytics/store/modules/merge_requests/mutation_types'; import * as types from 'ee/analytics/code_review_analytics/store/modules/merge_requests/mutation_types';
import getInitialState from 'ee/analytics/code_review_analytics/store/modules/merge_requests/state'; import getInitialState from 'ee/analytics/code_review_analytics/store/modules/merge_requests/state';
import createFlash from '~/flash'; import createFlash from '~/flash';
import mockMergeRequests from '../../../mock_data'; import { mockMergeRequests } from '../../../mock_data';
jest.mock('~/flash', () => jest.fn()); jest.mock('~/flash', () => jest.fn());
...@@ -34,8 +34,8 @@ describe('Code review analytics mergeRequests actions', () => { ...@@ -34,8 +34,8 @@ describe('Code review analytics mergeRequests actions', () => {
beforeEach(() => { beforeEach(() => {
state = { state = {
filters: { filters: {
milestoneTitle: null, milestones: { selected: null },
labelName: [], labels: { selected: [] },
}, },
...getInitialState(), ...getInitialState(),
}; };
......
import * as types from 'ee/analytics/code_review_analytics/store/modules/merge_requests/mutation_types'; import * as types from 'ee/analytics/code_review_analytics/store/modules/filters/mutation_types';
import mutations from 'ee/analytics/code_review_analytics/store/modules/merge_requests/mutations'; import mutations from 'ee/analytics/code_review_analytics/store/modules/filters/mutations';
import getInitialState from 'ee/analytics/code_review_analytics/store/modules/merge_requests/state'; import getInitialState from 'ee/analytics/code_review_analytics/store/modules/filters/state';
import mockMergeRequests from '../../../mock_data'; import { mockMilestones } from '../../../mock_data';
describe('Code review analytics mergeRequests mutations', () => { describe('Code review analytics filters mutations', () => {
let state; let state;
const pageInfo = { const milestoneTitle = 'my milestone';
page: 1, const labelName = ['first label', 'second label'];
nextPage: 2,
previousPage: 1,
perPage: 10,
total: 50,
totalPages: 5,
};
beforeEach(() => { beforeEach(() => {
state = getInitialState(); state = getInitialState();
}); });
describe(types.SET_PROJECT_ID, () => { describe(types.SET_MILESTONES_ENDPOINT, () => {
it('sets the project id', () => { it('sets the milestone path', () => {
mutations[types.SET_PROJECT_ID](state, 1); mutations[types.SET_MILESTONES_ENDPOINT](state, 'milestone_path');
expect(state.projectId).toBe(1); expect(state.milestonesEndpoint).toEqual('milestone_path');
}); });
}); });
describe(types.REQUEST_MERGE_REQUESTS, () => { describe(types.SET_LABELS_ENDPOINT, () => {
it('sets the labels path', () => {
mutations[types.SET_LABELS_ENDPOINT](state, 'labels_path');
expect(state.labelsEndpoint).toEqual('labels_path');
});
});
describe(types.REQUEST_MILESTONES, () => {
it('sets isLoading to true', () => { it('sets isLoading to true', () => {
mutations[types.REQUEST_MERGE_REQUESTS](state); mutations[types.REQUEST_MILESTONES](state);
expect(state.isLoading).toBe(true); expect(state.milestones.isLoading).toBe(true);
}); });
}); });
describe(types.RECEIVE_MERGE_REQUESTS_SUCCESS, () => { describe(types.RECEIVE_MILESTONES_SUCCESS, () => {
it('updates mergeRequests with the received data and updates the pageInfo', () => { it('updates milestone data with the received data', () => {
mutations[types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, { mutations[types.RECEIVE_MILESTONES_SUCCESS](state, mockMilestones);
pageInfo,
mergeRequests: mockMergeRequests,
});
expect(state.isLoading).toBe(false); expect(state.milestones.isLoading).toBe(false);
expect(state.errorCode).toBe(null); expect(state.milestones.errorCode).toBe(null);
expect(state.mergeRequests).toEqual(mockMergeRequests); expect(state.milestones.data).toEqual(mockMilestones);
expect(state.pageInfo).toEqual(pageInfo);
}); });
}); });
describe(types.RECEIVE_MERGE_REQUESTS_ERROR, () => { describe(types.RECEIVE_MILESTONES_ERROR, () => {
const errorCode = 500; const errorCode = 500;
beforeEach(() => { beforeEach(() => {
mutations[types.RECEIVE_MERGE_REQUESTS_ERROR](state, errorCode); mutations[types.RECEIVE_MILESTONES_ERROR](state, errorCode);
}); });
it('sets isLoading to false', () => { it('sets isLoading to false', () => {
expect(state.isLoading).toBe(false); expect(state.milestones.isLoading).toBe(false);
}); });
it('sets errorCode to 500', () => { it('sets errorCode to 500', () => {
expect(state.errorCode).toBe(errorCode); expect(state.milestones.errorCode).toBe(errorCode);
}); });
it('clears data', () => { it('clears data', () => {
expect(state.mergeRequests).toEqual([]); expect(state.milestones.data).toEqual([]);
expect(state.pageInfo).toEqual({});
}); });
}); });
describe('SET_PAGE', () => { describe(types.SET_FILTERS, () => {
it('sets the page on the pageInfo object', () => { it('updates selected milestone and labels', () => {
mutations[types.SET_PAGE](state, 2); mutations[types.SET_FILTERS](state, {
selectedMilestone: milestoneTitle,
selectedLabels: labelName,
});
expect(state.pageInfo.page).toBe(2); expect(state.milestones.selected).toBe(milestoneTitle);
expect(state.labels.selected).toBe(labelName);
}); });
}); });
}); });
...@@ -8834,6 +8834,12 @@ msgstr "" ...@@ -8834,6 +8834,12 @@ msgstr ""
msgid "Failed to load groups & users." msgid "Failed to load groups & users."
msgstr "" msgstr ""
msgid "Failed to load labels. Please try again."
msgstr ""
msgid "Failed to load milestones. Please try again."
msgstr ""
msgid "Failed to load related branches" msgid "Failed to load related branches"
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