diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-12-08 21:14:31 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-12-08 21:14:31 +0300 |
commit | bb0d99269b1bee11939e6a3ddfcefed8c6fd4874 (patch) | |
tree | 58f5d3f64251e1847a1bfb77d76ead2abb16c899 /app/assets/javascripts/search/sidebar | |
parent | f1ce233e6ab6535afef76f10528e104672426710 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/search/sidebar')
8 files changed, 471 insertions, 12 deletions
diff --git a/app/assets/javascripts/search/sidebar/components/all_scopes_start_filters.vue b/app/assets/javascripts/search/sidebar/components/all_scopes_start_filters.vue new file mode 100644 index 00000000000..cb017b6898b --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/all_scopes_start_filters.vue @@ -0,0 +1,19 @@ +<script> +import GroupFilter from './group_filter.vue'; +import ProjectFilter from './project_filter.vue'; + +export default { + name: 'AllScopesStartFilters', + components: { + GroupFilter, + ProjectFilter, + }, +}; +</script> + +<template> + <div class="gl-px-5 gl-pt-6"> + <group-filter class="gl-mb-5" /> + <project-filter class="gl-mb-5" /> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index 307be0b0aa0..bbee0e441cc 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -25,6 +25,7 @@ import NotesFilters from './notes_filters.vue'; import CommitsFilters from './commits_filters.vue'; import MilestonesFilters from './milestones_filters.vue'; import WikiBlobsFilters from './wiki_blobs_filters.vue'; +import AllScopesStartFilters from './all_scopes_start_filters.vue'; export default { name: 'GlobalSearchSidebar', @@ -40,8 +41,16 @@ export default { DomElementListener, CommitsFilters, MilestonesFilters, + AllScopesStartFilters, }, mixins: [glFeatureFlagsMixin()], + props: { + headerText: { + required: false, + type: String, + default: '', + }, + }, computed: { ...mapState(['searchType']), ...mapGetters(['currentScope']), @@ -82,6 +91,13 @@ export default { <section> <dom-element-listener selector="#js-open-mobile-filters" @click="toggleFiltersFromSidebar" /> <sidebar-portal> + <all-scopes-start-filters /> + <div + v-if="headerText" + class="gl-px-5 gl-pt-3 gl-pb-2 gl-m-0 gl-reset-line-height gl-font-weight-bold gl-font-sm super-sidebar-context-header" + > + {{ headerText }} + </div> <scope-sidebar-navigation /> <issues-filters v-if="showIssuesFilters" /> <merge-requests-filters v-if="showMergeRequestFilters" /> diff --git a/app/assets/javascripts/search/sidebar/components/group_filter.vue b/app/assets/javascripts/search/sidebar/components/group_filter.vue new file mode 100644 index 00000000000..20231cdda6a --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/group_filter.vue @@ -0,0 +1,87 @@ +<script> +import { isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports +import { mapState, mapActions, mapGetters } from 'vuex'; +import { s__ } from '~/locale'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; +import SearchableDropdown from './searchable_dropdown.vue'; + +export default { + name: 'GroupFilter', + i18n: { + groupFieldLabel: s__('GlobalSearch|Group'), + }, + components: { + SearchableDropdown, + }, + data() { + return { + search: '', + labelId: 'group-filter-dropdown-id', + }; + }, + computed: { + ...mapState(['query', 'groups', 'fetchingGroups', 'groupInitialJson', 'useSidebarNavigation']), + ...mapGetters(['frequentGroups', 'currentScope']), + selectedGroup() { + return isEmpty(this.groupInitialJson) ? ANY_OPTION : this.groupInitialJson; + }, + }, + watch: { + search() { + this.debounceSearch(); + }, + }, + created() { + // This tracks groups searched via the top nav search bar + if (this.query.nav_source === 'navbar' && this.groupInitialJson?.id) { + this.setFrequentGroup(this.groupInitialJson); + } + }, + methods: { + ...mapActions(['fetchGroups', 'setFrequentGroup', 'loadFrequentGroups']), + firstLoad() { + this.loadFrequentGroups(); + this.fetchGroups(); + }, + handleGroupChange(group) { + // If group.id is null we are clearing the filter and don't need to store that in LS. + if (group.id) { + this.setFrequentGroup(group); + } + + visitUrl( + setUrlParams({ + [GROUP_DATA.queryParam]: group.id, + [PROJECT_DATA.queryParam]: null, + nav_source: null, + scope: this.currentScope, + }), + ); + }, + }, + GROUP_DATA, +}; +</script> + +<template> + <div> + <h5 :id="labelId" class="gl-mt-0 gl-mb-5 gl-font-sm"> + {{ $options.i18n.groupFieldLabel }} + </h5> + <searchable-dropdown + data-testid="group-filter" + :header-text="$options.GROUP_DATA.headerText" + :name="$options.GROUP_DATA.name" + :loading="fetchingGroups" + :selected-item="selectedGroup" + :items="groups" + :frequent-items="frequentGroups" + :search-handler="fetchGroups" + :label-id="labelId" + @first-open="firstLoad" + @change="handleGroupChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/project_filter.vue b/app/assets/javascripts/search/sidebar/components/project_filter.vue new file mode 100644 index 00000000000..76983644e60 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/project_filter.vue @@ -0,0 +1,94 @@ +<script> +import { isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports +import { mapState, mapActions, mapGetters } from 'vuex'; +import { s__ } from '~/locale'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/sidebar/constants'; +import SearchableDropdown from './searchable_dropdown.vue'; + +export default { + name: 'ProjectFilter', + i18n: { + projectFieldLabel: s__('GlobalSearch|Project'), + }, + components: { + SearchableDropdown, + }, + data() { + return { + search: '', + labelId: 'projects-filter-dropdown-id', + }; + }, + computed: { + ...mapState([ + 'query', + 'projects', + 'fetchingProjects', + 'projectInitialJson', + 'useSidebarNavigation', + ]), + ...mapGetters(['frequentProjects', 'currentScope']), + selectedProject() { + return isEmpty(this.projectInitialJson) ? ANY_OPTION : this.projectInitialJson; + }, + }, + watch: { + search() { + this.debounceSearch(); + }, + }, + created() { + // This tracks projects searched via the top nav search bar + if (this.query.nav_source === 'navbar' && this.projectInitialJson?.id) { + this.setFrequentProject(this.projectInitialJson); + } + }, + methods: { + ...mapActions(['fetchProjects', 'setFrequentProject', 'loadFrequentProjects']), + firstLoad() { + this.loadFrequentProjects(); + this.fetchProjects(); + }, + handleProjectChange(project) { + // If project.id is null we are clearing the filter and don't need to store that in LS. + if (project.id) { + this.setFrequentProject(project); + } + + // This determines if we need to update the group filter or not + const queryParams = { + ...(project.namespace?.id && { [GROUP_DATA.queryParam]: project.namespace.id }), + [PROJECT_DATA.queryParam]: project.id, + nav_source: null, + scope: this.currentScope, + }; + + visitUrl(setUrlParams(queryParams)); + }, + }, + PROJECT_DATA, +}; +</script> + +<template> + <div> + <h5 :id="labelId" class="gl-mt-0 gl-mb-5 gl-font-sm"> + {{ $options.i18n.projectFieldLabel }} + </h5> + <searchable-dropdown + data-testid="project-filter" + :header-text="$options.PROJECT_DATA.headerText" + :name="$options.PROJECT_DATA.name" + :loading="fetchingProjects" + :selected-item="selectedProject" + :items="projects" + :frequent-items="frequentProjects" + :search-handler="fetchProjects" + :label-id="labelId" + @first-open="firstLoad" + @change="handleProjectChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue index f30618ad9b7..874803a720d 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue @@ -2,6 +2,7 @@ // eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; +import eventHub from '~/super_sidebar/event_hub'; import NavItem from '~/super_sidebar/components/nav_item.vue'; import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants'; @@ -18,6 +19,8 @@ export default { ...mapGetters(['navigationItems']), }, created() { + eventHub.$emit('toggle-menu-header', false); + if (this.urlQuery?.search) { this.fetchSidebarCount(); } diff --git a/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue b/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue new file mode 100644 index 00000000000..c1f0bfc59f3 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue @@ -0,0 +1,222 @@ +<script> +import { GlCollapsibleListbox, GlAvatar } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import highlight from '~/lib/utils/highlight'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { __, s__, n__ } from '~/locale'; +import { ANY_OPTION } from '../constants'; + +export default { + name: 'SearchableDropdown', + components: { + GlAvatar, + GlCollapsibleListbox, + }, + directives: { + SafeHtml, + }, + i18n: { + frequentlySearched: __('Frequently searched'), + availableGroups: s__('GlobalSearch|All available groups'), + nothingFound: s__('GlobalSearch|Nothing found…'), + reset: s__('GlobalSearch|Reset'), + itemsFound(count) { + return n__('%d item found', '%d items found', count); + }, + }, + props: { + headerText: { + type: String, + required: false, + default: "__('Filter')", + }, + name: { + type: String, + required: false, + default: 'name', + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + selectedItem: { + type: Object, + required: true, + }, + items: { + type: Array, + required: false, + default: () => [], + }, + frequentItems: { + type: Array, + required: false, + default: () => [], + }, + searchHandler: { + type: Function, + required: true, + }, + labelId: { + type: String, + required: false, + default: 'labelId', + }, + }, + data() { + return { + searchText: '', + hasBeenOpened: false, + showableItems: [], + searchInProgress: false, + }; + }, + watch: { + items() { + if (this.searchText === '') { + this.showableItems = this.defaultItems(); + } + }, + }, + created() { + this.showableItems = this.defaultItems(); + }, + methods: { + defaultItems() { + const frequentItems = this.convertItemsFormat([...this.frequentItems]); + const nonFrequentItems = this.convertItemsFormat([ + ...this.uniqueItems(this.items, this.frequentItems), + ]); + + return [ + { + text: '', + options: [ + { + value: ANY_OPTION.name, + text: ANY_OPTION.name, + ...ANY_OPTION, + }, + ], + }, + { + text: this.$options.i18n.frequentlySearched, + options: frequentItems, + }, + { + text: this.$options.i18n.availableGroups, + options: nonFrequentItems, + }, + ].filter((group) => { + return group.options.length > 0; + }); + }, + search(search) { + this.searchText = search; + this.searchInProgress = true; + + if (search !== '') { + debounce(() => { + this.searchHandler(this.searchText); + this.showableItems = this.convertItemsFormat([...this.items]); + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS)(); + + return; + } + + this.showableItems = this.defaultItems(); + }, + openDropdown() { + if (!this.hasBeenOpened) { + this.hasBeenOpened = true; + this.$emit('first-open'); + } + }, + resetDropdown() { + this.$emit('change', ANY_OPTION); + }, + convertItemsFormat(items) { + return items.map((item) => ({ value: item.id, text: item.full_name, ...item })); + }, + truncatedNamespace(item) { + const itemDuplicat = { ...item }; + const namespaceWithFallback = itemDuplicat.name_with_namespace + ? itemDuplicat.name_with_namespace + : itemDuplicat.full_name; + + return truncateNamespace(namespaceWithFallback); + }, + highlightedItemName(item) { + return highlight(item.name, item.searchText); + }, + onSelectGroup(selected) { + if (selected === ANY_OPTION.name) { + this.$emit('change', ANY_OPTION); + return; + } + + const flatShowableItems = [...this.frequentItems, ...this.items]; + const newSelectedItem = flatShowableItems.find((item) => item.id === selected); + this.$emit('change', newSelectedItem); + }, + uniqueItems(allItems, frequentItems) { + return allItems.filter((item) => { + const itemNotIdentical = frequentItems.some((fitem) => fitem.id === item.id); + return Boolean(!itemNotIdentical); + }); + }, + }, + ANY_OPTION, + AVATAR_SHAPE_OPTION_RECT, +}; +</script> + +<template> + <gl-collapsible-listbox + :items="showableItems" + :header-text="headerText" + :toggle-text="selectedItem[name]" + :no-results-text="$options.i18n.nothingFound" + :selected="selectedItem.id" + :searching="loading" + :reset-button-label="$options.i18n.reset" + :toggle-aria-labelled-by="labelId" + searchable + block + @shown="openDropdown" + @search="search" + @select="onSelectGroup" + @reset="resetDropdown" + > + <template #search-summary-sr-only> + {{ $options.i18n.itemsFound(showableItems.length) }} + </template> + <template #list-item="{ item }"> + <div class="gl-display-flex gl-align-items-center"> + <gl-avatar + :src="item.avatar_url" + :entity-id="item.id" + :entity-name="item.name" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :size="32" + class="gl-mr-3" + aria-hidden="true" + /> + <div class="gl-display-flex gl-flex-direction-column"> + <span + v-safe-html="highlightedItemName(item)" + class="gl-font-weight-bold gl-white-space-nowrap" + data-testid="item-title" + ></span> + <span class="gl-font-sm gl-text-gray-700" data-testid="item-namespace"> + {{ truncatedNamespace(item) }}</span + > + </div> + </div> + </template> + </gl-collapsible-listbox> +</template> diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js index 95906c840d7..e3b0db670b5 100644 --- a/app/assets/javascripts/search/sidebar/constants/index.js +++ b/app/assets/javascripts/search/sidebar/constants/index.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const SCOPE_ISSUES = 'issues'; export const SCOPE_MERGE_REQUESTS = 'merge_requests'; export const SCOPE_BLOB = 'blobs'; @@ -26,3 +28,23 @@ export const TRACKING_LABEL_RESET = 'Reset Filters'; export const SEARCH_TYPE_BASIC = 'basic'; export const SEARCH_TYPE_ADVANCED = 'advanced'; export const SEARCH_TYPE_ZOEKT = 'zoekt'; + +export const ANY_OPTION = { + id: null, + name: __('Any'), + name_with_namespace: __('Any'), +}; + +export const GROUP_DATA = { + headerText: __('Filter results by group'), + queryParam: 'group_id', + name: 'name', + fullName: 'full_name', +}; + +export const PROJECT_DATA = { + headerText: __('Filter results by project'), + queryParam: 'project_id', + name: 'name', + fullName: 'name_with_namespace', +}; diff --git a/app/assets/javascripts/search/sidebar/index.js b/app/assets/javascripts/search/sidebar/index.js index 3a699355dc9..9a7472ccad3 100644 --- a/app/assets/javascripts/search/sidebar/index.js +++ b/app/assets/javascripts/search/sidebar/index.js @@ -4,27 +4,23 @@ import GlobalSearchSidebar from './components/app.vue'; Vue.use(Translate); -export const sidebarInitState = () => { - const el = document.getElementById('js-search-sidebar'); - if (!el) return {}; - - const { navigationJson, searchType } = el.dataset; - - const navigationJsonParsed = JSON.parse(navigationJson); - - return { navigationJsonParsed, searchType }; -}; - export const initSidebar = (store) => { const el = document.getElementById('js-search-sidebar'); + const hederEl = document.getElementById('super-sidebar-context-header'); + const headerText = hederEl.innerText; if (!el) return false; return new Vue({ el, + name: 'GlobalSearchSidebar', store, render(createElement) { - return createElement(GlobalSearchSidebar); + return createElement(GlobalSearchSidebar, { + props: { + headerText, + }, + }); }, }); }; |