Commit 52df6481 authored by Zack Cuddy's avatar Zack Cuddy Committed by Mark Florian

Geo Status Page 2.0 - Remove Actions

parent b1c95d46
......@@ -4,6 +4,7 @@ import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
export default {
...Api,
geoNodePath: '/api/:version/geo_nodes/:id',
geoNodesPath: '/api/:version/geo_nodes',
geoNodesStatusPath: '/api/:version/geo_nodes/status',
geoReplicationPath: '/api/:version/geo_replication/:replicable',
......@@ -346,6 +347,11 @@ export default {
return axios.put(`${url}/${node.id}`, node);
},
removeGeoNode(id) {
const url = Api.buildUrl(this.geoNodePath).replace(':id', encodeURIComponent(id));
return axios.delete(url);
},
getApplicationSettings() {
const url = Api.buildUrl(this.applicationSettingsPath);
return axios.get(url);
......
<script>
import { GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui';
import { GlLink, GlButton, GlLoadingIcon, GlModal } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { s__, __ } from '~/locale';
import { GEO_INFO_URL } from '../constants';
import { GEO_INFO_URL, REMOVE_NODE_MODAL_ID } from '../constants';
import GeoNodes from './geo_nodes.vue';
import GeoNodesEmptyState from './geo_nodes_empty_state.vue';
......@@ -15,6 +15,10 @@ export default {
),
learnMore: __('Learn more'),
addSite: s__('Geo|Add site'),
modalTitle: s__('Geo|Remove secondary node'),
modalBody: s__(
'Geo|Removing a Geo secondary node stops the synchronization to that node. Are you sure?',
),
},
components: {
GlLink,
......@@ -22,6 +26,7 @@ export default {
GlLoadingIcon,
GeoNodes,
GeoNodesEmptyState,
GlModal,
},
props: {
newNodeUrl: {
......@@ -43,9 +48,19 @@ export default {
this.fetchNodes();
},
methods: {
...mapActions(['fetchNodes']),
...mapActions(['fetchNodes', 'cancelNodeRemoval', 'removeNode']),
},
GEO_INFO_URL,
MODAL_PRIMARY_ACTION: {
text: s__('Geo|Remove node'),
attributes: {
variant: 'danger',
},
},
MODAL_CANCEL_ACTION: {
text: __('Cancel'),
},
REMOVE_NODE_MODAL_ID,
};
</script>
......@@ -75,5 +90,15 @@ export default {
<geo-nodes v-for="node in nodes" :key="node.id" :node="node" />
<geo-nodes-empty-state v-if="noNodes" :svg-path="geoNodesEmptyStateSvg" />
</div>
<gl-modal
:modal-id="$options.REMOVE_NODE_MODAL_ID"
:title="$options.i18n.modalTitle"
:action-primary="$options.MODAL_PRIMARY_ACTION"
:action-cancel="$options.MODAL_CANCEL_ACTION"
@primary="removeNode"
@cancel="cancelNodeRemoval"
>
{{ $options.i18n.modalBody }}
</gl-modal>
</section>
</template>
<script>
import { mapActions } from 'vuex';
import { REMOVE_NODE_MODAL_ID } from 'ee/geo_nodes_beta/constants';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import GeoNodeActionsDesktop from './geo_node_actions_desktop.vue';
import GeoNodeActionsMobile from './geo_node_actions_mobile.vue';
......@@ -14,12 +17,23 @@ export default {
required: true,
},
},
methods: {
...mapActions(['prepNodeRemoval']),
async warnNodeRemoval() {
await this.prepNodeRemoval(this.node.id);
this.$root.$emit(BV_SHOW_MODAL, REMOVE_NODE_MODAL_ID);
},
},
};
</script>
<template>
<div>
<geo-node-actions-mobile class="gl-lg-display-none" :node="node" />
<geo-node-actions-desktop class="gl-display-none gl-lg-display-flex" :node="node" />
<geo-node-actions-mobile class="gl-lg-display-none" :node="node" @remove="warnNodeRemoval" />
<geo-node-actions-desktop
class="gl-display-none gl-lg-display-flex"
:node="node"
@remove="warnNodeRemoval"
/>
</div>
</template>
......@@ -30,6 +30,7 @@ export default {
category="secondary"
:disabled="node.primary"
data-testid="geo-desktop-remove-action"
@click="$emit('remove')"
>{{ $options.i18n.removeButtonLabel }}</gl-button
>
</div>
......
......@@ -33,7 +33,11 @@ export default {
<gl-icon name="ellipsis_h" />
</template>
<gl-dropdown-item :href="node.webEditUrl">{{ $options.i18n.editButtonLabel }}</gl-dropdown-item>
<gl-dropdown-item :disabled="node.primary" data-testid="geo-mobile-remove-action">
<gl-dropdown-item
:disabled="node.primary"
data-testid="geo-mobile-remove-action"
@click="$emit('remove')"
>
<span :class="dropdownRemoveClass">{{ $options.i18n.removeButtonLabel }}</span>
</gl-dropdown-item>
</gl-dropdown>
......
......@@ -68,3 +68,5 @@ export const STATUS_DELAY_THRESHOLD_MS = 600000;
export const REPOSITORY = 'repository';
export const BLOB = 'blob';
export const REMOVE_NODE_MODAL_ID = 'remove-node-modal';
......@@ -25,3 +25,24 @@ export const fetchNodes = ({ commit }) => {
commit(types.RECEIVE_NODES_ERROR);
});
};
export const prepNodeRemoval = ({ commit }, id) => {
commit(types.STAGE_NODE_REMOVAL, id);
};
export const cancelNodeRemoval = ({ commit }) => {
commit(types.UNSTAGE_NODE_REMOVAL);
};
export const removeNode = ({ commit, state }) => {
commit(types.REQUEST_NODE_REMOVAL);
return Api.removeGeoNode(state.nodeToBeRemoved)
.then(() => {
commit(types.RECEIVE_NODE_REMOVAL_SUCCESS);
})
.catch(() => {
createFlash({ message: s__('Geo|There was an error deleting the Geo Node') });
commit(types.RECEIVE_NODE_REMOVAL_ERROR);
});
};
export const REQUEST_NODES = 'REQUEST_NODES';
export const RECEIVE_NODES_SUCCESS = 'RECEIVE_NODES_SUCCESS';
export const RECEIVE_NODES_ERROR = 'RECEIVE_NODES_ERROR';
export const STAGE_NODE_REMOVAL = 'STAGE_NODE_REMOVAL';
export const UNSTAGE_NODE_REMOVAL = 'UNSTAGE_NODE_REMOVAL';
export const REQUEST_NODE_REMOVAL = 'REQUEST_NODE_REMOVAL';
export const RECEIVE_NODE_REMOVAL_SUCCESS = 'RECEIVE_NODE_REMOVAL_SUCCESS';
export const RECEIVE_NODE_REMOVAL_ERROR = 'RECEIVE_NODE_REMOVAL_ERROR';
......@@ -12,4 +12,25 @@ export default {
state.isLoading = false;
state.nodes = [];
},
[types.STAGE_NODE_REMOVAL](state, id) {
state.nodeToBeRemoved = id;
},
[types.UNSTAGE_NODE_REMOVAL](state) {
state.nodeToBeRemoved = null;
},
[types.REQUEST_NODE_REMOVAL](state) {
state.isLoading = true;
},
[types.RECEIVE_NODE_REMOVAL_SUCCESS](state) {
state.isLoading = false;
const index = state.nodes.findIndex((n) => n.id === state.nodeToBeRemoved);
state.nodes.splice(index, 1);
state.nodeToBeRemoved = null;
},
[types.RECEIVE_NODE_REMOVAL_ERROR](state) {
state.isLoading = false;
state.nodeToBeRemoved = null;
},
};
......@@ -4,5 +4,6 @@ const createState = ({ primaryVersion, primaryRevision, replicableTypes }) => ({
replicableTypes,
nodes: [],
isLoading: false,
nodeToBeRemoved: null,
});
export default createState;
......@@ -783,6 +783,22 @@ describe('Api', () => {
});
});
});
describe('removeGeoNode', () => {
it('DELETES with correct ID', () => {
mockNode = {
id: 1,
};
jest.spyOn(Api, 'buildUrl').mockReturnValue(`${expectedUrl}/${mockNode.id}`);
jest.spyOn(axios, 'delete');
mock.onDelete(`${expectedUrl}/${mockNode.id}`).replyOnce(httpStatus.OK, {});
return Api.removeGeoNode(mockNode.id).then(() => {
expect(axios.delete).toHaveBeenCalledWith(`${expectedUrl}/${mockNode.id}`);
});
});
});
});
describe('Application Settings', () => {
......
import { GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui';
import { GlLink, GlButton, GlLoadingIcon, GlModal } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import GeoNodesBetaApp from 'ee/geo_nodes_beta/components/app.vue';
......@@ -21,6 +21,8 @@ describe('GeoNodesBetaApp', () => {
const actionSpies = {
fetchNodes: jest.fn(),
removeNode: jest.fn(),
cancelNodeRemoval: jest.fn(),
};
const defaultProps = {
......@@ -59,6 +61,7 @@ describe('GeoNodesBetaApp', () => {
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findGeoEmptyState = () => wrapper.findComponent(GeoNodesEmptyState);
const findGeoNodes = () => wrapper.findAllComponents(GeoNodes);
const findGlModal = () => wrapper.findComponent(GlModal);
describe('template', () => {
describe('always', () => {
......@@ -74,6 +77,10 @@ describe('GeoNodesBetaApp', () => {
expect(findGeoLearnMoreLink().exists()).toBe(true);
expect(findGeoLearnMoreLink().attributes('href')).toBe(GEO_INFO_URL);
});
it('renders the GlModal', () => {
expect(findGlModal().exists()).toBe(true);
});
});
describe.each`
......@@ -129,4 +136,22 @@ describe('GeoNodesBetaApp', () => {
expect(actionSpies.fetchNodes).toHaveBeenCalledTimes(1);
});
});
describe('Modal Events', () => {
beforeEach(() => {
createComponent();
});
it('calls removeNode when modal primary button clicked', () => {
findGlModal().vm.$emit('primary');
expect(actionSpies.removeNode).toHaveBeenCalled();
});
it('calls cancelNodeRemoval when modal cancel button clicked', () => {
findGlModal().vm.$emit('cancel');
expect(actionSpies.cancelNodeRemoval).toHaveBeenCalled();
});
});
});
......@@ -66,6 +66,12 @@ describe('GeoNodeActionsDesktop', () => {
MOCK_NODES[0].webEditUrl,
);
});
it('emits remove when remove button is clicked', () => {
findGeoDesktopActionsRemoveButton().vm.$emit('click');
expect(wrapper.emitted('remove')).toHaveLength(1);
});
});
describe.each`
......
......@@ -72,6 +72,12 @@ describe('GeoNodeActionsMobile', () => {
MOCK_NODES[0].webEditUrl,
);
});
it('emits remove when remove button is clicked', () => {
findGeoMobileActionsRemoveDropdownItem().vm.$emit('click');
expect(wrapper.emitted('remove')).toHaveLength(1);
});
});
describe.each`
......
......@@ -3,11 +3,14 @@ import Vuex from 'vuex';
import GeoNodeActions from 'ee/geo_nodes_beta/components/header/geo_node_actions.vue';
import GeoNodeActionsDesktop from 'ee/geo_nodes_beta/components/header/geo_node_actions_desktop.vue';
import GeoNodeActionsMobile from 'ee/geo_nodes_beta/components/header/geo_node_actions_mobile.vue';
import { REMOVE_NODE_MODAL_ID } from 'ee/geo_nodes_beta/constants';
import {
MOCK_NODES,
MOCK_PRIMARY_VERSION,
MOCK_REPLICABLE_TYPES,
} from 'ee_jest/geo_nodes_beta/mock_data';
import waitForPromises from 'helpers/wait_for_promises';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -15,6 +18,10 @@ localVue.use(Vuex);
describe('GeoNodeActions', () => {
let wrapper;
const actionSpies = {
prepNodeRemoval: jest.fn(),
};
const defaultProps = {
node: MOCK_NODES[0],
};
......@@ -27,6 +34,7 @@ describe('GeoNodeActions', () => {
replicableTypes: MOCK_REPLICABLE_TYPES,
...initialState,
},
actions: actionSpies,
});
wrapper = shallowMount(GeoNodeActions, {
......@@ -64,4 +72,49 @@ describe('GeoNodeActions', () => {
]);
});
});
describe('events', () => {
describe('remove', () => {
beforeEach(() => {
createComponent();
jest.spyOn(wrapper.vm.$root, '$emit');
});
it('preps node for removal and opens model after promise returns on desktop', async () => {
findGeoDesktopActions().vm.$emit('remove');
expect(actionSpies.prepNodeRemoval).toHaveBeenCalledWith(
expect.any(Object),
MOCK_NODES[0].id,
);
expect(wrapper.vm.$root.$emit).not.toHaveBeenCalledWith(
BV_SHOW_MODAL,
REMOVE_NODE_MODAL_ID,
);
await waitForPromises();
expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith(BV_SHOW_MODAL, REMOVE_NODE_MODAL_ID);
});
it('preps node for removal and opens model after promise returns on mobile', async () => {
findGeoMobileActions().vm.$emit('remove');
expect(actionSpies.prepNodeRemoval).toHaveBeenCalledWith(
expect.any(Object),
MOCK_NODES[0].id,
);
expect(wrapper.vm.$root.$emit).not.toHaveBeenCalledWith(
BV_SHOW_MODAL,
REMOVE_NODE_MODAL_ID,
);
await waitForPromises();
expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith(BV_SHOW_MODAL, REMOVE_NODE_MODAL_ID);
});
});
});
});
......@@ -72,4 +72,67 @@ describe('GeoNodesBeta Store Actions', () => {
});
});
});
describe('removeNode', () => {
describe('on success', () => {
beforeEach(() => {
mock.onDelete(/api\/.*\/geo_nodes/).replyOnce(200, {});
});
it('should dispatch the correct mutations', () => {
return testAction({
action: actions.removeNode,
payload: null,
state,
expectedMutations: [
{ type: types.REQUEST_NODE_REMOVAL },
{ type: types.RECEIVE_NODE_REMOVAL_SUCCESS },
],
});
});
});
describe('on error', () => {
beforeEach(() => {
mock.onDelete(/api\/(.*)\/geo_nodes/).reply(500);
});
it('should dispatch the correct mutations', () => {
return testAction({
action: actions.removeNode,
payload: null,
state,
expectedMutations: [
{ type: types.REQUEST_NODE_REMOVAL },
{ type: types.RECEIVE_NODE_REMOVAL_ERROR },
],
}).then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
createFlash.mockClear();
});
});
});
});
describe('prepNodeRemoval', () => {
it('should dispatch the correct mutations', () => {
return testAction({
action: actions.prepNodeRemoval,
payload: 1,
state,
expectedMutations: [{ type: types.STAGE_NODE_REMOVAL, payload: 1 }],
});
});
});
describe('cancelNodeRemoval', () => {
it('should dispatch the correct mutations', () => {
return testAction({
action: actions.cancelNodeRemoval,
payload: null,
state,
expectedMutations: [{ type: types.UNSTAGE_NODE_REMOVAL }],
});
});
});
});
......@@ -47,4 +47,62 @@ describe('GeoNodesBeta Store Mutations', () => {
expect(state.nodes).toEqual([]);
});
});
describe('STAGE_NODE_REMOVAL', () => {
it('sets nodeToBeRemoved to node id', () => {
mutations[types.STAGE_NODE_REMOVAL](state, 1);
expect(state.nodeToBeRemoved).toBe(1);
});
});
describe('UNSTAGE_NODE_REMOVAL', () => {
beforeEach(() => {
state.nodeToBeRemoved = 1;
});
it('sets nodeToBeRemoved to null', () => {
mutations[types.UNSTAGE_NODE_REMOVAL](state);
expect(state.nodeToBeRemoved).toBe(null);
});
});
describe('REQUEST_NODE_REMOVAL', () => {
it('sets isLoading to true', () => {
mutations[types.REQUEST_NODE_REMOVAL](state);
expect(state.isLoading).toBe(true);
});
});
describe('RECEIVE_NODE_REMOVAL_SUCCESS', () => {
beforeEach(() => {
state.isLoading = true;
state.nodes = [{ id: 1 }, { id: 2 }];
state.nodeToBeRemoved = 1;
});
it('removes node, clears nodeToBeRemoved, and ends loading', () => {
mutations[types.RECEIVE_NODE_REMOVAL_SUCCESS](state);
expect(state.isLoading).toBe(false);
expect(state.nodes).toEqual([{ id: 2 }]);
expect(state.nodeToBeRemoved).toEqual(null);
});
});
describe('RECEIVE_NODE_REMOVAL_ERROR', () => {
beforeEach(() => {
state.isLoading = true;
state.nodeToBeRemoved = 1;
});
it('resets state', () => {
mutations[types.RECEIVE_NODE_REMOVAL_ERROR](state);
expect(state.isLoading).toBe(false);
expect(state.nodeToBeRemoved).toEqual(null);
});
});
});
......@@ -14637,9 +14637,18 @@ msgstr ""
msgid "Geo|Remove entry"
msgstr ""
msgid "Geo|Remove node"
msgstr ""
msgid "Geo|Remove secondary node"
msgstr ""
msgid "Geo|Remove tracking database entry"
msgstr ""
msgid "Geo|Removing a Geo secondary node stops the synchronization to that node. Are you sure?"
msgstr ""
msgid "Geo|Replicated data is verified with the secondary node(s) using checksums."
msgstr ""
......@@ -14727,6 +14736,9 @@ msgstr ""
msgid "Geo|There are no %{replicable_type} to show"
msgstr ""
msgid "Geo|There was an error deleting the Geo Node"
msgstr ""
msgid "Geo|There was an error fetching the Geo Nodes"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment