Commit dc3ed8aa authored by Paul Slaughter's avatar Paul Slaughter

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

Geo Sites - Filter by Search

See merge request gitlab-org/gitlab!79550
parents 71f5147d d8479184
<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;
} }
return state.nodes.filter((n) => // If the healthStatus is not falsey, we group that as status "unknown"
n.healthStatus return (n) => (n.healthStatus ? n.healthStatus.toLowerCase() === status : status === 'unknown');
? n.healthStatus.toLowerCase() === state.statusFilter };
: state.statusFilter === 'unknown',
); const filterBySearch = (search) => {
if (!search) {
return () => true;
}
return (n) =>
n.name?.toLowerCase().includes(search.toLowerCase()) ||
n.url?.toLowerCase().includes(search.toLowerCase());
};
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