Commit 8f5d2fc5 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '297396_03-gldropdown-topbar-scoped-search' into 'master'

Global Search - Header Search Scoped Items

See merge request gitlab-org/gitlab!69805
parents ef42200e f84a1a5a
<script>
import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
import HeaderSearchScopedItems from './header_search_scoped_items.vue';
export default {
name: 'HeaderSearchApp',
......@@ -12,6 +15,7 @@ export default {
components: {
GlSearchBoxByType,
HeaderSearchDefaultItems,
HeaderSearchScopedItems,
},
data() {
return {
......@@ -19,17 +23,34 @@ export default {
};
},
computed: {
...mapState(['search']),
...mapGetters(['searchQuery']),
searchText: {
get() {
return this.search;
},
set(value) {
this.setSearch(value);
},
},
showSearchDropdown() {
return this.showDropdown && gon?.current_username;
},
showDefaultItems() {
return !this.searchText;
},
},
methods: {
...mapActions(['setSearch']),
openDropdown() {
this.showDropdown = true;
},
closeDropdown() {
this.showDropdown = false;
},
submitSearch() {
return visitUrl(this.searchQuery);
},
},
};
</script>
......@@ -37,10 +58,13 @@ export default {
<template>
<section v-outside="closeDropdown" class="header-search gl-relative">
<gl-search-box-by-type
v-model="searchText"
:debounce="500"
autocomplete="off"
:placeholder="$options.i18n.searchPlaceholder"
@focus="openDropdown"
@click="openDropdown"
@keydown.enter="submitSearch"
@keydown.esc="closeDropdown"
/>
<div
......@@ -49,7 +73,10 @@ export default {
class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-left-0 gl-z-index-1 gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0"
>
<div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2">
<header-search-default-items />
<header-search-default-items v-if="showDefaultItems" />
<template v-else>
<header-search-scoped-items />
</template>
</div>
</div>
</section>
......
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
export default {
name: 'HeaderSearchScopedItems',
components: {
GlDropdownItem,
},
computed: {
...mapState(['search']),
...mapGetters(['scopedSearchOptions']),
},
};
</script>
<template>
<div>
<gl-dropdown-item
v-for="(option, index) in scopedSearchOptions"
:id="`scoped-${index}`"
:key="index"
tabindex="-1"
:href="option.url"
>
"<span class="gl-font-weight-bold">{{ search }}</span
>" {{ option.description }}
<span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span>
</gl-dropdown-item>
</div>
</template>
......@@ -9,3 +9,9 @@ export const MSG_MR_ASSIGNED_TO_ME = __('Merge requests assigned to me');
export const MSG_MR_IM_REVIEWER = __("Merge requests that I'm a reviewer");
export const MSG_MR_IVE_CREATED = __("Merge requests I've created");
export const MSG_IN_ALL_GITLAB = __('in all GitLab');
export const MSG_IN_GROUP = __('in group');
export const MSG_IN_PROJECT = __('in project');
......@@ -12,13 +12,13 @@ export const initHeaderSearchApp = () => {
return false;
}
const { issuesPath, mrPath } = el.dataset;
const { searchPath, issuesPath, mrPath } = el.dataset;
let { searchContext } = el.dataset;
searchContext = JSON.parse(searchContext);
return new Vue({
el,
store: createStore({ issuesPath, mrPath, searchContext }),
store: createStore({ searchPath, issuesPath, mrPath, searchContext }),
render(createElement) {
return createElement(HeaderSearchApp);
},
......
import * as types from './mutation_types';
export const setSearch = ({ commit }, value) => {
commit(types.SET_SEARCH, value);
};
import { objectToQuery } from '~/lib/utils/url_utility';
import {
MSG_ISSUES_ASSIGNED_TO_ME,
MSG_ISSUES_IVE_CREATED,
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
MSG_IN_PROJECT,
MSG_IN_GROUP,
MSG_IN_ALL_GITLAB,
} from '../constants';
export const searchQuery = (state) => {
const query = {
search: state.search,
nav_source: 'navbar',
project_id: state.searchContext.project?.id,
group_id: state.searchContext.group?.id,
scope: state.searchContext.scope,
};
return `${state.searchPath}?${objectToQuery(query)}`;
};
export const scopedIssuesPath = (state) => {
return (
state.searchContext.project_metadata?.issues_path ||
......@@ -48,3 +65,71 @@ export const defaultSearchOptions = (state, getters) => {
},
];
};
export const projectUrl = (state) => {
if (!state.searchContext.project || !state.searchContext.group) {
return null;
}
const query = {
search: state.search,
nav_source: 'navbar',
project_id: state.searchContext.project.id,
group_id: state.searchContext.group.id,
scope: state.searchContext.scope,
};
return `${state.searchPath}?${objectToQuery(query)}`;
};
export const groupUrl = (state) => {
if (!state.searchContext.group) {
return null;
}
const query = {
search: state.search,
nav_source: 'navbar',
group_id: state.searchContext.group.id,
scope: state.searchContext.scope,
};
return `${state.searchPath}?${objectToQuery(query)}`;
};
export const allUrl = (state) => {
const query = {
search: state.search,
nav_source: 'navbar',
scope: state.searchContext.scope,
};
return `${state.searchPath}?${objectToQuery(query)}`;
};
export const scopedSearchOptions = (state, getters) => {
const options = [];
if (state.searchContext.project) {
options.push({
scope: state.searchContext.project.name,
description: MSG_IN_PROJECT,
url: getters.projectUrl,
});
}
if (state.searchContext.group) {
options.push({
scope: state.searchContext.group.name,
description: MSG_IN_GROUP,
url: getters.groupUrl,
});
}
options.push({
description: MSG_IN_ALL_GITLAB,
url: getters.allUrl,
});
return options;
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import createState from './state';
Vue.use(Vuex);
export const getStoreConfig = ({ issuesPath, mrPath, searchContext }) => ({
export const getStoreConfig = ({ searchPath, issuesPath, mrPath, searchContext }) => ({
actions,
getters,
state: createState({ issuesPath, mrPath, searchContext }),
mutations,
state: createState({ searchPath, issuesPath, mrPath, searchContext }),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
......
import * as types from './mutation_types';
export default {
[types.SET_SEARCH](state, value) {
state.search = value;
},
};
const createState = ({ issuesPath, mrPath, searchContext }) => ({
const createState = ({ searchPath, issuesPath, mrPath, searchContext }) => ({
searchPath,
issuesPath,
mrPath,
searchContext,
search: '',
});
export default createState;
......@@ -32,6 +32,7 @@
- unless current_controller?(:search)
- if Feature.enabled?(:new_header_search)
#js-header-search.header-search{ data: { 'search-context' => search_context.to_json,
'search-path' => search_path,
'issues-path' => issues_dashboard_path,
'mr-path' => merge_requests_dashboard_path } }
%input{ type: "text", placeholder: _('Search or jump to...'), class: 'form-control gl-form-input' }
......
......@@ -39934,9 +39934,18 @@ msgstr ""
msgid "in"
msgstr ""
msgid "in all GitLab"
msgstr ""
msgid "in group"
msgstr ""
msgid "in group %{link_to_group}"
msgstr ""
msgid "in project"
msgstr ""
msgid "in project %{link_to_project}"
msgstr ""
......
import { GlSearchBoxByType } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import HeaderSearchApp from '~/header_search/components/app.vue';
import { ESC_KEY } from '~/lib/utils/keys';
import { MOCK_USERNAME } from '../mock_data';
import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue';
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
import { ENTER_KEY, ESC_KEY } from '~/lib/utils/keys';
import { visitUrl } from '~/lib/utils/url_utility';
import { MOCK_SEARCH, MOCK_SEARCH_QUERY, MOCK_USERNAME } from '../mock_data';
Vue.use(Vuex);
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
}));
describe('HeaderSearchApp', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMountExtended(HeaderSearchApp);
const actionSpies = {
setSearch: jest.fn(),
};
const createComponent = (initialState) => {
const store = new Vuex.Store({
state: {
...initialState,
},
actions: actionSpies,
getters: {
searchQuery: () => MOCK_SEARCH_QUERY,
},
});
wrapper = shallowMountExtended(HeaderSearchApp, {
store,
});
};
afterEach(() => {
......@@ -17,6 +44,8 @@ describe('HeaderSearchApp', () => {
const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu');
const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems);
const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems);
describe('template', () => {
it('always renders Header Search Input', () => {
......@@ -43,6 +72,29 @@ describe('HeaderSearchApp', () => {
});
});
});
describe.each`
search | showDefault | showScoped
${null} | ${true} | ${false}
${''} | ${true} | ${false}
${MOCK_SEARCH} | ${false} | ${true}
`('Header Search Dropdown Items', ({ search, showDefault, showScoped }) => {
describe(`when search is ${search}`, () => {
beforeEach(() => {
createComponent({ search });
window.gon.current_username = MOCK_USERNAME;
wrapper.setData({ showDropdown: true });
});
it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => {
expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault);
});
it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => {
expect(findHeaderSearchScopedItems().exists()).toBe(showScoped);
});
});
});
});
describe('events', () => {
......@@ -86,6 +138,22 @@ describe('HeaderSearchApp', () => {
expect(findHeaderSearchDropdown().exists()).toBe(false);
});
});
it('calls setSearch when search input event is fired', async () => {
findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH);
await wrapper.vm.$nextTick();
expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH);
});
it('submits a search onKey-Enter', async () => {
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
await wrapper.vm.$nextTick();
expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
});
});
});
});
import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { trimText } from 'helpers/text_helper';
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
import { MOCK_SEARCH, MOCK_SCOPED_SEARCH_OPTIONS } from '../mock_data';
Vue.use(Vuex);
describe('HeaderSearchScopedItems', () => {
let wrapper;
const createComponent = (initialState) => {
const store = new Vuex.Store({
state: {
search: MOCK_SEARCH,
...initialState,
},
getters: {
scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS,
},
});
wrapper = shallowMount(HeaderSearchScopedItems, {
store,
});
};
afterEach(() => {
wrapper.destroy();
});
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text()));
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
describe('template', () => {
describe('Dropdown items', () => {
beforeEach(() => {
createComponent();
});
it('renders item for each option in scopedSearchOptions', () => {
expect(findDropdownItems()).toHaveLength(MOCK_SCOPED_SEARCH_OPTIONS.length);
});
it('renders titles correctly', () => {
const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) =>
trimText(`"${MOCK_SEARCH}" ${o.description} ${o.scope || ''}`),
);
expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
});
it('renders links correctly', () => {
const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url);
expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
});
});
});
});
......@@ -4,14 +4,37 @@ import {
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
MSG_IN_PROJECT,
MSG_IN_GROUP,
MSG_IN_ALL_GITLAB,
} from '~/header_search/constants';
export const MOCK_USERNAME = 'anyone';
export const MOCK_SEARCH_PATH = '/search';
export const MOCK_ISSUE_PATH = '/dashboard/issues';
export const MOCK_MR_PATH = '/dashboard/merge_requests';
export const MOCK_ALL_PATH = '/';
export const MOCK_PROJECT = {
id: 123,
name: 'MockProject',
path: '/mock-project',
};
export const MOCK_GROUP = {
id: 321,
name: 'MockGroup',
path: '/mock-group',
};
export const MOCK_SEARCH_QUERY = 'http://gitlab.com/search?search=test';
export const MOCK_SEARCH = 'test';
export const MOCK_SEARCH_CONTEXT = {
project: null,
project_metadata: {},
......@@ -41,3 +64,20 @@ export const MOCK_DEFAULT_SEARCH_OPTIONS = [
url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`,
},
];
export const MOCK_SCOPED_SEARCH_OPTIONS = [
{
scope: MOCK_PROJECT.name,
description: MSG_IN_PROJECT,
url: MOCK_PROJECT.path,
},
{
scope: MOCK_GROUP.name,
description: MSG_IN_GROUP,
url: MOCK_GROUP.path,
},
{
description: MSG_IN_ALL_GITLAB,
url: MOCK_ALL_PATH,
},
];
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/header_search/store/actions';
import * as types from '~/header_search/store/mutation_types';
import createState from '~/header_search/store/state';
import { MOCK_SEARCH } from '../mock_data';
describe('Header Search Store Actions', () => {
let state;
beforeEach(() => {
state = createState({});
});
afterEach(() => {
state = null;
});
describe('setSearch', () => {
it('calls the SET_SEARCH mutation', () => {
return testAction({
action: actions.setSearch,
payload: MOCK_SEARCH,
state,
expectedMutations: [{ type: types.SET_SEARCH, payload: MOCK_SEARCH }],
});
});
});
});
......@@ -2,10 +2,16 @@ import * as getters from '~/header_search/store/getters';
import initState from '~/header_search/store/state';
import {
MOCK_USERNAME,
MOCK_SEARCH_PATH,
MOCK_ISSUE_PATH,
MOCK_MR_PATH,
MOCK_SEARCH_CONTEXT,
MOCK_DEFAULT_SEARCH_OPTIONS,
MOCK_SCOPED_SEARCH_OPTIONS,
MOCK_PROJECT,
MOCK_GROUP,
MOCK_ALL_PATH,
MOCK_SEARCH,
} from '../mock_data';
describe('Header Search Store Getters', () => {
......@@ -13,6 +19,7 @@ describe('Header Search Store Getters', () => {
const createState = (initialState) => {
state = initState({
searchPath: MOCK_SEARCH_PATH,
issuesPath: MOCK_ISSUE_PATH,
mrPath: MOCK_MR_PATH,
searchContext: MOCK_SEARCH_CONTEXT,
......@@ -24,6 +31,30 @@ describe('Header Search Store Getters', () => {
state = null;
});
describe.each`
group | project | expectedPath
${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=undefined&scope=issues`}
${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=${MOCK_GROUP.id}&scope=issues`}
${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
`('searchQuery', ({ group, project, expectedPath }) => {
describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
beforeEach(() => {
createState({
searchContext: {
group,
project,
scope: 'issues',
},
});
state.search = MOCK_SEARCH;
});
it(`should return ${expectedPath}`, () => {
expect(getters.searchQuery(state)).toBe(expectedPath);
});
});
});
describe.each`
group | group_metadata | project | project_metadata | expectedPath
${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH}
......@@ -72,6 +103,71 @@ describe('Header Search Store Getters', () => {
});
});
describe.each`
group | project | expectedPath
${null} | ${null} | ${null}
${MOCK_GROUP} | ${null} | ${null}
${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
`('projectUrl', ({ group, project, expectedPath }) => {
describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
beforeEach(() => {
createState({
searchContext: {
group,
project,
scope: 'issues',
},
});
state.search = MOCK_SEARCH;
});
it(`should return ${expectedPath}`, () => {
expect(getters.projectUrl(state)).toBe(expectedPath);
});
});
});
describe.each`
group | project | expectedPath
${null} | ${null} | ${null}
${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`}
${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`}
`('groupUrl', ({ group, project, expectedPath }) => {
describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
beforeEach(() => {
createState({
searchContext: {
group,
project,
scope: 'issues',
},
});
state.search = MOCK_SEARCH;
});
it(`should return ${expectedPath}`, () => {
expect(getters.groupUrl(state)).toBe(expectedPath);
});
});
});
describe('allUrl', () => {
const expectedPath = `${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`;
beforeEach(() => {
createState({
searchContext: {
scope: 'issues',
},
});
state.search = MOCK_SEARCH;
});
it(`should return ${expectedPath}`, () => {
expect(getters.allUrl(state)).toBe(expectedPath);
});
});
describe('defaultSearchOptions', () => {
const mockGetters = {
scopedIssuesPath: MOCK_ISSUE_PATH,
......@@ -89,4 +185,27 @@ describe('Header Search Store Getters', () => {
);
});
});
describe('scopedSearchOptions', () => {
const mockGetters = {
projectUrl: MOCK_PROJECT.path,
groupUrl: MOCK_GROUP.path,
allUrl: MOCK_ALL_PATH,
};
beforeEach(() => {
createState({
searchContext: {
project: MOCK_PROJECT,
group: MOCK_GROUP,
},
});
});
it('returns the correct array', () => {
expect(getters.scopedSearchOptions(state, mockGetters)).toStrictEqual(
MOCK_SCOPED_SEARCH_OPTIONS,
);
});
});
});
import * as types from '~/header_search/store/mutation_types';
import mutations from '~/header_search/store/mutations';
import createState from '~/header_search/store/state';
import { MOCK_SEARCH } from '../mock_data';
describe('Header Search Store Mutations', () => {
let state;
beforeEach(() => {
state = createState({});
});
describe('SET_SEARCH', () => {
it('sets search to value', () => {
mutations[types.SET_SEARCH](state, MOCK_SEARCH);
expect(state.search).toBe(MOCK_SEARCH);
});
});
});
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