-
+
@@ -494,6 +512,9 @@ export default {
:installed="applications.knative.installed"
:install-failed="applications.knative.installFailed"
:install-application-request-params="{ hostname: applications.knative.hostname }"
+ :uninstallable="applications.knative.uninstallable"
+ :uninstall-successful="applications.knative.uninstallSuccessful"
+ :uninstall-failed="applications.knative.uninstallFailed"
:disabled="!helmInstalled"
v-bind="applications.knative"
title-link="https://github.com/knative/docs"
@@ -505,9 +526,9 @@ export default {
s__(`ClusterIntegration|You must have an RBAC-enabled cluster
to install Knative.`)
}}
-
- {{ __('More information') }}
-
+
{{
+ __('More information')
+ }}
@@ -572,9 +593,9 @@ export default {
`ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`,
)
}}
-
- {{ __('More information') }}
-
+
{{
+ __('More information')
+ }}
-// TODO: Implement loading button component
import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import { APPLICATION_STATUS } from '~/clusters/constants';
+
+const { UPDATING, UNINSTALLING } = APPLICATION_STATUS;
export default {
components: {
LoadingButton,
},
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ disabled() {
+ return [UNINSTALLING, UPDATING].includes(this.status);
+ },
+ loading() {
+ return this.status === UNINSTALLING;
+ },
+ label() {
+ return this.loading ? this.__('Uninstalling') : this.__('Uninstall');
+ },
+ },
};
-
+
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
new file mode 100644
index 00000000000..80ba2f22198
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
@@ -0,0 +1,66 @@
+
+
+ {{ warningText }} {{ customAppWarningText }}
+
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index 48dbce9676e..8fd752092c9 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -28,16 +28,23 @@ export const APPLICATION_STATUS = {
export const APPLICATION_INSTALLED_STATUSES = [
APPLICATION_STATUS.INSTALLED,
APPLICATION_STATUS.UPDATING,
+ APPLICATION_STATUS.UNINSTALLING,
];
// These are only used client-side
export const UPDATE_EVENT = 'update';
export const INSTALL_EVENT = 'install';
+export const UNINSTALL_EVENT = 'uninstall';
+export const HELM = 'helm';
export const INGRESS = 'ingress';
export const JUPYTER = 'jupyter';
export const KNATIVE = 'knative';
export const RUNNER = 'runner';
export const CERT_MANAGER = 'cert_manager';
+export const PROMETHEUS = 'prometheus';
+
+export const APPLICATIONS = [HELM, INGRESS, JUPYTER, KNATIVE, RUNNER, CERT_MANAGER, PROMETHEUS];
+
export const INGRESS_DOMAIN_SUFFIX = '.nip.io';
diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js
index aafb2350ae4..14b80a116a7 100644
--- a/app/assets/javascripts/clusters/services/application_state_machine.js
+++ b/app/assets/javascripts/clusters/services/application_state_machine.js
@@ -1,4 +1,4 @@
-import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT } from '../constants';
+import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT, UNINSTALL_EVENT } from '../constants';
const {
NO_STATUS,
@@ -11,6 +11,8 @@ const {
UPDATING,
UPDATED,
UPDATE_ERRORED,
+ UNINSTALLING,
+ UNINSTALL_ERRORED,
} = APPLICATION_STATUS;
const applicationStateMachine = {
@@ -52,6 +54,15 @@ const applicationStateMachine = {
updateFailed: true,
},
},
+ [UNINSTALLING]: {
+ target: UNINSTALLING,
+ },
+ [UNINSTALL_ERRORED]: {
+ target: INSTALLED,
+ effects: {
+ uninstallFailed: true,
+ },
+ },
},
},
[NOT_INSTALLABLE]: {
@@ -97,6 +108,13 @@ const applicationStateMachine = {
updateSuccessful: false,
},
},
+ [UNINSTALL_EVENT]: {
+ target: UNINSTALLING,
+ effects: {
+ uninstallFailed: false,
+ uninstallSuccessful: false,
+ },
+ },
},
},
[UPDATING]: {
@@ -116,6 +134,22 @@ const applicationStateMachine = {
},
},
},
+ [UNINSTALLING]: {
+ on: {
+ [INSTALLABLE]: {
+ target: INSTALLABLE,
+ effects: {
+ uninstallSuccessful: true,
+ },
+ },
+ [UNINSTALL_ERRORED]: {
+ target: INSTALLED,
+ effects: {
+ uninstallFailed: true,
+ },
+ },
+ },
+ },
};
/**
diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js
index dea33ac44c5..01f3732de7e 100644
--- a/app/assets/javascripts/clusters/services/clusters_service.js
+++ b/app/assets/javascripts/clusters/services/clusters_service.js
@@ -29,6 +29,10 @@ export default class ClusterService {
return axios.patch(this.appUpdateEndpointMap[appId], params);
}
+ uninstallApplication(appId, params) {
+ return axios.delete(this.appInstallEndpointMap[appId], params);
+ }
+
static updateCluster(endpoint, data) {
return axios.put(endpoint, data);
}
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index c2e30960659..1b4d7e8372c 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -10,6 +10,7 @@ import {
APPLICATION_STATUS,
INSTALL_EVENT,
UPDATE_EVENT,
+ UNINSTALL_EVENT,
} from '../constants';
import transitionApplicationState from '../services/application_state_machine';
@@ -21,6 +22,9 @@ const applicationInitialState = {
requestReason: null,
installed: false,
installFailed: false,
+ uninstallable: false,
+ uninstallFailed: false,
+ uninstallSuccessful: false,
};
export default class ClusterStore {
@@ -116,6 +120,14 @@ export default class ClusterStore {
this.handleApplicationEvent(appId, APPLICATION_STATUS.UPDATE_ERRORED);
}
+ uninstallApplication(appId) {
+ this.handleApplicationEvent(appId, UNINSTALL_EVENT);
+ }
+
+ notifyUninstallFailure(appId) {
+ this.handleApplicationEvent(appId, APPLICATION_STATUS.UNINSTALL_ERRORED);
+ }
+
handleApplicationEvent(appId, event) {
const currentAppState = this.state.applications[appId];
@@ -141,6 +153,7 @@ export default class ClusterStore {
status_reason: statusReason,
version,
update_available: upgradeAvailable,
+ can_uninstall: uninstallable,
} = serverAppEntry;
const currentApplicationState = this.state.applications[appId] || {};
const nextApplicationState = transitionApplicationState(currentApplicationState, status);
@@ -150,8 +163,7 @@ export default class ClusterStore {
...nextApplicationState,
statusReason,
installed: isApplicationInstalled(nextApplicationState.status),
- // Make sure uninstallable is always false until this feature is unflagged
- uninstallable: false,
+ uninstallable,
};
if (appId === INGRESS) {
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 53222a2bd4d..323a3dbecd5 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -34,10 +34,10 @@
.modal-body {
background-color: $modal-body-bg;
line-height: $line-height-base;
- min-height: $modal-body-height;
position: relative;
padding: #{3 * $grid-size} #{2 * $grid-size};
text-align: left;
+ white-space: normal;
.form-actions {
margin: #{2 * $grid-size} #{-2 * $grid-size} #{-2 * $grid-size};
diff --git a/changelogs/unreleased/60777-uninstall-button.yml b/changelogs/unreleased/60777-uninstall-button.yml
new file mode 100644
index 00000000000..a2727b16ef1
--- /dev/null
+++ b/changelogs/unreleased/60777-uninstall-button.yml
@@ -0,0 +1,5 @@
+---
+title: Implement UI for uninstalling Cluster’s managed apps
+merge_request: 27559
+author:
+type: added
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index e6dda6a35c2..cae94fb2af4 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1984,6 +1984,9 @@ msgstr ""
msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
msgstr ""
+msgid "ClusterIntegration|%{title} uninstalled successfully."
+msgstr ""
+
msgid "ClusterIntegration|%{title} upgraded successfully."
msgstr ""
@@ -2011,6 +2014,9 @@ msgstr ""
msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
msgstr ""
+msgid "ClusterIntegration|All data will be deleted and cannot be restored."
+msgstr ""
+
msgid "ClusterIntegration|Alternatively"
msgstr ""
@@ -2026,6 +2032,9 @@ msgstr ""
msgid "ClusterIntegration|An error occurred while trying to fetch zone machine types: %{error}"
msgstr ""
+msgid "ClusterIntegration|Any running pipelines will be canceled."
+msgstr ""
+
msgid "ClusterIntegration|Applications"
msgstr ""
@@ -2317,6 +2326,9 @@ msgstr ""
msgid "ClusterIntegration|Request to begin installing failed"
msgstr ""
+msgid "ClusterIntegration|Request to begin uninstalling failed"
+msgstr ""
+
msgid "ClusterIntegration|Retry update"
msgstr ""
@@ -2371,6 +2383,9 @@ msgstr ""
msgid "ClusterIntegration|Something went wrong while installing %{title}"
msgstr ""
+msgid "ClusterIntegration|Something went wrong while uninstalling %{title}"
+msgstr ""
+
msgid "ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain."
msgstr ""
@@ -2380,6 +2395,15 @@ msgstr ""
msgid "ClusterIntegration|The URL used to access the Kubernetes API."
msgstr ""
+msgid "ClusterIntegration|The associated IP will be deleted and cannot be restored."
+msgstr ""
+
+msgid "ClusterIntegration|The associated certifcate will be deleted and cannot be restored."
+msgstr ""
+
+msgid "ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored."
+msgstr ""
+
msgid "ClusterIntegration|The endpoint is in the process of being assigned. Please check your Kubernetes cluster or Quotas on Google Kubernetes Engine if it takes a long time."
msgstr ""
@@ -2395,6 +2419,9 @@ msgstr ""
msgid "ClusterIntegration|Toggle Kubernetes cluster"
msgstr ""
+msgid "ClusterIntegration|Uninstall %{appTitle}"
+msgstr ""
+
msgid "ClusterIntegration|Update failed. Please check the logs and try again."
msgstr ""
@@ -2422,6 +2449,9 @@ msgstr ""
msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
+msgid "ClusterIntegration|You are about to uninstall %{appTitle} from your cluster."
+msgstr ""
+
msgid "ClusterIntegration|You must first install Helm Tiller before installing the applications below"
msgstr ""
diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js
index a61103397eb..73897107f67 100644
--- a/spec/frontend/clusters/clusters_bundle_spec.js
+++ b/spec/frontend/clusters/clusters_bundle_spec.js
@@ -1,12 +1,12 @@
import Clusters from '~/clusters/clusters_bundle';
-import { APPLICATION_STATUS, INGRESS_DOMAIN_SUFFIX } from '~/clusters/constants';
+import { APPLICATION_STATUS, INGRESS_DOMAIN_SUFFIX, APPLICATIONS } from '~/clusters/constants';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { loadHTMLFixture } from 'helpers/fixtures';
import { setTestTimeout } from 'helpers/timeout';
import $ from 'jquery';
-const { INSTALLING, INSTALLABLE, INSTALLED } = APPLICATION_STATUS;
+const { INSTALLING, INSTALLABLE, INSTALLED, UNINSTALLING } = APPLICATION_STATUS;
describe('Clusters', () => {
setTestTimeout(1000);
@@ -212,55 +212,16 @@ describe('Clusters', () => {
});
describe('installApplication', () => {
- it('tries to install helm', () => {
+ it.each(APPLICATIONS)('tries to install %s', applicationId => {
jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
- cluster.store.state.applications.helm.status = INSTALLABLE;
+ cluster.store.state.applications[applicationId].status = INSTALLABLE;
- cluster.installApplication({ id: 'helm' });
+ cluster.installApplication({ id: applicationId });
- expect(cluster.store.state.applications.helm.status).toEqual(INSTALLING);
- expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
- expect(cluster.service.installApplication).toHaveBeenCalledWith('helm', undefined);
- });
-
- it('tries to install ingress', () => {
- jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
-
- cluster.store.state.applications.ingress.status = INSTALLABLE;
-
- cluster.installApplication({ id: 'ingress' });
-
- expect(cluster.store.state.applications.ingress.status).toEqual(INSTALLING);
- expect(cluster.store.state.applications.ingress.requestReason).toEqual(null);
- expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress', undefined);
- });
-
- it('tries to install runner', () => {
- jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
-
- cluster.store.state.applications.runner.status = INSTALLABLE;
-
- cluster.installApplication({ id: 'runner' });
-
- expect(cluster.store.state.applications.runner.status).toEqual(INSTALLING);
- expect(cluster.store.state.applications.runner.requestReason).toEqual(null);
- expect(cluster.service.installApplication).toHaveBeenCalledWith('runner', undefined);
- });
-
- it('tries to install jupyter', () => {
- jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
-
- cluster.installApplication({
- id: 'jupyter',
- params: { hostname: cluster.store.state.applications.jupyter.hostname },
- });
-
- cluster.store.state.applications.jupyter.status = INSTALLABLE;
- expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null);
- expect(cluster.service.installApplication).toHaveBeenCalledWith('jupyter', {
- hostname: cluster.store.state.applications.jupyter.hostname,
- });
+ expect(cluster.store.state.applications[applicationId].status).toEqual(INSTALLING);
+ expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null);
+ expect(cluster.service.installApplication).toHaveBeenCalledWith(applicationId, undefined);
});
it('sets error request status when the request fails', () => {
@@ -272,10 +233,6 @@ describe('Clusters', () => {
const promise = cluster.installApplication({ id: 'helm' });
- expect(cluster.store.state.applications.helm.status).toEqual(INSTALLING);
- expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
- expect(cluster.service.installApplication).toHaveBeenCalled();
-
return promise.then(() => {
expect(cluster.store.state.applications.helm.status).toEqual(INSTALLABLE);
expect(cluster.store.state.applications.helm.installFailed).toBe(true);
@@ -285,6 +242,37 @@ describe('Clusters', () => {
});
});
+ describe('uninstallApplication', () => {
+ it.each(APPLICATIONS)('tries to uninstall %s', applicationId => {
+ jest.spyOn(cluster.service, 'uninstallApplication').mockResolvedValueOnce();
+
+ cluster.store.state.applications[applicationId].status = INSTALLED;
+
+ cluster.uninstallApplication({ id: applicationId });
+
+ expect(cluster.store.state.applications[applicationId].status).toEqual(UNINSTALLING);
+ expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null);
+ expect(cluster.service.uninstallApplication).toHaveBeenCalledWith(applicationId);
+ });
+
+ it('sets error request status when the uninstall request fails', () => {
+ jest
+ .spyOn(cluster.service, 'uninstallApplication')
+ .mockRejectedValueOnce(new Error('STUBBED ERROR'));
+
+ cluster.store.state.applications.helm.status = INSTALLED;
+
+ const promise = cluster.uninstallApplication({ id: 'helm' });
+
+ return promise.then(() => {
+ expect(cluster.store.state.applications.helm.status).toEqual(INSTALLED);
+ expect(cluster.store.state.applications.helm.uninstallFailed).toBe(true);
+
+ expect(cluster.store.state.applications.helm.requestReason).toBeDefined();
+ });
+ });
+ });
+
describe('handleSuccess', () => {
beforeEach(() => {
jest.spyOn(cluster.store, 'updateStateFromServer').mockReturnThis();
diff --git a/spec/frontend/clusters/components/application_row_spec.js b/spec/frontend/clusters/components/application_row_spec.js
index 17273b7d5b1..7c781b72355 100644
--- a/spec/frontend/clusters/components/application_row_spec.js
+++ b/spec/frontend/clusters/components/application_row_spec.js
@@ -1,7 +1,10 @@
import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
import eventHub from '~/clusters/event_hub';
import { APPLICATION_STATUS } from '~/clusters/constants';
import applicationRow from '~/clusters/components/application_row.vue';
+import UninstallApplicationConfirmationModal from '~/clusters/components/uninstall_application_confirmation_modal.vue';
+
import mountComponent from 'helpers/vue_mount_component_helper';
import { DEFAULT_APPLICATION_STATE } from '../services/mock_data';
@@ -194,11 +197,52 @@ describe('Application Row', () => {
...DEFAULT_APPLICATION_STATE,
installed: true,
uninstallable: true,
+ status: APPLICATION_STATUS.NOT_INSTALLABLE,
});
const uninstallButton = vm.$el.querySelector('.js-cluster-application-uninstall-button');
expect(uninstallButton).toBeTruthy();
});
+
+ it('displays a success toast message if application uninstall was successful', () => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ title: 'GitLab Runner',
+ uninstallSuccessful: false,
+ });
+
+ vm.$toast = { show: jest.fn() };
+ vm.uninstallSuccessful = true;
+
+ return vm.$nextTick(() => {
+ expect(vm.$toast.show).toHaveBeenCalledWith('GitLab Runner uninstalled successfully.');
+ });
+ });
+ });
+
+ describe('when confirmation modal triggers confirm event', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallowMount(ApplicationRow, {
+ propsData: {
+ ...DEFAULT_APPLICATION_STATE,
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('triggers uninstallApplication event', () => {
+ jest.spyOn(eventHub, '$emit');
+ wrapper.find(UninstallApplicationConfirmationModal).vm.$emit('confirm');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('uninstallApplication', {
+ id: DEFAULT_APPLICATION_STATE.id,
+ });
+ });
});
describe('Upgrade button', () => {
@@ -304,7 +348,7 @@ describe('Application Row', () => {
vm.$toast = { show: jest.fn() };
vm.updateSuccessful = true;
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(vm.$toast.show).toHaveBeenCalledWith('GitLab Runner upgraded successfully.');
});
});
@@ -360,60 +404,88 @@ describe('Application Row', () => {
});
describe('Error block', () => {
- it('does not show error block when there is no error', () => {
- vm = mountComponent(ApplicationRow, {
- ...DEFAULT_APPLICATION_STATE,
- status: null,
- });
- const generalErrorMessage = vm.$el.querySelector(
- '.js-cluster-application-general-error-message',
- );
+ describe('when nothing fails', () => {
+ it('does not show error block', () => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ });
+ const generalErrorMessage = vm.$el.querySelector(
+ '.js-cluster-application-general-error-message',
+ );
- expect(generalErrorMessage).toBeNull();
+ expect(generalErrorMessage).toBeNull();
+ });
});
- it('shows status reason when install fails', () => {
+ describe('when install or uninstall fails', () => {
const statusReason = 'We broke it 0.0';
- vm = mountComponent(ApplicationRow, {
- ...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_STATUS.ERROR,
- statusReason,
- installFailed: true,
+ const requestReason = 'We broke the request 0.0';
+
+ beforeEach(() => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_STATUS.ERROR,
+ statusReason,
+ requestReason,
+ installFailed: true,
+ });
});
- const generalErrorMessage = vm.$el.querySelector(
- '.js-cluster-application-general-error-message',
- );
- const statusErrorMessage = vm.$el.querySelector(
- '.js-cluster-application-status-error-message',
- );
- expect(generalErrorMessage.textContent.trim()).toEqual(
- `Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`,
- );
+ it('shows status reason if it is available', () => {
+ const statusErrorMessage = vm.$el.querySelector(
+ '.js-cluster-application-status-error-message',
+ );
- expect(statusErrorMessage.textContent.trim()).toEqual(statusReason);
+ expect(statusErrorMessage.textContent.trim()).toEqual(statusReason);
+ });
+
+ it('shows request reason if it is available', () => {
+ const requestErrorMessage = vm.$el.querySelector(
+ '.js-cluster-application-request-error-message',
+ );
+
+ expect(requestErrorMessage.textContent.trim()).toEqual(requestReason);
+ });
});
- it('shows request reason when REQUEST_FAILURE', () => {
- const requestReason = 'We broke thre request 0.0';
- vm = mountComponent(ApplicationRow, {
- ...DEFAULT_APPLICATION_STATE,
- status: APPLICATION_STATUS.INSTALLABLE,
- installFailed: true,
- requestReason,
+ describe('when install fails', () => {
+ beforeEach(() => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_STATUS.ERROR,
+ installFailed: true,
+ });
});
- const generalErrorMessage = vm.$el.querySelector(
- '.js-cluster-application-general-error-message',
- );
- const requestErrorMessage = vm.$el.querySelector(
- '.js-cluster-application-request-error-message',
- );
- expect(generalErrorMessage.textContent.trim()).toEqual(
- `Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`,
- );
+ it('shows a general message indicating the installation failed', () => {
+ const generalErrorMessage = vm.$el.querySelector(
+ '.js-cluster-application-general-error-message',
+ );
- expect(requestErrorMessage.textContent.trim()).toEqual(requestReason);
+ expect(generalErrorMessage.textContent.trim()).toEqual(
+ `Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`,
+ );
+ });
+ });
+
+ describe('when uninstall fails', () => {
+ beforeEach(() => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_STATUS.ERROR,
+ uninstallFailed: true,
+ });
+ });
+
+ it('shows a general message indicating the uninstalling failed', () => {
+ const generalErrorMessage = vm.$el.querySelector(
+ '.js-cluster-application-general-error-message',
+ );
+
+ expect(generalErrorMessage.textContent.trim()).toEqual(
+ `Something went wrong while uninstalling ${DEFAULT_APPLICATION_STATE.title}`,
+ );
+ });
});
});
});
diff --git a/spec/frontend/clusters/components/uninstall_application_button_spec.js b/spec/frontend/clusters/components/uninstall_application_button_spec.js
new file mode 100644
index 00000000000..9f9397d4d41
--- /dev/null
+++ b/spec/frontend/clusters/components/uninstall_application_button_spec.js
@@ -0,0 +1,32 @@
+import { shallowMount } from '@vue/test-utils';
+import UninstallApplicationButton from '~/clusters/components/uninstall_application_button.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import { APPLICATION_STATUS } from '~/clusters/constants';
+
+const { INSTALLED, UPDATING, UNINSTALLING } = APPLICATION_STATUS;
+
+describe('UninstallApplicationButton', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(UninstallApplicationButton, {
+ propsData: { ...props },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe.each`
+ status | loading | disabled | label
+ ${INSTALLED} | ${false} | ${false} | ${'Uninstall'}
+ ${UPDATING} | ${false} | ${true} | ${'Uninstall'}
+ ${UNINSTALLING} | ${true} | ${true} | ${'Uninstalling'}
+ `('when app status is $status', ({ loading, disabled, status, label }) => {
+ it(`renders a loading=${loading}, disabled=${disabled} button with label="${label}"`, () => {
+ createComponent({ status });
+ expect(wrapper.find(LoadingButton).props()).toMatchObject({ loading, disabled, label });
+ });
+ });
+});
diff --git a/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js b/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js
new file mode 100644
index 00000000000..6a7126b45cd
--- /dev/null
+++ b/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js
@@ -0,0 +1,47 @@
+import { shallowMount } from '@vue/test-utils';
+import UninstallApplicationConfirmationModal from '~/clusters/components/uninstall_application_confirmation_modal.vue';
+import { GlModal } from '@gitlab/ui';
+import { INGRESS } from '~/clusters/constants';
+
+describe('UninstallApplicationConfirmationModal', () => {
+ let wrapper;
+ const appTitle = 'Ingress';
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(UninstallApplicationConfirmationModal, {
+ propsData: { ...props },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ beforeEach(() => {
+ createComponent({ application: INGRESS, applicationTitle: appTitle });
+ });
+
+ it(`renders a modal with a title "Uninstall ${appTitle}"`, () => {
+ expect(wrapper.find(GlModal).attributes('title')).toEqual(`Uninstall ${appTitle}`);
+ });
+
+ it(`renders a modal with an ok button labeled "Uninstall ${appTitle}"`, () => {
+ expect(wrapper.find(GlModal).attributes('ok-title')).toEqual(`Uninstall ${appTitle}`);
+ });
+
+ it('triggers confirm event when ok button is clicked', () => {
+ wrapper.find(GlModal).vm.$emit('ok');
+
+ expect(wrapper.emitted('confirm')).toBeTruthy();
+ });
+
+ it('displays a warning text indicating the app will be uninstalled', () => {
+ expect(wrapper.text()).toContain(`You are about to uninstall ${appTitle} from your cluster.`);
+ });
+
+ it('displays a custom warning text depending on the application', () => {
+ expect(wrapper.text()).toContain(
+ `The associated load balancer and IP will be deleted and cannot be restored.`,
+ );
+ });
+});
diff --git a/spec/frontend/clusters/services/application_state_machine_spec.js b/spec/frontend/clusters/services/application_state_machine_spec.js
index e74b7910572..e057e2ac955 100644
--- a/spec/frontend/clusters/services/application_state_machine_spec.js
+++ b/spec/frontend/clusters/services/application_state_machine_spec.js
@@ -1,5 +1,10 @@
import transitionApplicationState from '~/clusters/services/application_state_machine';
-import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT } from '~/clusters/constants';
+import {
+ APPLICATION_STATUS,
+ UNINSTALL_EVENT,
+ UPDATE_EVENT,
+ INSTALL_EVENT,
+} from '~/clusters/constants';
const {
NO_STATUS,
@@ -12,6 +17,8 @@ const {
UPDATING,
UPDATED,
UPDATE_ERRORED,
+ UNINSTALLING,
+ UNINSTALL_ERRORED,
} = APPLICATION_STATUS;
const NO_EFFECTS = 'no effects';
@@ -21,16 +28,18 @@ describe('applicationStateMachine', () => {
describe(`current state is ${NO_STATUS}`, () => {
it.each`
- expectedState | event | effects
- ${INSTALLING} | ${SCHEDULED} | ${NO_EFFECTS}
- ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS}
- ${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS}
- ${INSTALLING} | ${INSTALLING} | ${NO_EFFECTS}
- ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS}
- ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }}
- ${UPDATING} | ${UPDATING} | ${NO_EFFECTS}
- ${INSTALLED} | ${UPDATED} | ${NO_EFFECTS}
- ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }}
+ expectedState | event | effects
+ ${INSTALLING} | ${SCHEDULED} | ${NO_EFFECTS}
+ ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS}
+ ${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS}
+ ${INSTALLING} | ${INSTALLING} | ${NO_EFFECTS}
+ ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS}
+ ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }}
+ ${UPDATING} | ${UPDATING} | ${NO_EFFECTS}
+ ${INSTALLED} | ${UPDATED} | ${NO_EFFECTS}
+ ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }}
+ ${UNINSTALLING} | ${UNINSTALLING} | ${NO_EFFECTS}
+ ${INSTALLED} | ${UNINSTALL_ERRORED} | ${{ uninstallFailed: true }}
`(`transitions to $expectedState on $event event and applies $effects`, data => {
const { expectedState, event, effects } = data;
const currentAppState = {
@@ -99,8 +108,9 @@ describe('applicationStateMachine', () => {
describe(`current state is ${INSTALLED}`, () => {
it.each`
- expectedState | event | effects
- ${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }}
+ expectedState | event | effects
+ ${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }}
+ ${UNINSTALLING} | ${UNINSTALL_EVENT} | ${{ uninstallFailed: false, uninstallSuccessful: false }}
`(`transitions to $expectedState on $event event and applies $effects`, data => {
const { expectedState, event, effects } = data;
const currentAppState = {
@@ -131,4 +141,22 @@ describe('applicationStateMachine', () => {
});
});
});
+
+ describe(`current state is ${UNINSTALLING}`, () => {
+ it.each`
+ expectedState | event | effects
+ ${INSTALLABLE} | ${INSTALLABLE} | ${{ uninstallSuccessful: true }}
+ ${INSTALLED} | ${UNINSTALL_ERRORED} | ${{ uninstallFailed: true }}
+ `(`transitions to $expectedState on $event event and applies $effects`, data => {
+ const { expectedState, event, effects } = data;
+ const currentAppState = {
+ status: UNINSTALLING,
+ };
+
+ expect(transitionApplicationState(currentAppState, event)).toEqual({
+ status: expectedState,
+ ...effects,
+ });
+ });
+ });
});
diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js
index 1e896af1c7d..41ad398e924 100644
--- a/spec/frontend/clusters/services/mock_data.js
+++ b/spec/frontend/clusters/services/mock_data.js
@@ -11,6 +11,7 @@ const CLUSTERS_MOCK_DATA = {
name: 'helm',
status: APPLICATION_STATUS.INSTALLABLE,
status_reason: null,
+ can_uninstall: false,
},
{
name: 'ingress',
@@ -18,32 +19,38 @@ const CLUSTERS_MOCK_DATA = {
status_reason: 'Cannot connect',
external_ip: null,
external_hostname: null,
+ can_uninstall: false,
},
{
name: 'runner',
status: APPLICATION_STATUS.INSTALLING,
status_reason: null,
+ can_uninstall: false,
},
{
name: 'prometheus',
status: APPLICATION_STATUS.ERROR,
status_reason: 'Cannot connect',
+ can_uninstall: false,
},
{
name: 'jupyter',
status: APPLICATION_STATUS.INSTALLING,
status_reason: 'Cannot connect',
+ can_uninstall: false,
},
{
name: 'knative',
status: APPLICATION_STATUS.INSTALLING,
status_reason: 'Cannot connect',
+ can_uninstall: false,
},
{
name: 'cert_manager',
status: APPLICATION_STATUS.ERROR,
status_reason: 'Cannot connect',
email: 'test@example.com',
+ can_uninstall: false,
},
],
},
diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js
index a20e0439555..aa926bb36d7 100644
--- a/spec/frontend/clusters/stores/clusters_store_spec.js
+++ b/spec/frontend/clusters/stores/clusters_store_spec.js
@@ -63,6 +63,8 @@ describe('Clusters Store', () => {
installed: false,
installFailed: false,
uninstallable: false,
+ uninstallSuccessful: false,
+ uninstallFailed: false,
},
ingress: {
title: 'Ingress',
@@ -74,6 +76,8 @@ describe('Clusters Store', () => {
installed: false,
installFailed: true,
uninstallable: false,
+ uninstallSuccessful: false,
+ uninstallFailed: false,
},
runner: {
title: 'GitLab Runner',
@@ -89,6 +93,8 @@ describe('Clusters Store', () => {
updateFailed: false,
updateSuccessful: false,
uninstallable: false,
+ uninstallSuccessful: false,
+ uninstallFailed: false,
},
prometheus: {
title: 'Prometheus',
@@ -98,6 +104,8 @@ describe('Clusters Store', () => {
installed: false,
installFailed: true,
uninstallable: false,
+ uninstallSuccessful: false,
+ uninstallFailed: false,
},
jupyter: {
title: 'JupyterHub',
@@ -108,6 +116,8 @@ describe('Clusters Store', () => {
installed: false,
installFailed: false,
uninstallable: false,
+ uninstallSuccessful: false,
+ uninstallFailed: false,
},
knative: {
title: 'Knative',
@@ -121,6 +131,8 @@ describe('Clusters Store', () => {
installed: false,
installFailed: false,
uninstallable: false,
+ uninstallSuccessful: false,
+ uninstallFailed: false,
},
cert_manager: {
title: 'Cert-Manager',
@@ -131,6 +143,8 @@ describe('Clusters Store', () => {
email: mockResponseData.applications[6].email,
installed: false,
uninstallable: false,
+ uninstallSuccessful: false,
+ uninstallFailed: false,
},
},
});