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>
import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { GlTabs, GlTab, GlBadge, GlSearchBoxByType } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { HEALTH_STATUS_UI, STATUS_FILTER_QUERY_PARAM } from 'ee/geo_nodes/constants';
......@@ -8,11 +9,13 @@ export default {
name: 'GeoNodesFilters',
i18n: {
allTab: s__('Geo|All'),
searchPlaceholder: s__('Geo|Filter Geo sites'),
},
components: {
GlTabs,
GlTab,
GlBadge,
GlSearchBoxByType,
},
props: {
totalNodes: {
......@@ -23,6 +26,15 @@ export default {
},
computed: {
...mapGetters(['countNodesForStatus']),
...mapState(['searchFilter']),
search: {
get() {
return this.searchFilter;
},
set(search) {
this.setSearchFilter(search);
},
},
tabs() {
const ALL_TAB = { text: this.$options.i18n.allTab, count: this.totalNodes, status: null };
const tabs = [ALL_TAB];
......@@ -38,8 +50,13 @@ export default {
return tabs;
},
},
watch: {
searchFilter(search) {
updateHistory({ url: setUrlParams({ search: search || null }), replace: true });
},
},
methods: {
...mapActions(['setStatusFilter']),
...mapActions(['setStatusFilter', 'setSearchFilter']),
tabChange(tabIndex) {
this.setStatusFilter(this.tabs[tabIndex]?.status);
},
......@@ -50,6 +67,7 @@ export default {
<template>
<gl-tabs
class="gl-display-grid geo-node-filter-grid-columns"
sync-active-tab-with-query-params
:query-param-name="$options.STATUS_FILTER_QUERY_PARAM"
data-testid="geo-sites-filter"
......@@ -61,5 +79,8 @@ export default {
<gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
</template>
</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>
</template>
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { getParameterByName } from '~/lib/utils/url_utility';
import Translate from '~/vue_shared/translate';
import GeoNodesApp from './components/app.vue';
import createStore from './store';
......@@ -14,13 +15,14 @@ export const initGeoNodes = () => {
}
const { primaryVersion, primaryRevision, newNodeUrl, geoNodesEmptyStateSvg } = el.dataset;
const searchFilter = getParameterByName('search') || '';
let { replicableTypes } = el.dataset;
replicableTypes = convertObjectPropsToCamelCase(JSON.parse(replicableTypes), { deep: true });
return new Vue({
el,
store: createStore({ primaryVersion, primaryRevision, replicableTypes }),
store: createStore({ primaryVersion, primaryRevision, replicableTypes, searchFilter }),
render(createElement) {
return createElement(GeoNodesApp, {
props: {
......
......@@ -50,3 +50,7 @@ export const removeNode = ({ commit, state }) => {
export const setStatusFilter = ({ commit }, 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) => {
return !node.primary || state.nodes.length === 1;
};
export const filteredNodes = (state) => {
if (!state.statusFilter) {
return state.nodes;
const filterByStatus = (status) => {
if (!status) {
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) =>
n.healthStatus
? n.healthStatus.toLowerCase() === state.statusFilter
: state.statusFilter === 'unknown',
);
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) => {
return state.nodes.filter((n) =>
n.healthStatus ? n.healthStatus.toLowerCase() === status : status === 'unknown',
).length;
return state.nodes.filter(filterByStatus(status)).length;
};
......@@ -7,11 +7,16 @@ import createState from './state';
Vue.use(Vuex);
export const getStoreConfig = ({ primaryVersion, primaryRevision, replicableTypes }) => ({
export const getStoreConfig = ({
primaryVersion,
primaryRevision,
replicableTypes,
searchFilter = '',
}) => ({
actions,
getters,
mutations,
state: createState({ primaryVersion, primaryRevision, replicableTypes }),
state: createState({ primaryVersion, primaryRevision, replicableTypes, searchFilter }),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
......
......@@ -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 SET_STATUS_FILTER = 'SET_STATUS_FILTER';
export const SET_SEARCH_FILTER = 'SET_SEARCH_FILTER';
......@@ -36,4 +36,7 @@ export default {
[types.SET_STATUS_FILTER](state, 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,
primaryRevision,
replicableTypes,
searchFilter,
statusFilter: null,
nodes: [],
isLoading: false,
nodeToBeRemoved: null,
statusFilter: null,
});
export default createState;
......@@ -59,3 +59,11 @@
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
expect(all('.geo-node-details-grid-columns').last).to have_link('Open replications', href: expected_url)
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
visit admin_geo_nodes_path
tab_count = find('[data-testid="geo-sites-filter"] .active .badge').text.to_i
......@@ -98,6 +98,39 @@ RSpec.describe 'GEO Nodes', :geo do
expect(page).to have_current_path(admin_geo_nodes_path(status: 'unknown'))
expect(results_count).to be(tab_count)
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
......
import { GlTabs, GlTab } from '@gitlab/ui';
import { GlTabs, GlTab, GlSearchBoxByType } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex';
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 { HEALTH_STATUS_UI, STATUS_FILTER_QUERY_PARAM } from 'ee/geo_nodes/constants';
import * as urlUtils from '~/lib/utils/url_utility';
Vue.use(Vuex);
const MOCK_TAB_COUNT = 5;
const MOCK_SEARCH = 'test search';
describe('GeoNodesFilters', () => {
let wrapper;
......@@ -18,6 +21,7 @@ describe('GeoNodesFilters', () => {
const actionSpies = {
setStatusFilter: jest.fn(),
setSearchFilter: jest.fn(),
};
const createComponent = (initialState, props, getters) => {
......@@ -50,6 +54,7 @@ describe('GeoNodesFilters', () => {
const findAllGlTabs = () => wrapper.findAllComponents(GlTab);
const findAllGlTabTitles = () => wrapper.findAllComponents(GlTab).wrappers.map((w) => w.text());
const findAllTab = () => findAllGlTabs().at(0);
const findGlSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
describe('template', () => {
describe('always', () => {
......@@ -70,6 +75,10 @@ describe('GeoNodesFilters', () => {
expect(findAllTab().exists()).toBe(true);
expect(findAllTab().text()).toBe(`All ${MOCK_TAB_COUNT}`);
});
it('renders the GlSearchBox', () => {
expect(findGlSearchBox().exists()).toBe(true);
});
});
describe('conditional tabs', () => {
......@@ -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 = [
},
];
export const MOCK_HEALTH_STATUS_NODES = [
export const MOCK_FILTER_NODES = [
{
name: 'healthy1',
url: 'url/1',
healthStatus: 'Healthy',
},
{
name: 'healthy2',
url: 'url/2',
healthStatus: 'Healthy',
},
{
name: 'unhealthy1',
url: 'url/3',
healthStatus: 'Unhealthy',
},
{
name: 'disabled1',
url: 'url/4',
healthStatus: 'Disabled',
},
{
name: 'offline1',
url: 'url/5',
healthStatus: 'Offline',
},
{
name: 'unknown1',
url: 'url/6',
healthStatus: null,
},
];
......@@ -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 {
MOCK_PRIMARY_VERIFICATION_INFO,
MOCK_SECONDARY_VERIFICATION_INFO,
MOCK_SECONDARY_SYNC_INFO,
MOCK_HEALTH_STATUS_NODES,
MOCK_FILTER_NODES,
} from '../mock_data';
describe('GeoNodes Store Getters', () => {
......@@ -72,18 +72,26 @@ 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}`, () => {
status | search | expectedNodes
${null} | ${''} | ${MOCK_FILTER_NODES}
${'healthy'} | ${''} | ${[MOCK_FILTER_NODES[0], MOCK_FILTER_NODES[1]]}
${'unhealthy'} | ${''} | ${[MOCK_FILTER_NODES[2]]}
${'disabled'} | ${''} | ${[MOCK_FILTER_NODES[3]]}
${'offline'} | ${''} | ${[MOCK_FILTER_NODES[4]]}
${'unknown'} | ${''} | ${[MOCK_FILTER_NODES[5]]}
${null} | ${MOCK_FILTER_NODES[1].name} | ${[MOCK_FILTER_NODES[1]]}
${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(() => {
state.nodes = MOCK_HEALTH_STATUS_NODES;
state.nodes = MOCK_FILTER_NODES;
state.statusFilter = status;
state.searchFilter = search;
});
it('should return the correct filtered array', () => {
......@@ -102,7 +110,7 @@ describe('GeoNodes Store Getters', () => {
`('countNodesForStatus', ({ status, expectedCount }) => {
describe(`when status is ${status}`, () => {
beforeEach(() => {
state.nodes = MOCK_HEALTH_STATUS_NODES;
state.nodes = MOCK_FILTER_NODES;
});
it(`should return ${expectedCount}`, () => {
......
......@@ -113,4 +113,12 @@ describe('GeoNodes Store Mutations', () => {
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 ""
msgid "Geo|Failed"
msgstr ""
msgid "Geo|Filter Geo sites"
msgstr ""
msgid "Geo|Filter by 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