diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-20 18:40:28 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-20 18:40:28 +0300 |
commit | b595cb0c1dec83de5bdee18284abe86614bed33b (patch) | |
tree | 8c3d4540f193c5ff98019352f554e921b3a41a72 /app/assets/javascripts/header_search | |
parent | 2f9104a328fc8a4bddeaa4627b595166d24671d0 (diff) |
Add latest changes from gitlab-org/gitlab@15-2-stable-eev15.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/header_search')
5 files changed, 215 insertions, 34 deletions
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index adf304aebc7..0c4f9640972 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -1,8 +1,17 @@ <script> -import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui'; +import { + GlSearchBoxByType, + GlOutsideDirective as Outside, + GlIcon, + GlToken, + GlSafeHtmlDirective as SafeHtml, + GlTooltipDirective, + GlResizeObserverDirective, +} from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { debounce } from 'lodash'; import { visitUrl } from '~/lib/utils/url_utility'; +import { truncate } from '~/lib/utils/text_utility'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { s__, sprintf } from '~/locale'; import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; @@ -12,6 +21,8 @@ import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, SEARCH_SHORTCUTS_MIN_CHARACTERS, + SCOPE_TOKEN_MAX_LENGTH, + INPUT_FIELD_PADDING, } from '../constants'; import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from './header_search_default_items.vue'; @@ -34,14 +45,22 @@ export default { 'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.', ), searchResultsLoading: s__('GlobalSearch|Search results are loading'), + searchResultsScope: s__('GlobalSearch|in %{scope}'), + kbdHelp: sprintf( + s__('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search'), + { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, + false, + ), }, - directives: { Outside }, + directives: { SafeHtml, Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, components: { GlSearchBoxByType, HeaderSearchDefaultItems, HeaderSearchScopedItems, HeaderSearchAutocompleteItems, DropdownKeyboardNavigation, + GlIcon, + GlToken, }, data() { return { @@ -50,8 +69,8 @@ export default { }; }, computed: { - ...mapState(['search', 'loading']), - ...mapGetters(['searchQuery', 'searchOptions', 'autocompleteGroupedSearchOptions']), + ...mapState(['search', 'loading', 'searchContext']), + ...mapGetters(['searchQuery', 'searchOptions']), searchText: { get() { return this.search; @@ -70,16 +89,17 @@ export default { return Boolean(gon?.current_username); }, showSearchDropdown() { - const hasResultsUnderMinCharacters = - this.searchText?.length === 1 ? this?.autocompleteGroupedSearchOptions?.length > 0 : true; + if (!this.showDropdown || !this.isLoggedIn) { + return false; + } - return this.showDropdown && this.isLoggedIn && hasResultsUnderMinCharacters; + return this.searchOptions?.length > 0; }, showDefaultItems() { return !this.searchText; }, - showShortcuts() { - return this.searchText && this.searchText?.length >= SEARCH_SHORTCUTS_MIN_CHARACTERS; + showScopes() { + return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; }, defaultIndex() { if (this.showDefaultItems) { @@ -88,11 +108,11 @@ export default { return FIRST_DROPDOWN_INDEX; }, + searchInputDescribeBy() { if (this.isLoggedIn) { return this.$options.i18n.searchInputDescribeByWithDropdown; } - return this.$options.i18n.searchInputDescribeByNoDropdown; }, dropdownResultsDescription() { @@ -112,8 +132,26 @@ export default { count: this.searchOptions.length, }); }, - headerSearchActivityDescriptor() { - return this.showDropdown ? 'is-active' : 'is-not-active'; + searchBarStateIndicator() { + const hasIcon = + this.searchContext?.project || this.searchContext?.group ? 'has-icon' : 'has-no-icon'; + const isSearching = this.showScopes ? 'is-searching' : 'is-not-searching'; + const isActive = this.showSearchDropdown ? 'is-active' : 'is-not-active'; + return `${isActive} ${isSearching} ${hasIcon}`; + }, + searchBarItem() { + return this.searchOptions?.[0]; + }, + infieldHelpContent() { + return this.searchBarItem?.scope || this.searchBarItem?.description; + }, + infieldHelpIcon() { + return this.searchBarItem?.icon; + }, + scopeTokenTitle() { + return sprintf(this.$options.i18n.searchResultsScope, { + scope: this.infieldHelpContent, + }); }, }, methods: { @@ -127,6 +165,9 @@ export default { this.$emit('toggleDropdown', this.showDropdown); }, submitSearch() { + if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) { + return null; + } return visitUrl(this.currentFocusedOption?.url || this.searchQuery); }, getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) { @@ -136,8 +177,19 @@ 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) { + return; + } + inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`; + }, }, SEARCH_BOX_INDEX, + FIRST_DROPDOWN_INDEX, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, }; @@ -149,10 +201,12 @@ export default { role="search" :aria-label="$options.i18n.searchGitlab" class="header-search gl-relative gl-rounded-base gl-w-full" - :class="headerSearchActivityDescriptor" + :class="searchBarStateIndicator" + data-testid="header-search-form" > <gl-search-box-by-type id="search" + ref="searchInputBox" v-model="searchText" role="searchbox" class="gl-z-index-1" @@ -165,7 +219,34 @@ export default { @click="openDropdown" @input="getAutocompleteOptions" @keydown.enter.stop.prevent="submitSearch" + @keydown.esc.stop.prevent="closeDropdown" /> + <gl-token + v-if="showScopes" + v-gl-resize-observer-directive="observeTokenWidth" + class="in-search-scope-help" + :view-only="true" + :title="scopeTokenTitle" + ><gl-icon + v-if="infieldHelpIcon" + class="gl-mr-2" + :aria-label="infieldHelpContent" + :name="infieldHelpIcon" + :size="16" + />{{ + getTruncatedScope( + sprintf($options.i18n.searchResultsScope, { + scope: infieldHelpContent, + }), + ) + }} + </gl-token> + <kbd + v-gl-tooltip.bottom.hover.html + class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper" + :title="$options.i18n.kbdHelp" + >/</kbd + > <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{ searchInputDescribeBy }}</span> @@ -187,7 +268,7 @@ export default { <dropdown-keyboard-navigation v-model="currentFocusIndex" :max="searchOptions.length - 1" - :min="$options.SEARCH_BOX_INDEX" + :min="$options.FIRST_DROPDOWN_INDEX" :default-index="defaultIndex" @tab="closeDropdown" /> @@ -197,7 +278,7 @@ export default { /> <template v-else> <header-search-scoped-items - v-if="showShortcuts" + v-if="showScopes" :current-focused-option="currentFocusedOption" /> <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue index 34d1bd71399..f5be1bcb786 100644 --- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue @@ -1,13 +1,16 @@ <script> -import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; -import { __, sprintf } from '~/locale'; +import { s__, sprintf } from '~/locale'; +import { truncate } from '~/lib/utils/text_utility'; +import { SCOPE_TOKEN_MAX_LENGTH } from '../constants'; export default { name: 'HeaderSearchScopedItems', components: { GlDropdownItem, - GlDropdownDivider, + GlIcon, + GlToken, }, props: { currentFocusedOption: { @@ -25,12 +28,21 @@ export default { return this.currentFocusedOption?.html_id === option.html_id; }, ariaLabel(option) { - return sprintf(__('%{search} %{description} %{scope}'), { + return sprintf(s__('GlobalSearch| %{search} %{description} %{scope}'), { search: this.search, - description: option.description, + description: option.description || option.icon, scope: option.scope || '', }); }, + titleLabel(option) { + return sprintf(s__('GlobalSearch|in %{scope}'), { + search: this.search, + scope: option.scope || option.description, + }); + }, + getTruncatedScope(scope) { + return truncate(scope, SCOPE_TOKEN_MAX_LENGTH); + }, }, }; </script> @@ -42,18 +54,30 @@ export default { :id="option.html_id" :ref="option.html_id" :key="option.html_id" + class="gl-max-w-full" :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" :aria-selected="isOptionFocused(option)" :aria-label="ariaLabel(option)" tabindex="-1" :href="option.url" + :title="titleLabel(option)" > - <span aria-hidden="true"> - "<span class="gl-font-weight-bold">{{ search }}</span - >" {{ option.description }} - <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span> + <span + ref="token-text-content" + class="gl-display-flex gl-justify-content-start search-text-content gl-line-height-24 gl-align-items-start gl-flex-direction-row gl-w-full" + > + <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-relative gl-pt-2" /> + <span class="gl-flex-grow-1 gl-relative"> + <gl-token + class="in-dropdown-scope-help has-icon gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right gl-mr-n3!" + :view-only="true" + > + <gl-icon v-if="option.icon" :name="option.icon" class="gl-mr-2" /> + <span>{{ getTruncatedScope(titleLabel(option)) }}</span> + </gl-token> + {{ search }} + </span> </span> </gl-dropdown-item> - <gl-dropdown-divider v-if="autocompleteGroupedSearchOptions.length > 0" /> </div> </template> diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index 045a552efb0..a026386b2bd 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -10,15 +10,21 @@ export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a re export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created"); -export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|in all GitLab'); +export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|all GitLab'); -export const MSG_IN_GROUP = s__('GlobalSearch|in group'); +export const MSG_IN_GROUP = s__('GlobalSearch|group'); -export const MSG_IN_PROJECT = s__('GlobalSearch|in project'); +export const MSG_IN_PROJECT = s__('GlobalSearch|project'); -export const GROUPS_CATEGORY = 'Groups'; +export const ICON_PROJECT = 'project'; -export const PROJECTS_CATEGORY = 'Projects'; +export const ICON_GROUP = 'group'; + +export const ICON_SUBGROUP = 'subgroup'; + +export const GROUPS_CATEGORY = s__('GlobalSearch|Groups'); + +export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects'); export const ISSUES_CATEGORY = 'Recent issues'; @@ -39,3 +45,9 @@ export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2; export const SEARCH_INPUT_DESCRIPTION = 'search-input-description'; export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description'; + +export const SCOPE_TOKEN_MAX_LENGTH = 36; + +export const INPUT_FIELD_PADDING = 52; + +export const HEADER_INIT_EVENTS = ['input', 'focus']; diff --git a/app/assets/javascripts/header_search/init.js b/app/assets/javascripts/header_search/init.js new file mode 100644 index 00000000000..4e9404007ec --- /dev/null +++ b/app/assets/javascripts/header_search/init.js @@ -0,0 +1,53 @@ +import * as Sentry from '@sentry/browser'; +import { HEADER_INIT_EVENTS } from './constants'; + +async function eventHandler(callback = () => {}) { + if (this.newHeaderSearchFeatureFlag) { + const { initHeaderSearchApp } = await import( + /* webpackChunkName: 'globalSearch' */ '~/header_search' + ).catch((error) => Sentry.captureException(error)); + + // In case the user started searching before we bootstrapped, + // let's pass the search along. + const initialSearchValue = this.searchInputBox.value; + initHeaderSearchApp(initialSearchValue); + + // this is new #search input element. We need to re-find it. + // And re-focus in it. + document.querySelector('#search').focus(); + callback(); + return; + } + + const { default: initSearchAutocomplete } = await import( + /* webpackChunkName: 'globalSearch' */ '../search_autocomplete' + ).catch((error) => Sentry.captureException(error)); + + const searchDropdown = initSearchAutocomplete(); + searchDropdown.onSearchInputFocus(); + callback(); +} + +function cleanEventListeners() { + HEADER_INIT_EVENTS.forEach((eventType) => { + document.querySelector('#search').removeEventListener(eventType, eventHandler); + }); +} + +function initHeaderSearch() { + const searchInputBox = document.querySelector('#search'); + + HEADER_INIT_EVENTS.forEach((eventType) => { + searchInputBox?.addEventListener( + eventType, + eventHandler.bind( + { searchInputBox, newHeaderSearchFeatureFlag: gon?.features?.newHeaderSearch }, + cleanEventListeners, + ), + { once: true }, + ); + }); +} + +export default initHeaderSearch; +export { eventHandler, cleanEventListeners }; diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js index 7d08aa859fb..da7bccd35c0 100644 --- a/app/assets/javascripts/header_search/store/getters.js +++ b/app/assets/javascripts/header_search/store/getters.js @@ -7,9 +7,13 @@ import { MSG_MR_ASSIGNED_TO_ME, MSG_MR_IM_REVIEWER, MSG_MR_IVE_CREATED, - MSG_IN_PROJECT, - MSG_IN_GROUP, + ICON_GROUP, + ICON_SUBGROUP, + ICON_PROJECT, MSG_IN_ALL_GITLAB, + PROJECTS_CATEGORY, + GROUPS_CATEGORY, + SEARCH_SHORTCUTS_MIN_CHARACTERS, } from '../constants'; export const searchQuery = (state) => { @@ -149,7 +153,8 @@ export const scopedSearchOptions = (state, getters) => { options.push({ html_id: 'scoped-in-project', scope: state.searchContext.project?.name || '', - description: MSG_IN_PROJECT, + scopeCategory: PROJECTS_CATEGORY, + icon: ICON_PROJECT, url: getters.projectUrl, }); } @@ -158,7 +163,8 @@ export const scopedSearchOptions = (state, getters) => { options.push({ html_id: 'scoped-in-group', scope: state.searchContext.group?.name || '', - description: MSG_IN_GROUP, + scopeCategory: GROUPS_CATEGORY, + icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP, url: getters.groupUrl, }); } @@ -190,6 +196,7 @@ export const autocompleteGroupedSearchOptions = (state) => { results.push(groupedOptions[option.category]); } }); + return results; }; @@ -205,5 +212,9 @@ export const searchOptions = (state, getters) => { [], ); + if (state.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) { + return sortedAutocompleteOptions; + } + return getters.scopedSearchOptions.concat(sortedAutocompleteOptions); }; |