diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-06-16 12:09:20 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-06-16 12:09:20 +0300 |
commit | 8ae36d93f1a63874b584f0488fde88c1fee999c4 (patch) | |
tree | f1788ba1a7fb00248ff008f817f6feea89304339 /app | |
parent | b394e58cc2e52e17e7991e3d7b705aa383c362ee (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
22 files changed, 521 insertions, 106 deletions
diff --git a/app/assets/javascripts/search/sidebar/components/issues_filters.vue b/app/assets/javascripts/search/sidebar/components/issues_filters.vue index 2ab5dfb8dea..8928f80d83a 100644 --- a/app/assets/javascripts/search/sidebar/components/issues_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/issues_filters.vue @@ -2,6 +2,7 @@ import { GlButton, GlLink } from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; import Tracking from '~/tracking'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { HR_DEFAULT_CLASSES, TRACKING_ACTION_CLICK, @@ -12,6 +13,8 @@ import { import { confidentialFilterData } from '../constants/confidential_filter_data'; import { stateFilterData } from '../constants/state_filter_data'; import ConfidentialityFilter from './confidentiality_filter.vue'; +import { labelFilterData } from './label_filter/data'; +import LabelFilter from './label_filter/index.vue'; import StatusFilter from './status_filter.vue'; export default { @@ -21,7 +24,9 @@ export default { GlLink, StatusFilter, ConfidentialityFilter, + LabelFilter, }, + mixins: [glFeatureFlagsMixin()], computed: { ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']), ...mapGetters(['currentScope']), @@ -34,6 +39,12 @@ export default { showStatusFilter() { return Object.values(stateFilterData.scopes).includes(this.currentScope); }, + showLabelFilter() { + return ( + Object.values(labelFilterData.scopes).includes(this.currentScope) && + this.glFeatures.searchIssueLabelAggregation + ); + }, hrClasses() { return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; }, @@ -61,7 +72,8 @@ export default { <hr v-if="!useNewNavigation" :class="hrClasses" /> <status-filter v-if="showStatusFilter" class="gl-mb-5" /> <confidentiality-filter v-if="showConfidentialityFilter" class="gl-mb-5" /> - <div class="gl-display-flex gl-align-items-center gl-mt-5"> + <label-filter v-if="showLabelFilter" /> + <div class="gl-display-flex gl-align-items-center gl-mt-4"> <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> {{ __('Apply') }} </gl-button> diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue new file mode 100644 index 00000000000..74855482b5d --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue @@ -0,0 +1,291 @@ +<script> +import { + GlSearchBoxByType, + GlLabel, + GlLoadingIcon, + GlDropdownDivider, + GlDropdownSectionHeader, + GlFormCheckboxGroup, + GlDropdownForm, + GlAlert, + GlOutsideDirective as Outside, +} from '@gitlab/ui'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { uniq } from 'lodash'; +import { rgbFromHex } from '@gitlab/ui/dist/utils/utils'; +import { slugify } from '~/lib/utils/text_utility'; +import { s__, sprintf } from '~/locale'; + +import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; + +import { + SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, + SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_DESCRIBED_BY_DEFAULT, + SEARCH_DESCRIBED_BY_UPDATED, + SEARCH_RESULTS_LOADING, +} from '~/vue_shared/global_search/constants'; + +import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants'; +import LabelDropdownItems from './label_dropdown_items.vue'; + +import { + FIRST_DROPDOWN_INDEX, + SEARCH_BOX_INDEX, + SEARCH_RESULTS_DESCRIPTION, + SEARCH_INPUT_DESCRIPTION, + labelFilterData, +} from './data'; + +import { trackSelectCheckbox, trackOpenDropdown } from './tracking'; + +export default { + name: 'LabelFilter', + directives: { Outside }, + components: { + DropdownKeyboardNavigation, + GlSearchBoxByType, + LabelDropdownItems, + GlLabel, + GlDropdownDivider, + GlDropdownSectionHeader, + GlFormCheckboxGroup, + GlDropdownForm, + GlLoadingIcon, + GlAlert, + }, + data() { + return { + currentFocusIndex: SEARCH_BOX_INDEX, + isFocused: false, + }; + }, + i18n: { + SEARCH_LABELS: s__('GlobalSearch|Search labels'), + DROPDOWN_HEADER: s__('GlobalSearch|Label(s)'), + AGGREGATIONS_ERROR_MESSAGE: s__('GlobalSearch|Fetching aggregations error.'), + SEARCH_DESCRIBED_BY_DEFAULT, + SEARCH_RESULTS_LOADING, + SEARCH_DESCRIBED_BY_UPDATED, + SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, + }, + computed: { + ...mapState(['useSidebarNavigation', 'searchLabelString', 'query', 'aggregations']), + ...mapGetters([ + 'filteredLabels', + 'filteredUnselectedLabels', + 'filteredAppliedSelectedLabels', + 'appliedSelectedLabels', + 'filteredUnappliedSelectedLabels', + ]), + searchInputDescribeBy() { + if (this.isLoggedIn) { + return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN; + } + return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN; + }, + dropdownResultsDescription() { + if (!this.showSearchDropdown) { + return ''; // This allows aria-live to see register an update when the dropdown is shown + } + + if (this.showDefaultItems) { + return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, { + count: this.filteredLabels.length, + }); + } + + return this.loading + ? this.$options.i18n.SEARCH_RESULTS_LOADING + : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, { + count: this.filteredLabels.length, + }); + }, + currentFocusedOption() { + return this.filteredLabels[this.currentFocusIndex] || null; + }, + currentFocusedId() { + return `${slugify(this.currentFocusedOption?.parent_full_name || 'undefined-name')}_${slugify( + this.currentFocusedOption?.title || 'undefined-title', + )}`; + }, + defaultIndex() { + if (this.showDefaultItems) { + return SEARCH_BOX_INDEX; + } + return FIRST_DROPDOWN_INDEX; + }, + hasSelectedLabels() { + return this.filteredAppliedSelectedLabels.length > 0; + }, + hasUnselectedLabels() { + return this.filteredUnselectedLabels.length > 0; + }, + dividerClasses() { + return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD]; + }, + labelSearchBox() { + return this.$refs.searchLabelInputBox?.$el.querySelector('[role=searchbox]'); + }, + combinedSelectedFilters() { + const appliedSelectedLabelKeys = this.appliedSelectedLabels.map((label) => label.key); + const { labels = [] } = this.query; + + return uniq([...appliedSelectedLabelKeys, ...labels]); + }, + searchLabels: { + get() { + return this.searchLabelString; + }, + set(value) { + this.setLabelFilterSearch({ value }); + }, + }, + selectedFilters: { + get() { + return this.combinedSelectedFilters; + }, + set(value) { + this.setQuery({ key: this.$options.labelFilterData?.filterParam, value }); + + trackSelectCheckbox(value); + }, + }, + }, + async created() { + await this.fetchAllAggregation(); + }, + methods: { + ...mapActions(['fetchAllAggregation', 'setQuery', 'closeLabel', 'setLabelFilterSearch']), + openDropdown() { + this.isFocused = true; + + trackOpenDropdown(); + }, + closeDropdown(event) { + const { target } = event; + + if (this.labelSearchBox !== target) { + this.isFocused = false; + } + }, + onLabelClose(event) { + if (!event?.target?.closest('.gl-label')?.dataset) { + return; + } + + const { key } = event.target.closest('.gl-label').dataset; + this.closeLabel({ key }); + }, + reactiveLabelColor(label) { + const { color, key } = label; + + return this.query?.labels?.some((labelKey) => labelKey === key) + ? color + : `rgba(${rgbFromHex(color)}, 0.3)`; + }, + isLabelClosable(label) { + const { key } = label; + return this.query?.labels?.some((labelKey) => labelKey === key); + }, + }, + FIRST_DROPDOWN_INDEX, + SEARCH_RESULTS_DESCRIPTION, + SEARCH_INPUT_DESCRIPTION, + labelFilterData, +}; +</script> + +<template> + <div class="gl-pb-0 gl-md-pt-0 label-filter gl-relative"> + <h5 + class="gl-my-0" + data-testid="label-filter-title" + :class="{ 'gl-font-sm': useSidebarNavigation }" + > + {{ $options.labelFilterData.header }} + </h5> + <div class="gl-my-5"> + <gl-label + v-for="label in appliedSelectedLabels" + :key="label.key" + class="gl-mr-2 gl-mb-2 gl-bg-gray-10" + :data-key="label.key" + :background-color="reactiveLabelColor(label)" + :title="label.title" + :show-close-button="isLabelClosable(label)" + @close="onLabelClose" + /> + </div> + <gl-search-box-by-type + ref="searchLabelInputBox" + v-model="searchLabels" + role="searchbox" + autocomplete="off" + :placeholder="$options.i18n.SEARCH_LABELS" + :aria-activedescendant="currentFocusedId" + :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" + @focusin="openDropdown" + @keydown.esc="closeDropdown" + /> + <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{ + searchInputDescribeBy + }}</span> + <span + role="region" + :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" + class="gl-sr-only" + aria-live="polite" + aria-atomic="true" + > + {{ dropdownResultsDescription }} + </span> + <div + v-if="isFocused" + v-outside="closeDropdown" + data-testid="header-search-dropdown-menu" + class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute 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 gl-mt-3 gl-z-index-1" + :class="{ + 'gl-max-w-none!': useSidebarNavigation, + 'gl-min-w-full!': useSidebarNavigation, + 'gl-w-full!': useSidebarNavigation, + }" + > + <div class="header-search-dropdown-content gl-py-2"> + <dropdown-keyboard-navigation + v-model="currentFocusIndex" + :max="filteredLabels.length - 1" + :min="$options.FIRST_DROPDOWN_INDEX" + :default-index="defaultIndex" + :enable-cycle="true" + /> + <div v-if="!aggregations.error"> + <gl-dropdown-section-header v-if="hasSelectedLabels || hasUnselectedLabels">{{ + $options.i18n.DROPDOWN_HEADER + }}</gl-dropdown-section-header> + <gl-dropdown-form> + <gl-form-checkbox-group v-model="selectedFilters"> + <label-dropdown-items + v-if="hasSelectedLabels" + data-testid="selected-lavel-items" + :labels="filteredAppliedSelectedLabels" + /> + <gl-dropdown-divider v-if="hasSelectedLabels && hasUnselectedLabels" /> + <label-dropdown-items + v-if="hasUnselectedLabels" + data-testid="unselected-lavel-items" + :labels="filteredUnselectedLabels" + /> + </gl-form-checkbox-group> + </gl-dropdown-form> + </div> + <gl-alert v-else :dismissible="false" variant="danger"> + {{ $options.i18n.AGGREGATIONS_ERROR_MESSAGE }} + </gl-alert> + <gl-loading-icon v-if="aggregations.fetching" size="lg" class="my-4" /> + </div> + </div> + <hr v-if="!useSidebarNavigation" :class="dividerClasses" /> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue b/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue new file mode 100644 index 00000000000..7a9e6a2e4fc --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue @@ -0,0 +1,43 @@ +<script> +import { GlFormCheckbox } from '@gitlab/ui'; + +export default { + name: 'LabelDropdownItems', + components: { + GlFormCheckbox, + }, + props: { + labels: { + type: Array, + required: true, + }, + }, +}; +</script> +<template> + <ul class="gl-list-style-none gl-px-0"> + <li + v-for="label in labels" + :id="label.key" + :ref="label.key" + :key="label.key" + :aria-label="label.title" + tabindex="-1" + class="gl-px-5 gl-py-3 label-filter-menu-item" + > + <gl-form-checkbox + class="label-with-color-checkbox gl-display-inline-flex gl-h-5 gl-min-h-5" + :value="label.key" + > + <span + data-testid="label-color-indicator" + class="gl-rounded-base gl-w-5 gl-h-5 gl-display-inline-block gl-vertical-align-bottom gl-mr-3" + :style="{ 'background-color': label.color }" + ></span> + <span class="gl-reset-text-align gl-m-0 gl-p-0 label-title">{{ + label.title + }}</span></gl-form-checkbox + > + </li> + </ul> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/label_filter/tracking.js new file mode 100644 index 00000000000..c38922a559c --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/label_filter/tracking.js @@ -0,0 +1,21 @@ +import Tracking from '~/tracking'; + +export const TRACKING_CATEGORY = 'Language filters'; +export const TRACKING_LABEL_FILTER = 'Label Key'; + +export const TRACKING_LABEL_DROPDOWN = 'Dropdown'; +export const TRACKING_LABEL_CHECKBOX = 'Label Checkbox'; + +export const TRACKING_ACTION_SELECT = 'search:agreggations:label:select'; +export const TRACKING_ACTION_SHOW = 'search:agreggations:label:show'; + +export const trackSelectCheckbox = (value) => + Tracking.event(TRACKING_ACTION_SELECT, TRACKING_LABEL_CHECKBOX, { + label: TRACKING_LABEL_FILTER, + property: value, + }); + +export const trackOpenDropdown = () => + Tracking.event(TRACKING_ACTION_SHOW, TRACKING_LABEL_DROPDOWN, { + label: TRACKING_LABEL_DROPDOWN, + }); diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue index e531abf523b..c10b14bd116 100644 --- a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue @@ -2,7 +2,6 @@ import { GlButton, GlAlert, GlForm } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { __, s__, sprintf } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants'; import { convertFiltersData } from '../../utils'; import CheckboxFilter from './checkbox_filter.vue'; @@ -24,7 +23,6 @@ export default { GlAlert, GlForm, }, - mixins: [glFeatureFlagsMixin()], data() { return { showAll: false, diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js index d31d2b5ae11..c7cb595f42f 100644 --- a/app/assets/javascripts/search/store/getters.js +++ b/app/assets/javascripts/search/store/getters.js @@ -41,8 +41,11 @@ export const filteredLabels = (state) => { export const filteredAppliedSelectedLabels = (state) => filteredLabels(state)?.filter((label) => state?.urlQuery?.labels?.includes(label.key)); -export const appliedSelectedLabels = (state) => - labelAggregationBuckets(state)?.filter((label) => state?.urlQuery?.labels?.includes(label.key)); +export const appliedSelectedLabels = (state) => { + return labelAggregationBuckets(state)?.filter((label) => + state?.urlQuery?.labels?.includes(label.key), + ); +}; export const filteredUnappliedSelectedLabels = (state) => filteredLabels(state)?.filter((label) => state?.query?.labels?.includes(label.key)); diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_autocomplete_item.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_autocomplete_item.vue deleted file mode 100644 index 6ca1e40f1f6..00000000000 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_autocomplete_item.vue +++ /dev/null @@ -1,37 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import highlight from '~/lib/utils/highlight'; - -export default { - name: 'CommandPaletteCommandAutocompleteItem', - components: { - GlIcon, - }, - directives: { - SafeHtml, - }, - props: { - command: { - type: Object, - required: true, - }, - searchQuery: { - type: String, - required: true, - }, - }, - computed: { - highlightedName() { - return highlight(this.command.text, this.searchQuery); - }, - }, -}; -</script> - -<template> - <div class="gl-display-flex gl-align-items-center"> - <gl-icon v-if="command.icon" class="gl-mr-3" :name="command.icon" /> - <span v-safe-html="highlightedName" class="gl-text-gray-900"></span> - </div> -</template> 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 4c1f3d3e0eb..96e6c9bab9e 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 @@ -3,30 +3,29 @@ import { debounce } from 'lodash'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { GlDisclosureDropdownGroup, GlLoadingIcon } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; -import { joinPaths } from '~/lib/utils/url_utility'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { getFormattedItem } from '../utils'; import { - USERS_ENDPOINT, COMMON_HANDLES, COMMAND_HANDLE, USER_HANDLE, + PROJECT_HANDLE, + ISSUE_HANDLE, GLOBAL_COMMANDS_GROUP_TITLE, - USERS_GROUP_TITLE, PAGES_GROUP_TITLE, + GROUP_TITLES, } from './constants'; -import { userMapper, commandMapper, linksReducer } from './utils'; -import UserAutocompleteItem from './user_autocomplete_item.vue'; -import CommandAutocompleteItem from './command_autocomplete_item.vue'; +import SearchItem from './search_item.vue'; +import { commandMapper, linksReducer, autocompleteQuery } from './utils'; export default { name: 'CommandPaletteItems', components: { GlDisclosureDropdownGroup, GlLoadingIcon, - UserAutocompleteItem, - CommandAutocompleteItem, + SearchItem, }, - inject: ['commandPaletteCommands', 'commandPaletteLinks'], + inject: ['commandPaletteCommands', 'commandPaletteLinks', 'autocompletePath', 'searchContext'], props: { searchQuery: { type: String, @@ -74,7 +73,16 @@ export default { return this.groups?.length && this.groups.some((group) => group.items?.length); }, hasSearchQuery() { - return this.searchQuery?.length; + if (this.isCommandMode) { + return this.searchQuery?.length > 0; + } + return this.searchQuery?.length > 2; + }, + searchTerm() { + if (this.handle === ISSUE_HANDLE) { + return `${ISSUE_HANDLE}${this.searchQuery}`; + } + return this.searchQuery; }, }, watch: { @@ -85,7 +93,9 @@ export default { this.getCommandsAndPages(); break; case USER_HANDLE: - this.getUsers(); + case PROJECT_HANDLE: + case ISSUE_HANDLE: + this.getScopedItems(); break; default: break; @@ -103,7 +113,6 @@ export default { this.groups = [...this.commands]; return; } - const matchedLinks = this.filterBySearchQuery(this.links); if (this.filteredCommands.length || matchedLinks.length) { @@ -121,24 +130,22 @@ export default { }); } }, - getUsers: debounce(function debouncedUserSearch() { + getScopedItems: debounce(function debouncedSearch() { if (this.searchQuery && this.searchQuery.length < 3) return null; this.loading = true; return axios - .get(joinPaths(gon.relative_url_root || '', USERS_ENDPOINT), { - params: { - search: this.searchQuery, - }, - }) + .get( + autocompleteQuery({ + path: this.autocompletePath, + searchTerm: this.searchTerm, + handle: this.handle, + projectId: this.searchContext.project?.id, + }), + ) .then(({ data }) => { - this.groups = [ - { - name: USERS_GROUP_TITLE, - items: data.map(userMapper), - }, - ]; + this.groups = this.getGroups(data); }) .catch((error) => { this.error = error; @@ -147,6 +154,14 @@ export default { this.loading = false; }); }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + getGroups(data) { + return [ + { + name: GROUP_TITLES[this.handle], + items: data.map(getFormattedItem), + }, + ]; + }, }, }; </script> @@ -164,12 +179,7 @@ export default { class="{'gl-mt-0!': index===0}" > <template #list-item="{ item }"> - <user-autocomplete-item v-if="isUserMode" :user="item" :search-query="searchQuery" /> - <command-autocomplete-item - v-if="isCommandMode" - :command="item" - :search-query="searchQuery" - /> + <search-item :item="item" :search-query="searchQuery" /> </template> </gl-disclosure-dropdown-group> </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 a3bb102f6f9..9dab16984f5 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 @@ -1,26 +1,45 @@ import { s__, sprintf } from '~/locale'; -export const USERS_ENDPOINT = '/-/autocomplete/users.json'; export const COMMAND_HANDLE = '>'; export const USER_HANDLE = '@'; +export const PROJECT_HANDLE = '&'; +export const ISSUE_HANDLE = '#'; -export const COMMON_HANDLES = [COMMAND_HANDLE, USER_HANDLE]; +export const COMMON_HANDLES = [COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE, ISSUE_HANDLE]; export const SEARCH_OR_COMMAND_MODE_PLACEHOLDER = sprintf( s__( - 'CommandPalette|Type %{commandHandle} for command, %{userHandle} for user or perform generic search...', + 'CommandPalette|Type %{commandHandle} for command, %{userHandle} for user, %{projectHandle} for project, %{issueHandle} for issue or perform generic search...', ), { commandHandle: COMMAND_HANDLE, userHandle: USER_HANDLE, + issueHandle: ISSUE_HANDLE, + projectHandle: PROJECT_HANDLE, }, false, ); -export const SEARCH_SCOPE = { +export const SEARCH_SCOPE_PLACEHOLDER = { [COMMAND_HANDLE]: s__('CommandPalette|command'), [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)'), +}; + +export const SEARCH_SCOPE = { + [USER_HANDLE]: 'user', + [PROJECT_HANDLE]: 'project', + [ISSUE_HANDLE]: 'issue', }; export const GLOBAL_COMMANDS_GROUP_TITLE = s__('CommandPalette|Global Commands'); -export const USERS_GROUP_TITLE = s__('CommandPalette|Users'); +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 GROUP_TITLES = { + [USER_HANDLE]: USERS_GROUP_TITLE, + [PROJECT_HANDLE]: PROJECTS_GROUP_TITLE, + [ISSUE_HANDLE]: ISSUE_GROUP_TITLE, +}; 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 201d21f56fe..dce2b24f551 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 } from './constants'; +import { COMMON_HANDLES, SEARCH_SCOPE_PLACEHOLDER } from './constants'; export default { name: 'FakeSearchInput', @@ -16,7 +16,7 @@ export default { }, computed: { placeholder() { - return SEARCH_SCOPE[this.scope]; + return SEARCH_SCOPE_PLACEHOLDER[this.scope]; }, }, }; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/user_autocomplete_item.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/search_item.vue index 6a22df79c0f..b940c7c24c6 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/user_autocomplete_item.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/search_item.vue @@ -1,19 +1,20 @@ <script> -import { GlAvatar } from '@gitlab/ui'; +import { GlAvatar, GlIcon } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; export default { - name: 'CommandPaletteUserAutocompleteItem', + name: 'CommandPaletteSearchItem', components: { GlAvatar, + GlIcon, }, directives: { SafeHtml, }, props: { - user: { + item: { type: Object, required: true, }, @@ -24,7 +25,7 @@ export default { }, computed: { highlightedName() { - return highlight(this.user.text, this.searchQuery); + return highlight(this.item.text, this.searchQuery); }, }, AVATAR_SHAPE_OPTION_RECT, @@ -34,18 +35,23 @@ export default { <template> <div class="gl-display-flex gl-align-items-center"> <gl-avatar - v-if="user.avatar_url" + v-if="item.avatar_url !== undefined" class="gl-mr-3" - :src="user.avatar_url" - :entity-id="user.id" - :entity-name="user.text" - :size="16" + :src="item.avatar_url" + :entity-id="item.entity_id" + :entity-name="item.entity_name" + :size="item.avatar_size" :shape="$options.AVATAR_SHAPE_OPTION_RECT" aria-hidden="true" /> + <gl-icon v-if="item.icon" class="gl-mr-3" :name="item.icon" /> <span class="gl-display-flex gl-flex-direction-column"> <span v-safe-html="highlightedName" class="gl-text-gray-900"></span> - <span v-safe-html="user.username" class="gl-font-sm gl-text-gray-500"></span> + <span + v-if="item.namespace" + v-safe-html="item.namespace" + class="gl-font-sm gl-text-gray-500" + ></span> </span> </div> </template> 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 be1edcc6e16..5c8c0e59eaf 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,8 +1,6 @@ -export const userMapper = ({ name: text, web_url: href, ...user } = {}) => ({ - text, - href, - ...user, -}); +import { isNil, omitBy } from 'lodash'; +import { objectToQuery } from '~/lib/utils/url_utility'; +import { SEARCH_SCOPE } 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 @@ -33,3 +31,17 @@ export const linksReducer = (acc, menuItem) => { } return acc; }; + +export const autocompleteQuery = ({ path, searchTerm, handle, projectId }) => { + const query = omitBy( + { + term: searchTerm, + project_id: projectId, + filter: 'search', + scope: SEARCH_SCOPE[handle], + }, + isNil, + ); + + return `${path}?${objectToQuery(query)}`; +}; diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index 7f49d261bcb..f6afde02fa5 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -89,6 +89,8 @@ export const initSuperSidebar = () => { ...getTrialStatusWidgetData(sidebarData), commandPaletteCommands, commandPaletteLinks, + autocompletePath, + searchContext, }, store: createStore({ searchPath, diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue index 04dc3b20dff..6764ad4ce73 100644 --- a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue @@ -9,6 +9,11 @@ export default { GlCollapsibleListbox, }, props: { + headerText: { + type: String, + required: false, + default: '', + }, value: { type: String, required: true, @@ -98,6 +103,7 @@ export default { type="hidden" /> <gl-collapsible-listbox + :header-text="headerText" :items="filteredListboxItems" :toggle-text="selectedTimezoneLabel" :toggle-class="additionalClass" diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index e57dad9e4cb..5fdab7891ec 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -105,6 +105,7 @@ padding: 5px; box-shadow: none; width: 100%; + resize: none !important; } .md-suggestion-diff { diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index a3c6499bc54..45aefe48538 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -34,6 +34,7 @@ class SearchController < ApplicationController before_action only: :show do update_scope_for_code_search end + rescue_from ActiveRecord::QueryCanceled, with: :render_timeout layout 'search' @@ -111,11 +112,12 @@ class SearchController < ApplicationController @project = search_service.project @ref = params[:project_ref] if params[:project_ref].present? @filter = params[:filter] + @scope = params[:scope] # Cache the response on the frontend expires_in 1.minute - render json: Gitlab::Json.dump(search_autocomplete_opts(term, filter: @filter)) + render json: Gitlab::Json.dump(search_autocomplete_opts(term, filter: @filter, scope: @scope)) end def opensearch diff --git a/app/finders/namespaces/projects_finder.rb b/app/finders/namespaces/projects_finder.rb index c96f9527dd8..0194ee40801 100644 --- a/app/finders/namespaces/projects_finder.rb +++ b/app/finders/namespaces/projects_finder.rb @@ -32,6 +32,8 @@ module Namespaces namespace.projects.with_route end + collection = collection.not_aimed_for_deletion if params[:not_aimed_for_deletion].present? + collection = filter_projects(collection) sort(collection) diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb index 726e78f9971..f0781058bea 100644 --- a/app/graphql/resolvers/namespace_projects_resolver.rb +++ b/app/graphql/resolvers/namespace_projects_resolver.rb @@ -7,6 +7,11 @@ module Resolvers default_value: false, description: 'Include also subgroup projects.' + argument :not_aimed_for_deletion, GraphQL::Types::Boolean, + required: false, + default_value: false, + description: 'Include projects that are not aimed for deletion.' + argument :search, GraphQL::Types::String, required: false, default_value: nil, @@ -60,6 +65,7 @@ module Resolvers def finder_params(args) { include_subgroups: args.dig(:include_subgroups), + not_aimed_for_deletion: args.dig(:not_aimed_for_deletion), sort: args.dig(:sort), search: args.dig(:search), ids: parse_gids(args.dig(:ids)), diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 1d58239aea9..ecaa65dd129 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -14,12 +14,12 @@ module SearchHelper :project_ids ].freeze - def search_autocomplete_opts(term, filter: nil) + def search_autocomplete_opts(term, filter: nil, scope: nil) return unless current_user results = case filter&.to_sym when :search - resource_results(term) + resource_results(term, scope: scope) when :generic [ recent_items_autocomplete(term), @@ -36,8 +36,9 @@ module SearchHelper results.flatten { |item| item[:label] } end - def resource_results(term) + def resource_results(term, scope: nil) return [] if term.length < Gitlab::Search::Params::MIN_TERM_LENGTH + return scope_specific_results(term, scope) if scope.present? [ groups_autocomplete(term), @@ -47,6 +48,19 @@ module SearchHelper ].flatten end + def scope_specific_results(term, scope) + case scope&.to_sym + when :project + projects_autocomplete(term) + when :user + users_autocomplete(term) + when :issue + recent_issues_autocomplete(term) + else + [] + end + end + def generic_results(term) search_pattern = Regexp.new(Regexp.escape(term), "i") diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 2ce45b90330..116108ceaf9 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -2055,6 +2055,10 @@ class MergeRequest < ApplicationRecord NewMergeRequestWorker.perform_async(id, author_id) end + def check_for_spam?(*) + spammable_attribute_changed? && project.public? + end + private attr_accessor :skip_fetch_ref diff --git a/app/views/shared/runners/_runner_details.html.haml b/app/views/shared/runners/_runner_details.html.haml index 686cd1a081b..30e5587c413 100644 --- a/app/views/shared/runners/_runner_details.html.haml +++ b/app/views/shared/runners/_runner_details.html.haml @@ -1,4 +1,4 @@ -%h1.page-title.gl-font-size-h-display +%h1.page-title.gl-font-size-h-display.gl-display-flex.gl-align-items-center = s_('Runners|Runner #%{runner_id}') % { runner_id: runner.id } = render 'shared/runners/runner_type_badge', runner: runner diff --git a/app/views/shared/runners/_runner_type_badge.html.haml b/app/views/shared/runners/_runner_type_badge.html.haml index a8a93f3dd76..9930f60b755 100644 --- a/app/views/shared/runners/_runner_type_badge.html.haml +++ b/app/views/shared/runners/_runner_type_badge.html.haml @@ -1,7 +1,7 @@ - -- if runner.instance_type? - = gl_badge_tag s_('Runners|shared'), variant: :success -- elsif runner.group_type? - = gl_badge_tag s_('Runners|group'), variant: :success -- else - = gl_badge_tag s_('Runners|project'), variant: :info +.gl-ml-2 + - if runner.instance_type? + = gl_badge_tag s_('Runners|shared'), variant: :success + - elsif runner.group_type? + = gl_badge_tag s_('Runners|group'), variant: :success + - else + = gl_badge_tag s_('Runners|project'), variant: :info |