Commit 98bdb53a authored by Zack Cuddy's avatar Zack Cuddy Committed by Ezekiel Kigbo

Global Search - Search Form Vue

This MR changes the remaining search
form into Vue.

This allows us to remove the custom hook ins
for the text highlighting.

This will also allow us to remove the
individual bootstrapping of
the Project/Group Filter.
parent 91369aba
import Search from './search';
import { initSearchApp } from '~/search';
document.addEventListener('DOMContentLoaded', () => {
initSearchApp(); // Vue Bootstrap
return new Search(); // Legacy Search Methods
initSearchApp();
});
import $ from 'jquery';
import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
import Project from '~/pages/projects/project';
import { visitUrl } from '~/lib/utils/url_utility';
import refreshCounts from './refresh_counts';
export default class Search {
constructor() {
this.searchInput = '.js-search-input';
this.searchClear = '.js-search-clear';
setHighlightClass(); // Code Highlighting
this.eventListeners(); // Search Form Actions
refreshCounts(); // Other Scope Tab Counts
Project.initRefSwitcher(); // Code Search Branch Picker
}
eventListeners() {
$(document).off('keyup', this.searchInput).on('keyup', this.searchInput, this.searchKeyUp);
$(document)
.off('click', this.searchClear)
.on('click', this.searchClear, this.clearSearchField.bind(this));
$('a.js-search-clear').off('click', this.clearSearchFilter).on('click', this.clearSearchFilter);
}
static submitSearch() {
return $('.js-search-form').submit();
}
searchKeyUp() {
const $input = $(this);
if ($input.val() === '') {
$('.js-search-clear').addClass('hidden');
} else {
$('.js-search-clear').removeClass('hidden');
}
}
clearSearchField() {
return $(this.searchInput).val('').trigger('keyup').focus();
}
// We need to manually follow the link on the anchors
// that have this event bound, as their `click` default
// behavior is prevented by the toggle logic.
/* eslint-disable-next-line class-methods-use-this */
clearSearchFilter(ev) {
const $target = $(ev.currentTarget);
visitUrl($target.href);
ev.stopPropagation();
}
}
export default () => {
export default (search = '') => {
const highlightLineClass = 'hll';
const contentBody = document.getElementById('content-body');
const searchTerm = contentBody.querySelector('.js-search-input').value.toLowerCase();
const searchTerm = search.toLowerCase();
const blobs = contentBody.querySelectorAll('.blob-result');
blobs.forEach((blob) => {
......
import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
import Project from '~/pages/projects/project';
import refreshCounts from '~/pages/search/show/refresh_counts';
import { queryToObject } from '~/lib/utils/url_utility';
import createStore from './store';
import { initTopbar } from './topbar';
......@@ -7,8 +10,14 @@ export const initSearchApp = () => {
// Similar to url_utility.decodeUrlParameter
// Our query treats + as %20. This replaces the query + symbols with %20.
const sanitizedSearch = window.location.search.replace(/\+/g, '%20');
const store = createStore({ query: queryToObject(sanitizedSearch) });
const query = queryToObject(sanitizedSearch);
const store = createStore({ query });
initTopbar(store);
initSidebar(store);
setHighlightClass(query.search); // Code Highlighting
refreshCounts(); // Other Scope Tab Counts
Project.initRefSwitcher(); // Code Search Branch Picker
};
<script>
import { mapState, mapActions } from 'vuex';
import { GlForm, GlSearchBoxByType, GlButton } from '@gitlab/ui';
import GroupFilter from './group_filter.vue';
import ProjectFilter from './project_filter.vue';
export default {
name: 'GlobalSearchTopbar',
components: {
GlForm,
GlSearchBoxByType,
GroupFilter,
ProjectFilter,
GlButton,
},
props: {
groupInitialData: {
type: Object,
required: false,
default: () => ({}),
},
projectInitialData: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
...mapState(['query']),
search: {
get() {
return this.query ? this.query.search : '';
},
set(value) {
this.setQuery({ key: 'search', value });
},
},
showFilters() {
return !this.query.snippets || this.query.snippets === 'false';
},
},
methods: {
...mapActions(['applyQuery', 'setQuery']),
},
};
</script>
<template>
<gl-form class="search-page-form" @submit.prevent="applyQuery">
<section class="gl-display-lg-flex gl-align-items-flex-end">
<div class="gl-flex-fill-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2">
<label>{{ __('What are you searching for?') }}</label>
<gl-search-box-by-type
id="dashboard_search"
v-model="search"
name="search"
:placeholder="__(`Search for projects, issues, etc.`)"
/>
</div>
<div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
<label class="gl-display-block">{{ __('Group') }}</label>
<group-filter :initial-data="groupInitialData" />
</div>
<div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
<label class="gl-display-block">{{ __('Project') }}</label>
<project-filter :initial-data="projectInitialData" />
</div>
<gl-button class="btn-search gl-lg-ml-2" variant="success" type="submit">{{
__('Search')
}}</gl-button>
</section>
</gl-form>
</template>
......@@ -37,6 +37,7 @@ export default {
<template>
<searchable-dropdown
data-testid="group-filter"
:header-text="$options.GROUP_DATA.headerText"
:selected-display-value="$options.GROUP_DATA.selectedDisplayValue"
:items-display-value="$options.GROUP_DATA.itemsDisplayValue"
......
......@@ -40,6 +40,7 @@ export default {
<template>
<searchable-dropdown
data-testid="project-filter"
:header-text="$options.PROJECT_DATA.headerText"
:selected-display-value="$options.PROJECT_DATA.selectedDisplayValue"
:items-display-value="$options.PROJECT_DATA.itemsDisplayValue"
......
......@@ -101,7 +101,7 @@ export default {
@keydown.enter.stop="resetDropdown"
@click.stop="resetDropdown"
>
<gl-icon name="clear" class="gl-text-gray-200! gl-hover-text-blue-800!" />
<gl-icon name="clear" />
</gl-button>
<gl-icon name="chevron-down" />
</template>
......
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import GroupFilter from './components/group_filter.vue';
import ProjectFilter from './components/project_filter.vue';
import GlobalSearchTopbar from './components/app.vue';
Vue.use(Translate);
const mountSearchableDropdown = (store, { id, component }) => {
const el = document.getElementById(id);
export const initTopbar = (store) => {
const el = document.getElementById('js-search-topbar');
if (!el) {
return false;
}
let { initialData } = el.dataset;
let { groupInitialData, projectInitialData } = el.dataset;
initialData = JSON.parse(initialData);
groupInitialData = JSON.parse(groupInitialData);
projectInitialData = JSON.parse(projectInitialData);
return new Vue({
el,
store,
render(createElement) {
return createElement(component, {
return createElement(GlobalSearchTopbar, {
props: {
initialData,
groupInitialData,
projectInitialData,
},
});
},
});
};
const searchableDropdowns = [
{
id: 'js-search-group-dropdown',
component: GroupFilter,
},
{
id: 'js-search-project-dropdown',
component: ProjectFilter,
},
];
export const initTopbar = (store) =>
searchableDropdowns.map((dropdown) => mountSearchableDropdown(store, dropdown));
- if params[:group_id].present?
= hidden_field_tag :group_id, params[:group_id]
- if params[:project_id].present?
= hidden_field_tag :project_id, params[:project_id]
- project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace)
.dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "group-filter" } }
%label.d-block{ for: "dashboard_search_group" }
= _("Group")
%input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-data": @group.to_json } }
.dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "project-filter" } }
%label.d-block{ for: "dashboard_search_project" }
= _("Project")
%input#js-search-project-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-data": project_attributes.to_json } }
= form_tag search_path, method: :get, class: 'search-page-form js-search-form' do |f|
= hidden_field_tag :snippets, params[:snippets]
= hidden_field_tag :scope, params[:scope]
= hidden_field_tag :repository_ref, params[:repository_ref]
.d-lg-flex.align-items-end
.search-field-holder.form-group.mr-lg-1.mb-lg-0
%label{ for: "dashboard_search" }
= _("What are you searching for?")
.gl-search-box-by-type
= search_field_tag :search, params[:search], placeholder: _("Search for projects, issues, etc."), class: "gl-form-input form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false
= sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon')
%button.search-clear.js-search-clear{ class: [("hidden" if params[:search].blank?), "has-tooltip"], type: "button", tabindex: "-1", title: _('Clear') }
= sprite_icon('clear')
%span.sr-only
= _("Clear search")
- unless params[:snippets].eql? 'true'
= render 'filter'
.d-flex-center.flex-column.flex-lg-row
= button_tag _("Search"), class: "gl-button btn btn-success btn-search mt-lg-0 ml-lg-1 align-self-end"
- @hide_top_links = true
- page_title @search_term
- @hide_breadcrumbs = true
- if params[:group_id].present?
= hidden_field_tag :group_id, params[:group_id]
- if params[:project_id].present?
= hidden_field_tag :project_id, params[:project_id]
- project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace)
- if @search_results
- page_description(_("%{count} %{scope} for term '%{term}'") % { count: @search_results.formatted_count(@scope), scope: @scope, term: @search_term })
......@@ -11,7 +16,7 @@
= render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' }
.gl-mt-3
= render 'search/form'
#js-search-topbar{ data: { "group-initial-data": @group.to_json, "project-initial-data": project_attributes.to_json } }
- if @search_term
= render 'search/category'
= render 'search/results'
---
title: Global Search - UX Cleanup of Search Bar
merge_request: 51409
author:
type: changed
import setHighlightClass from '~/search/highlight_blob_search_result';
export default () => {
export default (searchTerm) => {
const highlightLineClass = 'hll';
const contentBody = document.getElementById('content-body');
const blobs = contentBody.querySelectorAll('.blob-result');
// Supports Basic (backed by Gitaly) Search highlighting
setHighlightClass();
setHighlightClass(searchTerm);
// Supports Advanced (backed by Elasticsearch) Search highlighting
blobs.forEach((blob) => {
......
......@@ -2,6 +2,7 @@ import setHighlightClass from 'ee/search/highlight_blob_search_result';
const fixture = 'ee/search/blob_search_result.html';
const ceFixture = 'search/blob_search_result.html';
const searchKeyword = 'Send'; // spec/frontend/fixtures/search.rb#79
describe('ee/search/highlight_blob_search_result', () => {
preloadFixtures(fixture, ceFixture);
......@@ -10,7 +11,7 @@ describe('ee/search/highlight_blob_search_result', () => {
it('highlights lines with search term occurrence', () => {
loadFixtures(ceFixture);
setHighlightClass();
setHighlightClass(searchKeyword);
expect(document.querySelectorAll('.blob-result .hll').length).toBe(4);
});
......@@ -19,7 +20,7 @@ describe('ee/search/highlight_blob_search_result', () => {
it('highlights lines which have been identified by Elasticsearch', () => {
loadFixtures(fixture);
setHighlightClass();
setHighlightClass(searchKeyword);
expect(document.querySelectorAll('.blob-result .hll').length).toBe(3);
});
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'User searches for commits' do
RSpec.describe 'User searches for commits', :js do
let(:project) { create(:project, :repository) }
let(:sha) { '6d394385cf567f80a8fd85055db1ab4c5295806f' }
let(:user) { create(:user) }
......@@ -41,7 +41,7 @@ RSpec.describe 'User searches for commits' do
submit_search('See merge request')
select_search_scope('Commits')
expect(page).to have_selector('.commit-row-description', count: 9)
expect(page).to have_selector('.commit-row-description', visible: false, count: 9)
end
end
end
......@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'User searches for projects' do
RSpec.describe 'User searches for projects', :js do
let!(:project) { create(:project, :public, name: 'Shop') }
context 'when signed out' do
......
import setHighlightClass from '~/search/highlight_blob_search_result';
const fixture = 'search/blob_search_result.html';
const searchKeyword = 'Send'; // spec/frontend/fixtures/search.rb#79
describe('search/highlight_blob_search_result', () => {
preloadFixtures(fixture);
......@@ -8,7 +9,7 @@ describe('search/highlight_blob_search_result', () => {
beforeEach(() => loadFixtures(fixture));
it('highlights lines with search term occurrence', () => {
setHighlightClass();
setHighlightClass(searchKeyword);
expect(document.querySelectorAll('.blob-result .hll').length).toBe(4);
});
......
import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
import { initSearchApp } from '~/search';
import createStore from '~/search/store';
jest.mock('~/search/store');
jest.mock('~/search/topbar');
jest.mock('~/search/sidebar');
jest.mock('ee_else_ce/search/highlight_blob_search_result');
describe('initSearchApp', () => {
let defaultLocation;
......@@ -42,6 +44,7 @@ describe('initSearchApp', () => {
it(`decodes ${search} to ${decodedSearch}`, () => {
expect(createStore).toHaveBeenCalledWith({ query: { search: decodedSearch } });
expect(setHighlightClass).toHaveBeenCalledWith(decodedSearch);
});
});
});
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlForm, GlSearchBoxByType, GlButton } from '@gitlab/ui';
import { MOCK_QUERY } from 'jest/search/mock_data';
import GlobalSearchTopbar from '~/search/topbar/components/app.vue';
import GroupFilter from '~/search/topbar/components/group_filter.vue';
import ProjectFilter from '~/search/topbar/components/project_filter.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('GlobalSearchTopbar', () => {
let wrapper;
const actionSpies = {
applyQuery: jest.fn(),
setQuery: jest.fn(),
};
const createComponent = (initialState) => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
actions: actionSpies,
});
wrapper = shallowMount(GlobalSearchTopbar, {
localVue,
store,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findTopbarForm = () => wrapper.find(GlForm);
const findGlSearchBox = () => wrapper.find(GlSearchBoxByType);
const findGroupFilter = () => wrapper.find(GroupFilter);
const findProjectFilter = () => wrapper.find(ProjectFilter);
const findSearchButton = () => wrapper.find(GlButton);
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders Topbar Form always', () => {
expect(findTopbarForm().exists()).toBe(true);
});
describe('Search box', () => {
it('renders always', () => {
expect(findGlSearchBox().exists()).toBe(true);
});
describe('onSearch', () => {
const testSearch = 'test search';
beforeEach(() => {
findGlSearchBox().vm.$emit('input', testSearch);
});
it('calls setQuery when input event is fired from GlSearchBoxByType', () => {
expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), {
key: 'search',
value: testSearch,
});
});
});
});
describe.each`
snippets | showFilters
${null} | ${true}
${{ query: { snippets: '' } }} | ${true}
${{ query: { snippets: false } }} | ${true}
${{ query: { snippets: true } }} | ${false}
${{ query: { snippets: 'false' } }} | ${true}
${{ query: { snippets: 'true' } }} | ${false}
`('topbar filters', ({ snippets, showFilters }) => {
beforeEach(() => {
createComponent(snippets);
});
it(`does${showFilters ? '' : ' not'} render when snippets is ${JSON.stringify(
snippets,
)}`, () => {
expect(findGroupFilter().exists()).toBe(showFilters);
expect(findProjectFilter().exists()).toBe(showFilters);
});
});
it('renders SearchButton always', () => {
expect(findSearchButton().exists()).toBe(true);
});
});
describe('actions', () => {
beforeEach(() => {
createComponent();
});
it('clicking SearchButton calls applyQuery', () => {
findTopbarForm().vm.$emit('submit', { preventDefault: () => {} });
expect(actionSpies.applyQuery).toHaveBeenCalled();
});
});
});
import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
import Search from '~/pages/search/show/search';
jest.mock('~/api');
jest.mock('ee_else_ce/search/highlight_blob_search_result');
describe('Search', () => {
const fixturePath = 'search/show.html';
preloadFixtures(fixturePath);
describe('constructor side effects', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('highlights lines with search terms in blob search results', () => {
new Search(); // eslint-disable-line no-new
expect(setHighlightClass).toHaveBeenCalled();
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'search/_filter' do
context 'when the search page is opened' do
it 'displays the correct elements' do
render
expect(rendered).to have_selector('label[for="dashboard_search_group"]')
expect(rendered).to have_selector('input#js-search-group-dropdown')
expect(rendered).to have_selector('label[for="dashboard_search_project"]')
expect(rendered).to have_selector('input#js-search-project-dropdown')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'search/_form' do
context 'when the search page is opened' do
it 'displays the correct elements' do
render
expect(rendered).to have_selector('.search-field-holder.form-group')
expect(rendered).to have_selector('label[for="dashboard_search"]')
end
end
end
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