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>
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