Commit 5a9b0af6 authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '336882-geo-sites-filter' into 'master'

Geo Sites - Filter By Status

See merge request gitlab-org/gitlab!79070
parents 631d3e14 bd82e591
<script>
import { GlLink, GlButton, GlLoadingIcon, GlModal, GlSprintf } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { mapActions, mapState, mapGetters } from 'vuex';
import { s__, __ } from '~/locale';
import { GEO_INFO_URL, REMOVE_NODE_MODAL_ID } from '../constants';
import GeoNodesFilters from './geo_nodes_filters.vue';
import GeoNodes from './geo_nodes.vue';
import GeoNodesEmptyState from './geo_nodes_empty_state.vue';
......@@ -25,6 +26,7 @@ export default {
GlLink,
GlButton,
GlLoadingIcon,
GeoNodesFilters,
GeoNodes,
GeoNodesEmptyState,
GlModal,
......@@ -42,14 +44,15 @@ export default {
},
computed: {
...mapState(['nodes', 'isLoading']),
...mapGetters(['filteredNodes']),
noNodes() {
return !this.nodes || this.nodes.length === 0;
},
primaryNodes() {
return this.nodes.filter((n) => n.primary);
return this.filteredNodes.filter((n) => n.primary);
},
secondaryNodes() {
return this.nodes.filter((n) => !n.primary);
return this.filteredNodes.filter((n) => !n.primary);
},
},
created() {
......@@ -100,14 +103,19 @@ export default {
<gl-loading-icon v-if="isLoading" size="xl" class="gl-mt-5" />
<template v-if="!isLoading">
<div v-if="!noNodes">
<h4 class="gl-font-lg gl-my-5">{{ $options.i18n.primarySite }}</h4>
<geo-nodes-filters :total-nodes="nodes.length" />
<h4 v-if="primaryNodes.length" class="gl-font-lg gl-my-5">
{{ $options.i18n.primarySite }}
</h4>
<geo-nodes
v-for="node in primaryNodes"
:key="node.id"
:node="node"
data-testid="primary-nodes"
/>
<h4 class="gl-font-lg gl-my-5">{{ $options.i18n.secondarySite }}</h4>
<h4 v-if="secondaryNodes.length" class="gl-font-lg gl-my-5">
{{ $options.i18n.secondarySite }}
</h4>
<geo-nodes
v-for="node in secondaryNodes"
:key="node.id"
......
<script>
import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { s__ } from '~/locale';
import { HEALTH_STATUS_UI, STATUS_FILTER_QUERY_PARAM } from 'ee/geo_nodes/constants';
export default {
name: 'GeoNodesFilters',
i18n: {
allTab: s__('Geo|All'),
},
components: {
GlTabs,
GlTab,
GlBadge,
},
props: {
totalNodes: {
type: Number,
required: false,
default: 0,
},
},
computed: {
...mapGetters(['countNodesForStatus']),
tabs() {
const ALL_TAB = { text: this.$options.i18n.allTab, count: this.totalNodes, status: null };
const tabs = [ALL_TAB];
Object.entries(HEALTH_STATUS_UI).forEach(([status, tab]) => {
const count = this.countNodesForStatus(status);
if (count) {
tabs.push({ ...tab, count, status });
}
});
return tabs;
},
},
methods: {
...mapActions(['setStatusFilter']),
tabChange(tabIndex) {
this.setStatusFilter(this.tabs[tabIndex]?.status);
},
},
STATUS_FILTER_QUERY_PARAM,
};
</script>
<template>
<gl-tabs
sync-active-tab-with-query-params
:query-param-name="$options.STATUS_FILTER_QUERY_PARAM"
data-testid="geo-sites-filter"
@input="tabChange"
>
<gl-tab v-for="tab in tabs" :key="tab.text" :query-param-value="tab.status">
<template #title>
<span>{{ tab.text }}</span>
<gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
</template>
</gl-tab>
</gl-tabs>
</template>
......@@ -78,3 +78,5 @@ export const REPOSITORY = 'repository';
export const BLOB = 'blob';
export const REMOVE_NODE_MODAL_ID = 'remove-node-modal';
export const STATUS_FILTER_QUERY_PARAM = 'status';
......@@ -46,3 +46,7 @@ export const removeNode = ({ commit, state }) => {
commit(types.RECEIVE_NODE_REMOVAL_ERROR);
});
};
export const setStatusFilter = ({ commit }, status) => {
commit(types.SET_STATUS_FILTER, status);
};
......@@ -59,3 +59,21 @@ export const canRemoveNode = (state) => (id) => {
return !node.primary || state.nodes.length === 1;
};
export const filteredNodes = (state) => {
if (!state.statusFilter) {
return state.nodes;
}
return state.nodes.filter((n) =>
n.healthStatus
? n.healthStatus.toLowerCase() === state.statusFilter
: state.statusFilter === 'unknown',
);
};
export const countNodesForStatus = (state) => (status) => {
return state.nodes.filter((n) =>
n.healthStatus ? n.healthStatus.toLowerCase() === status : status === 'unknown',
).length;
};
......@@ -8,3 +8,5 @@ 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';
export const SET_STATUS_FILTER = 'SET_STATUS_FILTER';
......@@ -33,4 +33,7 @@ export default {
state.isLoading = false;
state.nodeToBeRemoved = null;
},
[types.SET_STATUS_FILTER](state, status) {
state.statusFilter = status;
},
};
......@@ -5,5 +5,6 @@ const createState = ({ primaryVersion, primaryRevision, replicableTypes }) => ({
nodes: [],
isLoading: false,
nodeToBeRemoved: null,
statusFilter: null,
});
export default createState;
......@@ -58,6 +58,47 @@ RSpec.describe 'GEO Nodes', :geo do
expect(all('.geo-node-details-grid-columns').last).to have_link('Open replications', href: expected_url)
end
context 'Status Filters', :js do
it 'defaults to the All tab when a status query is not already set' do
visit admin_geo_nodes_path
tab_count = find('[data-testid="geo-sites-filter"] .active .badge').text.to_i
results_count = page.all('[data-testid="primary-nodes"]').length + page.all('[data-testid="secondary-nodes"]').length
expect(find('[data-testid="geo-sites-filter"] .active')).to have_content('All')
expect(results_count).to be(tab_count)
end
it 'sets the correct tab when a status query is already set' do
visit admin_geo_nodes_path(status: 'unknown')
tab_count = find('[data-testid="geo-sites-filter"] .active .badge').text.to_i
results_count = page.all('[data-testid="primary-nodes"]').length + page.all('[data-testid="secondary-nodes"]').length
expect(find('[data-testid="geo-sites-filter"] .active')).not_to have_content('All')
expect(find('[data-testid="geo-sites-filter"] .active')).to have_content('Unknown')
expect(results_count).to be(tab_count)
end
it 'properly updates the query and sets the tab when a new one is clicked' do
visit admin_geo_nodes_path
tab_count = find('[data-testid="geo-sites-filter"] .active .badge').text.to_i
results_count = page.all('[data-testid="primary-nodes"]').length + page.all('[data-testid="secondary-nodes"]').length
expect(find('[data-testid="geo-sites-filter"] .active')).to have_content('All')
expect(results_count).to be(tab_count)
click_link 'Unknown'
wait_for_requests
tab_count = find('[data-testid="geo-sites-filter"] .active .badge').text.to_i
results_count = page.all('[data-testid="primary-nodes"]').length + page.all('[data-testid="secondary-nodes"]').length
expect(find('[data-testid="geo-sites-filter"] .active')).not_to have_content('All')
expect(find('[data-testid="geo-sites-filter"] .active')).to have_content('Unknown')
expect(page).to have_current_path(admin_geo_nodes_path(status: 'unknown'))
expect(results_count).to be(tab_count)
end
end
end
end
end
import { GlLink, GlButton, GlLoadingIcon, GlModal, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import GeoNodesApp from 'ee/geo_nodes/components/app.vue';
import GeoNodes from 'ee/geo_nodes/components/geo_nodes.vue';
import GeoNodesEmptyState from 'ee/geo_nodes/components/geo_nodes_empty_state.vue';
import { GEO_INFO_URL } from 'ee/geo_nodes/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { MOCK_NODES, MOCK_NEW_NODE_URL, MOCK_EMPTY_STATE_SVG } from '../mock_data';
Vue.use(Vuex);
......@@ -25,24 +24,26 @@ describe('GeoNodesApp', () => {
geoNodesEmptyStateSvg: MOCK_EMPTY_STATE_SVG,
};
const createComponent = (initialState, props) => {
const createComponent = (initialState, props, getters) => {
const store = new Vuex.Store({
state: {
...initialState,
},
actions: actionSpies,
getters: {
filteredNodes: () => [],
...getters,
},
});
wrapper = extendedWrapper(
shallowMount(GeoNodesApp, {
wrapper = shallowMountExtended(GeoNodesApp, {
store,
propsData: {
...defaultProps,
...props,
},
stubs: { GlSprintf },
}),
);
});
};
afterEach(() => {
......@@ -58,6 +59,8 @@ describe('GeoNodesApp', () => {
const findPrimaryGeoNodes = () => wrapper.findAllByTestId('primary-nodes');
const findSecondaryGeoNodes = () => wrapper.findAllByTestId('secondary-nodes');
const findGlModal = () => wrapper.findComponent(GlModal);
const findPrimarySiteTitle = () => wrapper.findByText('Primary site');
const findSecondarySiteTitle = () => wrapper.findByText('Secondary site');
describe('template', () => {
describe('always', () => {
......@@ -89,7 +92,7 @@ describe('GeoNodesApp', () => {
`conditionally`,
({ isLoading, nodes, showLoadingIcon, showNodes, showEmptyState, showAddButton }) => {
beforeEach(() => {
createComponent({ isLoading, nodes });
createComponent({ isLoading, nodes }, null, { filteredNodes: () => nodes });
});
describe(`when isLoading is ${isLoading} & nodes length ${nodes.length}`, () => {
......@@ -117,7 +120,7 @@ describe('GeoNodesApp', () => {
const secondaryNodes = MOCK_NODES.filter((n) => !n.primary);
beforeEach(() => {
createComponent({ nodes: MOCK_NODES });
createComponent({ nodes: MOCK_NODES }, null, { filteredNodes: () => MOCK_NODES });
});
it('renders the correct Geo Node component for each node', () => {
......@@ -125,6 +128,28 @@ describe('GeoNodesApp', () => {
expect(findSecondaryGeoNodes()).toHaveLength(secondaryNodes.length);
});
});
describe.each`
description | nodes | primaryTitle | secondaryTitle
${'with both primary and secondary nodes'} | ${MOCK_NODES} | ${true} | ${true}
${'with only primary nodes'} | ${MOCK_NODES.filter((n) => n.primary)} | ${true} | ${false}
${'with only secondary nodes'} | ${MOCK_NODES.filter((n) => !n.primary)} | ${false} | ${true}
${'with no nodes'} | ${[]} | ${false} | ${false}
`('Site Titles', ({ description, nodes, primaryTitle, secondaryTitle }) => {
describe(`${description}`, () => {
beforeEach(() => {
createComponent({ nodes }, null, { filteredNodes: () => nodes });
});
it(`should ${primaryTitle ? '' : 'not '}render the Primary Site Title`, () => {
expect(findPrimarySiteTitle().exists()).toBe(primaryTitle);
});
it(`should ${secondaryTitle ? '' : 'not '}render the Secondary Site Title`, () => {
expect(findSecondarySiteTitle().exists()).toBe(secondaryTitle);
});
});
});
});
describe('onCreate', () => {
......
import { GlTabs, GlTab } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import GeoNodesFilters from 'ee/geo_nodes/components/geo_nodes_filters.vue';
import { HEALTH_STATUS_UI, STATUS_FILTER_QUERY_PARAM } from 'ee/geo_nodes/constants';
Vue.use(Vuex);
const MOCK_TAB_COUNT = 5;
describe('GeoNodesFilters', () => {
let wrapper;
const defaultProps = {
totalNodes: MOCK_TAB_COUNT,
};
const actionSpies = {
setStatusFilter: jest.fn(),
};
const createComponent = (initialState, props, getters) => {
const store = new Vuex.Store({
state: {
...initialState,
},
actions: actionSpies,
getters: {
countNodesForStatus: () => () => 0,
...getters,
},
});
wrapper = shallowMountExtended(GeoNodesFilters, {
store,
propsData: {
...defaultProps,
...props,
},
stubs: { GlTab },
});
};
afterEach(() => {
wrapper.destroy();
});
const findGlTabs = () => wrapper.findComponent(GlTabs);
const findAllGlTabs = () => wrapper.findAllComponents(GlTab);
const findAllGlTabTitles = () => wrapper.findAllComponents(GlTab).wrappers.map((w) => w.text());
const findAllTab = () => findAllGlTabs().at(0);
describe('template', () => {
describe('always', () => {
beforeEach(() => {
createComponent();
});
it('renders the GlTabs', () => {
expect(findGlTabs().exists()).toBe(true);
});
it('allows GlTabs to manage the query param', () => {
expect(findGlTabs().attributes('syncactivetabwithqueryparams')).toBe('true');
expect(findGlTabs().attributes('queryparamname')).toBe(STATUS_FILTER_QUERY_PARAM);
});
it('renders the All tab with the totalNodes count', () => {
expect(findAllTab().exists()).toBe(true);
expect(findAllTab().text()).toBe(`All ${MOCK_TAB_COUNT}`);
});
});
describe('conditional tabs', () => {
describe('when every status has counts', () => {
beforeEach(() => {
createComponent(
null,
{ totalNodes: MOCK_TAB_COUNT },
{ countNodesForStatus: () => () => MOCK_TAB_COUNT },
);
});
it('renders every status tab', () => {
const expectedTabTitles = [
`All ${MOCK_TAB_COUNT}`,
...Object.values(HEALTH_STATUS_UI).map((tab) => `${tab.text} ${MOCK_TAB_COUNT}`),
];
expect(findAllGlTabTitles()).toStrictEqual(expectedTabTitles);
});
});
describe('when only certain statuses have counts', () => {
const MOCK_COUNTER_GETTER = () => (status) => {
if (status === 'healthy' || status === 'unhealthy') {
return MOCK_TAB_COUNT;
}
return 0;
};
beforeEach(() => {
createComponent(
null,
{ totalNodes: MOCK_TAB_COUNT },
{ countNodesForStatus: MOCK_COUNTER_GETTER },
);
});
it('renders only those status tabs', () => {
const expectedTabTitles = [
`All ${MOCK_TAB_COUNT}`,
`Healthy ${MOCK_TAB_COUNT}`,
`Unhealthy ${MOCK_TAB_COUNT}`,
];
expect(findAllGlTabTitles()).toStrictEqual(expectedTabTitles);
});
});
});
});
describe('methods', () => {
describe('when clicking each tab', () => {
const expectedTabs = [
{ status: null },
...Object.keys(HEALTH_STATUS_UI).map((status) => {
return { status };
}),
];
beforeEach(() => {
createComponent(
null,
{ totalNodes: MOCK_TAB_COUNT },
{ countNodesForStatus: () => () => MOCK_TAB_COUNT },
);
});
it('calls setStatusFilter with the correct status', () => {
for (let i = 0; i < findAllGlTabs().length; i += 1) {
findGlTabs().vm.$emit('input', i);
expect(actionSpies.setStatusFilter).toHaveBeenCalledWith(
expect.any(Object),
expectedTabs[i].status,
);
}
});
});
});
});
......@@ -241,3 +241,24 @@ export const MOCK_NODE_STATUSES_RES = [
web_geo_projects_url: 'http://127.0.0.1:3002/replication/projects',
},
];
export const MOCK_HEALTH_STATUS_NODES = [
{
healthStatus: 'Healthy',
},
{
healthStatus: 'Healthy',
},
{
healthStatus: 'Unhealthy',
},
{
healthStatus: 'Disabled',
},
{
healthStatus: 'Offline',
},
{
healthStatus: null,
},
];
......@@ -135,4 +135,15 @@ describe('GeoNodes Store Actions', () => {
});
});
});
describe('setStatusFilter', () => {
it('should dispatch the correct mutations', () => {
return testAction({
action: actions.setStatusFilter,
payload: 'healthy',
state,
expectedMutations: [{ type: types.SET_STATUS_FILTER, payload: 'healthy' }],
});
});
});
});
......@@ -7,6 +7,7 @@ import {
MOCK_PRIMARY_VERIFICATION_INFO,
MOCK_SECONDARY_VERIFICATION_INFO,
MOCK_SECONDARY_SYNC_INFO,
MOCK_HEALTH_STATUS_NODES,
} from '../mock_data';
describe('GeoNodes Store Getters', () => {
......@@ -69,4 +70,44 @@ describe('GeoNodes Store Getters', () => {
});
});
});
describe.each`
status | expectedNodes
${null} | ${MOCK_HEALTH_STATUS_NODES}
${'healthy'} | ${[{ healthStatus: 'Healthy' }, { healthStatus: 'Healthy' }]}
${'unhealthy'} | ${[{ healthStatus: 'Unhealthy' }]}
${'offline'} | ${[{ healthStatus: 'Offline' }]}
${'disabled'} | ${[{ healthStatus: 'Disabled' }]}
${'unknown'} | ${[{ healthStatus: null }]}
`('filteredNodes', ({ status, expectedNodes }) => {
describe(`when status is ${status}`, () => {
beforeEach(() => {
state.nodes = MOCK_HEALTH_STATUS_NODES;
state.statusFilter = status;
});
it('should return the correct filtered array', () => {
expect(getters.filteredNodes(state)).toStrictEqual(expectedNodes);
});
});
});
describe.each`
status | expectedCount
${'healthy'} | ${2}
${'unhealthy'} | ${1}
${'offline'} | ${1}
${'disabled'} | ${1}
${'unknown'} | ${1}
`('countNodesForStatus', ({ status, expectedCount }) => {
describe(`when status is ${status}`, () => {
beforeEach(() => {
state.nodes = MOCK_HEALTH_STATUS_NODES;
});
it(`should return ${expectedCount}`, () => {
expect(getters.countNodesForStatus(state)(status)).toBe(expectedCount);
});
});
});
});
......@@ -105,4 +105,12 @@ describe('GeoNodes Store Mutations', () => {
expect(state.nodeToBeRemoved).toEqual(null);
});
});
describe('SET_STATUS_FILTER', () => {
it('sets statusFilter', () => {
mutations[types.SET_STATUS_FILTER](state, 'healthy');
expect(state.statusFilter).toBe('healthy');
});
});
});
......@@ -15797,6 +15797,9 @@ msgstr ""
msgid "Geo|Adjust your filters/search criteria above. If you believe this may be an error, please refer to the %{linkStart}Geo Troubleshooting%{linkEnd} documentation for more information."
msgstr ""
msgid "Geo|All"
msgstr ""
msgid "Geo|All %{replicable_name}"
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