Commit 862f6bf8 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '292941-poc-search-general-settings' into 'master'

POC: Search general settings

See merge request gitlab-org/gitlab!50166
parents ff3934cb 771a8a50
......@@ -10,6 +10,7 @@ import initProjectPermissionsSettings from '../shared/permissions';
import initProjectDeleteButton from '~/projects/project_delete_button';
import UserCallout from '~/user_callout';
import initServiceDesk from '~/projects/settings_service_desk';
import mountSearchSettings from './mount_search_settings';
document.addEventListener('DOMContentLoaded', () => {
initFilePickers();
......@@ -30,4 +31,6 @@ document.addEventListener('DOMContentLoaded', () => {
'.js-general-settings-form, .js-mr-settings-form, .js-mr-approvals-form',
),
);
mountSearchSettings();
});
const mountSearchSettings = async () => {
const el = document.querySelector('.js-search-settings-app');
if (el) {
const { default: initSearch } = await import(
/* webpackChunkName: 'search_settings' */ '~/search_settings'
);
initSearch({ el });
}
};
export default mountSearchSettings;
<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, lowerSearchTerm) =>
node.textContent.toLowerCase().includes(lowerSearchTerm) &&
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.toLowerCase())
? 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 $ from 'jquery';
import { expandSection, closeSection } from '~/settings_panels';
import SearchSettings from '~/search_settings/components/search_settings.vue';
const initSearch = ({ el }) =>
new Vue({
el,
render: (h) =>
h(SearchSettings, {
ref: 'searchSettings',
props: {
searchRoot: document.querySelector('#content-body'),
sectionSelector: 'section.settings',
},
on: {
collapse: (section) => closeSection($(section)),
expand: (section) => expandSection($(section)),
},
}),
});
export default initSearch;
import $ from 'jquery';
import { __ } from './locale';
function expandSection($section) {
export function expandSection($section) {
$section.find('.js-settings-toggle:not(.js-settings-toggle-trigger-only)').text(__('Collapse'));
// eslint-disable-next-line @gitlab/no-global-event-off
$section.find('.settings-content').off('scroll.expandSection').scrollTop(0);
......@@ -13,7 +13,7 @@ function expandSection($section) {
}
}
function closeSection($section) {
export function closeSection($section) {
$section.find('.js-settings-toggle:not(.js-settings-toggle-trigger-only)').text(__('Expand'));
$section.find('.settings-content').on('scroll.expandSection', () => expandSection($section));
$section.removeClass('expanded');
......@@ -24,7 +24,7 @@ function closeSection($section) {
}
}
function toggleSection($section) {
export function toggleSection($section) {
$section.removeClass('no-animate');
if ($section.hasClass('expanded')) {
closeSection($section);
......
......@@ -3,8 +3,7 @@
- @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default?
- if Feature.enabled?(:search_settings_in_page, @project, default_enabled: false)
= render "shared/search_settings"
= render "shared/search_settings"
%section.settings.general-settings.no-animate.expanded#js-general-settings
.settings-header
......
.search-box-by-type.gl-mt-5
= sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon s16')
%input#search-settings-input.gl-form-input.gl-w-full.gl-search-box-by-type-input{ type: 'search', placeholder: _('Search settings') }
- if Feature.enabled?(:search_settings_in_page, @project, default_enabled: false)
.js-search-settings-app
import waitForPromises from 'helpers/wait_for_promises';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initSearch from '~/search_settings';
import mountSearchSettings from '~/pages/projects/edit/mount_search_settings';
jest.mock('~/search_settings');
describe('pages/projects/edit/mount_search_settings', () => {
afterEach(() => {
initSearch.mockReset();
resetHTMLFixture();
});
it('initializes search settings when js-search-settings-app is available', async () => {
setHTMLFixture('<div class="js-search-settings-app"></div>');
mountSearchSettings();
await waitForPromises();
expect(initSearch).toHaveBeenCalled();
});
it('does not initialize search settings when js-search-settings-app is unavailable', async () => {
mountSearchSettings();
await waitForPromises();
expect(initSearch).not.toHaveBeenCalled();
});
});
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 sections = () => Array.from(document.querySelectorAll(SECTION_SELECTOR));
const sectionsCount = () => sections().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('expands first section and collapses the rest', () => {
clearSearch();
const [firstSection, ...otherSections] = sections();
expect(wrapper.emitted()).toEqual({
expand: [[firstSection]],
collapse: otherSections.map((x) => [x]),
});
});
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 $ from 'jquery';
import { setHTMLFixture } from 'helpers/fixtures';
import initSearch from '~/search_settings';
import { expandSection, closeSection } from '~/settings_panels';
jest.mock('~/settings_panels');
describe('search_settings/index', () => {
let app;
beforeEach(() => {
const el = document.createElement('div');
setHTMLFixture('<div id="content-body"></div>');
app = initSearch({ el });
});
afterEach(() => {
app.$destroy();
});
it('calls settings_panel.onExpand when expand event is emitted', () => {
const section = { name: 'section' };
app.$refs.searchSettings.$emit('expand', section);
expect(expandSection).toHaveBeenCalledWith($(section));
});
it('calls settings_panel.closeSection when collapse event is emitted', () => {
const section = { name: 'section' };
app.$refs.searchSettings.$emit('collapse', section);
expect(closeSection).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