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'; ...@@ -4,6 +4,7 @@ import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
export default { export default {
...Api, ...Api,
geoNodePath: '/api/:version/geo_nodes/:id',
geoNodesPath: '/api/:version/geo_nodes', geoNodesPath: '/api/:version/geo_nodes',
geoNodesStatusPath: '/api/:version/geo_nodes/status', geoNodesStatusPath: '/api/:version/geo_nodes/status',
geoReplicationPath: '/api/:version/geo_replication/:replicable', geoReplicationPath: '/api/:version/geo_replication/:replicable',
...@@ -346,6 +347,11 @@ export default { ...@@ -346,6 +347,11 @@ export default {
return axios.put(`${url}/${node.id}`, node); return axios.put(`${url}/${node.id}`, node);
}, },
removeGeoNode(id) {
const url = Api.buildUrl(this.geoNodePath).replace(':id', encodeURIComponent(id));
return axios.delete(url);
},
getApplicationSettings() { getApplicationSettings() {
const url = Api.buildUrl(this.applicationSettingsPath); const url = Api.buildUrl(this.applicationSettingsPath);
return axios.get(url); return axios.get(url);
......
<script> <script>
import { GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui'; import { GlLink, GlButton, GlLoadingIcon, GlModal } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import { s__, __ } from '~/locale'; 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 GeoNodes from './geo_nodes.vue';
import GeoNodesEmptyState from './geo_nodes_empty_state.vue'; import GeoNodesEmptyState from './geo_nodes_empty_state.vue';
...@@ -15,6 +15,10 @@ export default { ...@@ -15,6 +15,10 @@ export default {
), ),
learnMore: __('Learn more'), learnMore: __('Learn more'),
addSite: s__('Geo|Add site'), 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: { components: {
GlLink, GlLink,
...@@ -22,6 +26,7 @@ export default { ...@@ -22,6 +26,7 @@ export default {
GlLoadingIcon, GlLoadingIcon,
GeoNodes, GeoNodes,
GeoNodesEmptyState, GeoNodesEmptyState,
GlModal,
}, },
props: { props: {
newNodeUrl: { newNodeUrl: {
...@@ -43,9 +48,19 @@ export default { ...@@ -43,9 +48,19 @@ export default {
this.fetchNodes(); this.fetchNodes();
}, },
methods: { methods: {
...mapActions(['fetchNodes']), ...mapActions(['fetchNodes', 'cancelNodeRemoval', 'removeNode']),
}, },
GEO_INFO_URL, GEO_INFO_URL,
MODAL_PRIMARY_ACTION: {
text: s__('Geo|Remove node'),
attributes: {
variant: 'danger',
},
},
MODAL_CANCEL_ACTION: {
text: __('Cancel'),
},
REMOVE_NODE_MODAL_ID,
}; };
</script> </script>
...@@ -75,5 +90,15 @@ export default { ...@@ -75,5 +90,15 @@ export default {
<geo-nodes v-for="node in nodes" :key="node.id" :node="node" /> <geo-nodes v-for="node in nodes" :key="node.id" :node="node" />
<geo-nodes-empty-state v-if="noNodes" :svg-path="geoNodesEmptyStateSvg" /> <geo-nodes-empty-state v-if="noNodes" :svg-path="geoNodesEmptyStateSvg" />
</div> </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> </section>
</template> </template>
<script> <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 GeoNodeActionsDesktop from './geo_node_actions_desktop.vue';
import GeoNodeActionsMobile from './geo_node_actions_mobile.vue'; import GeoNodeActionsMobile from './geo_node_actions_mobile.vue';
...@@ -14,12 +17,23 @@ export default { ...@@ -14,12 +17,23 @@ export default {
required: true, required: true,
}, },
}, },
methods: {
...mapActions(['prepNodeRemoval']),
async warnNodeRemoval() {
await this.prepNodeRemoval(this.node.id);
this.$root.$emit(BV_SHOW_MODAL, REMOVE_NODE_MODAL_ID);
},
},
}; };
</script> </script>
<template> <template>
<div> <div>
<geo-node-actions-mobile class="gl-lg-display-none" :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" /> <geo-node-actions-desktop
class="gl-display-none gl-lg-display-flex"
:node="node"
@remove="warnNodeRemoval"
/>
</div> </div>
</template> </template>
...@@ -30,6 +30,7 @@ export default { ...@@ -30,6 +30,7 @@ export default {
category="secondary" category="secondary"
:disabled="node.primary" :disabled="node.primary"
data-testid="geo-desktop-remove-action" data-testid="geo-desktop-remove-action"
@click="$emit('remove')"
>{{ $options.i18n.removeButtonLabel }}</gl-button >{{ $options.i18n.removeButtonLabel }}</gl-button
> >
</div> </div>
......
...@@ -33,7 +33,11 @@ export default { ...@@ -33,7 +33,11 @@ export default {
<gl-icon name="ellipsis_h" /> <gl-icon name="ellipsis_h" />
</template> </template>
<gl-dropdown-item :href="node.webEditUrl">{{ $options.i18n.editButtonLabel }}</gl-dropdown-item> <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> <span :class="dropdownRemoveClass">{{ $options.i18n.removeButtonLabel }}</span>
</gl-dropdown-item> </gl-dropdown-item>
</gl-dropdown> </gl-dropdown>
......
...@@ -68,3 +68,5 @@ export const STATUS_DELAY_THRESHOLD_MS = 600000; ...@@ -68,3 +68,5 @@ export const STATUS_DELAY_THRESHOLD_MS = 600000;
export const REPOSITORY = 'repository'; export const REPOSITORY = 'repository';
export const BLOB = 'blob'; export const BLOB = 'blob';
export const REMOVE_NODE_MODAL_ID = 'remove-node-modal';
...@@ -25,3 +25,24 @@ export const fetchNodes = ({ commit }) => { ...@@ -25,3 +25,24 @@ export const fetchNodes = ({ commit }) => {
commit(types.RECEIVE_NODES_ERROR); 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 REQUEST_NODES = 'REQUEST_NODES';
export const RECEIVE_NODES_SUCCESS = 'RECEIVE_NODES_SUCCESS'; export const RECEIVE_NODES_SUCCESS = 'RECEIVE_NODES_SUCCESS';
export const RECEIVE_NODES_ERROR = 'RECEIVE_NODES_ERROR'; 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 { ...@@ -12,4 +12,25 @@ export default {
state.isLoading = false; state.isLoading = false;
state.nodes = []; 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 }) => ({ ...@@ -4,5 +4,6 @@ const createState = ({ primaryVersion, primaryRevision, replicableTypes }) => ({
replicableTypes, replicableTypes,
nodes: [], nodes: [],
isLoading: false, isLoading: false,
nodeToBeRemoved: null,
}); });
export default createState; export default createState;
...@@ -783,6 +783,22 @@ describe('Api', () => { ...@@ -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', () => { 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 { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import GeoNodesBetaApp from 'ee/geo_nodes_beta/components/app.vue'; import GeoNodesBetaApp from 'ee/geo_nodes_beta/components/app.vue';
...@@ -21,6 +21,8 @@ describe('GeoNodesBetaApp', () => { ...@@ -21,6 +21,8 @@ describe('GeoNodesBetaApp', () => {
const actionSpies = { const actionSpies = {
fetchNodes: jest.fn(), fetchNodes: jest.fn(),
removeNode: jest.fn(),
cancelNodeRemoval: jest.fn(),
}; };
const defaultProps = { const defaultProps = {
...@@ -59,6 +61,7 @@ describe('GeoNodesBetaApp', () => { ...@@ -59,6 +61,7 @@ describe('GeoNodesBetaApp', () => {
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findGeoEmptyState = () => wrapper.findComponent(GeoNodesEmptyState); const findGeoEmptyState = () => wrapper.findComponent(GeoNodesEmptyState);
const findGeoNodes = () => wrapper.findAllComponents(GeoNodes); const findGeoNodes = () => wrapper.findAllComponents(GeoNodes);
const findGlModal = () => wrapper.findComponent(GlModal);
describe('template', () => { describe('template', () => {
describe('always', () => { describe('always', () => {
...@@ -74,6 +77,10 @@ describe('GeoNodesBetaApp', () => { ...@@ -74,6 +77,10 @@ describe('GeoNodesBetaApp', () => {
expect(findGeoLearnMoreLink().exists()).toBe(true); expect(findGeoLearnMoreLink().exists()).toBe(true);
expect(findGeoLearnMoreLink().attributes('href')).toBe(GEO_INFO_URL); expect(findGeoLearnMoreLink().attributes('href')).toBe(GEO_INFO_URL);
}); });
it('renders the GlModal', () => {
expect(findGlModal().exists()).toBe(true);
});
}); });
describe.each` describe.each`
...@@ -129,4 +136,22 @@ describe('GeoNodesBetaApp', () => { ...@@ -129,4 +136,22 @@ describe('GeoNodesBetaApp', () => {
expect(actionSpies.fetchNodes).toHaveBeenCalledTimes(1); 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', () => { ...@@ -66,6 +66,12 @@ describe('GeoNodeActionsDesktop', () => {
MOCK_NODES[0].webEditUrl, MOCK_NODES[0].webEditUrl,
); );
}); });
it('emits remove when remove button is clicked', () => {
findGeoDesktopActionsRemoveButton().vm.$emit('click');
expect(wrapper.emitted('remove')).toHaveLength(1);
});
}); });
describe.each` describe.each`
......
...@@ -72,6 +72,12 @@ describe('GeoNodeActionsMobile', () => { ...@@ -72,6 +72,12 @@ describe('GeoNodeActionsMobile', () => {
MOCK_NODES[0].webEditUrl, MOCK_NODES[0].webEditUrl,
); );
}); });
it('emits remove when remove button is clicked', () => {
findGeoMobileActionsRemoveDropdownItem().vm.$emit('click');
expect(wrapper.emitted('remove')).toHaveLength(1);
});
}); });
describe.each` describe.each`
......
...@@ -3,11 +3,14 @@ import Vuex from 'vuex'; ...@@ -3,11 +3,14 @@ import Vuex from 'vuex';
import GeoNodeActions from 'ee/geo_nodes_beta/components/header/geo_node_actions.vue'; 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 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 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 { import {
MOCK_NODES, MOCK_NODES,
MOCK_PRIMARY_VERSION, MOCK_PRIMARY_VERSION,
MOCK_REPLICABLE_TYPES, MOCK_REPLICABLE_TYPES,
} from 'ee_jest/geo_nodes_beta/mock_data'; } 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(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -15,6 +18,10 @@ localVue.use(Vuex); ...@@ -15,6 +18,10 @@ localVue.use(Vuex);
describe('GeoNodeActions', () => { describe('GeoNodeActions', () => {
let wrapper; let wrapper;
const actionSpies = {
prepNodeRemoval: jest.fn(),
};
const defaultProps = { const defaultProps = {
node: MOCK_NODES[0], node: MOCK_NODES[0],
}; };
...@@ -27,6 +34,7 @@ describe('GeoNodeActions', () => { ...@@ -27,6 +34,7 @@ describe('GeoNodeActions', () => {
replicableTypes: MOCK_REPLICABLE_TYPES, replicableTypes: MOCK_REPLICABLE_TYPES,
...initialState, ...initialState,
}, },
actions: actionSpies,
}); });
wrapper = shallowMount(GeoNodeActions, { wrapper = shallowMount(GeoNodeActions, {
...@@ -64,4 +72,49 @@ describe('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', () => { ...@@ -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', () => { ...@@ -47,4 +47,62 @@ describe('GeoNodesBeta Store Mutations', () => {
expect(state.nodes).toEqual([]); 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 "" ...@@ -14637,9 +14637,18 @@ msgstr ""
msgid "Geo|Remove entry" msgid "Geo|Remove entry"
msgstr "" msgstr ""
msgid "Geo|Remove node"
msgstr ""
msgid "Geo|Remove secondary node"
msgstr ""
msgid "Geo|Remove tracking database entry" msgid "Geo|Remove tracking database entry"
msgstr "" 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." msgid "Geo|Replicated data is verified with the secondary node(s) using checksums."
msgstr "" msgstr ""
...@@ -14727,6 +14736,9 @@ msgstr "" ...@@ -14727,6 +14736,9 @@ msgstr ""
msgid "Geo|There are no %{replicable_type} to show" msgid "Geo|There are no %{replicable_type} to show"
msgstr "" msgstr ""
msgid "Geo|There was an error deleting the Geo Node"
msgstr ""
msgid "Geo|There was an error fetching the Geo Nodes" msgid "Geo|There was an error fetching the Geo Nodes"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment