Commit 4cdf9c9d authored by orozot's avatar orozot Committed by Natalia Tepluhina

Fix: search setting only highlight matched text

parent afc46c74
<script> <script>
import { GlSearchBoxByType } from '@gitlab/ui'; import { GlSearchBoxByType } from '@gitlab/ui';
import { uniq } from 'lodash'; import { uniq, escapeRegExp } from 'lodash';
import { EXCLUDED_NODES, HIDE_CLASS, HIGHLIGHT_CLASS, TYPING_DELAY } from '../constants'; import {
EXCLUDED_NODES,
HIDE_CLASS,
HIGHLIGHT_CLASS,
NONE_PADDING_CLASS,
TYPING_DELAY,
} from '../constants';
const origExpansions = new Map(); const origExpansions = new Map();
...@@ -37,9 +43,13 @@ const resetSections = ({ sectionSelector }) => { ...@@ -37,9 +43,13 @@ const resetSections = ({ sectionSelector }) => {
}; };
const clearHighlights = () => { const clearHighlights = () => {
document document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((element) => {
.querySelectorAll(`.${HIGHLIGHT_CLASS}`) const { parentNode } = element;
.forEach((element) => element.classList.remove(HIGHLIGHT_CLASS)); const textNode = document.createTextNode(element.textContent);
parentNode.replaceChild(textNode, element);
parentNode.normalize();
});
}; };
const hideSectionsExcept = (sectionSelector, visibleSections) => { const hideSectionsExcept = (sectionSelector, visibleSections) => {
...@@ -50,17 +60,41 @@ const hideSectionsExcept = (sectionSelector, visibleSections) => { ...@@ -50,17 +60,41 @@ const hideSectionsExcept = (sectionSelector, visibleSections) => {
}); });
}; };
const highlightElements = (elements = []) => { const transformMatchElement = (element, searchTerm) => {
elements.forEach((element) => element.classList.add(HIGHLIGHT_CLASS)); const textStr = element.textContent;
const escapedSearchTerm = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
const textList = textStr.split(escapedSearchTerm);
const replaceFragment = document.createDocumentFragment();
textList.forEach((text) => {
let addElement = document.createTextNode(text);
if (escapedSearchTerm.test(text)) {
addElement = document.createElement('mark');
addElement.className = `${HIGHLIGHT_CLASS} ${NONE_PADDING_CLASS}`;
addElement.textContent = text;
escapedSearchTerm.lastIndex = 0;
}
replaceFragment.appendChild(addElement);
});
return replaceFragment;
};
const highlightElements = (elements = [], searchTerm) => {
elements.forEach((element) => {
const replaceFragment = transformMatchElement(element, searchTerm);
element.innerHTML = '';
element.appendChild(replaceFragment);
});
}; };
const displayResults = ({ sectionSelector, expandSection }, matches) => { const displayResults = ({ sectionSelector, expandSection, searchTerm }, matches) => {
const elements = matches.map((match) => match.parentElement); const elements = matches.map((match) => match.parentElement);
const sections = uniq(elements.map((element) => findSettingsSection(sectionSelector, element))); const sections = uniq(elements.map((element) => findSettingsSection(sectionSelector, element)));
hideSectionsExcept(sectionSelector, sections); hideSectionsExcept(sectionSelector, sections);
sections.forEach(expandSection); sections.forEach(expandSection);
highlightElements(elements); highlightElements(elements, searchTerm);
}; };
const clearResults = (params) => { const clearResults = (params) => {
...@@ -116,21 +150,21 @@ export default { ...@@ -116,21 +150,21 @@ export default {
}, },
methods: { methods: {
search(value) { search(value) {
this.searchTerm = value;
const displayOptions = { const displayOptions = {
sectionSelector: this.sectionSelector, sectionSelector: this.sectionSelector,
expandSection: this.expandSection, expandSection: this.expandSection,
collapseSection: this.collapseSection, collapseSection: this.collapseSection,
isExpanded: this.isExpandedFn, isExpanded: this.isExpandedFn,
searchTerm: this.searchTerm,
}; };
this.searchTerm = value;
clearResults(displayOptions); clearResults(displayOptions);
if (value.length) { if (value.length) {
saveExpansionState(document.querySelectorAll(this.sectionSelector), displayOptions); saveExpansionState(document.querySelectorAll(this.sectionSelector), displayOptions);
displayResults(displayOptions, search(this.searchRoot, value)); displayResults(displayOptions, search(this.searchRoot, this.searchTerm));
} else { } else {
restoreExpansionState(displayOptions); restoreExpansionState(displayOptions);
} }
......
...@@ -7,5 +7,8 @@ export const HIDE_CLASS = 'gl-display-none'; ...@@ -7,5 +7,8 @@ export const HIDE_CLASS = 'gl-display-none';
// used to highlight the text that matches the * search term // used to highlight the text that matches the * search term
export const HIGHLIGHT_CLASS = 'gl-bg-orange-100'; export const HIGHLIGHT_CLASS = 'gl-bg-orange-100';
// used to remove padding for text that matches the * search term
export const NONE_PADDING_CLASS = 'gl-p-0';
// How many seconds to wait until the user * stops typing // How many seconds to wait until the user * stops typing
export const TYPING_DELAY = 400; export const TYPING_DELAY = 400;
...@@ -11,6 +11,7 @@ describe('search_settings/components/search_settings.vue', () => { ...@@ -11,6 +11,7 @@ describe('search_settings/components/search_settings.vue', () => {
const GENERAL_SETTINGS_ID = 'js-general-settings'; const GENERAL_SETTINGS_ID = 'js-general-settings';
const ADVANCED_SETTINGS_ID = 'js-advanced-settings'; const ADVANCED_SETTINGS_ID = 'js-advanced-settings';
const EXTRA_SETTINGS_ID = 'js-extra-settings'; const EXTRA_SETTINGS_ID = 'js-extra-settings';
const TEXT_CONTAIN_SEARCH_TERM = `This text contain ${SEARCH_TERM} and <script>alert("111")</script> others.`;
let wrapper; let wrapper;
...@@ -33,6 +34,21 @@ describe('search_settings/components/search_settings.vue', () => { ...@@ -33,6 +34,21 @@ describe('search_settings/components/search_settings.vue', () => {
const visibleSectionsCount = () => const visibleSectionsCount = () =>
document.querySelectorAll(`${SECTION_SELECTOR}:not(.${HIDE_CLASS})`).length; document.querySelectorAll(`${SECTION_SELECTOR}:not(.${HIDE_CLASS})`).length;
const highlightedElementsCount = () => document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).length; const highlightedElementsCount = () => document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).length;
const highlightedTextNodes = () => {
const highlightedList = Array.from(document.querySelectorAll(`.${HIGHLIGHT_CLASS}`));
return highlightedList.every((element) => {
return element.textContent.toLowerCase() === SEARCH_TERM.toLowerCase();
});
};
const matchParentElement = () => {
const highlightedList = Array.from(document.querySelectorAll(`.${HIGHLIGHT_CLASS}`));
return highlightedList.map((element) => {
return element.parentNode;
});
};
const findSearchBox = () => wrapper.find(GlSearchBoxByType); const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const search = (term) => { const search = (term) => {
findSearchBox().vm.$emit('input', term); findSearchBox().vm.$emit('input', term);
...@@ -52,6 +68,7 @@ describe('search_settings/components/search_settings.vue', () => { ...@@ -52,6 +68,7 @@ describe('search_settings/components/search_settings.vue', () => {
</section> </section>
<section id="${EXTRA_SETTINGS_ID}" class="settings"> <section id="${EXTRA_SETTINGS_ID}" class="settings">
<span>${SEARCH_TERM}</span> <span>${SEARCH_TERM}</span>
<span>${TEXT_CONTAIN_SEARCH_TERM}</span>
</section> </section>
</div> </div>
</div> </div>
...@@ -82,7 +99,23 @@ describe('search_settings/components/search_settings.vue', () => { ...@@ -82,7 +99,23 @@ describe('search_settings/components/search_settings.vue', () => {
it('highlight elements that match the search term', () => { it('highlight elements that match the search term', () => {
search(SEARCH_TERM); search(SEARCH_TERM);
expect(highlightedElementsCount()).toBe(1); expect(highlightedElementsCount()).toBe(2);
});
it('highlight only search term and not the whole line', () => {
search(SEARCH_TERM);
expect(highlightedTextNodes()).toBe(true);
});
it('prevents search xss', () => {
search(SEARCH_TERM);
const parentNodeList = matchParentElement();
parentNodeList.forEach((element) => {
const scriptElement = element.getElementsByTagName('script');
expect(scriptElement.length).toBe(0);
});
}); });
describe('default', () => { describe('default', () => {
......
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