Commit d6f77c8e authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'led/288313-fix-storage-usage-quotas-pagination' into 'master'

Fix namespace usage quotas storage pagination

See merge request gitlab-org/gitlab!80752
parents 0ae48c6a bd5c66fc
...@@ -22,4 +22,8 @@ module PaginationHelper ...@@ -22,4 +22,8 @@ module PaginationHelper
def paginate_with_count(collection, remote: nil, total_pages: nil) def paginate_with_count(collection, remote: nil, total_pages: nil)
paginate(collection, remote: remote, theme: 'gitlab', total_pages: total_pages) paginate(collection, remote: remote, theme: 'gitlab', total_pages: total_pages)
end end
def page_size
Kaminari.config.default_per_page
end
end end
...@@ -9,7 +9,7 @@ import { ...@@ -9,7 +9,7 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PROJECTS_PER_PAGE, PROJECT_TABLE_LABEL_STORAGE_USAGE } from '../constants'; import { PROJECT_TABLE_LABEL_STORAGE_USAGE } from '../constants';
import query from '../queries/namespace_storage.query.graphql'; import query from '../queries/namespace_storage.query.graphql';
import { formatUsageSize, parseGetStorageResults } from '../utils'; import { formatUsageSize, parseGetStorageResults } from '../utils';
import ProjectList from './project_list.vue'; import ProjectList from './project_list.vue';
...@@ -39,7 +39,13 @@ export default { ...@@ -39,7 +39,13 @@ export default {
PROJECT_TABLE_LABEL_STORAGE_USAGE, PROJECT_TABLE_LABEL_STORAGE_USAGE,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
inject: ['namespacePath', 'purchaseStorageUrl', 'isTemporaryStorageIncreaseVisible', 'helpLinks'], inject: [
'namespacePath',
'purchaseStorageUrl',
'isTemporaryStorageIncreaseVisible',
'helpLinks',
'defaultPerPage',
],
apollo: { apollo: {
namespace: { namespace: {
query, query,
...@@ -48,7 +54,7 @@ export default { ...@@ -48,7 +54,7 @@ export default {
fullPath: this.namespacePath, fullPath: this.namespacePath,
searchTerm: this.searchTerm, searchTerm: this.searchTerm,
withExcessStorageData: this.isAdditionalStorageFlagEnabled, withExcessStorageData: this.isAdditionalStorageFlagEnabled,
first: PROJECTS_PER_PAGE, first: this.defaultPerPage,
}; };
}, },
update: parseGetStorageResults, update: parseGetStorageResults,
...@@ -121,7 +127,6 @@ export default { ...@@ -121,7 +127,6 @@ export default {
variables: { variables: {
fullPath: this.namespacePath, fullPath: this.namespacePath,
withExcessStorageData: this.isAdditionalStorageFlagEnabled, withExcessStorageData: this.isAdditionalStorageFlagEnabled,
first: PROJECTS_PER_PAGE,
...vars, ...vars,
}, },
updateQuery(previousResult, { fetchMoreResult }) { updateQuery(previousResult, { fetchMoreResult }) {
...@@ -131,12 +136,12 @@ export default { ...@@ -131,12 +136,12 @@ export default {
}, },
onPrev(before) { onPrev(before) {
if (this.pageInfo?.hasPreviousPage) { if (this.pageInfo?.hasPreviousPage) {
this.fetchMoreProjects({ before }); this.fetchMoreProjects({ before, last: this.defaultPerPage, first: undefined });
} }
}, },
onNext(after) { onNext(after) {
if (this.pageInfo?.hasNextPage) { if (this.pageInfo?.hasNextPage) {
this.fetchMoreProjects({ after }); this.fetchMoreProjects({ after, first: this.defaultPerPage });
} }
}, },
}, },
......
...@@ -78,8 +78,6 @@ export const STORAGE_USAGE_THRESHOLDS = { ...@@ -78,8 +78,6 @@ export const STORAGE_USAGE_THRESHOLDS = {
[ERROR_THRESHOLD]: 1.0, [ERROR_THRESHOLD]: 1.0,
}; };
export const PROJECTS_PER_PAGE = 20;
export const projectHelpLinks = { export const projectHelpLinks = {
usageQuotas: helpPagePath('user/usage_quotas'), usageQuotas: helpPagePath('user/usage_quotas'),
buildArtifacts: helpPagePath('ci/pipelines/job_artifacts', { buildArtifacts: helpPagePath('ci/pipelines/job_artifacts', {
......
...@@ -13,6 +13,7 @@ export default () => { ...@@ -13,6 +13,7 @@ export default () => {
purchaseStorageUrl, purchaseStorageUrl,
buyAddonTargetAttr, buyAddonTargetAttr,
isTemporaryStorageIncreaseVisible, isTemporaryStorageIncreaseVisible,
defaultPerPage,
} = el.dataset; } = el.dataset;
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
...@@ -28,6 +29,7 @@ export default () => { ...@@ -28,6 +29,7 @@ export default () => {
buyAddonTargetAttr, buyAddonTargetAttr,
isTemporaryStorageIncreaseVisible, isTemporaryStorageIncreaseVisible,
helpLinks, helpLinks,
defaultPerPage: Number(defaultPerPage),
}, },
render(createElement) { render(createElement) {
return createElement(NamespaceStorageApp); return createElement(NamespaceStorageApp);
......
...@@ -4,7 +4,8 @@ query getNamespaceStorageStatistics( ...@@ -4,7 +4,8 @@ query getNamespaceStorageStatistics(
$fullPath: ID! $fullPath: ID!
$withExcessStorageData: Boolean = false $withExcessStorageData: Boolean = false
$searchTerm: String = "" $searchTerm: String = ""
$first: Int! $first: Int
$last: Int
$after: String $after: String
$before: String $before: String
) { ) {
...@@ -33,6 +34,7 @@ query getNamespaceStorageStatistics( ...@@ -33,6 +34,7 @@ query getNamespaceStorageStatistics(
includeSubgroups: true includeSubgroups: true
search: $searchTerm search: $searchTerm
first: $first first: $first
last: $last
after: $after after: $after
before: $before before: $before
sort: STORAGE sort: STORAGE
......
...@@ -43,4 +43,4 @@ ...@@ -43,4 +43,4 @@
.tab-pane#shared-runners-usage-quota-tab .tab-pane#shared-runners-usage-quota-tab
#js-shared-runner-usage-quota{ data: { namespace_id: @group.id } } #js-shared-runner-usage-quota{ data: { namespace_id: @group.id } }
.tab-pane#storage-quota-tab .tab-pane#storage-quota-tab
#js-storage-counter-app{ data: { namespace_path: @group.full_path, purchase_storage_url: url_to_purchase_storage, buy_addon_target_attr: buy_addon_target_attr, is_temporary_storage_increase_visible: temporary_storage_increase_visible?(@group).to_s } } #js-storage-counter-app{ data: { namespace_path: @group.full_path, purchase_storage_url: url_to_purchase_storage, buy_addon_target_attr: buy_addon_target_attr, is_temporary_storage_increase_visible: temporary_storage_increase_visible?(@group).to_s, default_per_page: page_size } }
...@@ -28,4 +28,4 @@ ...@@ -28,4 +28,4 @@
= render "namespaces/pipelines_quota/list", = render "namespaces/pipelines_quota/list",
locals: { namespace: @namespace, projects: @projects } locals: { namespace: @namespace, projects: @projects }
.tab-pane#storage-quota-tab .tab-pane#storage-quota-tab
#js-storage-counter-app{ data: { namespace_path: @namespace.full_path, purchase_storage_url: url_to_purchase_storage, buy_addon_target_attr: buy_addon_target_attr, is_temporary_storage_increase_visible: temporary_storage_increase_visible?(@namespace).to_s } } #js-storage-counter-app{ data: { namespace_path: @namespace.full_path, purchase_storage_url: url_to_purchase_storage, buy_addon_target_attr: buy_addon_target_attr, is_temporary_storage_increase_visible: temporary_storage_increase_visible?(@namespace).to_s, default_per_page: page_size } }
...@@ -53,7 +53,7 @@ RSpec.describe 'Groups > Usage Quotas' do ...@@ -53,7 +53,7 @@ RSpec.describe 'Groups > Usage Quotas' do
end end
it 'renders a 404' do it 'renders a 404' do
visit_pipeline_quota_page visit_usage_quotas_page
expect(page).to have_gitlab_http_status(:not_found) expect(page).to have_gitlab_http_status(:not_found)
end end
...@@ -66,7 +66,7 @@ RSpec.describe 'Groups > Usage Quotas' do ...@@ -66,7 +66,7 @@ RSpec.describe 'Groups > Usage Quotas' do
include_examples 'linked in group settings dropdown' include_examples 'linked in group settings dropdown'
it 'shows correct group quota info' do it 'shows correct group quota info' do
visit_pipeline_quota_page visit_usage_quotas_page
page.within('.pipeline-quota') do page.within('.pipeline-quota') do
expect(page).to have_content("400 / Unlimited minutes") expect(page).to have_content("400 / Unlimited minutes")
...@@ -82,7 +82,7 @@ RSpec.describe 'Groups > Usage Quotas' do ...@@ -82,7 +82,7 @@ RSpec.describe 'Groups > Usage Quotas' do
include_examples 'linked in group settings dropdown' include_examples 'linked in group settings dropdown'
it 'shows correct group quota info' do it 'shows correct group quota info' do
visit_pipeline_quota_page visit_usage_quotas_page
page.within('.pipeline-quota') do page.within('.pipeline-quota') do
expect(page).to have_content("0%") expect(page).to have_content("0%")
...@@ -115,7 +115,7 @@ RSpec.describe 'Groups > Usage Quotas' do ...@@ -115,7 +115,7 @@ RSpec.describe 'Groups > Usage Quotas' do
include_examples 'linked in group settings dropdown' include_examples 'linked in group settings dropdown'
it 'shows correct group quota info' do it 'shows correct group quota info' do
visit_pipeline_quota_page visit_usage_quotas_page
page.within('.pipeline-quota') do page.within('.pipeline-quota') do
expect(page).to have_content("300 / 500 minutes") expect(page).to have_content("300 / 500 minutes")
...@@ -135,14 +135,14 @@ RSpec.describe 'Groups > Usage Quotas' do ...@@ -135,14 +135,14 @@ RSpec.describe 'Groups > Usage Quotas' do
let(:gitlab_dot_com) { false } let(:gitlab_dot_com) { false }
it "does not show 'Buy additional minutes' button" do it "does not show 'Buy additional minutes' button" do
visit_pipeline_quota_page visit_usage_quotas_page
expect(page).not_to have_content('Buy additional minutes') expect(page).not_to have_content('Buy additional minutes')
end end
end end
it 'has correct tracking setup and shows correct group quota and projects info' do it 'has correct tracking setup and shows correct group quota and projects info' do
visit_pipeline_quota_page visit_usage_quotas_page
page.within('.pipeline-quota') do page.within('.pipeline-quota') do
expect(page).to have_content("1000 / 500 minutes") expect(page).to have_content("1000 / 500 minutes")
...@@ -168,7 +168,7 @@ RSpec.describe 'Groups > Usage Quotas' do ...@@ -168,7 +168,7 @@ RSpec.describe 'Groups > Usage Quotas' do
let(:group) { create(:group, parent: root_ancestor) } let(:group) { create(:group, parent: root_ancestor) }
it 'does not show subproject' do it 'does not show subproject' do
visit_pipeline_quota_page visit_usage_quotas_page
expect(page).to have_gitlab_http_status(:not_found) expect(page).to have_gitlab_http_status(:not_found)
end end
...@@ -179,7 +179,7 @@ RSpec.describe 'Groups > Usage Quotas' do ...@@ -179,7 +179,7 @@ RSpec.describe 'Groups > Usage Quotas' do
let!(:subproject) { create(:project, namespace: subgroup, shared_runners_enabled: true) } let!(:subproject) { create(:project, namespace: subgroup, shared_runners_enabled: true) }
it 'does show projects of subgroup' do it 'does show projects of subgroup' do
visit_pipeline_quota_page visit_usage_quotas_page
expect(page).to have_content(project.full_name) expect(page).to have_content(project.full_name)
expect(page).to have_content(subproject.full_name) expect(page).to have_content(subproject.full_name)
...@@ -188,13 +188,56 @@ RSpec.describe 'Groups > Usage Quotas' do ...@@ -188,13 +188,56 @@ RSpec.describe 'Groups > Usage Quotas' do
context 'when purchasing CI minutes' do context 'when purchasing CI minutes' do
it 'points to GitLab CI minutes purchase flow' do it 'points to GitLab CI minutes purchase flow' do
visit_pipeline_quota_page visit_usage_quotas_page
expect(page).to have_link('Buy additional minutes', href: buy_minutes_subscriptions_path(selected_group: group.id)) expect(page).to have_link('Buy additional minutes', href: buy_minutes_subscriptions_path(selected_group: group.id))
end end
end end
def visit_pipeline_quota_page context 'pagination', :js do
visit group_usage_quotas_path(group) let(:per_page) { 1 }
let!(:projects) { create_list(:project, 3, namespace: group) }
before do
allow(Kaminari.config).to receive(:default_per_page).and_return(per_page)
visit_usage_quotas_page('storage-quota-tab')
end
it 'paginates correctly to page 3 and back' do
expect(page).to have_selector('.js-project-link', count: per_page)
page1_el_text = page.find('.js-project-link').text
click_next_page
expect(page).to have_selector('.js-project-link', count: per_page)
page2_el_text = page.find('.js-project-link').text
click_next_page
expect(page).to have_selector('.js-project-link', count: per_page)
page3_el_text = page.find('.js-project-link').text
click_prev_page
expect(page3_el_text).not_to eql(page2_el_text)
expect(page.find('.js-project-link').text).to eql(page2_el_text)
click_prev_page
expect(page.find('.js-project-link').text).to eql(page1_el_text)
expect(page).to have_selector('.js-project-link', count: per_page)
end
end
def visit_usage_quotas_page(anchor = 'seats-quota-tab')
visit group_usage_quotas_path(group, anchor: anchor)
end
def click_next_page
page.find('[data-testid="nextButton"]').click
wait_for_requests
end
def click_prev_page
page.find('[data-testid="prevButton"]').click
wait_for_requests
end end
end end
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import NamespaceStorageApp from 'ee/usage_quotas/storage/components/namespace_storage_app.vue'; import NamespaceStorageApp from 'ee/usage_quotas/storage/components/namespace_storage_app.vue';
import CollapsibleProjectStorageDetail from 'ee/usage_quotas/storage/components/collapsible_project_storage_detail.vue'; import CollapsibleProjectStorageDetail from 'ee/usage_quotas/storage/components/collapsible_project_storage_detail.vue';
import ProjectList from 'ee/usage_quotas/storage/components/project_list.vue'; import ProjectList from 'ee/usage_quotas/storage/components/project_list.vue';
...@@ -19,6 +18,7 @@ const TEST_LIMIT = 1000; ...@@ -19,6 +18,7 @@ const TEST_LIMIT = 1000;
describe('NamespaceStorageApp', () => { describe('NamespaceStorageApp', () => {
let wrapper; let wrapper;
let $apollo;
const findTotalUsage = () => wrapper.find("[data-testid='total-usage']"); const findTotalUsage = () => wrapper.find("[data-testid='total-usage']");
const findPurchaseStorageLink = () => wrapper.find("[data-testid='purchase-storage-link']"); const findPurchaseStorageLink = () => wrapper.find("[data-testid='purchase-storage-link']");
...@@ -37,7 +37,7 @@ describe('NamespaceStorageApp', () => { ...@@ -37,7 +37,7 @@ describe('NamespaceStorageApp', () => {
additionalRepoStorageByNamespace = false, additionalRepoStorageByNamespace = false,
namespace = {}, namespace = {},
} = {}) => { } = {}) => {
const $apollo = { $apollo = {
queries: { queries: {
namespace: { namespace: {
loading, loading,
...@@ -66,7 +66,9 @@ describe('NamespaceStorageApp', () => { ...@@ -66,7 +66,9 @@ describe('NamespaceStorageApp', () => {
}; };
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent({
namespace: namespaceData,
});
}); });
afterEach(() => { afterEach(() => {
...@@ -74,67 +76,40 @@ describe('NamespaceStorageApp', () => { ...@@ -74,67 +76,40 @@ describe('NamespaceStorageApp', () => {
}); });
it('renders the 2 projects', async () => { it('renders the 2 projects', async () => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
namespace: namespaceData,
});
await nextTick();
expect(wrapper.findAllComponents(CollapsibleProjectStorageDetail)).toHaveLength(3); expect(wrapper.findAllComponents(CollapsibleProjectStorageDetail)).toHaveLength(3);
}); });
describe('limit', () => { describe('limit', () => {
it('when limit is set it renders limit information', async () => { it('when limit is set it renders limit information', async () => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
namespace: namespaceData,
});
await nextTick();
expect(wrapper.text()).toContain(formatUsageSize(namespaceData.limit)); expect(wrapper.text()).toContain(formatUsageSize(namespaceData.limit));
}); });
it('when limit is 0 it does not render limit information', async () => { it('when limit is 0 it does not render limit information', async () => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details createComponent({
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
namespace: { ...namespaceData, limit: 0 }, namespace: { ...namespaceData, limit: 0 },
}); });
await nextTick();
expect(wrapper.text()).not.toContain(formatUsageSize(0)); expect(wrapper.text()).not.toContain(formatUsageSize(0));
}); });
}); });
it('renders N/A for totalUsage when no rootStorageStatistics is provided', async () => {
expect(findTotalUsage().text()).toContain('N/A');
});
describe('with rootStorageStatistics information', () => { describe('with rootStorageStatistics information', () => {
it('renders total usage', async () => { beforeEach(() => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details createComponent({
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
namespace: withRootStorageStatistics, namespace: withRootStorageStatistics,
}); });
});
await nextTick(); it('renders total usage', async () => {
expect(findTotalUsage().text()).toContain(withRootStorageStatistics.totalUsage); expect(findTotalUsage().text()).toContain(withRootStorageStatistics.totalUsage);
}); });
});
describe('with additional_repo_storage_by_namespace feature', () => { describe('with additional_repo_storage_by_namespace feature', () => {
it('usage_graph component hidden is when feature is false', async () => { it('usage_graph component hidden is when feature is false', async () => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
namespace: withRootStorageStatistics,
});
await nextTick();
expect(findUsageGraph().exists()).toBe(true); expect(findUsageGraph().exists()).toBe(true);
expect(findUsageStatistics().exists()).toBe(false); expect(findUsageStatistics().exists()).toBe(false);
expect(findStorageInlineAlert().exists()).toBe(false); expect(findStorageInlineAlert().exists()).toBe(false);
...@@ -146,26 +121,11 @@ describe('NamespaceStorageApp', () => { ...@@ -146,26 +121,11 @@ describe('NamespaceStorageApp', () => {
namespace: withRootStorageStatistics, namespace: withRootStorageStatistics,
}); });
await nextTick();
expect(findUsageStatistics().exists()).toBe(true); expect(findUsageStatistics().exists()).toBe(true);
expect(findUsageGraph().exists()).toBe(false); expect(findUsageGraph().exists()).toBe(false);
expect(findStorageInlineAlert().exists()).toBe(true); expect(findStorageInlineAlert().exists()).toBe(true);
}); });
}); });
describe('without rootStorageStatistics information', () => {
it('renders N/A', async () => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
namespace: namespaceData,
});
await nextTick();
expect(findTotalUsage().text()).toContain('N/A');
});
}); });
describe('purchase storage link', () => { describe('purchase storage link', () => {
...@@ -207,10 +167,8 @@ describe('NamespaceStorageApp', () => { ...@@ -207,10 +167,8 @@ describe('NamespaceStorageApp', () => {
describe('when temporary storage increase is visible', () => { describe('when temporary storage increase is visible', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ provide: { isTemporaryStorageIncreaseVisible: 'true' } }); createComponent({
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details provide: { isTemporaryStorageIncreaseVisible: 'true' },
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
namespace: { namespace: {
...namespaceData, ...namespaceData,
limit: TEST_LIMIT, limit: TEST_LIMIT,
...@@ -280,29 +238,61 @@ describe('NamespaceStorageApp', () => { ...@@ -280,29 +238,61 @@ describe('NamespaceStorageApp', () => {
}); });
}); });
describe('renders projects table pagination component', () => { describe('projects table pagination component', () => {
const namespaceWithPageInfo = { const namespaceWithPageInfo = (
pageInfo = {
hasPreviousPage: false,
hasNextPage: true,
},
) => ({
namespace: { namespace: {
...withRootStorageStatistics, ...withRootStorageStatistics,
projects: { projects: {
...withRootStorageStatistics.projects, ...withRootStorageStatistics.projects,
pageInfo: { pageInfo,
hasPreviousPage: false,
hasNextPage: true,
},
}, },
}, },
}; });
beforeEach(() => { beforeEach(() => {
createComponent(namespaceWithPageInfo); createComponent(namespaceWithPageInfo());
}); });
it('with disabled "Prev" button', () => { it('has "Prev" button disabled', () => {
expect(findPrevButton().attributes().disabled).toBe('disabled'); expect(findPrevButton().attributes().disabled).toBe('disabled');
}); });
it('with enabled "Next" button', () => { it('has "Next" button enabled', () => {
expect(findNextButton().attributes().disabled).toBeUndefined(); expect(findNextButton().attributes().disabled).toBeUndefined();
}); });
describe('apollo calls', () => {
beforeEach(() => {
createComponent(
namespaceWithPageInfo({
hasPreviousPage: true,
hasNextPage: true,
}),
);
$apollo.queries.namespace.fetchMore = jest.fn().mockResolvedValue();
});
it('contains correct `first` and `last` values when clicking "Prev" button', () => {
findPrevButton().trigger('click');
expect($apollo.queries.namespace.fetchMore).toHaveBeenCalledWith(
expect.objectContaining({
variables: expect.objectContaining({ first: undefined, last: expect.any(Number) }),
}),
);
});
it('contains `first` value when clicking "Next" button', () => {
findNextButton().trigger('click');
expect($apollo.queries.namespace.fetchMore).toHaveBeenCalledWith(
expect.objectContaining({
variables: expect.objectContaining({ first: expect.any(Number) }),
}),
);
});
});
}); });
}); });
...@@ -154,6 +154,7 @@ export const defaultProjectProvideValues = { ...@@ -154,6 +154,7 @@ export const defaultProjectProvideValues = {
}; };
export const defaultNamespaceProvideValues = { export const defaultNamespaceProvideValues = {
defaultPerPage: 20,
namespacePath: 'h5bp', namespacePath: 'h5bp',
purchaseStorageUrl: '', purchaseStorageUrl: '',
buyAddonTargetAttr: '_blank', buyAddonTargetAttr: '_blank',
......
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