Commit d2d03188 authored by Anna Vovchenko's avatar Anna Vovchenko Committed by Nicolò Maria Mezzopera

Add view all tab to the clusters page

parent e10e31d5
...@@ -11,9 +11,6 @@ export default { ...@@ -11,9 +11,6 @@ export default {
getStartedDocsUrl: helpPagePath('user/clusters/agent/index', { getStartedDocsUrl: helpPagePath('user/clusters/agent/index', {
anchor: 'define-a-configuration-repository', anchor: 'define-a-configuration-repository',
}), }),
integrationsDocsUrl: helpPagePath('user/clusters/agent/index', {
anchor: 'get-started-with-gitops-and-the-gitlab-agent',
}),
components: { components: {
GlButton, GlButton,
GlEmptyState, GlEmptyState,
...@@ -30,6 +27,11 @@ export default { ...@@ -30,6 +27,11 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
isChildComponent: {
default: false,
required: false,
type: Boolean,
},
}, },
computed: { computed: {
repositoryPath() { repositoryPath() {
...@@ -92,6 +94,7 @@ export default { ...@@ -92,6 +94,7 @@ export default {
<template #actions> <template #actions>
<gl-button <gl-button
v-if="!isChildComponent"
v-gl-modal-directive="$options.modalId" v-gl-modal-directive="$options.modalId"
:disabled="!hasConfigurations" :disabled="!hasConfigurations"
data-testid="integration-primary-button" data-testid="integration-primary-button"
......
...@@ -20,6 +20,9 @@ export default { ...@@ -20,6 +20,9 @@ export default {
this.updateTreeList(data); this.updateTreeList(data);
return data; return data;
}, },
result() {
this.emitAgentsLoaded();
},
}, },
}, },
components: { components: {
...@@ -36,11 +39,21 @@ export default { ...@@ -36,11 +39,21 @@ export default {
required: false, required: false,
type: String, type: String,
}, },
isChildComponent: {
default: false,
required: false,
type: Boolean,
},
limit: {
default: null,
required: false,
type: Number,
},
}, },
data() { data() {
return { return {
cursor: { cursor: {
first: MAX_LIST_COUNT, first: this.limit ? this.limit : MAX_LIST_COUNT,
last: null, last: null,
}, },
folderList: {}, folderList: {},
...@@ -68,7 +81,7 @@ export default { ...@@ -68,7 +81,7 @@ export default {
return this.$apollo.queries.agents.loading; return this.$apollo.queries.agents.loading;
}, },
showPagination() { showPagination() {
return this.agentPageInfo.hasPreviousPage || this.agentPageInfo.hasNextPage; return !this.limit && (this.agentPageInfo.hasPreviousPage || this.agentPageInfo.hasNextPage);
}, },
treePageInfo() { treePageInfo() {
return this.agents?.project?.repository?.tree?.trees?.pageInfo || {}; return this.agents?.project?.repository?.tree?.trees?.pageInfo || {};
...@@ -128,6 +141,10 @@ export default { ...@@ -128,6 +141,10 @@ export default {
} }
return 'unused'; return 'unused';
}, },
emitAgentsLoaded() {
const count = this.agents?.project?.clusterAgents?.count;
this.$emit('onAgentsLoad', count);
},
}, },
}; };
</script> </script>
...@@ -144,7 +161,11 @@ export default { ...@@ -144,7 +161,11 @@ export default {
</div> </div>
</div> </div>
<agent-empty-state v-else :has-configurations="hasConfigurations" /> <agent-empty-state
v-else
:has-configurations="hasConfigurations"
:is-child-component="isChildComponent"
/>
</section> </section>
<gl-alert v-else variant="danger" :dismissible="false"> <gl-alert v-else variant="danger" :dismissible="false">
......
...@@ -34,6 +34,18 @@ export default { ...@@ -34,6 +34,18 @@ export default {
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
props: {
isChildComponent: {
default: false,
required: false,
type: Boolean,
},
limit: {
default: null,
required: false,
type: Number,
},
},
computed: { computed: {
...mapState([ ...mapState([
'clusters', 'clusters',
...@@ -100,10 +112,14 @@ export default { ...@@ -100,10 +112,14 @@ export default {
}, },
}, },
mounted() { mounted() {
if (this.limit) {
this.setClustersPerPage(this.limit);
}
this.fetchClusters(); this.fetchClusters();
}, },
methods: { methods: {
...mapActions(['fetchClusters', 'reportSentryError', 'setPage']), ...mapActions(['fetchClusters', 'reportSentryError', 'setPage', 'setClustersPerPage']),
k8sQuantityToGb(quantity) { k8sQuantityToGb(quantity) {
if (!quantity) { if (!quantity) {
return 0; return 0;
...@@ -312,10 +328,10 @@ export default { ...@@ -312,10 +328,10 @@ export default {
</template> </template>
</gl-table> </gl-table>
<clusters-empty-state v-else /> <clusters-empty-state v-else :is-child-component="isChildComponent" />
<gl-pagination <gl-pagination
v-if="hasClustersPerPage" v-if="hasClustersPerPage && !limit"
v-model="currentPage" v-model="currentPage"
:per-page="clustersPerPage" :per-page="clustersPerPage"
:total-items="totalClusters" :total-items="totalClusters"
......
...@@ -13,6 +13,13 @@ export default { ...@@ -13,6 +13,13 @@ export default {
GlSprintf, GlSprintf,
}, },
inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'newClusterPath'], inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'newClusterPath'],
props: {
isChildComponent: {
default: false,
required: false,
type: Boolean,
},
},
learnMoreHelpUrl: helpPagePath('user/project/clusters/index'), learnMoreHelpUrl: helpPagePath('user/project/clusters/index'),
multipleClustersHelpUrl: helpPagePath('user/project/clusters/multiple_kubernetes_clusters'), multipleClustersHelpUrl: helpPagePath('user/project/clusters/multiple_kubernetes_clusters'),
computed: { computed: {
...@@ -54,6 +61,7 @@ export default { ...@@ -54,6 +61,7 @@ export default {
<template #actions> <template #actions>
<gl-button <gl-button
v-if="!isChildComponent"
data-testid="integration-primary-button" data-testid="integration-primary-button"
data-qa-selector="add_kubernetes_cluster_link" data-qa-selector="add_kubernetes_cluster_link"
category="primary" category="primary"
......
<script> <script>
import { GlTabs, GlTab } from '@gitlab/ui'; import { GlTabs, GlTab } from '@gitlab/ui';
import { CLUSTERS_TABS } from '../constants'; import { CLUSTERS_TABS, MAX_CLUSTERS_LIST, MAX_LIST_COUNT, AGENT } from '../constants';
import Agents from './agents.vue'; import Agents from './agents.vue';
import InstallAgentModal from './install_agent_modal.vue'; import InstallAgentModal from './install_agent_modal.vue';
import ClustersActions from './clusters_actions.vue'; import ClustersActions from './clusters_actions.vue';
import Clusters from './clusters.vue'; import Clusters from './clusters.vue';
import ClustersViewAll from './clusters_view_all.vue';
export default { export default {
components: { components: {
GlTabs, GlTabs,
GlTab, GlTab,
ClustersActions, ClustersActions,
ClustersViewAll,
Clusters, Clusters,
Agents, Agents,
InstallAgentModal, InstallAgentModal,
...@@ -26,11 +28,14 @@ export default { ...@@ -26,11 +28,14 @@ export default {
data() { data() {
return { return {
selectedTabIndex: 0, selectedTabIndex: 0,
maxAgents: MAX_CLUSTERS_LIST,
}; };
}, },
methods: { methods: {
onTabChange(tabName) { onTabChange(tabName) {
this.selectedTabIndex = CLUSTERS_TABS.findIndex((tab) => tab.queryParamValue === tabName); this.selectedTabIndex = CLUSTERS_TABS.findIndex((tab) => tab.queryParamValue === tabName);
this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST;
}, },
}, },
}; };
...@@ -63,6 +68,6 @@ export default { ...@@ -63,6 +68,6 @@ export default {
</template> </template>
</gl-tabs> </gl-tabs>
<install-agent-modal :default-branch-name="defaultBranchName" /> <install-agent-modal :default-branch-name="defaultBranchName" :max-agents="maxAgents" />
</div> </div>
</template> </template>
<script>
import {
GlCard,
GlSprintf,
GlPopover,
GlLink,
GlButton,
GlBadge,
GlLoadingIcon,
GlModalDirective,
} from '@gitlab/ui';
import { mapState } from 'vuex';
import {
AGENT_CARD_INFO,
CERTIFICATE_BASED_CARD_INFO,
MAX_CLUSTERS_LIST,
INSTALL_AGENT_MODAL_ID,
} from '../constants';
import Clusters from './clusters.vue';
import Agents from './agents.vue';
export default {
components: {
GlCard,
GlSprintf,
GlPopover,
GlLink,
GlButton,
GlBadge,
GlLoadingIcon,
Clusters,
Agents,
},
directives: {
GlModalDirective,
},
AGENT_CARD_INFO,
CERTIFICATE_BASED_CARD_INFO,
MAX_CLUSTERS_LIST,
INSTALL_AGENT_MODAL_ID,
inject: ['addClusterPath'],
props: {
defaultBranchName: {
default: '.noBranch',
required: false,
type: String,
},
},
data() {
return {
loadingAgents: true,
totalAgents: null,
};
},
computed: {
...mapState(['loadingClusters', 'totalClusters']),
isLoading() {
return this.loadingAgents || this.loadingClusters;
},
agentsCardTitle() {
let cardTitle;
if (this.totalAgents > 0) {
cardTitle = {
message: AGENT_CARD_INFO.title,
number: this.totalAgents < MAX_CLUSTERS_LIST ? this.totalAgents : MAX_CLUSTERS_LIST,
total: this.totalAgents,
};
} else {
cardTitle = {
message: AGENT_CARD_INFO.emptyTitle,
};
}
return cardTitle;
},
clustersCardTitle() {
let cardTitle;
if (this.totalClusters > 0) {
cardTitle = {
message: CERTIFICATE_BASED_CARD_INFO.title,
number: this.totalClusters < MAX_CLUSTERS_LIST ? this.totalClusters : MAX_CLUSTERS_LIST,
total: this.totalClusters,
};
} else {
cardTitle = {
message: CERTIFICATE_BASED_CARD_INFO.emptyTitle,
};
}
return cardTitle;
},
},
methods: {
cardFooterNumber(number) {
return number > MAX_CLUSTERS_LIST ? number : '';
},
onAgentsLoad(number) {
this.totalAgents = number;
this.loadingAgents = false;
},
changeTab($event, tab) {
$event.preventDefault();
this.$emit('changeTab', tab);
},
},
};
</script>
<template>
<div>
<gl-loading-icon v-if="isLoading" size="md" />
<div v-show="!isLoading" data-testid="clusters-cards-container">
<gl-card
header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between gl-py-4"
body-class="gl-pb-0"
footer-class="gl-text-right"
>
<template #header>
<h3 data-testid="agent-card-title" class="gl-my-0 gl-font-weight-normal gl-font-size-h2">
<gl-sprintf :message="agentsCardTitle.message"
><template #number>{{ agentsCardTitle.number }}</template>
<template #total>{{ agentsCardTitle.total }}</template>
</gl-sprintf>
</h3>
<gl-badge id="clusters-recommended-badge" size="md" variant="info">{{
$options.AGENT_CARD_INFO.tooltip.label
}}</gl-badge>
<gl-popover
target="clusters-recommended-badge"
container="viewport"
placement="bottom"
:title="$options.AGENT_CARD_INFO.tooltip.title"
>
<p class="gl-mb-0">
<gl-sprintf :message="$options.AGENT_CARD_INFO.tooltip.text">
<template #link="{ content }">
<gl-link
:href="$options.AGENT_CARD_INFO.tooltip.link"
target="_blank"
class="gl-font-sm"
>
{{ content }}</gl-link
>
</template>
</gl-sprintf>
</p>
</gl-popover>
</template>
<agents
:limit="$options.MAX_CLUSTERS_LIST"
:default-branch-name="defaultBranchName"
:is-child-component="true"
@onAgentsLoad="onAgentsLoad"
/>
<template #footer>
<gl-link
v-if="totalAgents"
data-testid="agents-tab-footer-link"
:href="`?tab=${$options.AGENT_CARD_INFO.tabName}`"
@click="changeTab($event, $options.AGENT_CARD_INFO.tabName)"
><gl-sprintf :message="$options.AGENT_CARD_INFO.footerText"
><template #number>{{ cardFooterNumber(totalAgents) }}</template></gl-sprintf
></gl-link
><gl-button
v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
class="gl-ml-4"
category="secondary"
variant="confirm"
>{{ $options.AGENT_CARD_INFO.actionText }}</gl-button
>
</template>
</gl-card>
<gl-card
class="gl-mt-6"
header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between"
body-class="gl-pb-0"
footer-class="gl-text-right"
>
<template #header>
<h3
class="gl-my-1 gl-font-weight-normal gl-font-size-h2"
data-testid="clusters-card-title"
>
<gl-sprintf :message="clustersCardTitle.message"
><template #number>{{ clustersCardTitle.number }}</template>
<template #total>{{ clustersCardTitle.total }}</template>
</gl-sprintf>
</h3>
</template>
<clusters :limit="$options.MAX_CLUSTERS_LIST" :is-child-component="true" />
<template #footer>
<gl-link
v-if="totalClusters"
data-testid="clusters-tab-footer-link"
:href="`?tab=${$options.CERTIFICATE_BASED_CARD_INFO.tabName}`"
@click="changeTab($event, $options.CERTIFICATE_BASED_CARD_INFO.tabName)"
><gl-sprintf :message="$options.CERTIFICATE_BASED_CARD_INFO.footerText"
><template #number>{{ cardFooterNumber(totalClusters) }}</template></gl-sprintf
></gl-link
><gl-button
category="secondary"
variant="confirm"
class="gl-ml-4"
:href="addClusterPath"
>{{ $options.CERTIFICATE_BASED_CARD_INFO.actionText }}</gl-button
>
</template>
</gl-card>
</div>
</div>
</template>
...@@ -12,7 +12,7 @@ import { helpPagePath } from '~/helpers/help_page_helper'; ...@@ -12,7 +12,7 @@ import { helpPagePath } from '~/helpers/help_page_helper';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CodeBlock from '~/vue_shared/components/code_block.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue';
import { generateAgentRegistrationCommand } from '../clusters_util'; import { generateAgentRegistrationCommand } from '../clusters_util';
import { INSTALL_AGENT_MODAL_ID, I18N_INSTALL_AGENT_MODAL, MAX_LIST_COUNT } from '../constants'; import { INSTALL_AGENT_MODAL_ID, I18N_INSTALL_AGENT_MODAL } from '../constants';
import { addAgentToStore } from '../graphql/cache_update'; import { addAgentToStore } from '../graphql/cache_update';
import createAgent from '../graphql/mutations/create_agent.mutation.graphql'; import createAgent from '../graphql/mutations/create_agent.mutation.graphql';
import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql'; import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql';
...@@ -41,6 +41,10 @@ export default { ...@@ -41,6 +41,10 @@ export default {
required: false, required: false,
type: String, type: String,
}, },
maxAgents: {
required: true,
type: Number,
},
}, },
data() { data() {
return { return {
...@@ -75,7 +79,7 @@ export default { ...@@ -75,7 +79,7 @@ export default {
getAgentsQueryVariables() { getAgentsQueryVariables() {
return { return {
defaultBranchName: this.defaultBranchName, defaultBranchName: this.defaultBranchName,
first: MAX_LIST_COUNT, first: this.maxAgents,
last: null, last: null,
projectPath: this.projectPath, projectPath: this.projectPath,
}; };
......
import { __, s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
export const MAX_LIST_COUNT = 25; export const MAX_LIST_COUNT = 25;
export const INSTALL_AGENT_MODAL_ID = 'install-agent'; export const INSTALL_AGENT_MODAL_ID = 'install-agent';
...@@ -167,7 +168,40 @@ export const I18N_CLUSTERS_EMPTY_STATE = { ...@@ -167,7 +168,40 @@ export const I18N_CLUSTERS_EMPTY_STATE = {
buttonText: s__('ClusterIntegration|Connect with a certificate'), buttonText: s__('ClusterIntegration|Connect with a certificate'),
}; };
export const AGENT_CARD_INFO = {
tabName: 'agent',
title: sprintf(s__('ClusterAgents|%{number} of %{total} Agent based integrations')),
emptyTitle: s__('ClusterAgents|No Agent based integrations'),
tooltip: {
label: s__('ClusterAgents|Recommended'),
title: s__('ClusterAgents|GitLab Agents'),
text: sprintf(
s__(
'ClusterAgents|GitLab Agents provide an increased level of security when integrating with clusters. %{linkStart}Learn more about the GitLab Kubernetes Agent.%{linkEnd}',
),
),
link: helpPagePath('user/clusters/agent/index'),
},
actionText: s__('ClusterAgents|Install new Agent'),
footerText: sprintf(s__('ClusterAgents|View all %{number} Agent based integrations')),
};
export const CERTIFICATE_BASED_CARD_INFO = {
tabName: 'certificate_based',
title: sprintf(s__('ClusterAgents|%{number} of %{total} Certificate based integrations')),
emptyTitle: s__('ClusterAgents|No Certificate based integrations'),
actionText: s__('ClusterAgents|Connect existing cluster'),
footerText: sprintf(s__('ClusterAgents|View all %{number} Certificate based integrations')),
};
export const MAX_CLUSTERS_LIST = 6;
export const CLUSTERS_TABS = [ export const CLUSTERS_TABS = [
{
title: s__('ClusterAgents|All'),
component: 'ClustersViewAll',
queryParamValue: 'all',
},
{ {
title: s__('ClusterAgents|Agent'), title: s__('ClusterAgents|Agent'),
component: 'agents', component: 'agents',
...@@ -186,3 +220,6 @@ export const CLUSTERS_ACTIONS = { ...@@ -186,3 +220,6 @@ export const CLUSTERS_ACTIONS = {
connectWithAgent: s__('ClusterAgents|Connect with Agent'), connectWithAgent: s__('ClusterAgents|Connect with Agent'),
connectExistingCluster: s__('ClusterAgents|Connect with certificate'), connectExistingCluster: s__('ClusterAgents|Connect with certificate'),
}; };
export const AGENT = 'agent';
export const CERTIFICATE_BASED = 'certificate_based';
...@@ -17,6 +17,7 @@ export function addAgentToStore(store, createClusterAgent, query, variables) { ...@@ -17,6 +17,7 @@ export function addAgentToStore(store, createClusterAgent, query, variables) {
}; };
draftData.project.clusterAgents.nodes.push(clusterAgent); draftData.project.clusterAgents.nodes.push(clusterAgent);
draftData.project.clusterAgents.count += 1;
draftData.project.repository.tree.trees.nodes.push(configuration); draftData.project.repository.tree.trees.nodes.push(configuration);
}); });
......
...@@ -20,6 +20,8 @@ query getAgents( ...@@ -20,6 +20,8 @@ query getAgents(
pageInfo { pageInfo {
...PageInfo ...PageInfo
} }
count
} }
repository { repository {
......
...@@ -30,7 +30,13 @@ export const fetchClusters = ({ state, commit, dispatch }) => { ...@@ -30,7 +30,13 @@ export const fetchClusters = ({ state, commit, dispatch }) => {
const poll = new Poll({ const poll = new Poll({
resource: { resource: {
fetchClusters: (paginatedEndPoint) => axios.get(paginatedEndPoint), fetchClusters: (paginatedEndPoint) =>
axios.get(paginatedEndPoint, {
params: {
page: state.page,
per_page: state.clustersPerPage,
},
}),
}, },
data: `${state.endpoint}?page=${state.page}`, data: `${state.endpoint}?page=${state.page}`,
method: 'fetchClusters', method: 'fetchClusters',
...@@ -78,3 +84,7 @@ export const fetchClusters = ({ state, commit, dispatch }) => { ...@@ -78,3 +84,7 @@ export const fetchClusters = ({ state, commit, dispatch }) => {
export const setPage = ({ commit }, page) => { export const setPage = ({ commit }, page) => {
commit(types.SET_PAGE, page); commit(types.SET_PAGE, page);
}; };
export const setClustersPerPage = ({ commit }, limit) => {
commit(types.SET_CLUSTERS_PER_PAGE, limit);
};
...@@ -2,3 +2,4 @@ export const SET_CLUSTERS_DATA = 'SET_CLUSTERS_DATA'; ...@@ -2,3 +2,4 @@ export const SET_CLUSTERS_DATA = 'SET_CLUSTERS_DATA';
export const SET_LOADING_CLUSTERS = 'SET_LOADING_CLUSTERS'; export const SET_LOADING_CLUSTERS = 'SET_LOADING_CLUSTERS';
export const SET_LOADING_NODES = 'SET_LOADING_NODES'; export const SET_LOADING_NODES = 'SET_LOADING_NODES';
export const SET_PAGE = 'SET_PAGE'; export const SET_PAGE = 'SET_PAGE';
export const SET_CLUSTERS_PER_PAGE = 'SET_CLUSTERS_PER_PAGE';
...@@ -18,4 +18,7 @@ export default { ...@@ -18,4 +18,7 @@ export default {
[types.SET_PAGE](state, value) { [types.SET_PAGE](state, value) {
state.page = Number(value) || 1; state.page = Number(value) || 1;
}, },
[types.SET_CLUSTERS_PER_PAGE](state, value) {
state.clustersPerPage = Number(value) || 1;
},
}; };
...@@ -5,7 +5,7 @@ export default (initialState = {}) => ({ ...@@ -5,7 +5,7 @@ export default (initialState = {}) => ({
endpoint: initialState.endpoint, endpoint: initialState.endpoint,
hasAncestorClusters: false, hasAncestorClusters: false,
clusters: [], clusters: [],
clustersPerPage: 0, clustersPerPage: 20,
loadingClusters: true, loadingClusters: true,
loadingNodes: true, loadingNodes: true,
page: 1, page: 1,
......
...@@ -7318,6 +7318,12 @@ msgstr "" ...@@ -7318,6 +7318,12 @@ msgstr ""
msgid "Cluster type must be specificed for Stages::ClusterEndpointInserter" msgid "Cluster type must be specificed for Stages::ClusterEndpointInserter"
msgstr "" msgstr ""
msgid "ClusterAgents|%{number} of %{total} Agent based integrations"
msgstr ""
msgid "ClusterAgents|%{number} of %{total} Certificate based integrations"
msgstr ""
msgid "ClusterAgents|Access tokens" msgid "ClusterAgents|Access tokens"
msgstr "" msgstr ""
...@@ -7333,6 +7339,9 @@ msgstr "" ...@@ -7333,6 +7339,9 @@ msgstr ""
msgid "ClusterAgents|Agent never connected to GitLab" msgid "ClusterAgents|Agent never connected to GitLab"
msgstr "" msgstr ""
msgid "ClusterAgents|All"
msgstr ""
msgid "ClusterAgents|Alternative installation methods" msgid "ClusterAgents|Alternative installation methods"
msgstr "" msgstr ""
...@@ -7351,6 +7360,9 @@ msgstr "" ...@@ -7351,6 +7360,9 @@ msgstr ""
msgid "ClusterAgents|Configuration" msgid "ClusterAgents|Configuration"
msgstr "" msgstr ""
msgid "ClusterAgents|Connect existing cluster"
msgstr ""
msgid "ClusterAgents|Connect with Agent" msgid "ClusterAgents|Connect with Agent"
msgstr "" msgstr ""
...@@ -7387,6 +7399,12 @@ msgstr "" ...@@ -7387,6 +7399,12 @@ msgstr ""
msgid "ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}." msgid "ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}."
msgstr "" msgstr ""
msgid "ClusterAgents|GitLab Agents"
msgstr ""
msgid "ClusterAgents|GitLab Agents provide an increased level of security when integrating with clusters. %{linkStart}Learn more about the GitLab Kubernetes Agent.%{linkEnd}"
msgstr ""
msgid "ClusterAgents|Go to the repository" msgid "ClusterAgents|Go to the repository"
msgstr "" msgstr ""
...@@ -7423,12 +7441,21 @@ msgstr "" ...@@ -7423,12 +7441,21 @@ msgstr ""
msgid "ClusterAgents|Never connected" msgid "ClusterAgents|Never connected"
msgstr "" msgstr ""
msgid "ClusterAgents|No Agent based integrations"
msgstr ""
msgid "ClusterAgents|No Certificate based integrations"
msgstr ""
msgid "ClusterAgents|Not connected" msgid "ClusterAgents|Not connected"
msgstr "" msgstr ""
msgid "ClusterAgents|Read more about getting started" msgid "ClusterAgents|Read more about getting started"
msgstr "" msgstr ""
msgid "ClusterAgents|Recommended"
msgstr ""
msgid "ClusterAgents|Recommended installation method" msgid "ClusterAgents|Recommended installation method"
msgstr "" msgstr ""
...@@ -7477,6 +7504,12 @@ msgstr "" ...@@ -7477,6 +7504,12 @@ msgstr ""
msgid "ClusterAgents|Use GitLab Agents to more securely integrate with your clusters to deploy your applications, run your pipelines, use review apps and much more." msgid "ClusterAgents|Use GitLab Agents to more securely integrate with your clusters to deploy your applications, run your pipelines, use review apps and much more."
msgstr "" msgstr ""
msgid "ClusterAgents|View all %{number} Agent based integrations"
msgstr ""
msgid "ClusterAgents|View all %{number} Certificate based integrations"
msgstr ""
msgid "ClusterAgents|You will need to create a token to connect to your agent" msgid "ClusterAgents|You will need to create a token to connect to your agent"
msgstr "" msgstr ""
......
...@@ -22,7 +22,7 @@ RSpec.describe 'ClusterAgents', :js do ...@@ -22,7 +22,7 @@ RSpec.describe 'ClusterAgents', :js do
end end
it 'displays empty state', :aggregate_failures do it 'displays empty state', :aggregate_failures do
expect(page).to have_content('Connect with a GitLab Agent') expect(page).to have_content('Install new Agent')
expect(page).to have_selector('.empty-state') expect(page).to have_selector('.empty-state')
end end
end end
......
...@@ -14,7 +14,7 @@ localVue.use(VueApollo); ...@@ -14,7 +14,7 @@ localVue.use(VueApollo);
describe('Agents', () => { describe('Agents', () => {
let wrapper; let wrapper;
const propsData = { const defaultProps = {
defaultBranchName: 'default', defaultBranchName: 'default',
}; };
const provideData = { const provideData = {
...@@ -22,12 +22,12 @@ describe('Agents', () => { ...@@ -22,12 +22,12 @@ describe('Agents', () => {
kasAddress: 'kas.example.com', kasAddress: 'kas.example.com',
}; };
const createWrapper = ({ agents = [], pageInfo = null, trees = [] }) => { const createWrapper = ({ props = {}, agents = [], pageInfo = null, trees = [], count = 0 }) => {
const provide = provideData; const provide = provideData;
const apolloQueryResponse = { const apolloQueryResponse = {
data: { data: {
project: { project: {
clusterAgents: { nodes: agents, pageInfo, tokens: { nodes: [] } }, clusterAgents: { nodes: agents, pageInfo, tokens: { nodes: [] }, count },
repository: { tree: { trees: { nodes: trees, pageInfo } } }, repository: { tree: { trees: { nodes: trees, pageInfo } } },
}, },
}, },
...@@ -40,7 +40,10 @@ describe('Agents', () => { ...@@ -40,7 +40,10 @@ describe('Agents', () => {
wrapper = shallowMount(Agents, { wrapper = shallowMount(Agents, {
localVue, localVue,
apolloProvider, apolloProvider,
propsData, propsData: {
...defaultProps,
...props,
},
provide: provideData, provide: provideData,
}); });
...@@ -80,6 +83,8 @@ describe('Agents', () => { ...@@ -80,6 +83,8 @@ describe('Agents', () => {
}, },
]; ];
const count = 2;
const trees = [ const trees = [
{ {
name: 'agent-2', name: 'agent-2',
...@@ -120,7 +125,7 @@ describe('Agents', () => { ...@@ -120,7 +125,7 @@ describe('Agents', () => {
]; ];
beforeEach(() => { beforeEach(() => {
return createWrapper({ agents, trees }); return createWrapper({ agents, count, trees });
}); });
it('should render agent table', () => { it('should render agent table', () => {
...@@ -132,6 +137,10 @@ describe('Agents', () => { ...@@ -132,6 +137,10 @@ describe('Agents', () => {
expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList);
}); });
it('should emit agents count to the parent component', () => {
expect(wrapper.emitted().onAgentsLoad).toEqual([[count]]);
});
describe('when the agent has recently connected tokens', () => { describe('when the agent has recently connected tokens', () => {
it('should set agent status to active', () => { it('should set agent status to active', () => {
expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList);
...@@ -179,6 +188,20 @@ describe('Agents', () => { ...@@ -179,6 +188,20 @@ describe('Agents', () => {
it('should pass pageInfo to the pagination component', () => { it('should pass pageInfo to the pagination component', () => {
expect(findPaginationButtons().props()).toMatchObject(pageInfo); expect(findPaginationButtons().props()).toMatchObject(pageInfo);
}); });
describe('when limit is passed from the parent component', () => {
beforeEach(() => {
return createWrapper({
props: { limit: 6 },
agents,
pageInfo,
});
});
it('should not render pagination buttons', () => {
expect(findPaginationButtons().exists()).toBe(false);
});
});
}); });
}); });
...@@ -235,7 +258,7 @@ describe('Agents', () => { ...@@ -235,7 +258,7 @@ describe('Agents', () => {
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(Agents, { wrapper = shallowMount(Agents, {
mocks, mocks,
propsData, propsData: defaultProps,
provide: provideData, provide: provideData,
}); });
......
...@@ -11,6 +11,10 @@ const canAddCluster = true; ...@@ -11,6 +11,10 @@ const canAddCluster = true;
describe('ClustersEmptyStateComponent', () => { describe('ClustersEmptyStateComponent', () => {
let wrapper; let wrapper;
const propsData = {
isChildComponent: false,
};
const provideData = { const provideData = {
clustersEmptyStateImage, clustersEmptyStateImage,
emptyStateHelpText: null, emptyStateHelpText: null,
...@@ -27,6 +31,7 @@ describe('ClustersEmptyStateComponent', () => { ...@@ -27,6 +31,7 @@ describe('ClustersEmptyStateComponent', () => {
beforeEach(() => { beforeEach(() => {
wrapper = shallowMountExtended(ClustersEmptyState, { wrapper = shallowMountExtended(ClustersEmptyState, {
store: ClusterStore(entryData), store: ClusterStore(entryData),
propsData,
provide: provideData, provide: provideData,
stubs: { GlEmptyState }, stubs: { GlEmptyState },
}); });
...@@ -36,8 +41,10 @@ describe('ClustersEmptyStateComponent', () => { ...@@ -36,8 +41,10 @@ describe('ClustersEmptyStateComponent', () => {
wrapper.destroy(); wrapper.destroy();
}); });
it('should render the action button', () => { describe('when the component is loaded independently', () => {
expect(findButton().exists()).toBe(true); it('should render the action button', () => {
expect(findButton().exists()).toBe(true);
});
}); });
describe('when the help text is not provided', () => { describe('when the help text is not provided', () => {
...@@ -46,11 +53,31 @@ describe('ClustersEmptyStateComponent', () => { ...@@ -46,11 +53,31 @@ describe('ClustersEmptyStateComponent', () => {
}); });
}); });
describe('when the component is loaded as a child component', () => {
beforeEach(() => {
propsData.isChildComponent = true;
wrapper = shallowMountExtended(ClustersEmptyState, {
store: ClusterStore(entryData),
propsData,
provide: provideData,
});
});
afterEach(() => {
propsData.isChildComponent = false;
});
it('should not render the action button', () => {
expect(findButton().exists()).toBe(false);
});
});
describe('when the help text is provided', () => { describe('when the help text is provided', () => {
beforeEach(() => { beforeEach(() => {
provideData.emptyStateHelpText = emptyStateHelpText; provideData.emptyStateHelpText = emptyStateHelpText;
wrapper = shallowMountExtended(ClustersEmptyState, { wrapper = shallowMountExtended(ClustersEmptyState, {
store: ClusterStore(entryData), store: ClusterStore(entryData),
propsData,
provide: provideData, provide: provideData,
}); });
}); });
...@@ -61,8 +88,14 @@ describe('ClustersEmptyStateComponent', () => { ...@@ -61,8 +88,14 @@ describe('ClustersEmptyStateComponent', () => {
}); });
describe('when the user cannot add clusters', () => { describe('when the user cannot add clusters', () => {
entryData.canAddCluster = false;
beforeEach(() => { beforeEach(() => {
wrapper.vm.$store.state.canAddCluster = false; wrapper = shallowMountExtended(ClustersEmptyState, {
store: ClusterStore(entryData),
propsData,
provide: provideData,
stubs: { GlEmptyState },
});
}); });
it('should disable the button', () => { it('should disable the button', () => {
expect(findButton().props('disabled')).toBe(true); expect(findButton().props('disabled')).toBe(true);
......
import { GlTabs, GlTab } from '@gitlab/ui'; import { GlTabs, GlTab } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ClustersMainView from '~/clusters_list/components/clusters_main_view.vue'; import ClustersMainView from '~/clusters_list/components/clusters_main_view.vue';
import { CLUSTERS_TABS } from '~/clusters_list/constants'; import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vue';
import {
AGENT,
CERTIFICATE_BASED,
CLUSTERS_TABS,
MAX_CLUSTERS_LIST,
MAX_LIST_COUNT,
} from '~/clusters_list/constants';
const defaultBranchName = 'default-branch'; const defaultBranchName = 'default-branch';
...@@ -26,6 +33,7 @@ describe('ClustersMainViewComponent', () => { ...@@ -26,6 +33,7 @@ describe('ClustersMainViewComponent', () => {
const findAllTabs = () => wrapper.findAllComponents(GlTab); const findAllTabs = () => wrapper.findAllComponents(GlTab);
const findGlTabAtIndex = (index) => findAllTabs().at(index); const findGlTabAtIndex = (index) => findAllTabs().at(index);
const findComponent = () => wrapper.findByTestId('clusters-tab-component'); const findComponent = () => wrapper.findByTestId('clusters-tab-component');
const findModal = () => wrapper.findComponent(InstallAgentModal);
it('renders `GlTabs` with `syncActiveTabWithQueryParams` and `queryParamName` props set', () => { it('renders `GlTabs` with `syncActiveTabWithQueryParams` and `queryParamName` props set', () => {
expect(findTabs().exists()).toBe(true); expect(findTabs().exists()).toBe(true);
...@@ -40,11 +48,16 @@ describe('ClustersMainViewComponent', () => { ...@@ -40,11 +48,16 @@ describe('ClustersMainViewComponent', () => {
expect(findComponent().props('defaultBranchName')).toBe(defaultBranchName); expect(findComponent().props('defaultBranchName')).toBe(defaultBranchName);
}); });
it('passes correct max-agents param to the modal', () => {
expect(findModal().props('maxAgents')).toBe(MAX_CLUSTERS_LIST);
});
describe('tabs', () => { describe('tabs', () => {
it.each` it.each`
tabTitle | queryParamValue | lineNumber tabTitle | queryParamValue | lineNumber
${'Agent'} | ${'agent'} | ${0} ${'All'} | ${'all'} | ${0}
${'Certificate based'} | ${'certificate_based'} | ${1} ${'Agent'} | ${AGENT} | ${1}
${'Certificate based'} | ${CERTIFICATE_BASED} | ${2}
`( `(
'renders correct tab title and query param value', 'renders correct tab title and query param value',
({ tabTitle, queryParamValue, lineNumber }) => { ({ tabTitle, queryParamValue, lineNumber }) => {
...@@ -53,4 +66,17 @@ describe('ClustersMainViewComponent', () => { ...@@ -53,4 +66,17 @@ describe('ClustersMainViewComponent', () => {
}, },
); );
}); });
describe('when the child component emits the tab change event', () => {
beforeEach(() => {
findComponent().vm.$emit('changeTab', AGENT);
});
it('changes the tab', () => {
expect(findTabs().attributes('value')).toBe('1');
});
it('passes correct max-agents param to the modal', () => {
expect(findModal().props('maxAgents')).toBe(MAX_LIST_COUNT);
});
});
}); });
...@@ -48,9 +48,9 @@ describe('Clusters', () => { ...@@ -48,9 +48,9 @@ describe('Clusters', () => {
mock.onGet(`${endpoint}?page=${header['x-page']}`).reply(response, body, header); mock.onGet(`${endpoint}?page=${header['x-page']}`).reply(response, body, header);
}; };
const mountWrapper = () => { const createWrapper = ({ propsData = {} }) => {
store = ClusterStore(entryData); store = ClusterStore(entryData);
wrapper = mount(Clusters, { provide: provideData, store, stubs: { GlTable } }); wrapper = mount(Clusters, { propsData, provide: provideData, store, stubs: { GlTable } });
return axios.waitForAll(); return axios.waitForAll();
}; };
...@@ -70,7 +70,7 @@ describe('Clusters', () => { ...@@ -70,7 +70,7 @@ describe('Clusters', () => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mockPollingApi(200, apiData, paginationHeader()); mockPollingApi(200, apiData, paginationHeader());
return mountWrapper(); return createWrapper({});
}); });
afterEach(() => { afterEach(() => {
...@@ -105,6 +105,16 @@ describe('Clusters', () => { ...@@ -105,6 +105,16 @@ describe('Clusters', () => {
expect(findEmptyState().exists()).toBe(true); expect(findEmptyState().exists()).toBe(true);
}); });
}); });
describe('when is loaded as a child component', () => {
beforeEach(() => {
createWrapper({ limit: 6 });
});
it("shouldn't render pagination buttons", () => {
expect(findPaginatedButtons().exists()).toBe(false);
});
});
}); });
describe('cluster icon', () => { describe('cluster icon', () => {
...@@ -248,7 +258,7 @@ describe('Clusters', () => { ...@@ -248,7 +258,7 @@ describe('Clusters', () => {
beforeEach(() => { beforeEach(() => {
mockPollingApi(200, apiData, paginationHeader(totalFirstPage, perPage, 1)); mockPollingApi(200, apiData, paginationHeader(totalFirstPage, perPage, 1));
return mountWrapper(); return createWrapper({});
}); });
it('should load to page 1 with header values', () => { it('should load to page 1 with header values', () => {
......
import { GlCard, GlLoadingIcon, GlButton, GlSprintf, GlBadge } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ClustersViewAll from '~/clusters_list/components/clusters_view_all.vue';
import Agents from '~/clusters_list/components/agents.vue';
import Clusters from '~/clusters_list/components/clusters.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import {
AGENT,
CERTIFICATE_BASED,
AGENT_CARD_INFO,
CERTIFICATE_BASED_CARD_INFO,
MAX_CLUSTERS_LIST,
INSTALL_AGENT_MODAL_ID,
} from '~/clusters_list/constants';
import { sprintf } from '~/locale';
const localVue = createLocalVue();
localVue.use(Vuex);
const addClusterPath = '/path/to/add/cluster';
const defaultBranchName = 'default-branch';
describe('ClustersViewAllComponent', () => {
let wrapper;
const event = {
preventDefault: jest.fn(),
};
const propsData = {
defaultBranchName,
};
const provideData = {
addClusterPath,
};
const entryData = {
loadingClusters: false,
totalClusters: 0,
};
const findCards = () => wrapper.findAllComponents(GlCard);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAgentsComponent = () => wrapper.findComponent(Agents);
const findClustersComponent = () => wrapper.findComponent(Clusters);
const findCardsContainer = () => wrapper.findByTestId('clusters-cards-container');
const findAgentCardTitle = () => wrapper.findByTestId('agent-card-title');
const findRecommendedBadge = () => wrapper.findComponent(GlBadge);
const findClustersCardTitle = () => wrapper.findByTestId('clusters-card-title');
const findFooterButton = (line) => findCards().at(line).findComponent(GlButton);
const createStore = (initialState) =>
new Vuex.Store({
state: initialState,
});
const createWrapper = ({ initialState }) => {
wrapper = shallowMountExtended(ClustersViewAll, {
localVue,
store: createStore(initialState),
propsData,
provide: provideData,
directives: {
GlModalDirective: createMockDirective(),
},
stubs: { GlCard, GlSprintf },
});
};
beforeEach(() => {
createWrapper({ initialState: entryData });
});
afterEach(() => {
wrapper.destroy();
});
describe('when agents and clusters are not loaded', () => {
const initialState = {
loadingClusters: true,
totalClusters: 0,
};
beforeEach(() => {
createWrapper({ initialState });
});
it('should show the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('when both agents and clusters are loaded', () => {
beforeEach(() => {
findAgentsComponent().vm.$emit('onAgentsLoad', 6);
});
it("shouldn't show the loading icon", () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('should make content visible', () => {
expect(findCardsContainer().isVisible()).toBe(true);
});
it('should render 2 cards', () => {
expect(findCards().length).toBe(2);
});
});
describe('agents card', () => {
it('should show recommended badge', () => {
expect(findRecommendedBadge().exists()).toBe(true);
});
it('should render Agents component', () => {
expect(findAgentsComponent().exists()).toBe(true);
});
it('should pass the limit prop', () => {
expect(findAgentsComponent().props('limit')).toBe(MAX_CLUSTERS_LIST);
});
it('should pass the default-branch-name prop', () => {
expect(findAgentsComponent().props('defaultBranchName')).toBe(defaultBranchName);
});
describe('when there are no agents', () => {
it('should show the empty title', () => {
expect(findAgentCardTitle().text()).toBe(AGENT_CARD_INFO.emptyTitle);
});
it('should show install new Agent button in the footer', () => {
expect(findFooterButton(0).exists()).toBe(true);
});
it('should render correct modal id for the agent link', () => {
const binding = getBinding(findFooterButton(0).element, 'gl-modal-directive');
expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
});
});
describe('when the agents are present', () => {
const findFooterLink = () => wrapper.findByTestId('agents-tab-footer-link');
const agentsNumber = 7;
beforeEach(() => {
findAgentsComponent().vm.$emit('onAgentsLoad', agentsNumber);
});
it('should show the correct title', () => {
expect(findAgentCardTitle().text()).toBe(
sprintf(AGENT_CARD_INFO.title, { number: MAX_CLUSTERS_LIST, total: agentsNumber }),
);
});
it('should show the link to the Agents tab in the footer', () => {
expect(findFooterLink().exists()).toBe(true);
expect(findFooterLink().text()).toBe(
sprintf(AGENT_CARD_INFO.footerText, { number: agentsNumber }),
);
expect(findFooterLink().attributes('href')).toBe(`?tab=${AGENT}`);
});
describe('when clicking on the footer link', () => {
beforeEach(() => {
findFooterLink().vm.$emit('click', event);
});
it('should trigger tab change', () => {
expect(wrapper.emitted('changeTab')).toEqual([[AGENT]]);
});
});
});
});
describe('clusters tab', () => {
it('should pass the limit prop', () => {
expect(findClustersComponent().props('limit')).toBe(MAX_CLUSTERS_LIST);
});
it('should pass the is-child-component prop', () => {
expect(findClustersComponent().props('isChildComponent')).toBe(true);
});
describe('when there are no clusters', () => {
it('should show the empty title', () => {
expect(findClustersCardTitle().text()).toBe(CERTIFICATE_BASED_CARD_INFO.emptyTitle);
});
it('should show install new Agent button in the footer', () => {
expect(findFooterButton(1).exists()).toBe(true);
});
it('should render correct href for the button in the footer', () => {
expect(findFooterButton(1).attributes('href')).toBe(addClusterPath);
});
});
describe('when the clusters are present', () => {
const findFooterLink = () => wrapper.findByTestId('clusters-tab-footer-link');
const clustersNumber = 7;
const initialState = {
loadingClusters: false,
totalClusters: clustersNumber,
};
beforeEach(() => {
createWrapper({ initialState });
});
it('should show the correct title', () => {
expect(findClustersCardTitle().text()).toBe(
sprintf(CERTIFICATE_BASED_CARD_INFO.title, {
number: MAX_CLUSTERS_LIST,
total: clustersNumber,
}),
);
});
it('should show the link to the Clusters tab in the footer', () => {
expect(findFooterLink().exists()).toBe(true);
expect(findFooterLink().text()).toBe(
sprintf(CERTIFICATE_BASED_CARD_INFO.footerText, { number: clustersNumber }),
);
});
describe('when clicking on the footer link', () => {
beforeEach(() => {
findFooterLink().vm.$emit('click', event);
});
it('should trigger tab change', () => {
expect(wrapper.emitted('changeTab')).toEqual([[CERTIFICATE_BASED]]);
});
});
});
});
});
...@@ -24,6 +24,7 @@ localVue.use(VueApollo); ...@@ -24,6 +24,7 @@ localVue.use(VueApollo);
const projectPath = 'path/to/project'; const projectPath = 'path/to/project';
const defaultBranchName = 'default'; const defaultBranchName = 'default';
const maxAgents = MAX_LIST_COUNT;
describe('InstallAgentModal', () => { describe('InstallAgentModal', () => {
let wrapper; let wrapper;
...@@ -56,6 +57,7 @@ describe('InstallAgentModal', () => { ...@@ -56,6 +57,7 @@ describe('InstallAgentModal', () => {
const propsData = { const propsData = {
defaultBranchName, defaultBranchName,
maxAgents,
}; };
wrapper = shallowMount(InstallAgentModal, { wrapper = shallowMount(InstallAgentModal, {
......
...@@ -16,6 +16,7 @@ const pageInfo = { ...@@ -16,6 +16,7 @@ const pageInfo = {
hasPreviousPage: false, hasPreviousPage: false,
startCursor: '', startCursor: '',
}; };
const count = 1;
export const createAgentResponse = { export const createAgentResponse = {
data: { data: {
...@@ -64,7 +65,7 @@ export const createAgentTokenErrorResponse = { ...@@ -64,7 +65,7 @@ export const createAgentTokenErrorResponse = {
export const getAgentResponse = { export const getAgentResponse = {
data: { data: {
project: { project: {
clusterAgents: { nodes: [{ ...agent, tokens }], pageInfo }, clusterAgents: { nodes: [{ ...agent, tokens }], pageInfo, count },
repository: { repository: {
tree: { tree: {
trees: { nodes: [{ ...agent, path: null }], pageInfo }, trees: { nodes: [{ ...agent, path: null }], pageInfo },
......
...@@ -57,4 +57,12 @@ describe('Admin statistics panel mutations', () => { ...@@ -57,4 +57,12 @@ describe('Admin statistics panel mutations', () => {
expect(state.page).toBe(123); expect(state.page).toBe(123);
}); });
}); });
describe(`${types.SET_CLUSTERS_PER_PAGE}`, () => {
it('changes clustersPerPage value', () => {
mutations[types.SET_CLUSTERS_PER_PAGE](state, 123);
expect(state.clustersPerPage).toBe(123);
});
});
}); });
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