Commit d8479184 authored by Zack Cuddy's avatar Zack Cuddy Committed by Paul Slaughter

Geo Sites - Filter by Search

This change adds a top level
search bar for the Geo Sites page.

This allows users to filter by
the name or url of their sites.

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

Changelog: changed
EE: true
parent b1072b3f
<script> <script>
import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; import { GlTabs, GlTab, GlBadge, GlSearchBoxByType } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions, mapState } from 'vuex';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { HEALTH_STATUS_UI, STATUS_FILTER_QUERY_PARAM } from 'ee/geo_nodes/constants'; import { HEALTH_STATUS_UI, STATUS_FILTER_QUERY_PARAM } from 'ee/geo_nodes/constants';
...@@ -8,11 +9,13 @@ export default { ...@@ -8,11 +9,13 @@ export default {
name: 'GeoNodesFilters', name: 'GeoNodesFilters',
i18n: { i18n: {
allTab: s__('Geo|All'), allTab: s__('Geo|All'),
searchPlaceholder: s__('Geo|Filter Geo sites'),
}, },
components: { components: {
GlTabs, GlTabs,
GlTab, GlTab,
GlBadge, GlBadge,
GlSearchBoxByType,
}, },
props: { props: {
totalNodes: { totalNodes: {
...@@ -23,6 +26,15 @@ export default { ...@@ -23,6 +26,15 @@ export default {
}, },
computed: { computed: {
...mapGetters(['countNodesForStatus']), ...mapGetters(['countNodesForStatus']),
...mapState(['searchFilter']),
search: {
get() {
return this.searchFilter;
},
set(search) {
this.setSearchFilter(search);
},
},
tabs() { tabs() {
const ALL_TAB = { text: this.$options.i18n.allTab, count: this.totalNodes, status: null }; const ALL_TAB = { text: this.$options.i18n.allTab, count: this.totalNodes, status: null };
const tabs = [ALL_TAB]; const tabs = [ALL_TAB];
...@@ -38,8 +50,13 @@ export default { ...@@ -38,8 +50,13 @@ export default {
return tabs; return tabs;
}, },
}, },
watch: {
searchFilter(search) {
updateHistory({ url: setUrlParams({ search: search || null }), replace: true });
},
},
methods: { methods: {
...mapActions(['setStatusFilter']), ...mapActions(['setStatusFilter', 'setSearchFilter']),
tabChange(tabIndex) { tabChange(tabIndex) {
this.setStatusFilter(this.tabs[tabIndex]?.status); this.setStatusFilter(this.tabs[tabIndex]?.status);
}, },
...@@ -50,6 +67,7 @@ export default { ...@@ -50,6 +67,7 @@ export default {
<template> <template>
<gl-tabs <gl-tabs
class="gl-display-grid geo-node-filter-grid-columns"
sync-active-tab-with-query-params sync-active-tab-with-query-params
:query-param-name="$options.STATUS_FILTER_QUERY_PARAM" :query-param-name="$options.STATUS_FILTER_QUERY_PARAM"
data-testid="geo-sites-filter" data-testid="geo-sites-filter"
...@@ -61,5 +79,8 @@ export default { ...@@ -61,5 +79,8 @@ export default {
<gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge> <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
</template> </template>
</gl-tab> </gl-tab>
<div class="gl-pb-3 gl-border-b-1 gl-border-b-solid gl-border-gray-100">
<gl-search-box-by-type v-model="search" :placeholder="$options.i18n.searchPlaceholder" />
</div>
</gl-tabs> </gl-tabs>
</template> </template>
import Vue from 'vue'; import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { getParameterByName } from '~/lib/utils/url_utility';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import GeoNodesApp from './components/app.vue'; import GeoNodesApp from './components/app.vue';
import createStore from './store'; import createStore from './store';
...@@ -14,13 +15,14 @@ export const initGeoNodes = () => { ...@@ -14,13 +15,14 @@ export const initGeoNodes = () => {
} }
const { primaryVersion, primaryRevision, newNodeUrl, geoNodesEmptyStateSvg } = el.dataset; const { primaryVersion, primaryRevision, newNodeUrl, geoNodesEmptyStateSvg } = el.dataset;
const searchFilter = getParameterByName('search') || '';
let { replicableTypes } = el.dataset; let { replicableTypes } = el.dataset;
replicableTypes = convertObjectPropsToCamelCase(JSON.parse(replicableTypes), { deep: true }); replicableTypes = convertObjectPropsToCamelCase(JSON.parse(replicableTypes), { deep: true });
return new Vue({ return new Vue({
el, el,
store: createStore({ primaryVersion, primaryRevision, replicableTypes }), store: createStore({ primaryVersion, primaryRevision, replicableTypes, searchFilter }),
render(createElement) { render(createElement) {
return createElement(GeoNodesApp, { return createElement(GeoNodesApp, {
props: { props: {
......
...@@ -50,3 +50,7 @@ export const removeNode = ({ commit, state }) => { ...@@ -50,3 +50,7 @@ export const removeNode = ({ commit, state }) => {
export const setStatusFilter = ({ commit }, status) => { export const setStatusFilter = ({ commit }, status) => {
commit(types.SET_STATUS_FILTER, status); commit(types.SET_STATUS_FILTER, status);
}; };
export const setSearchFilter = ({ commit }, search) => {
commit(types.SET_SEARCH_FILTER, search);
};
...@@ -60,20 +60,31 @@ export const canRemoveNode = (state) => (id) => { ...@@ -60,20 +60,31 @@ export const canRemoveNode = (state) => (id) => {
return !node.primary || state.nodes.length === 1; return !node.primary || state.nodes.length === 1;
}; };
export const filteredNodes = (state) => { const filterByStatus = (status) => {
if (!state.statusFilter) { if (!status) {
return state.nodes; return () => true;
}
// If the healthStatus is not falsey, we group that as status "unknown"
return (n) => (n.healthStatus ? n.healthStatus.toLowerCase() === status : status === 'unknown');
};
const filterBySearch = (search) => {
if (!search) {
return () => true;
} }
return state.nodes.filter((n) => return (n) =>
n.healthStatus n.name?.toLowerCase().includes(search.toLowerCase()) ||
? n.healthStatus.toLowerCase() === state.statusFilter n.url?.toLowerCase().includes(search.toLowerCase());
: state.statusFilter === 'unknown', };
);
export const filteredNodes = (state) => {
return state.nodes
.filter(filterByStatus(state.statusFilter))
.filter(filterBySearch(state.searchFilter));
}; };
export const countNodesForStatus = (state) => (status) => { export const countNodesForStatus = (state) => (status) => {
return state.nodes.filter((n) => return state.nodes.filter(filterByStatus(status)).length;
n.healthStatus ? n.healthStatus.toLowerCase() === status : status === 'unknown',
).length;
}; };
...@@ -7,11 +7,16 @@ import createState from './state'; ...@@ -7,11 +7,16 @@ import createState from './state';
Vue.use(Vuex); Vue.use(Vuex);
export const getStoreConfig = ({ primaryVersion, primaryRevision, replicableTypes }) => ({ export const getStoreConfig = ({
primaryVersion,
primaryRevision,
replicableTypes,
searchFilter = '',
}) => ({
actions, actions,
getters, getters,
mutations, mutations,
state: createState({ primaryVersion, primaryRevision, replicableTypes }), state: createState({ primaryVersion, primaryRevision, replicableTypes, searchFilter }),
}); });
const createStore = (config) => new Vuex.Store(getStoreConfig(config)); const createStore = (config) => new Vuex.Store(getStoreConfig(config));
......
...@@ -10,3 +10,4 @@ export const RECEIVE_NODE_REMOVAL_SUCCESS = 'RECEIVE_NODE_REMOVAL_SUCCESS'; ...@@ -10,3 +10,4 @@ export const RECEIVE_NODE_REMOVAL_SUCCESS = 'RECEIVE_NODE_REMOVAL_SUCCESS';
export const RECEIVE_NODE_REMOVAL_ERROR = 'RECEIVE_NODE_REMOVAL_ERROR'; export const RECEIVE_NODE_REMOVAL_ERROR = 'RECEIVE_NODE_REMOVAL_ERROR';
export const SET_STATUS_FILTER = 'SET_STATUS_FILTER'; export const SET_STATUS_FILTER = 'SET_STATUS_FILTER';
export const SET_SEARCH_FILTER = 'SET_SEARCH_FILTER';
...@@ -36,4 +36,7 @@ export default { ...@@ -36,4 +36,7 @@ export default {
[types.SET_STATUS_FILTER](state, status) { [types.SET_STATUS_FILTER](state, status) {
state.statusFilter = status; state.statusFilter = status;
}, },
[types.SET_SEARCH_FILTER](state, search) {
state.searchFilter = search;
},
}; };
const createState = ({ primaryVersion, primaryRevision, replicableTypes }) => ({ const createState = ({ primaryVersion, primaryRevision, replicableTypes, searchFilter }) => ({
primaryVersion, primaryVersion,
primaryRevision, primaryRevision,
replicableTypes, replicableTypes,
searchFilter,
statusFilter: null,
nodes: [], nodes: [],
isLoading: false, isLoading: false,
nodeToBeRemoved: null, nodeToBeRemoved: null,
statusFilter: null,
}); });
export default createState; export default createState;
...@@ -59,3 +59,11 @@ ...@@ -59,3 +59,11 @@
grid-template-columns: 1fr 1fr 2fr 2fr; grid-template-columns: 1fr 1fr 2fr 2fr;
} }
} }
.geo-node-filter-grid-columns {
grid-template-columns: 1fr;
@include media-breakpoint-up(md) {
grid-template-columns: 3fr 1fr;
}
}
...@@ -59,7 +59,7 @@ RSpec.describe 'GEO Nodes', :geo do ...@@ -59,7 +59,7 @@ RSpec.describe 'GEO Nodes', :geo do
expect(all('.geo-node-details-grid-columns').last).to have_link('Open replications', href: expected_url) expect(all('.geo-node-details-grid-columns').last).to have_link('Open replications', href: expected_url)
end end
context 'Status Filters', :js do context 'Node Filters', :js do
it 'defaults to the All tab when a status query is not already set' do it 'defaults to the All tab when a status query is not already set' do
visit admin_geo_nodes_path visit admin_geo_nodes_path
tab_count = find('[data-testid="geo-sites-filter"] .active .badge').text.to_i tab_count = find('[data-testid="geo-sites-filter"] .active .badge').text.to_i
...@@ -98,6 +98,39 @@ RSpec.describe 'GEO Nodes', :geo do ...@@ -98,6 +98,39 @@ RSpec.describe 'GEO Nodes', :geo do
expect(page).to have_current_path(admin_geo_nodes_path(status: 'unknown')) expect(page).to have_current_path(admin_geo_nodes_path(status: 'unknown'))
expect(results_count).to be(tab_count) expect(results_count).to be(tab_count)
end end
it 'properly updates the query and filters the nodes when a search is inputed' do
visit admin_geo_nodes_path
fill_in 'Filter Geo sites', with: geo_secondary.name
wait_for_requests
results_count = page.all('[data-testid="primary-nodes"]').length + page.all('[data-testid="secondary-nodes"]').length
expect(results_count).to be(1)
expect(page).to have_current_path(admin_geo_nodes_path(search: geo_secondary.name))
end
it 'properly sets the search when a search query is already set' do
visit admin_geo_nodes_path(search: geo_secondary.name)
results_count = page.all('[data-testid="primary-nodes"]').length + page.all('[data-testid="secondary-nodes"]').length
expect(find('input[placeholder="Filter Geo sites"]').value).to eq(geo_secondary.name)
expect(results_count).to be(1)
end
it 'properly handles both a status and search query' do
visit admin_geo_nodes_path(status: 'unknown', search: geo_secondary.name)
results = page.all(:xpath, '//div[@data-testid="primary-nodes"] | //div[@data-testid="secondary-nodes"]')
expect(find('[data-testid="geo-sites-filter"] .active')).to have_content('Unknown')
expect(find("input[placeholder='Filter Geo sites']").value).to eq(geo_secondary.name)
expect(results.length).to be(1)
expect(results[0]).to have_content(geo_secondary.name)
expect(results[0]).to have_content('Unknown')
end
end end
end end
end end
......
import { GlTabs, GlTab } from '@gitlab/ui'; import { GlTabs, GlTab, GlSearchBoxByType } from '@gitlab/ui';
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_HOST } from 'helpers/test_constants';
import GeoNodesFilters from 'ee/geo_nodes/components/geo_nodes_filters.vue'; import GeoNodesFilters from 'ee/geo_nodes/components/geo_nodes_filters.vue';
import { HEALTH_STATUS_UI, STATUS_FILTER_QUERY_PARAM } from 'ee/geo_nodes/constants'; import { HEALTH_STATUS_UI, STATUS_FILTER_QUERY_PARAM } from 'ee/geo_nodes/constants';
import * as urlUtils from '~/lib/utils/url_utility';
Vue.use(Vuex); Vue.use(Vuex);
const MOCK_TAB_COUNT = 5; const MOCK_TAB_COUNT = 5;
const MOCK_SEARCH = 'test search';
describe('GeoNodesFilters', () => { describe('GeoNodesFilters', () => {
let wrapper; let wrapper;
...@@ -18,6 +21,7 @@ describe('GeoNodesFilters', () => { ...@@ -18,6 +21,7 @@ describe('GeoNodesFilters', () => {
const actionSpies = { const actionSpies = {
setStatusFilter: jest.fn(), setStatusFilter: jest.fn(),
setSearchFilter: jest.fn(),
}; };
const createComponent = (initialState, props, getters) => { const createComponent = (initialState, props, getters) => {
...@@ -50,6 +54,7 @@ describe('GeoNodesFilters', () => { ...@@ -50,6 +54,7 @@ describe('GeoNodesFilters', () => {
const findAllGlTabs = () => wrapper.findAllComponents(GlTab); const findAllGlTabs = () => wrapper.findAllComponents(GlTab);
const findAllGlTabTitles = () => wrapper.findAllComponents(GlTab).wrappers.map((w) => w.text()); const findAllGlTabTitles = () => wrapper.findAllComponents(GlTab).wrappers.map((w) => w.text());
const findAllTab = () => findAllGlTabs().at(0); const findAllTab = () => findAllGlTabs().at(0);
const findGlSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
describe('template', () => { describe('template', () => {
describe('always', () => { describe('always', () => {
...@@ -70,6 +75,10 @@ describe('GeoNodesFilters', () => { ...@@ -70,6 +75,10 @@ describe('GeoNodesFilters', () => {
expect(findAllTab().exists()).toBe(true); expect(findAllTab().exists()).toBe(true);
expect(findAllTab().text()).toBe(`All ${MOCK_TAB_COUNT}`); expect(findAllTab().text()).toBe(`All ${MOCK_TAB_COUNT}`);
}); });
it('renders the GlSearchBox', () => {
expect(findGlSearchBox().exists()).toBe(true);
});
}); });
describe('conditional tabs', () => { describe('conditional tabs', () => {
...@@ -150,5 +159,35 @@ describe('GeoNodesFilters', () => { ...@@ -150,5 +159,35 @@ describe('GeoNodesFilters', () => {
} }
}); });
}); });
describe('when searching in searchbox', () => {
beforeEach(() => {
createComponent();
findGlSearchBox().vm.$emit('input', MOCK_SEARCH);
});
it('calls setSearchFilter', () => {
expect(actionSpies.setSearchFilter).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH);
});
});
});
describe('watchers', () => {
describe('searchFilter', () => {
beforeEach(() => {
createComponent({ searchFilter: null });
jest.spyOn(urlUtils, 'updateHistory');
wrapper.vm.$store.state.searchFilter = MOCK_SEARCH;
});
it('calls urlUtils.updateHistory when updated', () => {
expect(urlUtils.updateHistory).toHaveBeenCalledWith({
replace: true,
url: `${TEST_HOST}/?search=test+search`,
});
});
});
}); });
}); });
...@@ -242,23 +242,35 @@ export const MOCK_NODE_STATUSES_RES = [ ...@@ -242,23 +242,35 @@ export const MOCK_NODE_STATUSES_RES = [
}, },
]; ];
export const MOCK_HEALTH_STATUS_NODES = [ export const MOCK_FILTER_NODES = [
{ {
name: 'healthy1',
url: 'url/1',
healthStatus: 'Healthy', healthStatus: 'Healthy',
}, },
{ {
name: 'healthy2',
url: 'url/2',
healthStatus: 'Healthy', healthStatus: 'Healthy',
}, },
{ {
name: 'unhealthy1',
url: 'url/3',
healthStatus: 'Unhealthy', healthStatus: 'Unhealthy',
}, },
{ {
name: 'disabled1',
url: 'url/4',
healthStatus: 'Disabled', healthStatus: 'Disabled',
}, },
{ {
name: 'offline1',
url: 'url/5',
healthStatus: 'Offline', healthStatus: 'Offline',
}, },
{ {
name: 'unknown1',
url: 'url/6',
healthStatus: null, healthStatus: null,
}, },
]; ];
...@@ -146,4 +146,15 @@ describe('GeoNodes Store Actions', () => { ...@@ -146,4 +146,15 @@ describe('GeoNodes Store Actions', () => {
}); });
}); });
}); });
describe('setSearchFilter', () => {
it('should dispatch the correct mutations', () => {
return testAction({
action: actions.setSearchFilter,
payload: 'search',
state,
expectedMutations: [{ type: types.SET_SEARCH_FILTER, payload: 'search' }],
});
});
});
}); });
...@@ -7,7 +7,7 @@ import { ...@@ -7,7 +7,7 @@ import {
MOCK_PRIMARY_VERIFICATION_INFO, MOCK_PRIMARY_VERIFICATION_INFO,
MOCK_SECONDARY_VERIFICATION_INFO, MOCK_SECONDARY_VERIFICATION_INFO,
MOCK_SECONDARY_SYNC_INFO, MOCK_SECONDARY_SYNC_INFO,
MOCK_HEALTH_STATUS_NODES, MOCK_FILTER_NODES,
} from '../mock_data'; } from '../mock_data';
describe('GeoNodes Store Getters', () => { describe('GeoNodes Store Getters', () => {
...@@ -72,18 +72,26 @@ describe('GeoNodes Store Getters', () => { ...@@ -72,18 +72,26 @@ describe('GeoNodes Store Getters', () => {
}); });
describe.each` describe.each`
status | expectedNodes status | search | expectedNodes
${null} | ${MOCK_HEALTH_STATUS_NODES} ${null} | ${''} | ${MOCK_FILTER_NODES}
${'healthy'} | ${[{ healthStatus: 'Healthy' }, { healthStatus: 'Healthy' }]} ${'healthy'} | ${''} | ${[MOCK_FILTER_NODES[0], MOCK_FILTER_NODES[1]]}
${'unhealthy'} | ${[{ healthStatus: 'Unhealthy' }]} ${'unhealthy'} | ${''} | ${[MOCK_FILTER_NODES[2]]}
${'offline'} | ${[{ healthStatus: 'Offline' }]} ${'disabled'} | ${''} | ${[MOCK_FILTER_NODES[3]]}
${'disabled'} | ${[{ healthStatus: 'Disabled' }]} ${'offline'} | ${''} | ${[MOCK_FILTER_NODES[4]]}
${'unknown'} | ${[{ healthStatus: null }]} ${'unknown'} | ${''} | ${[MOCK_FILTER_NODES[5]]}
`('filteredNodes', ({ status, expectedNodes }) => { ${null} | ${MOCK_FILTER_NODES[1].name} | ${[MOCK_FILTER_NODES[1]]}
describe(`when status is ${status}`, () => { ${null} | ${MOCK_FILTER_NODES[3].url} | ${[MOCK_FILTER_NODES[3]]}
${'healthy'} | ${MOCK_FILTER_NODES[0].name} | ${[MOCK_FILTER_NODES[0]]}
${'healthy'} | ${MOCK_FILTER_NODES[0].name.toUpperCase()} | ${[MOCK_FILTER_NODES[0]]}
${'unhealthy'} | ${MOCK_FILTER_NODES[2].url} | ${[MOCK_FILTER_NODES[2]]}
${'unhealthy'} | ${MOCK_FILTER_NODES[2].url.toUpperCase()} | ${[MOCK_FILTER_NODES[2]]}
${'offline'} | ${'NOT A MATCH'} | ${[]}
`('filteredNodes', ({ status, search, expectedNodes }) => {
describe(`when status is ${status} and search is ${search}`, () => {
beforeEach(() => { beforeEach(() => {
state.nodes = MOCK_HEALTH_STATUS_NODES; state.nodes = MOCK_FILTER_NODES;
state.statusFilter = status; state.statusFilter = status;
state.searchFilter = search;
}); });
it('should return the correct filtered array', () => { it('should return the correct filtered array', () => {
...@@ -102,7 +110,7 @@ describe('GeoNodes Store Getters', () => { ...@@ -102,7 +110,7 @@ describe('GeoNodes Store Getters', () => {
`('countNodesForStatus', ({ status, expectedCount }) => { `('countNodesForStatus', ({ status, expectedCount }) => {
describe(`when status is ${status}`, () => { describe(`when status is ${status}`, () => {
beforeEach(() => { beforeEach(() => {
state.nodes = MOCK_HEALTH_STATUS_NODES; state.nodes = MOCK_FILTER_NODES;
}); });
it(`should return ${expectedCount}`, () => { it(`should return ${expectedCount}`, () => {
......
...@@ -113,4 +113,12 @@ describe('GeoNodes Store Mutations', () => { ...@@ -113,4 +113,12 @@ describe('GeoNodes Store Mutations', () => {
expect(state.statusFilter).toBe('healthy'); expect(state.statusFilter).toBe('healthy');
}); });
}); });
describe('SET_SEARCH_FILTER', () => {
it('sets searchFilter', () => {
mutations[types.SET_SEARCH_FILTER](state, 'search');
expect(state.searchFilter).toBe('search');
});
});
}); });
...@@ -15985,6 +15985,9 @@ msgstr "" ...@@ -15985,6 +15985,9 @@ msgstr ""
msgid "Geo|Failed" msgid "Geo|Failed"
msgstr "" msgstr ""
msgid "Geo|Filter Geo sites"
msgstr ""
msgid "Geo|Filter by name" msgid "Geo|Filter by name"
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