Commit b0217d65 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch '262063-global-search-topbar-vue' into 'master'

Global Search - Search Topbar Vue

See merge request gitlab-org/gitlab!51409
parents 50d0f272 98bdb53a
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