Commit de52a3a2 authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '331433-search-filters-refactor' into 'master'

Global Search - Refactor Group/Project Filters

See merge request gitlab-org/gitlab!62419
parents 35907eac 3157a8cc
...@@ -39,8 +39,8 @@ export default { ...@@ -39,8 +39,8 @@ export default {
<searchable-dropdown <searchable-dropdown
data-testid="group-filter" data-testid="group-filter"
:header-text="$options.GROUP_DATA.headerText" :header-text="$options.GROUP_DATA.headerText"
:selected-display-value="$options.GROUP_DATA.selectedDisplayValue" :name="$options.GROUP_DATA.name"
:items-display-value="$options.GROUP_DATA.itemsDisplayValue" :full-name="$options.GROUP_DATA.fullName"
:loading="fetchingGroups" :loading="fetchingGroups"
:selected-item="selectedGroup" :selected-item="selectedGroup"
:items="groups" :items="groups"
......
...@@ -42,8 +42,8 @@ export default { ...@@ -42,8 +42,8 @@ export default {
<searchable-dropdown <searchable-dropdown
data-testid="project-filter" data-testid="project-filter"
:header-text="$options.PROJECT_DATA.headerText" :header-text="$options.PROJECT_DATA.headerText"
:selected-display-value="$options.PROJECT_DATA.selectedDisplayValue" :name="$options.PROJECT_DATA.name"
:items-display-value="$options.PROJECT_DATA.itemsDisplayValue" :full-name="$options.PROJECT_DATA.fullName"
:loading="fetchingProjects" :loading="fetchingProjects"
:selected-item="selectedProject" :selected-item="selectedProject"
:items="projects" :items="projects"
......
...@@ -8,7 +8,10 @@ import { ...@@ -8,7 +8,10 @@ import {
GlButton, GlButton,
GlSkeletonLoader, GlSkeletonLoader,
GlTooltipDirective, GlTooltipDirective,
GlAvatar,
} from '@gitlab/ui'; } from '@gitlab/ui';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { ANY_OPTION } from '../constants'; import { ANY_OPTION } from '../constants';
...@@ -25,6 +28,7 @@ export default { ...@@ -25,6 +28,7 @@ export default {
GlIcon, GlIcon,
GlButton, GlButton,
GlSkeletonLoader, GlSkeletonLoader,
GlAvatar,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -35,12 +39,12 @@ export default { ...@@ -35,12 +39,12 @@ export default {
required: false, required: false,
default: "__('Filter')", default: "__('Filter')",
}, },
selectedDisplayValue: { name: {
type: String, type: String,
required: false, required: false,
default: 'name', default: 'name',
}, },
itemsDisplayValue: { fullName: {
type: String, type: String,
required: false, required: false,
default: 'name', default: 'name',
...@@ -75,6 +79,12 @@ export default { ...@@ -75,6 +79,12 @@ export default {
resetDropdown() { resetDropdown() {
this.$emit('change', ANY_OPTION); this.$emit('change', ANY_OPTION);
}, },
truncateNamespace(namespace) {
return truncateNamespace(namespace);
},
highlightedItemName(name) {
return highlight(name, this.searchText);
},
}, },
ANY_OPTION, ANY_OPTION,
}; };
...@@ -83,15 +93,16 @@ export default { ...@@ -83,15 +93,16 @@ export default {
<template> <template>
<gl-dropdown <gl-dropdown
class="gl-w-full" class="gl-w-full"
menu-class="gl-w-full!" menu-class="global-search-dropdown-menu"
toggle-class="gl-text-truncate" toggle-class="gl-text-truncate"
:header-text="headerText" :header-text="headerText"
:right="true"
@show="$emit('search', searchText)" @show="$emit('search', searchText)"
@shown="$refs.searchBox.focusInput()" @shown="$refs.searchBox.focusInput()"
> >
<template #button-content> <template #button-content>
<span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate"> <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate">
{{ selectedItem[selectedDisplayValue] }} {{ selectedItem[name] }}
</span> </span>
<gl-loading-icon v-if="loading" inline class="gl-mr-3" /> <gl-loading-icon v-if="loading" inline class="gl-mr-3" />
<gl-button <gl-button
...@@ -121,9 +132,10 @@ export default { ...@@ -121,9 +132,10 @@ export default {
class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2" class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2"
:is-check-item="true" :is-check-item="true"
:is-checked="isSelected($options.ANY_OPTION)" :is-checked="isSelected($options.ANY_OPTION)"
:is-check-centered="true"
@click="resetDropdown" @click="resetDropdown"
> >
{{ $options.ANY_OPTION.name }} <span data-testid="item-title">{{ $options.ANY_OPTION.name }}</span>
</gl-dropdown-item> </gl-dropdown-item>
</div> </div>
<div v-if="!loading"> <div v-if="!loading">
...@@ -132,9 +144,27 @@ export default { ...@@ -132,9 +144,27 @@ export default {
:key="item.id" :key="item.id"
:is-check-item="true" :is-check-item="true"
:is-checked="isSelected(item)" :is-checked="isSelected(item)"
:is-check-centered="true"
@click="$emit('change', item)" @click="$emit('change', item)"
> >
{{ item[itemsDisplayValue] }} <div class="gl-display-flex gl-align-items-center">
<gl-avatar
:src="item.avatar_url"
:entity-id="item.id"
:entity-name="item[name]"
shape="rect"
:size="32"
/>
<div class="gl-display-flex gl-flex-direction-column">
<!-- eslint-disable-next-line vue/no-v-html -->
<span data-testid="item-title" v-html="highlightedItemName(item[name])">{{
item[name]
}}</span>
<span class="gl-font-sm gl-text-gray-700" data-testid="item-namespace">{{
truncateNamespace(item[fullName])
}}</span>
</div>
</div>
</gl-dropdown-item> </gl-dropdown-item>
</div> </div>
<div v-if="loading" class="gl-mx-4 gl-mt-3"> <div v-if="loading" class="gl-mx-4 gl-mt-3">
......
...@@ -9,13 +9,13 @@ export const ANY_OPTION = Object.freeze({ ...@@ -9,13 +9,13 @@ export const ANY_OPTION = Object.freeze({
export const GROUP_DATA = { export const GROUP_DATA = {
headerText: __('Filter results by group'), headerText: __('Filter results by group'),
queryParam: 'group_id', queryParam: 'group_id',
selectedDisplayValue: 'name', name: 'name',
itemsDisplayValue: 'full_name', fullName: 'full_name',
}; };
export const PROJECT_DATA = { export const PROJECT_DATA = {
headerText: __('Filter results by project'), headerText: __('Filter results by project'),
queryParam: 'project_id', queryParam: 'project_id',
selectedDisplayValue: 'name_with_namespace', name: 'name',
itemsDisplayValue: 'name_with_namespace', fullName: 'name_with_namespace',
}; };
...@@ -295,6 +295,16 @@ input[type='checkbox']:hover { ...@@ -295,6 +295,16 @@ input[type='checkbox']:hover {
@include str-truncated(10em); @include str-truncated(10em);
} }
.global-search-dropdown-menu {
width: 100% !important;
max-width: 400px;
@include media-breakpoint-up(md) {
// This is larger than the container so width: 100% doesn't work.
width: 400px !important;
}
}
// Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling // Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling
/* stylelint-disable property-no-vendor-prefix */ /* stylelint-disable property-no-vendor-prefix */
input[type='search']::-webkit-search-decoration, input[type='search']::-webkit-search-decoration,
......
...@@ -32,7 +32,7 @@ RSpec.describe 'User searches for code' do ...@@ -32,7 +32,7 @@ RSpec.describe 'User searches for code' do
wait_for_requests wait_for_requests
page.within('[data-testid="project-filter"]') do page.within('[data-testid="project-filter"]') do
click_on(project.full_name) click_on(project.name)
end end
end end
......
...@@ -90,7 +90,7 @@ RSpec.describe 'User searches for issues', :js do ...@@ -90,7 +90,7 @@ RSpec.describe 'User searches for issues', :js do
wait_for_requests wait_for_requests
page.within('[data-testid="project-filter"]') do page.within('[data-testid="project-filter"]') do
click_on(project.full_name) click_on(project.name)
end end
search_for_issue(issue1.title) search_for_issue(issue1.title)
......
...@@ -55,7 +55,7 @@ RSpec.describe 'User searches for merge requests', :js do ...@@ -55,7 +55,7 @@ RSpec.describe 'User searches for merge requests', :js do
wait_for_requests wait_for_requests
page.within('[data-testid="project-filter"]') do page.within('[data-testid="project-filter"]') do
click_on(project.full_name) click_on(project.name)
end end
search_for_mr(merge_request1.title) search_for_mr(merge_request1.title)
......
...@@ -35,7 +35,7 @@ RSpec.describe 'User searches for milestones', :js do ...@@ -35,7 +35,7 @@ RSpec.describe 'User searches for milestones', :js do
wait_for_requests wait_for_requests
page.within('[data-testid="project-filter"]') do page.within('[data-testid="project-filter"]') do
click_on(project.full_name) click_on(project.name)
end end
fill_in('dashboard_search', with: milestone1.title) fill_in('dashboard_search', with: milestone1.title)
......
...@@ -23,7 +23,7 @@ RSpec.describe 'User searches for wiki pages', :js do ...@@ -23,7 +23,7 @@ RSpec.describe 'User searches for wiki pages', :js do
wait_for_requests wait_for_requests
page.within('[data-testid="project-filter"]') do page.within('[data-testid="project-filter"]') do
click_on(project.full_name) click_on(project.name)
end end
fill_in('dashboard_search', with: search_term) fill_in('dashboard_search', with: search_term)
......
...@@ -33,10 +33,10 @@ RSpec.describe 'User uses search filters', :js do ...@@ -33,10 +33,10 @@ RSpec.describe 'User uses search filters', :js do
wait_for_requests wait_for_requests
page.within('[data-testid="project-filter"]') do page.within('[data-testid="project-filter"]') do
click_on(group_project.full_name) click_on(group_project.name)
end end
expect(find('[data-testid="project-filter"]')).to have_content(group_project.full_name) expect(find('[data-testid="project-filter"]')).to have_content(group_project.name)
end end
context 'when the group filter is set' do context 'when the group filter is set' do
...@@ -65,10 +65,10 @@ RSpec.describe 'User uses search filters', :js do ...@@ -65,10 +65,10 @@ RSpec.describe 'User uses search filters', :js do
wait_for_requests wait_for_requests
page.within('[data-testid="project-filter"]') do page.within('[data-testid="project-filter"]') do
click_on(project.full_name) click_on(project.name)
end end
expect(find('[data-testid="project-filter"]')).to have_content(project.full_name) expect(find('[data-testid="project-filter"]')).to have_content(project.name)
end end
context 'when the project filter is set' do context 'when the project filter is set' do
......
...@@ -2,47 +2,49 @@ export const MOCK_QUERY = { ...@@ -2,47 +2,49 @@ export const MOCK_QUERY = {
scope: 'issues', scope: 'issues',
state: 'all', state: 'all',
confidential: null, confidential: null,
group_id: 'test_1', group_id: 1,
}; };
export const MOCK_GROUP = { export const MOCK_GROUP = {
name: 'test group', name: 'test group',
full_name: 'full name test group', full_name: 'full name / test group',
id: 'test_1', id: 1,
}; };
export const MOCK_GROUPS = [ export const MOCK_GROUPS = [
{ {
avatar_url: null,
name: 'test group', name: 'test group',
full_name: 'full name test group', full_name: 'full name / test group',
id: 'test_1', id: 1,
}, },
{ {
avatar_url: 'https://avatar.com',
name: 'test group 2', name: 'test group 2',
full_name: 'full name test group 2', full_name: 'full name / test group 2',
id: 'test_2', id: 2,
}, },
]; ];
export const MOCK_PROJECT = { export const MOCK_PROJECT = {
name: 'test project', name: 'test project',
namespace: MOCK_GROUP, namespace: MOCK_GROUP,
nameWithNamespace: 'test group test project', nameWithNamespace: 'test group / test project',
id: 'test_1', id: 1,
}; };
export const MOCK_PROJECTS = [ export const MOCK_PROJECTS = [
{ {
name: 'test project', name: 'test project',
namespace: MOCK_GROUP, namespace: MOCK_GROUP,
name_with_namespace: 'test group test project', name_with_namespace: 'test group / test project',
id: 'test_1', id: 1,
}, },
{ {
name: 'test project 2', name: 'test project 2',
namespace: MOCK_GROUP, namespace: MOCK_GROUP,
name_with_namespace: 'test group test project 2', name_with_namespace: 'test group / test project 2',
id: 'test_2', id: 2,
}, },
]; ];
......
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; import {
import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlSkeletonLoader,
GlAvatar,
} from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data'; import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
import { truncateNamespace } from '~/lib/utils/text_utility';
import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue'; import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants'; import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants';
const localVue = createLocalVue(); Vue.use(Vuex);
localVue.use(Vuex);
describe('Global Search Searchable Dropdown', () => { describe('Global Search Searchable Dropdown', () => {
let wrapper; let wrapper;
const defaultProps = { const defaultProps = {
headerText: GROUP_DATA.headerText, headerText: GROUP_DATA.headerText,
selectedDisplayValue: GROUP_DATA.selectedDisplayValue, name: GROUP_DATA.name,
itemsDisplayValue: GROUP_DATA.itemsDisplayValue, fullName: GROUP_DATA.fullName,
loading: false, loading: false,
selectedItem: ANY_OPTION, selectedItem: ANY_OPTION,
items: [], items: [],
...@@ -28,14 +36,15 @@ describe('Global Search Searchable Dropdown', () => { ...@@ -28,14 +36,15 @@ describe('Global Search Searchable Dropdown', () => {
}, },
}); });
wrapper = mountFn(SearchableDropdown, { wrapper = extendedWrapper(
localVue, mountFn(SearchableDropdown, {
store, store,
propsData: { propsData: {
...defaultProps, ...defaultProps,
...props, ...props,
}, },
}); }),
);
}; };
afterEach(() => { afterEach(() => {
...@@ -47,11 +56,18 @@ describe('Global Search Searchable Dropdown', () => { ...@@ -47,11 +56,18 @@ describe('Global Search Searchable Dropdown', () => {
const findGlDropdownSearch = () => findGlDropdown().find(GlSearchBoxByType); const findGlDropdownSearch = () => findGlDropdown().find(GlSearchBoxByType);
const findDropdownText = () => findGlDropdown().find('.dropdown-toggle-text'); const findDropdownText = () => findGlDropdown().find('.dropdown-toggle-text');
const findDropdownItems = () => findGlDropdown().findAll(GlDropdownItem); const findDropdownItems = () => findGlDropdown().findAll(GlDropdownItem);
const findDropdownItemsText = () => findDropdownItems().wrappers.map((w) => w.text()); const findDropdownItemTitles = () => wrapper.findAllByTestId('item-title');
const findDropdownItemNamespaces = () => wrapper.findAllByTestId('item-namespace');
const findDropdownAvatars = () => wrapper.findAllComponents(GlAvatar);
const findAnyDropdownItem = () => findDropdownItems().at(0); const findAnyDropdownItem = () => findDropdownItems().at(0);
const findFirstGroupDropdownItem = () => findDropdownItems().at(1); const findFirstGroupDropdownItem = () => findDropdownItems().at(1);
const findLoader = () => wrapper.find(GlSkeletonLoader); const findLoader = () => wrapper.find(GlSkeletonLoader);
const findDropdownItemTitlesText = () => findDropdownItemTitles().wrappers.map((w) => w.text());
const findDropdownItemNamespacesText = () =>
findDropdownItemNamespaces().wrappers.map((w) => w.text());
const findDropdownAvatarUrls = () => findDropdownAvatars().wrappers.map((w) => w.props('src'));
describe('template', () => { describe('template', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -93,9 +109,19 @@ describe('Global Search Searchable Dropdown', () => { ...@@ -93,9 +109,19 @@ describe('Global Search Searchable Dropdown', () => {
expect(findLoader().exists()).toBe(false); expect(findLoader().exists()).toBe(false);
}); });
it('renders an instance for each namespace', () => { it('renders titles correctly including Any', () => {
const resultsIncludeAny = ['Any'].concat(MOCK_GROUPS.map((n) => n.full_name)); const resultsIncludeAny = ['Any'].concat(MOCK_GROUPS.map((n) => n[GROUP_DATA.name]));
expect(findDropdownItemsText()).toStrictEqual(resultsIncludeAny); expect(findDropdownItemTitlesText()).toStrictEqual(resultsIncludeAny);
});
it('renders namespaces truncated correctly', () => {
const namespaces = MOCK_GROUPS.map((n) => truncateNamespace(n[GROUP_DATA.fullName]));
expect(findDropdownItemNamespacesText()).toStrictEqual(namespaces);
});
it('renders GlAvatar for each item', () => {
const avatars = MOCK_GROUPS.map((n) => n.avatar_url);
expect(findDropdownAvatarUrls()).toStrictEqual(avatars);
}); });
}); });
...@@ -109,7 +135,7 @@ describe('Global Search Searchable Dropdown', () => { ...@@ -109,7 +135,7 @@ describe('Global Search Searchable Dropdown', () => {
}); });
it('renders only Any in dropdown', () => { it('renders only Any in dropdown', () => {
expect(findDropdownItemsText()).toStrictEqual(['Any']); expect(findDropdownItemTitlesText()).toStrictEqual(['Any']);
}); });
}); });
...@@ -140,8 +166,8 @@ describe('Global Search Searchable Dropdown', () => { ...@@ -140,8 +166,8 @@ describe('Global Search Searchable Dropdown', () => {
createComponent({}, { selectedItem: MOCK_GROUP }, mount); createComponent({}, { selectedItem: MOCK_GROUP }, mount);
}); });
it('sets dropdown text to the selectedItem selectedDisplayValue', () => { it('sets dropdown text to the selectedItem name', () => {
expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.selectedDisplayValue]); expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.name]);
}); });
}); });
}); });
......
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