Commit 3e5f0b6c authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch '297396_02-gldropdown-topbar-search-default-dropdown' into 'master'

Global Search - Header Search Default Items

See merge request gitlab-org/gitlab!69307
parents aef01b71 64dd1ee9
<script>
import { GlSearchBoxByType } from '@gitlab/ui';
import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
import { __ } from '~/locale';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
export default {
name: 'HeaderSearchApp',
i18n: {
searchPlaceholder: __('Search or jump to...'),
},
directives: { Outside },
components: {
GlSearchBoxByType,
HeaderSearchDefaultItems,
},
data() {
return {
showDropdown: false,
};
},
computed: {
showSearchDropdown() {
return this.showDropdown && gon?.current_username;
},
},
methods: {
openDropdown() {
this.showDropdown = true;
},
closeDropdown() {
this.showDropdown = false;
},
},
};
</script>
<template>
<section class="header-search">
<gl-search-box-by-type autocomplete="off" :placeholder="$options.i18n.searchPlaceholder" />
<section v-outside="closeDropdown" class="header-search gl-relative">
<gl-search-box-by-type
autocomplete="off"
:placeholder="$options.i18n.searchPlaceholder"
@focus="openDropdown"
@click="openDropdown"
@keydown.esc="closeDropdown"
/>
<div
v-if="showSearchDropdown"
data-testid="header-search-dropdown-menu"
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 />
</div>
</div>
</section>
</template>
<script>
import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { __ } from '~/locale';
export default {
name: 'HeaderSearchDefaultItems',
i18n: {
allGitLab: __('All GitLab'),
},
components: {
GlDropdownSectionHeader,
GlDropdownItem,
},
computed: {
...mapState(['searchContext']),
...mapGetters(['defaultSearchOptions']),
sectionHeader() {
return (
this.searchContext.project?.name ||
this.searchContext.group?.name ||
this.$options.i18n.allGitLab
);
},
},
};
</script>
<template>
<div>
<gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="(option, index) in defaultSearchOptions"
:id="`default-${index}`"
:key="index"
tabindex="-1"
:href="option.url"
>
{{ option.title }}
</gl-dropdown-item>
</div>
</template>
import { __ } from '~/locale';
export const MSG_ISSUES_ASSIGNED_TO_ME = __('Issues assigned to me');
export const MSG_ISSUES_IVE_CREATED = __("Issues I've created");
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");
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import HeaderSearchApp from './components/app.vue';
import createStore from './store';
Vue.use(Translate);
......@@ -11,8 +12,13 @@ export const initHeaderSearchApp = () => {
return false;
}
const { issuesPath, mrPath } = el.dataset;
let { searchContext } = el.dataset;
searchContext = JSON.parse(searchContext);
return new Vue({
el,
store: createStore({ issuesPath, mrPath, searchContext }),
render(createElement) {
return createElement(HeaderSearchApp);
},
......
import {
MSG_ISSUES_ASSIGNED_TO_ME,
MSG_ISSUES_IVE_CREATED,
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
} from '../constants';
export const scopedIssuesPath = (state) => {
return (
state.searchContext.project_metadata?.issues_path ||
state.searchContext.group_metadata?.issues_path ||
state.issuesPath
);
};
export const scopedMRPath = (state) => {
return (
state.searchContext.project_metadata?.mr_path ||
state.searchContext.group_metadata?.mr_path ||
state.mrPath
);
};
export const defaultSearchOptions = (state, getters) => {
const userName = gon.current_username;
return [
{
title: MSG_ISSUES_ASSIGNED_TO_ME,
url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
},
{
title: MSG_ISSUES_IVE_CREATED,
url: `${getters.scopedIssuesPath}/?author_username=${userName}`,
},
{
title: MSG_MR_ASSIGNED_TO_ME,
url: `${getters.scopedMRPath}/?assignee_username=${userName}`,
},
{
title: MSG_MR_IM_REVIEWER,
url: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
},
{
title: MSG_MR_IVE_CREATED,
url: `${getters.scopedMRPath}/?author_username=${userName}`,
},
];
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as getters from './getters';
import createState from './state';
Vue.use(Vuex);
export const getStoreConfig = ({ issuesPath, mrPath, searchContext }) => ({
getters,
state: createState({ issuesPath, mrPath, searchContext }),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
export default createStore;
const createState = ({ issuesPath, mrPath, searchContext }) => ({
issuesPath,
mrPath,
searchContext,
});
export default createState;
......@@ -47,6 +47,15 @@ input[type='checkbox']:hover {
}
}
.header-search-dropdown-menu {
max-height: $dropdown-max-height;
top: $header-height;
}
.header-search-dropdown-content {
max-height: $dropdown-max-height;
}
.search {
margin: 0 8px;
......
......@@ -31,7 +31,9 @@
%li.nav-item.d-none.d-lg-block.m-auto
- unless current_controller?(:search)
- if Feature.enabled?(:new_header_search)
#js-header-search.header-search{ }
#js-header-search.header-search{ data: { 'search-context' => search_context.to_json,
'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' }
- else
= render 'layouts/search'
......
......@@ -3271,6 +3271,9 @@ msgstr ""
msgid "All (default)"
msgstr ""
msgid "All GitLab"
msgstr ""
msgid "All Members"
msgstr ""
......@@ -18760,12 +18763,18 @@ msgstr ""
msgid "Issues"
msgstr ""
msgid "Issues I've created"
msgstr ""
msgid "Issues Rate Limits"
msgstr ""
msgid "Issues and merge requests"
msgstr ""
msgid "Issues assigned to me"
msgstr ""
msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
msgstr ""
......@@ -21107,12 +21116,21 @@ msgstr ""
msgid "Merge requests"
msgstr ""
msgid "Merge requests I've created"
msgstr ""
msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
msgstr ""
msgid "Merge requests are read-only in a secondary Geo node"
msgstr ""
msgid "Merge requests assigned to me"
msgstr ""
msgid "Merge requests that I'm a reviewer"
msgstr ""
msgid "Merge the branch and fix any conflicts that come up"
msgstr ""
......
import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
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';
describe('HeaderSearchApp', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(HeaderSearchApp);
wrapper = shallowMountExtended(HeaderSearchApp);
};
afterEach(() => {
......@@ -14,14 +16,76 @@ describe('HeaderSearchApp', () => {
});
const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu');
describe('template', () => {
it('always renders Header Search Input', () => {
createComponent();
expect(findHeaderSearchInput().exists()).toBe(true);
});
describe.each`
showDropdown | username | showSearchDropdown
${false} | ${null} | ${false}
${false} | ${MOCK_USERNAME} | ${false}
${true} | ${null} | ${false}
${true} | ${MOCK_USERNAME} | ${true}
`('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => {
describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => {
beforeEach(() => {
createComponent();
window.gon.current_username = username;
wrapper.setData({ showDropdown });
});
it('renders Header Search Input always', () => {
expect(findHeaderSearchInput().exists()).toBe(true);
it(`should${showSearchDropdown ? '' : ' not'} render`, () => {
expect(findHeaderSearchDropdown().exists()).toBe(showSearchDropdown);
});
});
});
});
describe('events', () => {
beforeEach(() => {
createComponent();
window.gon.current_username = MOCK_USERNAME;
});
describe('Header Search Input', () => {
describe('when dropdown is closed', () => {
it('onFocus opens dropdown', async () => {
expect(findHeaderSearchDropdown().exists()).toBe(false);
findHeaderSearchInput().vm.$emit('focus');
await wrapper.vm.$nextTick();
expect(findHeaderSearchDropdown().exists()).toBe(true);
});
it('onClick opens dropdown', async () => {
expect(findHeaderSearchDropdown().exists()).toBe(false);
findHeaderSearchInput().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(findHeaderSearchDropdown().exists()).toBe(true);
});
});
describe('when dropdown is opened', () => {
beforeEach(() => {
wrapper.setData({ showDropdown: true });
});
it('onKey-Escape closes dropdown', async () => {
expect(findHeaderSearchDropdown().exists()).toBe(true);
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ESC_KEY }));
await wrapper.vm.$nextTick();
expect(findHeaderSearchDropdown().exists()).toBe(false);
});
});
});
});
});
import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue';
import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS } from '../mock_data';
Vue.use(Vuex);
describe('HeaderSearchDefaultItems', () => {
let wrapper;
const createComponent = (initialState) => {
const store = new Vuex.Store({
state: {
searchContext: MOCK_SEARCH_CONTEXT,
...initialState,
},
getters: {
defaultSearchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS,
},
});
wrapper = shallowMount(HeaderSearchDefaultItems, {
store,
});
};
afterEach(() => {
wrapper.destroy();
});
const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => 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 defaultSearchOptions', () => {
expect(findDropdownItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length);
});
it('renders titles correctly', () => {
const expectedTitles = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.title);
expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
});
it('renders links correctly', () => {
const expectedLinks = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.url);
expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
});
});
describe.each`
group | project | dropdownTitle
${null} | ${null} | ${'All GitLab'}
${{ name: 'Test Group' }} | ${null} | ${'Test Group'}
${{ name: 'Test Group' }} | ${{ name: 'Test Project' }} | ${'Test Project'}
`('Dropdown Header', ({ group, project, dropdownTitle }) => {
describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
beforeEach(() => {
createComponent({
searchContext: {
group,
project,
},
});
});
it(`should render as ${dropdownTitle}`, () => {
expect(findDropdownHeader().text()).toBe(dropdownTitle);
});
});
});
});
});
import {
MSG_ISSUES_ASSIGNED_TO_ME,
MSG_ISSUES_IVE_CREATED,
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
} from '~/header_search/constants';
export const MOCK_USERNAME = 'anyone';
export const MOCK_ISSUE_PATH = '/dashboard/issues';
export const MOCK_MR_PATH = '/dashboard/merge_requests';
export const MOCK_SEARCH_CONTEXT = {
project: null,
project_metadata: {},
group: null,
group_metadata: {},
};
export const MOCK_DEFAULT_SEARCH_OPTIONS = [
{
title: MSG_ISSUES_ASSIGNED_TO_ME,
url: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`,
},
{
title: MSG_ISSUES_IVE_CREATED,
url: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`,
},
{
title: MSG_MR_ASSIGNED_TO_ME,
url: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`,
},
{
title: MSG_MR_IM_REVIEWER,
url: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`,
},
{
title: MSG_MR_IVE_CREATED,
url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`,
},
];
import * as getters from '~/header_search/store/getters';
import initState from '~/header_search/store/state';
import {
MOCK_USERNAME,
MOCK_ISSUE_PATH,
MOCK_MR_PATH,
MOCK_SEARCH_CONTEXT,
MOCK_DEFAULT_SEARCH_OPTIONS,
} from '../mock_data';
describe('Header Search Store Getters', () => {
let state;
const createState = (initialState) => {
state = initState({
issuesPath: MOCK_ISSUE_PATH,
mrPath: MOCK_MR_PATH,
searchContext: MOCK_SEARCH_CONTEXT,
...initialState,
});
};
afterEach(() => {
state = null;
});
describe.each`
group | group_metadata | project | project_metadata | expectedPath
${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH}
${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${null} | ${null} | ${'group/path'}
${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ issues_path: 'project/path' }} | ${'project/path'}
`('scopedIssuesPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => {
describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
beforeEach(() => {
createState({
searchContext: {
group,
group_metadata,
project,
project_metadata,
},
});
});
it(`should return ${expectedPath}`, () => {
expect(getters.scopedIssuesPath(state)).toBe(expectedPath);
});
});
});
describe.each`
group | group_metadata | project | project_metadata | expectedPath
${null} | ${null} | ${null} | ${null} | ${MOCK_MR_PATH}
${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${null} | ${null} | ${'group/path'}
${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ mr_path: 'project/path' }} | ${'project/path'}
`('scopedMRPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => {
describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
beforeEach(() => {
createState({
searchContext: {
group,
group_metadata,
project,
project_metadata,
},
});
});
it(`should return ${expectedPath}`, () => {
expect(getters.scopedMRPath(state)).toBe(expectedPath);
});
});
});
describe('defaultSearchOptions', () => {
const mockGetters = {
scopedIssuesPath: MOCK_ISSUE_PATH,
scopedMRPath: MOCK_MR_PATH,
};
beforeEach(() => {
createState();
window.gon.current_username = MOCK_USERNAME;
});
it('returns the correct array', () => {
expect(getters.defaultSearchOptions(state, mockGetters)).toStrictEqual(
MOCK_DEFAULT_SEARCH_OPTIONS,
);
});
});
});
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