Commit bd82e591 authored by Zack Cuddy's avatar Zack Cuddy Committed by Enrique Alcántara

Geo Sites - Filter By Status

This change adds top level
filtering for the Geo Sites page.

This allows users to filter by
the health status of their nodes.

This status is stored in the
query param as well so that a refresh
will retain the filter.

Changelog: changed
EE: true
parent 9fcc3738
<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, {
store,
propsData: {
...defaultProps,
...props,
},
stubs: { GlSprintf },
}),
);
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');
});
});
});
......@@ -15800,6 +15800,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