diff options
Diffstat (limited to 'app')
40 files changed, 751 insertions, 599 deletions
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index 6a7ce4f1c41..301dd1c5669 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -204,7 +204,11 @@ export default class Shortcuts { } static focusSearch(e) { - $('#search').focus(); + if (gon.use_new_navigation) { + document.querySelector('#super-sidebar-search')?.click(); + } else { + document.querySelector('#search')?.focus(); + } if (e.preventDefault) { e.preventDefault(); diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 9cb96283689..5a002784937 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -135,6 +135,8 @@ function initNewNavToggle() { }); } -requestIdleCallback(initStatusTriggers); +if (!gon?.use_new_navigation) { + requestIdleCallback(initStatusTriggers); +} requestIdleCallback(initNavUserDropdownTracking); requestIdleCallback(initNewNavToggle); diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js index 7da3bab0a4b..520d7f627f6 100644 --- a/app/assets/javascripts/lib/utils/chart_utils.js +++ b/app/assets/javascripts/lib/utils/chart_utils.js @@ -1,3 +1,6 @@ +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import { __ } from '~/locale'; + const commonTooltips = () => ({ mode: 'x', intersect: false, @@ -98,3 +101,38 @@ export const firstAndLastY = (data) => { return [firstY, lastY]; }; + +const toolboxIconSvgPath = async (name) => { + return `path://${await getSvgIconPathContent(name)}`; +}; + +export const getToolboxOptions = async () => { + const promises = ['marquee-selection', 'redo', 'repeat', 'download'].map(toolboxIconSvgPath); + + try { + const [marqueeSelectionPath, redoPath, repeatPath, downloadPath] = await Promise.all(promises); + + return { + toolbox: { + feature: { + dataZoom: { + icon: { zoom: marqueeSelectionPath, back: redoPath }, + }, + restore: { + icon: repeatPath, + }, + saveAsImage: { + icon: downloadPath, + }, + }, + }, + }; + } catch (e) { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn(__('SVG could not be rendered correctly: '), e); + } + + return {}; + } +}; diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js index bd47f10b3ac..7cfcd11ece9 100644 --- a/app/assets/javascripts/lib/utils/keys.js +++ b/app/assets/javascripts/lib/utils/keys.js @@ -1,3 +1,7 @@ export const ESC_KEY = 'Escape'; export const ENTER_KEY = 'Enter'; export const BACKSPACE_KEY = 'Backspace'; +export const ARROW_DOWN_KEY = 'ArrowDown'; +export const ARROW_UP_KEY = 'ArrowUp'; +export const END_KEY = 'End'; +export const HOME_KEY = 'Home'; diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js new file mode 100644 index 00000000000..e6679323563 --- /dev/null +++ b/app/assets/javascripts/lib/utils/secret_detection.js @@ -0,0 +1,40 @@ +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { __ } from '~/locale'; + +export const i18n = { + defaultPrompt: __('This comment appears to have a token in it. Are you sure you want to add it?'), + primaryBtnText: __('Proceed'), +}; + +const sensitiveDataPatterns = [ + { + name: 'GitLab Personal Access Token', + regex: 'glpat-[0-9a-zA-Z_-]{20}', + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Feed Token', + regex: 'feed_token=[0-9a-zA-Z_-]{20}', + }, +]; + +export const containsSensitiveToken = (message) => { + for (const rule of sensitiveDataPatterns) { + const regex = new RegExp(rule.regex, 'gi'); + if (regex.test(message)) { + return true; + } + } + return false; +}; + +export async function confirmSensitiveAction(prompt = i18n.defaultPrompt) { + const confirmed = await confirmAction(prompt, { + primaryBtnVariant: 'danger', + primaryBtnText: i18n.primaryBtnText, + }); + if (!confirmed) { + return false; + } + return true; +} diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 4bcddb260e1..d06358aaef4 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -7,6 +7,7 @@ import { createAlert } from '~/alert'; import { badgeState } from '~/issuable/components/status_box.vue'; import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants'; import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; +import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; import { capitalizeFirstCharacter, convertToCamelCase, @@ -224,7 +225,7 @@ export default { handleSaveDraft() { this.handleSave({ isDraft: true }); }, - handleSave({ withIssueAction = false, isDraft = false } = {}) { + async handleSave({ withIssueAction = false, isDraft = false } = {}) { this.errors = []; if (this.note.length) { @@ -246,6 +247,13 @@ export default { noteData.data.note.type = constants.DISCUSSION_NOTE; } + if (containsSensitiveToken(this.note)) { + const confirmed = await confirmSensitiveAction(); + if (!confirmed) { + return; + } + } + this.note = ''; // Empty textarea while being requested. Repopulate in catch this.stopPolling(); diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 19e72da65f2..dfae43bf19c 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -81,7 +81,7 @@ export default { }, }, apollo: { - currentAttribute: { + issuable: { query() { const { current } = this.issuableAttributeQuery; const { query } = current[this.issuableType]; @@ -95,11 +95,12 @@ export default { }; }, update(data) { + return data.workspace?.issuable || {}; + }, + result({ data }) { if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpic) { this.hasCurrentAttribute = data?.workspace?.issuable.hasEpic; } - - return data?.workspace?.issuable.attribute; }, error(error) { createAlert({ @@ -108,13 +109,26 @@ export default { error, }); }, + subscribeToMore: { + document() { + return issuableAttributesQueries[this.issuableAttribute].subscription; + }, + variables() { + return { + issuableId: this.issuableId, + }; + }, + skip() { + return this.shouldSkipRealTimeEpicLinkUpdates; + }, + }, }, }, data() { return { updating: false, selectedTitle: null, - currentAttribute: null, + issuable: {}, hasCurrentAttribute: false, editConfirmation: false, tracking: { @@ -125,6 +139,12 @@ export default { }; }, computed: { + currentAttribute() { + return this.issuable.attribute; + }, + issuableId() { + return this.issuable.id; + }, issuableAttributeQuery() { return this.issuableAttributesQueries[this.issuableAttribute]; }, @@ -135,7 +155,7 @@ export default { return this.currentAttribute?.webUrl; }, loading() { - return this.$apollo.queries.currentAttribute.loading; + return this.$apollo.queries.issuable.loading; }, attributeTypeTitle() { return this.widgetTitleText[this.issuableAttribute]; @@ -170,6 +190,13 @@ export default { ? !this.editConfirmation : false; }, + shouldSkipRealTimeEpicLinkUpdates() { + return ( + !this.issuableId || + this.issuableAttribute !== IssuableAttributeType.Epic || + !this.glFeatures?.realTimeIssueEpicLinks + ); + }, }, methods: { updateAttribute({ id }) { 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 6798607b954..e8a54b0515e 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 @@ -6,73 +6,64 @@ import { GlToken, GlTooltipDirective, GlResizeObserverDirective, + GlModal, } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; -import { debounce } from 'lodash'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { debounce, clamp } from 'lodash'; import { truncate } from '~/lib/utils/text_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { sprintf } from '~/locale'; -import Tracking from '~/tracking'; -import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; +import { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys'; import { + MIN_SEARCH_TERM, SEARCH_GITLAB, - SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, - SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, - KBD_HELP, } from '~/vue_shared/global_search/constants'; import { - FIRST_DROPDOWN_INDEX, - SEARCH_BOX_INDEX, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, SEARCH_SHORTCUTS_MIN_CHARACTERS, SCOPE_TOKEN_MAX_LENGTH, INPUT_FIELD_PADDING, IS_SEARCHING, - IS_FOCUSED, - IS_NOT_FOCUSED, + SEARCH_MODAL_ID, + SEARCH_INPUT_SELECTOR, + SEARCH_RESULTS_ITEM_SELECTOR, } from '../constants'; -import HeaderSearchAutocompleteItems from './global_search_autocomplete_items.vue'; -import HeaderSearchDefaultItems from './global_search_default_items.vue'; -import HeaderSearchScopedItems from './global_search_scoped_items.vue'; +import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue'; +import GlobalSearchDefaultItems from './global_search_default_items.vue'; +import GlobalSearchScopedItems from './global_search_scoped_items.vue'; export default { - name: 'HeaderSearchApp', + name: 'GlobalSearchModal', + SEARCH_MODAL_ID, i18n: { SEARCH_GITLAB, - SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, - SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, - KBD_HELP, + MIN_SEARCH_TERM, }, directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, components: { GlSearchBoxByType, - HeaderSearchDefaultItems, - HeaderSearchScopedItems, - HeaderSearchAutocompleteItems, - DropdownKeyboardNavigation, + GlobalSearchDefaultItems, + GlobalSearchScopedItems, + GlobalSearchAutocompleteItems, GlIcon, GlToken, - }, - data() { - return { - showDropdown: false, - isFocused: false, - currentFocusIndex: SEARCH_BOX_INDEX, - }; + GlModal, }, computed: { ...mapState(['search', 'loading', 'searchContext']), - ...mapGetters(['searchQuery', 'searchOptions']), + ...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']), searchText: { get() { return this.search; @@ -81,51 +72,26 @@ export default { this.setSearch(value); }, }, - currentFocusedOption() { - return this.searchOptions[this.currentFocusIndex]; - }, - currentFocusedId() { - return this.currentFocusedOption?.html_id; - }, - isLoggedIn() { - return Boolean(gon?.current_username); - }, - showSearchDropdown() { - if (!this.showDropdown || !this.isLoggedIn) { - return false; - } - return this.searchOptions?.length > 0; - }, showDefaultItems() { return !this.searchText; }, searchTermOverMin() { return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; }, - defaultIndex() { - if (this.showDefaultItems) { - return SEARCH_BOX_INDEX; - } - return FIRST_DROPDOWN_INDEX; - }, - - searchInputDescribeBy() { - if (this.isLoggedIn) { - return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN; - } - return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN; + showScopedSearchItems() { + return this.searchTermOverMin && this.scopedSearchOptions.length > 1; }, - dropdownResultsDescription() { - if (!this.showSearchDropdown) { - return ''; // This allows aria-live to see register an update when the dropdown is shown - } - + searchResultsDescription() { if (this.showDefaultItems) { return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, { count: this.searchOptions.length, }); } + if (!this.searchTermOverMin) { + return this.$options.i18n.MIN_SEARCH_TERM; + } + return this.loading ? this.$options.i18n.SEARCH_RESULTS_LOADING : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, { @@ -135,12 +101,10 @@ export default { searchBarClasses() { return { [IS_SEARCHING]: this.searchTermOverMin, - [IS_FOCUSED]: this.isFocused, - [IS_NOT_FOCUSED]: !this.isFocused, }; }, showScopeHelp() { - return this.searchTermOverMin && this.isFocused; + return this.searchTermOverMin; }, searchBarItem() { return this.searchOptions?.[0]; @@ -159,47 +123,7 @@ export default { }, methods: { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), - openDropdown() { - this.showDropdown = true; - - // check isFocused state to avoid firing duplicate events - if (!this.isFocused) { - this.isFocused = true; - this.$emit('expandSearchBar', true); - - Tracking.event(undefined, 'focus_input', { - label: 'global_search', - property: 'navigation_top', - }); - } - }, - closeDropdown() { - this.showDropdown = false; - }, - collapseAndCloseSearchBar() { - // we need a delay on this method - // for the search bar not to remove - // the clear button from dom - // and register clicks on dropdown items - setTimeout(() => { - this.showDropdown = false; - this.isFocused = false; - this.$emit('collapseSearchBar'); - - Tracking.event(undefined, 'blur_input', { - label: 'global_search', - property: 'navigation_top', - }); - }, 200); - }, - 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) { - this.openDropdown(); if (!searchTerm) { this.clearAutocomplete(); } else { @@ -216,105 +140,174 @@ export default { } inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`; }, + getFocusableOptions() { + return Array.from( + this.$refs.resultsList?.querySelectorAll(SEARCH_RESULTS_ITEM_SELECTOR) || [], + ); + }, + onKeydown(event) { + const { code, target } = event; + + let stop = true; + + const elements = this.getFocusableOptions(); + if (elements.length < 1) return; + + const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR); + + if (code === HOME_KEY) { + this.focusItem(0, elements); + } else if (code === END_KEY) { + this.focusItem(elements.length - 1, elements); + } else if (code === ARROW_UP_KEY) { + if (isSearchInput) return; + + if (elements.indexOf(target) === 0) { + this.focusSearchInput(); + return; + } + this.focusNextItem(event, elements, -1); + } else if (code === ARROW_DOWN_KEY) { + this.focusNextItem(event, elements, 1); + } else if (code === ESC_KEY) { + this.$refs.searchModal.close(); + } else { + stop = false; + } + + if (stop) { + event.preventDefault(); + } + }, + focusSearchInput() { + this.$refs.searchInputBox.$el.querySelector('input').focus(); + }, + focusNextItem(event, elements, offset) { + const { target } = event; + const currentIndex = elements.indexOf(target); + const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1); + + this.focusItem(nextIndex, elements); + }, + focusItem(index, elements) { + this.nextFocusedItemIndex = index; + + elements[index]?.focus(); + }, + submitSearch() { + if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) { + return; + } + visitUrl(this.searchQuery); + }, }, - SEARCH_BOX_INDEX, - FIRST_DROPDOWN_INDEX, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, }; </script> <template> - <form - v-outside="closeDropdown" - role="search" - :aria-label="$options.i18n.SEARCH_GITLAB" - class="header-search gl-relative gl-rounded-base gl-w-full" - :class="searchBarClasses" - data-testid="header-search-form" + <gl-modal + ref="searchModal" + :modal-id="$options.SEARCH_MODAL_ID" + hide-header + hide-footer + hide-header-close + scrollable + body-class="gl-p-0!" + modal-class="global-search-modal" + :centered="false" > - <gl-search-box-by-type - id="search" - ref="searchInputBox" - v-model="searchText" - role="searchbox" - class="gl-z-index-1" - data-qa-selector="search_term_field" - autocomplete="off" - :placeholder="$options.i18n.SEARCH_GITLAB" - :aria-activedescendant="currentFocusedId" - :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" - @focus="openDropdown" - @click="openDropdown" - @blur="collapseAndCloseSearchBar" - @input="getAutocompleteOptions" - @keydown.enter.stop.prevent="submitSearch" - @keydown.esc.stop.prevent="closeDropdown" - /> - <gl-token - v-if="showScopeHelp" - 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.SEARCH_RESULTS_SCOPE, { - scope: infieldHelpContent, - }), - ) - }} - </gl-token> - <kbd - v-show="!isFocused" - 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.KBD_HELP" - >/</kbd - > - <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" + <form + role="search" + :aria-label="$options.i18n.SEARCH_GITLAB" + class="gl-relative gl-rounded-base gl-w-full" + :class="searchBarClasses" + data-testid="global-search-form" > - {{ dropdownResultsDescription }} - </span> - <div - v-if="showSearchDropdown" - 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" - > - <div class="header-search-dropdown-content gl-py-2"> - <dropdown-keyboard-navigation - v-model="currentFocusIndex" - :max="searchOptions.length - 1" - :min="$options.FIRST_DROPDOWN_INDEX" - :default-index="defaultIndex" - @tab="closeDropdown" - /> - <header-search-default-items - v-if="showDefaultItems" - :current-focused-option="currentFocusedOption" + <div class="gl-p-1"> + <gl-search-box-by-type + id="search" + ref="searchInputBox" + v-model="searchText" + role="searchbox" + data-testid="global-search-input" + autocomplete="off" + :placeholder="$options.i18n.SEARCH_GITLAB" + :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" + borderless + @input="getAutocompleteOptions" + @keydown.enter.stop.prevent="submitSearch" + @keydown="onKeydown" /> - <template v-else> - <header-search-scoped-items - v-if="searchTermOverMin" - :current-focused-option="currentFocusedOption" + <gl-token + v-if="showScopeHelp" + v-gl-resize-observer-directive="observeTokenWidth" + class="in-search-scope-help gl-sm-display-block gl-display-none" + view-only + :title="scopeTokenTitle" + > + <gl-icon + v-if="infieldHelpIcon" + class="gl-mr-2" + :aria-label="infieldHelpContent" + :name="infieldHelpIcon" + :size="16" /> - <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> + {{ + getTruncatedScope( + sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { scope: infieldHelpContent }), + ) + }} + </gl-token> + <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only"> + {{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }} + </span> + </div> + <span + role="region" + :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" + class="gl-sr-only" + aria-live="polite" + aria-atomic="true" + > + {{ searchResultsDescription }} + </span> + <div + ref="resultsList" + data-testid="global-search-results" + class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2" + @keydown="onKeydown" + > + <global-search-default-items v-if="showDefaultItems" /> + <template v-else> + <global-search-scoped-items v-if="showScopedSearchItems" /> + <global-search-autocomplete-items /> </template> </div> - </div> - </form> + + <template v-if="searchContext"> + <input + v-if="searchContext.group" + type="hidden" + name="group_id" + :value="searchContext.group.id" + /> + <input + v-if="searchContext.project" + type="hidden" + name="project_id" + :value="searchContext.project.id" + /> + + <template v-if="searchContext.group || searchContext.project"> + <input type="hidden" name="scope" :value="searchContext.scope" /> + <input type="hidden" name="search_code" :value="searchContext.code_search" /> + </template> + + <input type="hidden" name="snippets" :value="searchContext.for_snippets" /> + <input type="hidden" name="repository_ref" :value="searchContext.ref" /> + </template> + </form> + </gl-modal> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue index 1838214def6..cd623200b03 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue @@ -1,113 +1,36 @@ <script> -import { - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, - GlAvatar, - GlAlert, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlAvatar, GlAlert, GlLoadingIcon, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; -import { truncateNamespace } from '~/lib/utils/text_utility'; -import { - GROUPS_CATEGORY, - PROJECTS_CATEGORY, - MERGE_REQUEST_CATEGORY, - ISSUES_CATEGORY, - RECENT_EPICS_CATEGORY, - AUTOCOMPLETE_ERROR_MESSAGE, -} from '~/vue_shared/global_search/constants'; -import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants'; +import { AUTOCOMPLETE_ERROR_MESSAGE } from '~/vue_shared/global_search/constants'; export default { - name: 'HeaderSearchAutocompleteItems', + name: 'GlobalSearchAutocompleteItems', i18n: { AUTOCOMPLETE_ERROR_MESSAGE, }, components: { - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, GlAvatar, GlAlert, GlLoadingIcon, + GlDisclosureDropdownGroup, }, directives: { SafeHtml, }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, - }, computed: { - ...mapState(['search', 'loading', 'autocompleteError', 'searchContext']), - ...mapGetters(['autocompleteGroupedSearchOptions']), - }, - watch: { - currentFocusedOption() { - const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el; - - if (focusedElement) { - focusedElement.scrollIntoView(false); - } + ...mapState(['search', 'loading', 'autocompleteError']), + ...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']), + isPrecededByScopedOptions() { + return this.scopedSearchOptions.length > 1; }, }, methods: { - truncateNamespace(string) { - if (string.split(' / ').length > 2) { - return truncateNamespace(string); - } - - return string; - }, highlightedName(val) { return highlight(val, this.search); }, - avatarSize(data) { - if (data.category === GROUPS_CATEGORY || data.category === PROJECTS_CATEGORY) { - return LARGE_AVATAR_PX; - } - - return SMALL_AVATAR_PX; - }, - isOptionFocused(data) { - return this.currentFocusedOption?.html_id === data.html_id; - }, - isProjectsCategory(data) { - return data.category === PROJECTS_CATEGORY; - }, - getEntityId(data) { - switch (data.category) { - case GROUPS_CATEGORY: - case RECENT_EPICS_CATEGORY: - return data.group_id || data.id || this.searchContext?.group?.id; - case PROJECTS_CATEGORY: - case ISSUES_CATEGORY: - case MERGE_REQUEST_CATEGORY: - return data.project_id || data.id || this.searchContext?.project?.id; - default: - return data.id; - } - }, - getEntitytName(data) { - switch (data.category) { - case GROUPS_CATEGORY: - case RECENT_EPICS_CATEGORY: - return data.group_name || data.value || data.label || this.searchContext?.group?.name; - case PROJECTS_CATEGORY: - case ISSUES_CATEGORY: - case MERGE_REQUEST_CATEGORY: - return data.project_name || data.value || data.label || this.searchContext?.project?.name; - default: - return data.label; - } - }, }, AVATAR_SHAPE_OPTION_RECT, }; @@ -115,46 +38,46 @@ export default { <template> <div> - <template v-if="!loading"> - <div v-for="(option, index) in autocompleteGroupedSearchOptions" :key="option.category"> - <gl-dropdown-divider v-if="index > 0" /> - <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="data in option.data" - :id="data.html_id" - :ref="data.html_id" - :key="data.html_id" - :class="{ 'gl-bg-gray-50': isOptionFocused(data) }" - :aria-selected="isOptionFocused(data)" - :aria-label="data.label" - tabindex="-1" - :href="data.url" - > - <div class="gl-display-flex gl-align-items-center" aria-hidden="true"> + <ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none"> + <gl-disclosure-dropdown-group + v-for="group in autocompleteGroupedSearchOptions" + :key="group.name" + :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }" + :group="group" + bordered + > + <template #list-item="{ item }"> + <div class="gl-display-flex gl-align-items-center"> <gl-avatar - v-if="data.avatar_url !== undefined" - :src="data.avatar_url" - :entity-id="getEntityId(data)" - :entity-name="getEntitytName(data)" - :size="avatarSize(data)" + v-if="item.avatar_url !== undefined" + class="gl-mr-3" + :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" /> <span class="gl-display-flex gl-flex-direction-column"> <span - v-safe-html="highlightedName(data.value || data.label)" + v-safe-html="highlightedName(item.text)" class="gl-text-gray-900" + data-testid="autocomplete-item-name" ></span> <span - v-if="data.value" - v-safe-html="truncateNamespace(data.label)" + v-if="item.value" + v-safe-html="item.namespace" class="gl-font-sm gl-text-gray-500" + data-testid="autocomplete-item-namespace" ></span> </span> </div> - </gl-dropdown-item> - </div> - </template> + </template> + </gl-disclosure-dropdown-group> + </ul> + <gl-loading-icon v-else size="lg" class="my-4" /> + <gl-alert v-if="autocompleteError" class="gl-text-body gl-mt-2" diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue index f0d398297e9..239c61fd750 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue @@ -1,23 +1,15 @@ <script> -import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; +import { GlDisclosureDropdownGroup } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { ALL_GITLAB } from '~/vue_shared/global_search/constants'; export default { - name: 'HeaderSearchDefaultItems', + name: 'GlobalSearchDefaultItems', i18n: { ALL_GITLAB, }, components: { - GlDropdownSectionHeader, - GlDropdownItem, - }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, + GlDisclosureDropdownGroup, }, computed: { ...mapState(['searchContext']), @@ -29,30 +21,18 @@ export default { this.$options.i18n.ALL_GITLAB ); }, - }, - methods: { - isOptionFocused(option) { - return this.currentFocusedOption?.html_id === option.html_id; + defaultItemsGroup() { + return { + name: this.sectionHeader, + items: this.defaultSearchOptions, + }; }, }, }; </script> <template> - <div> - <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="option in defaultSearchOptions" - :id="option.html_id" - :ref="option.html_id" - :key="option.html_id" - :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" - :aria-selected="isOptionFocused(option)" - :aria-label="option.title" - tabindex="-1" - :href="option.url" - > - <span aria-hidden="true">{{ option.title }}</span> - </gl-dropdown-item> - </div> + <ul class="gl-p-0 gl-m-0 gl-list-style-none"> + <gl-disclosure-dropdown-group :group="defaultItemsGroup" bordered class="gl-mt-0!" /> + </ul> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue index 1ef88492b23..76600f829f6 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue @@ -1,47 +1,26 @@ <script> -import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui'; +import { GlIcon, GlToken, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { s__, sprintf } from '~/locale'; import { truncate } from '~/lib/utils/text_utility'; -import { SCOPED_SEARCH_ITEM_ARIA_LABEL } from '~/vue_shared/global_search/constants'; import { SCOPE_TOKEN_MAX_LENGTH } from '../constants'; export default { - name: 'HeaderSearchScopedItems', - i18n: { - SCOPED_SEARCH_ITEM_ARIA_LABEL, - }, + name: 'GlobalSearchScopedItems', components: { - GlDropdownItem, GlIcon, GlToken, - }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, + GlDisclosureDropdownGroup, }, computed: { ...mapState(['search']), - ...mapGetters(['scopedSearchOptions', 'autocompleteGroupedSearchOptions']), + ...mapGetters(['scopedSearchGroup']), }, methods: { - isOptionFocused(option) { - return this.currentFocusedOption?.html_id === option.html_id; - }, - ariaLabel(option) { - return sprintf(this.$options.i18n.SCOPED_SEARCH_ITEM_ARIA_LABEL, { - search: this.search, - description: option.description || option.icon, - scope: option.scope || '', - }); - }, - titleLabel(option) { + titleLabel(item) { return sprintf(s__('GlobalSearch|in %{scope}'), { search: this.search, - scope: option.scope || option.description, + scope: item.scope || item.description, }); }, getTruncatedScope(scope) { @@ -53,35 +32,23 @@ export default { <template> <div> - <gl-dropdown-item - v-for="option in scopedSearchOptions" - :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 - 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" + <ul class="gl-m-0 gl-p-0 gl-pb-2 gl-list-style-none"> + <gl-disclosure-dropdown-group :group="scopedSearchGroup" bordered class="gl-mt-0!"> + <template #list-item="{ item }"> + <span + class="gl-display-flex gl-align-items-center gl-line-height-24 gl-flex-direction-row gl-w-full" > - <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-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-pt-2 gl-mt-n2" /> + <span class="gl-flex-grow-1"> + <gl-token class="gl-flex-shrink-0 gl-white-space-nowrap gl-float-right" view-only> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-mr-2" /> + <span>{{ getTruncatedScope(titleLabel(item)) }}</span> + </gl-token> + {{ search }} + </span> + </span> + </template> + </gl-disclosure-dropdown-group> + </ul> </div> </template> 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 b9bb4e573fd..cb267df6122 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/constants.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js @@ -8,10 +8,6 @@ export const LARGE_AVATAR_PX = 32; export const SMALL_AVATAR_PX = 16; -export const FIRST_DROPDOWN_INDEX = 0; - -export const SEARCH_BOX_INDEX = -1; - export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2; export const SEARCH_INPUT_DESCRIPTION = 'search-input-description'; @@ -20,14 +16,13 @@ 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']; +export const INPUT_FIELD_PADDING = 84; export const IS_SEARCHING = 'is-searching'; -export const IS_FOCUSED = 'is-focused'; -export const IS_NOT_FOCUSED = 'is-not-focused'; export const FETCH_TYPES = ['generic', 'search']; +export const SEARCH_MODAL_ID = 'super-sidebar-search-modal'; + +export const SEARCH_INPUT_SELECTOR = '.gl-search-box-by-type-input-borderless'; -export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px'; +export const SEARCH_RESULTS_ITEM_SELECTOR = '.gl-new-dropdown-item'; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js index f86463b94d1..4a42f416206 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js @@ -1,6 +1,5 @@ import { omitBy, isNil } from 'lodash'; import { objectToQuery } from '~/lib/utils/url_utility'; - import { MSG_ISSUES_ASSIGNED_TO_ME, MSG_ISSUES_IVE_CREATED, @@ -10,8 +9,10 @@ import { MSG_IN_ALL_GITLAB, PROJECTS_CATEGORY, GROUPS_CATEGORY, - DROPDOWN_ORDER, + SEARCH_RESULTS_ORDER, } from '~/vue_shared/global_search/constants'; +import { getFormattedItem } from '../utils'; + import { ICON_GROUP, ICON_SUBGROUP, @@ -62,32 +63,27 @@ export const defaultSearchOptions = (state, getters) => { const issues = [ { - html_id: 'default-issues-assigned', - title: MSG_ISSUES_ASSIGNED_TO_ME, - url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, + text: MSG_ISSUES_ASSIGNED_TO_ME, + href: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, }, { - html_id: 'default-issues-created', - title: MSG_ISSUES_IVE_CREATED, - url: `${getters.scopedIssuesPath}/?author_username=${userName}`, + text: MSG_ISSUES_IVE_CREATED, + href: `${getters.scopedIssuesPath}/?author_username=${userName}`, }, ]; const mergeRequests = [ { - html_id: 'default-mrs-assigned', - title: MSG_MR_ASSIGNED_TO_ME, - url: `${getters.scopedMRPath}/?assignee_username=${userName}`, + text: MSG_MR_ASSIGNED_TO_ME, + href: `${getters.scopedMRPath}/?assignee_username=${userName}`, }, { - html_id: 'default-mrs-reviewer', - title: MSG_MR_IM_REVIEWER, - url: `${getters.scopedMRPath}/?reviewer_username=${userName}`, + text: MSG_MR_IM_REVIEWER, + href: `${getters.scopedMRPath}/?reviewer_username=${userName}`, }, { - html_id: 'default-mrs-created', - title: MSG_MR_IVE_CREATED, - url: `${getters.scopedMRPath}/?author_username=${userName}`, + text: MSG_MR_IVE_CREATED, + href: `${getters.scopedMRPath}/?author_username=${userName}`, }, ]; return [...(getters.scopedIssuesPath ? issues : []), ...mergeRequests]; @@ -145,58 +141,64 @@ export const allUrl = (state) => { }; export const scopedSearchOptions = (state, getters) => { - const options = []; + const items = []; if (state.searchContext?.project) { - options.push({ - html_id: 'scoped-in-project', + items.push({ + text: 'scoped-in-project', scope: state.searchContext.project?.name || '', scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, - url: getters.projectUrl, + href: getters.projectUrl, }); } if (state.searchContext?.group) { - options.push({ - html_id: 'scoped-in-group', + items.push({ + text: 'scoped-in-group', scope: state.searchContext.group?.name || '', scopeCategory: GROUPS_CATEGORY, icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP, - url: getters.groupUrl, + href: getters.groupUrl, }); } - options.push({ - html_id: 'scoped-in-all', + items.push({ + text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, - url: getters.allUrl, + href: getters.allUrl, }); - return options; + return items; +}; + +export const scopedSearchGroup = (state, getters) => { + const items = getters.scopedSearchOptions?.length ? getters.scopedSearchOptions.slice(1) : []; + return { items }; }; export const autocompleteGroupedSearchOptions = (state) => { const groupedOptions = {}; const results = []; - state.autocompleteOptions.forEach((option) => { - const category = groupedOptions[option.category]; + state.autocompleteOptions.forEach((item) => { + const group = groupedOptions[item.category]; + const formattedItem = getFormattedItem(item, state.searchContext); - if (category) { - category.data.push(option); + if (group) { + group.items.push(formattedItem); } else { - groupedOptions[option.category] = { - category: option.category, - data: [option], + groupedOptions[item.category] = { + name: formattedItem.category, + items: [formattedItem], }; - results.push(groupedOptions[option.category]); + results.push(groupedOptions[formattedItem.category]); } }); return results.sort( - (a, b) => DROPDOWN_ORDER.indexOf(a.category) - DROPDOWN_ORDER.indexOf(b.category), + (a, b) => SEARCH_RESULTS_ORDER.indexOf(a.name) - SEARCH_RESULTS_ORDER.indexOf(b.name), ); }; @@ -206,8 +208,8 @@ export const searchOptions = (state, getters) => { } const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce( - (options, group) => { - return [...options, ...group.data]; + (items, group) => { + return [...items, ...group.items]; }, [], ); @@ -216,5 +218,5 @@ export const searchOptions = (state, getters) => { return sortedAutocompleteOptions; } - return getters.scopedSearchOptions.concat(sortedAutocompleteOptions); + return (getters.scopedSearchOptions ?? []).concat(sortedAutocompleteOptions); }; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js index 6e65345757f..d7d9ebecd16 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js @@ -2,5 +2,4 @@ export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE'; export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS'; export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR'; export const CLEAR_AUTOCOMPLETE = 'CLEAR_AUTOCOMPLETE'; - export const SET_SEARCH = 'SET_SEARCH'; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js index 19b4d4ec330..9936c3f59d8 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js @@ -8,11 +8,7 @@ export default { }, [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) { state.loading = false; - state.autocompleteOptions = [...state.autocompleteOptions].concat( - data.map((d, i) => { - return { html_id: `autocomplete-${d.category}-${i}`, ...d }; - }), - ); + state.autocompleteOptions = [...state.autocompleteOptions].concat(data); state.autocompleteError = false; }, [types.RECEIVE_AUTOCOMPLETE_ERROR](state) { diff --git a/app/assets/javascripts/super_sidebar/components/global_search/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/utils.js new file mode 100644 index 00000000000..11d1fa1ab95 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/utils.js @@ -0,0 +1,81 @@ +import { pickBy } from 'lodash'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import { + GROUPS_CATEGORY, + PROJECTS_CATEGORY, + MERGE_REQUEST_CATEGORY, + ISSUES_CATEGORY, + RECENT_EPICS_CATEGORY, +} from '~/vue_shared/global_search/constants'; +import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from './constants'; + +const getTruncatedNamespace = (string) => { + if (string.split(' / ').length > 2) { + return truncateNamespace(string); + } + + return string; +}; +const getAvatarSize = (category) => { + if (category === GROUPS_CATEGORY || category === PROJECTS_CATEGORY) { + return LARGE_AVATAR_PX; + } + + return SMALL_AVATAR_PX; +}; + +const getEntityId = (item, searchContext) => { + switch (item.category) { + case GROUPS_CATEGORY: + case RECENT_EPICS_CATEGORY: + return item.group_id || item.id || searchContext?.group?.id; + case PROJECTS_CATEGORY: + case ISSUES_CATEGORY: + case MERGE_REQUEST_CATEGORY: + return item.project_id || item.id || searchContext?.project?.id; + default: + return item.id; + } +}; +const getEntityName = (item, searchContext) => { + switch (item.category) { + case GROUPS_CATEGORY: + case RECENT_EPICS_CATEGORY: + return item.group_name || item.value || item.label || searchContext?.group?.name; + case PROJECTS_CATEGORY: + case ISSUES_CATEGORY: + case MERGE_REQUEST_CATEGORY: + return item.project_name || item.value || item.label || searchContext?.project?.name; + default: + return item.label; + } +}; + +export const getFormattedItem = (item, searchContext) => { + const { id, category, value, label, url: href, avatar_url } = item; + let namespace; + const text = value || label; + if (value) { + namespace = getTruncatedNamespace(label); + } + const avatarSize = getAvatarSize(category); + const entityId = getEntityId(item, searchContext); + const entityName = getEntityName(item, searchContext); + + return pickBy( + { + id, + category, + value, + label, + text, + href, + avatar_url, + avatar_size: avatarSize, + namespace, + entity_id: entityId, + entity_name: entityName, + }, + (val) => val !== undefined, + ); +}; diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index e27acb60372..2597d0518e6 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -1,6 +1,6 @@ <script> -import { GlBadge, GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; import logo from '../../../../views/shared/_logo.svg'; import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; @@ -8,12 +8,14 @@ import CreateMenu from './create_menu.vue'; import Counter from './counter.vue'; import MergeRequestMenu from './merge_request_menu.vue'; import UserMenu from './user_menu.vue'; +import { SEARCH_MODAL_ID } from './global_search/constants'; export default { // "GitLab Next" is a proper noun, so don't translate "Next" /* eslint-disable-next-line @gitlab/require-i18n-strings */ NEXT_LABEL: 'Next', logo, + SEARCH_MODAL_ID, components: { Counter, CreateMenu, @@ -21,6 +23,10 @@ export default { GlButton, MergeRequestMenu, UserMenu, + SearchModal: () => + import( + /* webpackChunkName: 'global_search_modal' */ './global_search/components/global_search.vue' + ), }, i18n: { collapseSidebar: __('Collapse sidebar'), @@ -28,10 +34,16 @@ export default { issues: __('Issues'), mergeRequests: __('Merge requests'), search: __('Search'), + searchKbdHelp: sprintf( + s__('GlobalSearch|Search GitLab %{kbdOpen}/%{kbdClose}'), + { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, + false, + ), todoList: __('To-Do list'), }, directives: { GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, SafeHtml, }, inject: ['rootPath'], @@ -77,12 +89,18 @@ export default { @click="collapseSidebar" /> <create-menu :groups="sidebarData.create_new_menu_groups" /> + <gl-button + id="super-sidebar-search" + v-gl-tooltip.bottom.hover.html="$options.i18n.searchKbdHelp" + v-gl-modal="$options.SEARCH_MODAL_ID" + data-testid="super-sidebar-search-button" icon="search" :aria-label="$options.i18n.search" category="tertiary" - href="/search" /> + <search-modal /> + <user-menu :data="sidebarData" /> </div> <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"> diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index 34bbb3ce177..de3f3241366 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -167,6 +167,9 @@ export default { this.trackEvents(); this.initCallout(); }, + closeDropdown() { + this.$refs.userDropdown.close(); + }, initCallout() { if (this.showNotificationDot) { PersistentUserCallout.factory(this.$refs?.buyPipelineMinutesNotificationCallout.$el); @@ -189,6 +192,7 @@ export default { <template> <div> <gl-disclosure-dropdown + ref="userDropdown" placement="right" data-testid="user-dropdown" data-qa-selector="user_menu" @@ -220,6 +224,7 @@ export default { v-if="data.status.can_update" :item="statusItem" data-testid="status-item" + @action="closeDropdown" /> <gl-disclosure-dropdown-item diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index 4395cc2f5f0..c5e8c68b940 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -1,7 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { initStatusTriggers } from '../header'; +import createStore from './components/global_search/store'; import { bindSuperSidebarCollapsedEvents, initSuperSidebarCollapsedState, @@ -23,6 +25,10 @@ export const initSuperSidebar = () => { initSuperSidebarCollapsedState(); const { rootPath, sidebar, toggleNewNavEndpoint } = el.dataset; + const sidebarData = JSON.parse(sidebar); + const searchData = convertObjectPropsToCamelCase(sidebarData.search); + + const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData; return new Vue({ el, @@ -32,10 +38,18 @@ export const initSuperSidebar = () => { rootPath, toggleNewNavEndpoint, }, + store: createStore({ + searchPath, + issuesPath, + mrPath, + autocompletePath, + searchContext, + search: '', + }), render(h) { return h(SuperSidebar, { props: { - sidebarData: JSON.parse(sidebar), + sidebarData, }, }); }, diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js index 388e7c92f03..4211b9578a2 100644 --- a/app/assets/javascripts/vue_shared/global_search/constants.js +++ b/app/assets/javascripts/vue_shared/global_search/constants.js @@ -27,6 +27,10 @@ export const KBD_HELP = sprintf( { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, false, ); +export const MIN_SEARCH_TERM = s__( + 'GlobalSearch|The search term must be at least 3 characters long.', +); + export const SCOPED_SEARCH_ITEM_ARIA_LABEL = s__('GlobalSearch| %{search} %{description} %{scope}'); export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me'); diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue index d119cdc2785..7657bf8567b 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -1,7 +1,8 @@ <script> -import { GlButton, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import { isScopedLabel } from '~/lib/utils/common_utils'; import { createAlert } from '~/alert'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue'; @@ -24,6 +25,7 @@ import WorkItemTreeChildren from './work_item_tree_children.vue'; export default { components: { + GlLabel, GlLink, GlButton, GlIcon, @@ -71,6 +73,12 @@ export default { }; }, computed: { + labels() { + return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; + }, + allowsScopedLabels() { + return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels; + }, canHaveChildren() { return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE; }, @@ -166,6 +174,9 @@ export default { this.isLoadingChildren = false; } }, + showScopedLabel(label) { + return isScopedLabel(label) && this.allowsScopedLabels; + }, }, }; </script> @@ -190,66 +201,72 @@ export default { @click="toggleItem" /> <div - class="work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-rounded-base" - :class="[hasMetadata ? 'gl-py-3' : 'gl-py-0']" + class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-rounded-base" data-testid="links-child" > - <span - :id="`stateIcon-${childItem.id}`" - class="gl-cursor-help gl-mr-3 gl-line-height-32" - :class="{ 'gl-display-flex': hasMetadata }" - data-testid="item-status-icon" - > - <gl-icon - class="gl-text-secondary" - :class="iconClass" - :name="iconName" - :aria-label="stateTimestampTypeText" - /> - </span> - <div - class="gl-display-flex gl-flex-grow-1" - :class="{ - 'gl-flex-direction-column gl-align-items-flex-start': hasMetadata, - 'gl-align-items-center': !hasMetadata, - }" - > - <div class="gl-display-flex"> - <rich-timestamp-tooltip - :target="`stateIcon-${childItem.id}`" - :raw-timestamp="stateTimestamp" - :timestamp-type-text="stateTimestampTypeText" + <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0"> + <div + class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0" + > + <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0"> + <span + :id="`stateIcon-${childItem.id}`" + class="gl-cursor-help" + data-testid="item-status-icon" + > + <gl-icon + class="gl-text-secondary" + :class="iconClass" + :name="iconName" + :aria-label="stateTimestampTypeText" + /> + </span> + <rich-timestamp-tooltip + :target="`stateIcon-${childItem.id}`" + :raw-timestamp="stateTimestamp" + :timestamp-type-text="stateTimestampTypeText" + /> + <span v-if="childItem.confidential"> + <gl-icon + v-gl-tooltip.top + name="eye-slash" + class="gl-text-orange-500" + data-testid="confidential-icon" + :aria-label="__('Confidential')" + :title="__('Confidential')" + /> + </span> + <gl-link + :href="childPath" + class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold" + data-testid="item-title" + @click="$emit('click', $event)" + @mouseover="$emit('mouseover')" + @mouseout="$emit('mouseout')" + > + {{ childItem.title }} + </gl-link> + </div> + <work-item-link-child-metadata + v-if="hasMetadata" + :metadata-widgets="metadataWidgets" + class="gl-ml-6 ml-xl-0" /> - <gl-icon - v-if="childItem.confidential" - v-gl-tooltip.top - name="eye-slash" - class="gl-mr-2 gl-text-orange-500" - data-testid="confidential-icon" - :aria-label="__('Confidential')" - :title="__('Confidential')" + </div> + <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6"> + <gl-label + v-for="label in labels" + :key="label.id" + :title="label.title" + :background-color="label.color" + :description="label.description" + :scoped="showScopedLabel(label)" + class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm" + tooltip-placement="top" /> - <gl-link - :href="childPath" - class="gl-overflow-wrap-break gl-line-height-normal gl-text-black-normal! gl-font-weight-bold" - data-testid="item-title" - @click="$emit('click', $event)" - @mouseover="$emit('mouseover')" - @mouseout="$emit('mouseout')" - > - {{ childItem.title }} - </gl-link> </div> - <work-item-link-child-metadata - v-if="hasMetadata" - :metadata-widgets="metadataWidgets" - class="gl-mt-1" - /> </div> - <div - v-if="canUpdate" - class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" - > + <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex"> <work-item-links-menu :work-item-id="childItem.id" :parent-work-item-id="issuableGid" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue index 80802cb3858..ddeac2b92ae 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue @@ -1,16 +1,14 @@ <script> -import { GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui'; +import { GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; -import { isScopedLabel } from '~/lib/utils/common_utils'; import ItemMilestone from '~/issuable/components/issue_milestone.vue'; -import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS } from '../../constants'; +import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES } from '../../constants'; export default { components: { - GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, @@ -33,12 +31,6 @@ export default { assignees() { return this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes || []; }, - labels() { - return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; - }, - allowsScopedLabels() { - return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels; - }, assigneesCollapsedTooltip() { if (this.assignees.length > 2) { return sprintf(s__('WorkItem|%{count} more assignees'), { @@ -56,21 +48,16 @@ export default { return ''; }, }, - methods: { - showScopedLabel(label) { - return isScopedLabel(label) && this.allowsScopedLabels; - }, - }, }; </script> <template> - <div class="gl-display-flex gl-flex-wrap gl-align-items-center"> + <div class="gl-display-flex gl-md-justify-content-end gl-gap-3"> <slot></slot> <item-milestone v-if="milestone" :milestone="milestone" - class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!" + class="gl-display-flex gl-align-items-center gl-max-w-15 gl-font-sm gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!" /> <gl-avatars-inline v-if="assignees.length" @@ -81,7 +68,6 @@ export default { badge-tooltip-prop="name" :badge-sr-only-text="assigneesCollapsedTooltip" :class="assigneesContainerClass" - class="gl-mr-5" > <template #avatar="{ avatar }"> <gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name"> @@ -89,18 +75,6 @@ export default { </gl-avatar-link> </template> </gl-avatars-inline> - <div v-if="labels.length" class="gl-display-flex gl-flex-wrap"> - <gl-label - v-for="label in labels" - :key="label.id" - :title="label.title" - :background-color="label.color" - :description="label.description" - :scoped="showScopedLabel(label)" - class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm" - tooltip-placement="top" - /> - </div> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue index 97eaf2c0422..b72de98199e 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -186,7 +186,7 @@ export default { </template> <template #body> <div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty"> - <p class="gl-mb-3"> + <p class="gl-mb-0 gl-py-2 gl-ml-3 gl-text-gray-500"> {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} </p> </div> diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index bf447d417e6..bd0400cdaa3 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -220,3 +220,38 @@ } } } + +.global-search-modal { + padding: 3rem 0.5rem 0; + + &.gl-modal .modal-dialog { + align-items: flex-start; + } + + @include gl-media-breakpoint-up(sm) { + padding: 5rem 1rem 0; + } + + // This is a temporary workaround! + // the button in GitLab UI Search components need to be updated to not be the small size + // see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540 + .gl-search-box-by-type-clear.btn-sm { + padding: 0.5rem !important; + } + + .is-searching { + .in-search-scope-help { + position: absolute; + top: 0.625rem; + right: 2.5rem; + } + } + + .gl-search-box-by-type-input-borderless { + @include gl-rounded-base; + } + + .global-search-results { + max-height: 30rem; + } +} diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index 00c86c46ac8..5f6883623b2 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -87,19 +87,6 @@ } } -.work-item-link-child { - @include gl-border-1; - @include gl-border-solid; - @include gl-border-transparent; - @include gl-rounded-base; - - &:hover, - &:focus-within { - @include gl-bg-white; - @include gl-border-gray-50; - } -} - // sticky error placement for errors in modals , by default it is 83px for full view #work-item-detail-modal { .flash-container.flash-container-page.sticky { diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index 7e1ba49d442..d33d3b046e3 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -53,6 +53,8 @@ module Integrations :issues_events, :issues_url, :jenkins_url, + :jira_issue_prefix, + :jira_issue_regex, :jira_issue_transition_automatic, :jira_issue_transition_id, :manual_configuration, diff --git a/app/controllers/concerns/kas_cookie.rb b/app/controllers/concerns/kas_cookie.rb index ef58ab1972b..c66bf7c9e8c 100644 --- a/app/controllers/concerns/kas_cookie.rb +++ b/app/controllers/concerns/kas_cookie.rb @@ -3,6 +3,18 @@ module KasCookie extend ActiveSupport::Concern + included do + content_security_policy_with_context do |p| + next unless ::Gitlab::Kas::UserAccess.enabled? + + kas_url = ::Gitlab::Kas.tunnel_url + next if URI(kas_url).host == ::Gitlab.config.gitlab.host # already allowed, no need for exception + + kas_url += '/' unless kas_url.end_with?('/') + p.connect_src(*Array.wrap(p.directives['connect-src']), kas_url) + end + end + def set_kas_cookie return unless ::Gitlab::Kas::UserAccess.enabled? diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb index 95deacdc5b9..80c65948fff 100644 --- a/app/controllers/dashboard/application_controller.rb +++ b/app/controllers/dashboard/application_controller.rb @@ -14,3 +14,5 @@ class Dashboard::ApplicationController < ApplicationController @projects ||= current_user.authorized_projects.sorted_by_updated_desc.non_archived end end + +Dashboard::ApplicationController.prepend_mod diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a204023e34d..0851d2ef3e2 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -264,6 +264,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo status = merge! + Gitlab::ApplicationContext.push(merge_action_status: status.to_s) + if @merge_request.merge_error render json: { status: status, merge_error: @merge_request.merge_error } else diff --git a/app/graphql/resolvers/data_transfer_resolver.rb b/app/graphql/resolvers/data_transfer_resolver.rb index 1a240d2811f..ed97de0a256 100644 --- a/app/graphql/resolvers/data_transfer_resolver.rb +++ b/app/graphql/resolvers/data_transfer_resolver.rb @@ -38,16 +38,18 @@ module Resolvers def resolve(**_args) return unless Feature.enabled?(:data_transfer_monitoring) + # TODO: This is mock data as this feature is in development + # Follow this epic for recent progress: https://gitlab.com/groups/gitlab-org/-/epics/9330 start_date = Date.new(2023, 0o1, 0o1) date_for_index = ->(i) { (start_date + i.months).strftime('%Y-%m-%d') } - nodes = 0.upto(3).map do |i| + nodes = 0.upto(11).map do |i| { date: date_for_index.call(i), - repository_egress: 250_000, - artifacts_egress: 250_000, - packages_egress: 250_000, - registry_egress: 250_000 + repository_egress: rand(70000..550000), + artifacts_egress: rand(70000..550000), + packages_egress: rand(70000..550000), + registry_egress: rand(70000..550000) } end diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 1efbd4acdd9..56f4187ae42 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -81,7 +81,8 @@ module SidebarsHelper gitlab_com_and_canary: Gitlab.com_and_canary?, canary_toggle_com_url: Gitlab::Saas.canary_toggle_com_url, current_context: super_sidebar_current_context(project: project, group: group), - context_switcher_links: context_switcher_links + context_switcher_links: context_switcher_links, + search: search_data } end @@ -112,6 +113,16 @@ module SidebarsHelper private + def search_data + { + search_path: search_path, + issues_path: issues_dashboard_path, + mr_path: merge_requests_dashboard_path, + autocomplete_path: search_autocomplete_path, + search_context: header_search_context + } + end + def user_status_menu_data(user) { can_update: can?(user, :update_user_status, user), diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb index b05beb6c764..e68574c5fca 100644 --- a/app/models/concerns/mentionable/reference_regexes.rb +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -29,7 +29,7 @@ module Mentionable def self.external_pattern strong_memoize(:external_pattern) do - issue_pattern = Integrations::BaseIssueTracker.reference_pattern + issue_pattern = Integrations::BaseIssueTracker.base_reference_pattern link_patterns = URI::DEFAULT_PARSER.make_regexp(%w(http https)) reference_pattern(link_patterns, issue_pattern) end diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb index e0994305e9d..7a54d354007 100644 --- a/app/models/integrations/base_issue_tracker.rb +++ b/app/models/integrations/base_issue_tracker.rb @@ -14,7 +14,7 @@ module Integrations # This pattern does not support cross-project references # The other code assumes that this pattern is a superset of all # overridden patterns. See ReferenceRegexes.external_pattern - def self.reference_pattern(only_long: false) + def self.base_reference_pattern(only_long: false) if only_long /(\b[A-Z][A-Z0-9_]*-)#{Gitlab::Regex.issue}/ else @@ -22,6 +22,10 @@ module Integrations end end + def reference_pattern(only_long: false) + self.class.base_reference_pattern(only_long: only_long) + end + def handle_properties # this has been moved from initialize_properties and should be improved # as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 diff --git a/app/models/integrations/ewm.rb b/app/models/integrations/ewm.rb index 1b86ef73c85..003c896704a 100644 --- a/app/models/integrations/ewm.rb +++ b/app/models/integrations/ewm.rb @@ -6,7 +6,7 @@ module Integrations validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - def self.reference_pattern(only_long: true) + def reference_pattern(only_long: true) @reference_pattern ||= %r{(?<issue>\b(bug|task|work item|workitem|rtcwi|defect)\b\s+\d+)}i end diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index a1cdd55ceae..aa3730d9559 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -23,6 +23,8 @@ module Integrations validates :api_url, public_url: true, allow_blank: true validates :username, presence: true, if: :activated? validates :password, presence: true, if: :activated? + validates :jira_issue_prefix, untrusted_regexp: true, length: { maximum: 255 }, if: :activated? + validates :jira_issue_regex, untrusted_regexp: true, length: { maximum: 255 }, if: :activated? validates :jira_issue_transition_id, format: { @@ -72,6 +74,18 @@ module Integrations non_empty_password_help: -> { s_('JiraService|Leave blank to use your current password or API token.') }, help: -> { s_('JiraService|Password for the server version or an API token for the cloud version') } + field :jira_issue_regex, + section: SECTION_TYPE_CONFIGURATION, + required: false, + title: -> { s_('JiraService|Jira issue regex') }, + help: -> { s_('JiraService|Use regular expression to match Jira issue keys.') } + + field :jira_issue_prefix, + section: SECTION_TYPE_CONFIGURATION, + required: false, + title: -> { s_('JiraService|Jira issue prefix') }, + help: -> { s_('JiraService|Use a prefix to match Jira issue keys.') } + field :jira_issue_transition_id, api_only: true # TODO: we can probably just delegate as part of @@ -90,8 +104,8 @@ module Integrations end # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 - def self.reference_pattern(only_long: true) - @reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/ + def reference_pattern(only_long: true) + @reference_pattern ||= jira_issue_match_regex end def self.valid_jira_cloud_url?(url) @@ -166,6 +180,11 @@ module Integrations type: SECTION_TYPE_JIRA_TRIGGER, title: _('Trigger'), description: s_('JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.') + }, + { + type: SECTION_TYPE_CONFIGURATION, + title: _('Jira issue matching'), + description: s_('Configure custom rules for Jira issue key matching') } ] @@ -325,6 +344,12 @@ module Integrations private + def jira_issue_match_regex + match_regex = (jira_issue_regex.presence || Gitlab::Regex.jira_issue_key_regex) + + /\b#{jira_issue_prefix}(?<issue>#{match_regex})/ + end + def parse_project_from_issue_key(issue_key) issue_key.gsub(Gitlab::Regex.jira_issue_key_project_key_extraction_regex, '') end diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb index fa719f925ed..15246a37aa7 100644 --- a/app/models/integrations/youtrack.rb +++ b/app/models/integrations/youtrack.rb @@ -7,12 +7,11 @@ module Integrations validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 - def self.reference_pattern(only_long: false) - if only_long - /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)/ - else - /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)|(#{Issue.reference_prefix}#{Gitlab::Regex.issue})/ - end + def reference_pattern(only_long: false) + return @reference_pattern if defined?(@reference_pattern) + + regex_suffix = "|(#{Issue.reference_prefix}#{Gitlab::Regex.issue})" + @reference_pattern = /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)#{regex_suffix if only_long}/ end def title diff --git a/app/models/issue.rb b/app/models/issue.rb index b7290fa1842..160894a0c4f 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -57,11 +57,10 @@ class Issue < ApplicationRecord belongs_to :duplicated_to, class_name: 'Issue' belongs_to :closed_by, class_name: 'User' - belongs_to :iteration, foreign_key: 'sprint_id' belongs_to :work_item_type, class_name: 'WorkItems::Type', inverse_of: :work_items - belongs_to :moved_to, class_name: 'Issue' - has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id + belongs_to :moved_to, class_name: 'Issue', inverse_of: :moved_from + has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id, inverse_of: :moved_to has_internal_id :iid, scope: :namespace, track_if: -> { !importing? }, init: ->(issue, scope) do # we need this init for the case where the IID allocation in internal_ids#last_value diff --git a/app/models/iteration.rb b/app/models/iteration.rb deleted file mode 100644 index ebec24731ed..00000000000 --- a/app/models/iteration.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -# Placeholder class for model that is implemented in EE -class Iteration < ApplicationRecord - include IgnorableColumns - - self.table_name = 'sprints' - - def self.reference_prefix - '*iteration:' - end - - def self.reference_pattern - nil - end -end - -Iteration.prepend_mod_with('Iteration') diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 85e95a556a8..c1511ee1233 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -44,7 +44,6 @@ class MergeRequest < ApplicationRecord belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" belongs_to :merge_user, class_name: "User" - belongs_to :iteration, foreign_key: 'sprint_id' has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(mr, scope) do diff --git a/app/models/project.rb b/app/models/project.rb index 8a8e4848eb1..03aa131e71b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1625,7 +1625,7 @@ class Project < ApplicationRecord end def external_issue_reference_pattern - external_issue_tracker.class.reference_pattern(only_long: issues_enabled?) + external_issue_tracker.reference_pattern(only_long: issues_enabled?) end def default_issues_tracker? |