Commit ed5f9480 authored by Enrique Alcantara's avatar Enrique Alcantara

Search settings component

The search settings component allows
searching content within the settings
collapsed sections
parent ce6d8cff
<script>
import { GlSearchBoxByType } from '@gitlab/ui';
import { uniq } from 'lodash';
import { EXCLUDED_NODES, HIDE_CLASS, HIGHLIGHT_CLASS, TYPING_DELAY } from '../constants';
const findSettingsSection = (sectionSelector, node) => {
return node.parentElement.closest(sectionSelector);
};
const resetSections = ({ sectionSelector, expandSection, collapseSection }) => {
document.querySelectorAll(sectionSelector).forEach((section, index) => {
section.classList.remove(HIDE_CLASS);
if (index === 0) {
expandSection(section);
} else {
collapseSection(section);
}
});
};
const clearHighlights = () => {
document
.querySelectorAll(`.${HIGHLIGHT_CLASS}`)
.forEach((element) => element.classList.remove(HIGHLIGHT_CLASS));
};
const hideSectionsExcept = (sectionSelector, visibleSections) => {
Array.from(document.querySelectorAll(sectionSelector))
.filter((section) => !visibleSections.includes(section))
.forEach((section) => {
section.classList.add(HIDE_CLASS);
});
};
const highlightElements = (elements = []) => {
elements.forEach((element) => element.classList.add(HIGHLIGHT_CLASS));
};
const displayResults = ({ sectionSelector, expandSection }, matches) => {
const elements = matches.map((match) => match.parentElement);
const sections = uniq(elements.map((element) => findSettingsSection(sectionSelector, element)));
hideSectionsExcept(sectionSelector, sections);
sections.forEach(expandSection);
highlightElements(elements);
};
const clearResults = (params) => {
resetSections(params);
clearHighlights();
};
const includeNode = (node, searchTerm) =>
node.textContent.toLowerCase().includes(searchTerm.toLowerCase()) &&
EXCLUDED_NODES.every((excluded) => !node.parentElement.closest(excluded));
const search = (root, searchTerm) => {
const iterator = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
return includeNode(node, searchTerm) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
const results = [];
for (let currentNode = iterator.nextNode(); currentNode; currentNode = iterator.nextNode()) {
results.push(currentNode);
}
return results;
};
export default {
components: {
GlSearchBoxByType,
},
props: {
searchRoot: {
type: Element,
required: true,
},
sectionSelector: {
type: String,
required: true,
},
},
data() {
return {
searchTerm: '',
};
},
methods: {
search(value) {
const displayOptions = {
sectionSelector: this.sectionSelector,
expandSection: this.expandSection,
collapseSection: this.collapseSection,
};
this.searchTerm = value;
clearResults(displayOptions);
if (value.length) {
displayResults(displayOptions, search(this.searchRoot, value));
}
},
expandSection(section) {
this.$emit('expand', section);
},
collapseSection(section) {
this.$emit('collapse', section);
},
},
TYPING_DELAY,
};
</script>
<template>
<div class="gl-mt-5">
<gl-search-box-by-type
:value="searchTerm"
:debounce="$options.TYPING_DELAY"
:placeholder="__('Search settings')"
@input="search"
/>
</div>
</template>
/**
* We do not consider these nodes in the search index
*/
export const EXCLUDED_NODES = ['OPTION'];
/**
* Used to hide the sections that do not match
* the search term
*/
export const HIDE_CLASS = 'gl-display-none';
/**
* used to highlight the text that matches the
* search term
*/
export const HIGHLIGHT_CLASS = 'gl-bg-orange-50';
/**
* How many seconds to wait until the user
* stops typing
* */
export const TYPING_DELAY = 400;
import Vue from 'vue';
import SearchSettings from './components/search_settings.vue';
const initSearch = ({ el, searchRoot, sectionSelector, onCollapse, onExpand }) =>
new Vue({
el,
mounted() {
this.$refs.searchSettings.$on('expand', onExpand);
this.$refs.searchSettings.$on('collapse', onCollapse);
},
render: (h) =>
h(SearchSettings, {
ref: 'searchSettings',
props: {
searchRoot,
sectionSelector,
},
}),
});
export default initSearch;
import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SearchSettings from '~/search_settings/components/search_settings.vue';
import { HIGHLIGHT_CLASS, HIDE_CLASS } from '~/search_settings/constants';
describe('search_settings/components/search_settings.vue', () => {
const ROOT_ID = 'content-body';
const SECTION_SELECTOR = 'section.settings';
const SEARCH_TERM = 'Delete project';
const GENERAL_SETTINGS_ID = 'js-general-settings';
const ADVANCED_SETTINGS_ID = 'js-advanced-settings';
let wrapper;
const buildWrapper = () => {
wrapper = shallowMount(SearchSettings, {
propsData: {
searchRoot: document.querySelector(`#${ROOT_ID}`),
sectionSelector: SECTION_SELECTOR,
},
});
};
const sectionsCount = () => document.querySelectorAll(SECTION_SELECTOR).length;
const visibleSectionsCount = () =>
document.querySelectorAll(`${SECTION_SELECTOR}:not(.${HIDE_CLASS})`).length;
const highlightedElementsCount = () => document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).length;
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const search = (term) => {
findSearchBox().vm.$emit('input', term);
};
const clearSearch = () => search('');
beforeEach(() => {
setFixtures(`
<div>
<div class="js-search-app"></div>
<div id="${ROOT_ID}">
<section id="${GENERAL_SETTINGS_ID}" class="settings">
<span>General</span>
</section>
<section id="${ADVANCED_SETTINGS_ID}" class="settings">
<span>${SEARCH_TERM}</span>
</section>
</div>
</div>
`);
buildWrapper();
});
afterEach(() => {
wrapper.destroy();
});
it('hides sections that do not match the search term', () => {
const hiddenSection = document.querySelector(`#${GENERAL_SETTINGS_ID}`);
search(SEARCH_TERM);
expect(visibleSectionsCount()).toBe(1);
expect(hiddenSection.classList).toContain(HIDE_CLASS);
});
it('expands section that matches the search term', () => {
const section = document.querySelector(`#${ADVANCED_SETTINGS_ID}`);
search(SEARCH_TERM);
// Last called because expand is always called once to reset the page state
expect(wrapper.emitted().expand[1][0]).toBe(section);
});
it('highlight elements that match the search term', () => {
search(SEARCH_TERM);
expect(highlightedElementsCount()).toBe(1);
});
describe('when search term is cleared', () => {
beforeEach(() => {
search(SEARCH_TERM);
});
it('displays all sections', () => {
expect(visibleSectionsCount()).toBe(1);
clearSearch();
expect(visibleSectionsCount()).toBe(sectionsCount());
});
it('removes the highlight from all elements', () => {
expect(highlightedElementsCount()).toBe(1);
clearSearch();
expect(highlightedElementsCount()).toBe(0);
});
});
});
import initSearch from '~/search_settings';
describe('search_settings/index', () => {
let onExpand;
let onCollapse;
let app;
beforeEach(() => {
const searchRoot = document.createElement('div');
const el = document.createElement('div');
onExpand = jest.fn();
onCollapse = jest.fn();
app = initSearch({ el, searchRoot, sectionSelector: 'section', onExpand, onCollapse });
});
afterEach(() => {
app.$destroy();
});
it('calls onExpand function when expand event is emitted', () => {
const section = { name: 'section' };
app.$refs.searchSettings.$emit('expand', section);
expect(onExpand).toHaveBeenCalledWith(section);
});
it('calls onCollapse function when collapse event is emitted', () => {
const section = { name: 'section' };
app.$refs.searchSettings.$emit('collapse', section);
expect(onCollapse).toHaveBeenCalledWith(section);
});
});
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