diff options
Diffstat (limited to 'app/assets/javascripts/super_sidebar/components/global_search')
6 files changed, 193 insertions, 100 deletions
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue index 96e6c9bab9e..a1d0e400b5f 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue @@ -2,21 +2,25 @@ import { debounce } from 'lodash'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { GlDisclosureDropdownGroup, GlLoadingIcon } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { getFormattedItem } from '../utils'; + import { COMMON_HANDLES, COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE, ISSUE_HANDLE, - GLOBAL_COMMANDS_GROUP_TITLE, + PATH_HANDLE, PAGES_GROUP_TITLE, + PATH_GROUP_TITLE, GROUP_TITLES, + MAX_ROWS, } from './constants'; import SearchItem from './search_item.vue'; -import { commandMapper, linksReducer, autocompleteQuery } from './utils'; +import { commandMapper, linksReducer, autocompleteQuery, fileMapper } from './utils'; export default { name: 'CommandPaletteItems', @@ -25,7 +29,14 @@ export default { GlLoadingIcon, SearchItem, }, - inject: ['commandPaletteCommands', 'commandPaletteLinks', 'autocompletePath', 'searchContext'], + inject: [ + 'commandPaletteCommands', + 'commandPaletteLinks', + 'autocompletePath', + 'searchContext', + 'projectFilesPath', + 'projectBlobPath', + ], props: { searchQuery: { type: String, @@ -35,21 +46,45 @@ export default { type: String, required: true, validator: (value) => { - return COMMON_HANDLES.includes(value); + return [...COMMON_HANDLES, PATH_HANDLE].includes(value); }, }, }, data: () => ({ groups: [], - error: null, loading: false, + projectFiles: [], + debouncedSearch: debounce(function debouncedSearch() { + switch (this.handle) { + case COMMAND_HANDLE: + this.getCommandsAndPages(); + break; + /* TODO: Search for recent issues initiated by #(ISSUE_HANDLE) from the command palette scope + was removed as using the # in command palette conflicted + with the existing global search functionality to search for issue by its id. + The code that performs the Recent issues search was not removed from the code base + as it would be nice to bring it back when we decide how to combine both search by id and text. + In scope of https://gitlab.com/gitlab-org/gitlab/-/issues/417434 + we either bring back the search by #issue_text or remove the related code completely */ + case USER_HANDLE: + case PROJECT_HANDLE: + case ISSUE_HANDLE: + this.getScopedItems(); + break; + case PATH_HANDLE: + this.getProjectFiles(); + break; + default: + break; + } + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), }), computed: { isCommandMode() { return this.handle === COMMAND_HANDLE; }, - isUserMode() { - return this.handle === USER_HANDLE; + isPathMode() { + return this.handle === PATH_HANDLE; }, commands() { return this.commandPaletteCommands.map(commandMapper); @@ -62,7 +97,7 @@ export default { ? this.commands .map(({ name, items }) => { return { - name: name || GLOBAL_COMMANDS_GROUP_TITLE, + name, items: this.filterBySearchQuery(items, 'text'), }; }) @@ -73,7 +108,7 @@ export default { return this.groups?.length && this.groups.some((group) => group.items?.length); }, hasSearchQuery() { - if (this.isCommandMode) { + if (this.isCommandMode || this.isPathMode) { return this.searchQuery?.length > 0; } return this.searchQuery?.length > 2; @@ -84,44 +119,58 @@ export default { } return this.searchQuery; }, + filteredProjectFiles() { + if (!this.searchQuery) { + return this.projectFiles.slice(0, MAX_ROWS); + } + return this.filterBySearchQuery(this.projectFiles, 'text').slice(0, MAX_ROWS); + }, }, watch: { searchQuery: { handler() { - switch (this.handle) { - case COMMAND_HANDLE: - this.getCommandsAndPages(); - break; - case USER_HANDLE: - case PROJECT_HANDLE: - case ISSUE_HANDLE: - this.getScopedItems(); - break; - default: - break; - } + this.debouncedSearch(); }, immediate: true, }, }, + updated() { + this.$emit('updated'); + }, methods: { filterBySearchQuery(items, key = 'keywords') { return fuzzaldrinPlus.filter(items, this.searchQuery, { key }); }, + async getProjectFiles() { + if (!this.projectFiles.length) { + this.loading = true; + + try { + const response = await axios.get(this.projectFilesPath); + this.projectFiles = response?.data.map(fileMapper.bind(null, this.projectBlobPath)); + } catch (error) { + Sentry.captureException(error); + } finally { + this.loading = false; + } + } + + this.groups = [ + { + name: PATH_GROUP_TITLE, + items: this.filteredProjectFiles, + }, + ]; + }, getCommandsAndPages() { if (!this.searchQuery) { this.groups = [...this.commands]; return; } - const matchedLinks = this.filterBySearchQuery(this.links); - if (this.filteredCommands.length || matchedLinks.length) { - this.groups = []; - } + this.groups = [...this.filteredCommands]; - if (this.filteredCommands.length) { - this.groups = [...this.filteredCommands]; - } + const matchedLinks = this.filterBySearchQuery(this.links); if (matchedLinks.length) { this.groups.push({ @@ -130,62 +179,57 @@ export default { }); } }, - getScopedItems: debounce(function debouncedSearch() { - if (this.searchQuery && this.searchQuery.length < 3) return null; + async getScopedItems() { + if (this.searchQuery && this.searchQuery.length < 3) return; this.loading = true; - return axios - .get( + try { + const response = await axios.get( autocompleteQuery({ path: this.autocompletePath, searchTerm: this.searchTerm, handle: this.handle, projectId: this.searchContext.project?.id, }), - ) - .then(({ data }) => { - this.groups = this.getGroups(data); - }) - .catch((error) => { - this.error = error; - }) - .finally(() => { - this.loading = false; - }); - }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - getGroups(data) { - return [ - { - name: GROUP_TITLES[this.handle], - items: data.map(getFormattedItem), - }, - ]; + ); + + this.groups = [ + { + name: GROUP_TITLES[this.handle], + items: response.data.map(getFormattedItem), + }, + ]; + } catch (error) { + Sentry.captureException(error); + } finally { + this.loading = false; + } }, }, }; </script> <template> - <ul class="gl-p-0 gl-m-0 gl-list-style-none"> + <div> <gl-loading-icon v-if="loading" size="lg" class="gl-my-5" /> - <template v-else-if="hasResults"> + <ul v-else-if="hasResults" class="gl-p-0 gl-m-0 gl-list-style-none"> <gl-disclosure-dropdown-group v-for="(group, index) in groups" :key="index" :group="group" bordered - class="{'gl-mt-0!': index===0}" + :class="{ 'gl-mt-0!': index === 0 }" > <template #list-item="{ item }"> <search-item :item="item" :search-query="searchQuery" /> </template> </gl-disclosure-dropdown-group> - </template> + </ul> <div v-else-if="hasSearchQuery && !hasResults" class="gl-text-gray-700 gl-pl-5 gl-py-3"> {{ __('No results found') }} </div> - </ul> + </div> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js index 9dab16984f5..a43e621da44 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js @@ -2,19 +2,21 @@ import { s__, sprintf } from '~/locale'; export const COMMAND_HANDLE = '>'; export const USER_HANDLE = '@'; -export const PROJECT_HANDLE = '&'; +export const PROJECT_HANDLE = ':'; export const ISSUE_HANDLE = '#'; +export const PATH_HANDLE = '/'; -export const COMMON_HANDLES = [COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE, ISSUE_HANDLE]; +export const COMMON_HANDLES = [COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE]; export const SEARCH_OR_COMMAND_MODE_PLACEHOLDER = sprintf( s__( - 'CommandPalette|Type %{commandHandle} for command, %{userHandle} for user, %{projectHandle} for project, %{issueHandle} for issue or perform generic search...', + 'CommandPalette|Type %{commandHandle} for command, %{userHandle} for user, %{projectHandle} for project, %{pathHandle} for project file, or perform generic search...', ), { commandHandle: COMMAND_HANDLE, userHandle: USER_HANDLE, issueHandle: ISSUE_HANDLE, projectHandle: PROJECT_HANDLE, + pathHandle: PATH_HANDLE, }, false, ); @@ -24,6 +26,7 @@ export const SEARCH_SCOPE_PLACEHOLDER = { [USER_HANDLE]: s__('CommandPalette|user (enter at least 3 chars)'), [PROJECT_HANDLE]: s__('CommandPalette|project (enter at least 3 chars)'), [ISSUE_HANDLE]: s__('CommandPalette|issue (enter at least 3 chars)'), + [PATH_HANDLE]: s__('CommandPalette|go to project file'), }; export const SEARCH_SCOPE = { @@ -37,9 +40,13 @@ export const USERS_GROUP_TITLE = s__('GlobalSearch|Users'); export const PAGES_GROUP_TITLE = s__('CommandPalette|Pages'); export const PROJECTS_GROUP_TITLE = s__('GlobalSearch|Projects'); export const ISSUE_GROUP_TITLE = s__('GlobalSearch|Recent issues'); +export const PATH_GROUP_TITLE = s__('CommandPalette|Project files'); export const GROUP_TITLES = { [USER_HANDLE]: USERS_GROUP_TITLE, [PROJECT_HANDLE]: PROJECTS_GROUP_TITLE, [ISSUE_HANDLE]: ISSUE_GROUP_TITLE, + [PATH_HANDLE]: PATH_GROUP_TITLE, }; + +export const MAX_ROWS = 20; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue index dce2b24f551..efd93e88fa9 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue @@ -1,5 +1,5 @@ <script> -import { COMMON_HANDLES, SEARCH_SCOPE_PLACEHOLDER } from './constants'; +import { COMMON_HANDLES, PATH_HANDLE, SEARCH_SCOPE_PLACEHOLDER } from './constants'; export default { name: 'FakeSearchInput', @@ -11,7 +11,7 @@ export default { scope: { type: String, required: true, - validator: (value) => COMMON_HANDLES.includes(value), + validator: (value) => [...COMMON_HANDLES, PATH_HANDLE].includes(value), }, }, computed: { diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js index 5c8c0e59eaf..347a8ffb0b4 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js @@ -1,12 +1,12 @@ import { isNil, omitBy } from 'lodash'; -import { objectToQuery } from '~/lib/utils/url_utility'; -import { SEARCH_SCOPE } from './constants'; +import { objectToQuery, joinPaths } from '~/lib/utils/url_utility'; +import { SEARCH_SCOPE, GLOBAL_COMMANDS_GROUP_TITLE } from './constants'; export const commandMapper = ({ name, items }) => { // TODO: we filter out invite_members for now, because it is complicated to add the invite members modal here // and is out of scope for the basic command palette items. If it proves to be useful, we can add it later. return { - name, + name: name || GLOBAL_COMMANDS_GROUP_TITLE, items: items.filter(({ component }) => component !== 'invite_members'), }; }; @@ -32,6 +32,14 @@ export const linksReducer = (acc, menuItem) => { return acc; }; +export const fileMapper = (projectBlobPath, file) => { + return { + icon: 'doc-code', + text: file, + href: joinPaths(projectBlobPath, file), + }; +}; + export const autocompleteQuery = ({ path, searchTerm, handle, projectId }) => { const query = omitBy( { diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue index cb34f2b8c26..bec8c191b31 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue @@ -25,20 +25,24 @@ import { SEARCH_RESULTS_SCOPE, } from '~/vue_shared/global_search/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { darkModeEnabled } from '~/lib/utils/color_utils'; import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, SEARCH_SHORTCUTS_MIN_CHARACTERS, SCOPE_TOKEN_MAX_LENGTH, INPUT_FIELD_PADDING, - IS_SEARCHING, SEARCH_MODAL_ID, SEARCH_INPUT_SELECTOR, SEARCH_RESULTS_ITEM_SELECTOR, } from '../constants'; import CommandPaletteItems from '../command_palette/command_palette_items.vue'; import FakeSearchInput from '../command_palette/fake_search_input.vue'; -import { COMMON_HANDLES, SEARCH_OR_COMMAND_MODE_PLACEHOLDER } from '../command_palette/constants'; +import { + COMMON_HANDLES, + PATH_HANDLE, + SEARCH_OR_COMMAND_MODE_PLACEHOLDER, +} from '../command_palette/constants'; import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue'; import GlobalSearchDefaultItems from './global_search_default_items.vue'; import GlobalSearchScopedItems from './global_search_scoped_items.vue'; @@ -68,6 +72,11 @@ export default { FakeSearchInput, }, mixins: [glFeatureFlagMixin()], + data() { + return { + nextFocusedItemIndex: null, + }; + }, computed: { ...mapState(['search', 'loading', 'searchContext']), ...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']), @@ -108,34 +117,38 @@ export default { count: this.searchOptions.length, }); }, - searchBarClasses() { - return { - [IS_SEARCHING]: this.searchTermOverMin, - }; - }, - showScopeHelp() { + showScopeToken() { return this.searchTermOverMin && !this.isCommandMode; }, searchBarItem() { return this.searchOptions?.[0]; }, - infieldHelpContent() { + scopeTokenText() { return this.searchBarItem?.scope || this.searchBarItem?.description; }, - infieldHelpIcon() { - return this.searchBarItem?.icon; + scopeTokenIcon() { + if (!this.isCommandMode) { + return this.searchBarItem?.icon; + } + return null; }, - scopeTokenTitle() { + searchScope() { return sprintf(this.$options.i18n.SEARCH_RESULTS_SCOPE, { - scope: this.infieldHelpContent, + scope: this.scopeTokenText, }); }, - + truncatedSearchScope() { + return truncate(this.searchScope, SCOPE_TOKEN_MAX_LENGTH); + }, searchTextFirstChar() { return this.searchText?.trim().charAt(0); }, isCommandMode() { - return this.glFeatures?.commandPalette && COMMON_HANDLES.includes(this.searchTextFirstChar); + return ( + this.glFeatures?.commandPalette && + (COMMON_HANDLES.includes(this.searchTextFirstChar) || + (this.searchContext.project && this.searchTextFirstChar === PATH_HANDLE)) + ); }, commandPaletteQuery() { if (this.isCommandMode) { @@ -143,6 +156,14 @@ export default { } return ''; }, + commandHighlightClass() { + return darkModeEnabled() ? 'gl-bg-gray-10!' : 'gl-bg-gray-50!'; + }, + }, + watch: { + nextFocusedItemIndex() { + this.highlightFirstCommand(); + }, }, methods: { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), @@ -156,9 +177,6 @@ export default { this.fetchAutocompleteOptions(); } }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - getTruncatedScope(scope) { - return truncate(scope, SCOPE_TOKEN_MAX_LENGTH); - }, observeTokenWidth({ contentRect: { width } }) { const inputField = this.$refs?.searchInputBox?.$el?.querySelector('input'); if (!inputField) { @@ -206,7 +224,7 @@ export default { } }, focusSearchInput() { - this.$refs.searchInputBox.$el.querySelector('input').focus(); + this.$refs.searchInput.$el.querySelector('input').focus(); }, focusNextItem(event, elements, offset) { const { target } = event; @@ -221,11 +239,34 @@ export default { elements[index]?.focus(); }, submitSearch() { + if (this.isCommandMode) { + this.runFirstCommand(); + return; + } if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) { return; } visitUrl(this.searchQuery); }, + runFirstCommand() { + this.getFocusableOptions()[0]?.firstChild.click(); + }, + onSearchModalShown() { + this.$emit('shown'); + }, + onSearchModalHidden() { + this.searchText = ''; + this.$emit('hidden'); + }, + highlightFirstCommand() { + if (this.isCommandMode) { + const activeCommand = this.getFocusableOptions()[0]?.firstChild; + activeCommand?.classList.toggle( + this.commandHighlightClass, + Boolean(!this.nextFocusedItemIndex), + ); + } + }, }, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, @@ -243,24 +284,22 @@ export default { body-class="gl-p-0!" modal-class="global-search-modal" :centered="false" - @hidden="$emit('hidden')" - @shown="$emit('shown')" + @shown="onSearchModalShown" + @hide="onSearchModalHidden" > <form role="search" :aria-label="searchPlaceholder" class="gl-relative gl-rounded-base gl-w-full" - :class="searchBarClasses" data-testid="global-search-form" > <div class="gl-p-1 gl-relative"> <gl-search-box-by-type id="search" - ref="searchInputBox" + ref="searchInput" v-model="searchText" role="searchbox" data-testid="global-search-input" - data-qa-selector="global_search_input" autocomplete="off" :placeholder="searchPlaceholder" :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" @@ -270,24 +309,20 @@ export default { @keydown="onKeydown" /> <gl-token - v-if="showScopeHelp" + v-if="showScopeToken" v-gl-resize-observer-directive="observeTokenWidth" - class="in-search-scope-help gl-sm-display-block gl-display-none" + class="search-scope-help gl-absolute gl-sm-display-block gl-display-none" view-only - :title="scopeTokenTitle" + :title="searchScope" > <gl-icon - v-if="infieldHelpIcon" + v-if="scopeTokenIcon" class="gl-mr-2" - :aria-label="infieldHelpContent" - :name="infieldHelpIcon" + :aria-label="scopeTokenText" + :name="scopeTokenIcon" :size="16" /> - {{ - getTruncatedScope( - sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { scope: infieldHelpContent }), - ) - }} + {{ truncatedSearchScope }} </gl-token> <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only"> {{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }} @@ -319,6 +354,7 @@ export default { v-if="isCommandMode" :search-query="commandPaletteQuery" :handle="searchTextFirstChar" + @updated="highlightFirstCommand" /> <template v-else> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js index cb267df6122..5a860fcd1ab 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/constants.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js @@ -18,8 +18,6 @@ export const SCOPE_TOKEN_MAX_LENGTH = 36; export const INPUT_FIELD_PADDING = 84; -export const IS_SEARCHING = 'is-searching'; - export const FETCH_TYPES = ['generic', 'search']; export const SEARCH_MODAL_ID = 'super-sidebar-search-modal'; |