diff options
Diffstat (limited to 'app/assets/javascripts/super_sidebar')
35 files changed, 731 insertions, 995 deletions
diff --git a/app/assets/javascripts/super_sidebar/components/brand_logo.vue b/app/assets/javascripts/super_sidebar/components/brand_logo.vue index 1589f4978e1..02cf36fb053 100644 --- a/app/assets/javascripts/super_sidebar/components/brand_logo.vue +++ b/app/assets/javascripts/super_sidebar/components/brand_logo.vue @@ -26,20 +26,28 @@ export default { <template> <a - v-gl-tooltip:super-sidebar.hover.noninteractive.bottom.ds500="$options.i18n.homepage" + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage" class="brand-logo" :href="rootPath" - :title="$options.i18n.homepage" data-track-action="click_link" data-track-label="gitlab_logo_link" data-track-property="nav_core_menu" > + <span class="gl-sr-only">{{ $options.i18n.homepage }}</span> + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> <img v-if="logoUrl" + alt="" data-testid="brand-header-custom-logo" :src="logoUrl" class="gl-h-6 gl-max-w-full" /> - <span v-else v-safe-html="$options.logo" data-testid="brand-header-default-logo"></span> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + <span + v-else + v-safe-html="$options.logo" + aria-hidden + data-testid="brand-header-default-logo" + ></span> </a> </template> diff --git a/app/assets/javascripts/super_sidebar/components/context_header.vue b/app/assets/javascripts/super_sidebar/components/context_header.vue deleted file mode 100644 index 11b9840a409..00000000000 --- a/app/assets/javascripts/super_sidebar/components/context_header.vue +++ /dev/null @@ -1,56 +0,0 @@ -<script> -import { GlTruncate, GlAvatar, GlIcon } from '@gitlab/ui'; - -export default { - components: { - GlTruncate, - GlAvatar, - GlIcon, - }, - props: { - /* - * Contains metadata about the current view, e.g. `id`, `title` and `avatar` - */ - context: { - type: Object, - required: true, - }, - tag: { - type: String, - required: false, - default: 'div', - }, - }, - computed: { - avatarShape() { - return this.context.avatar_shape || 'rect'; - }, - }, -}; -</script> - -<template> - <component - :is="tag" - class="border-top border-bottom gl-border-gray-a-08! gl-display-flex gl-align-items-center gl-gap-3 gl-font-weight-bold gl-w-full gl-h-8 gl-px-4 gl-flex-shrink-0" - > - <span - v-if="context.icon" - class="gl-avatar avatar-container gl-bg-t-gray-a-08 icon-avatar rect-avatar s24" - > - <gl-icon class="gl-text-gray-700" :name="context.icon" :size="16" /> - </span> - <gl-avatar - v-else - :size="24" - :shape="avatarShape" - :entity-name="context.title" - :entity-id="context.id" - :src="context.avatar" - /> - <div class="gl-flex-grow-1 gl-overflow-auto gl-text-gray-900"> - <gl-truncate :text="context.title" /> - </div> - <slot name="end"></slot> - </component> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue deleted file mode 100644 index d4aa11b6e04..00000000000 --- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue +++ /dev/null @@ -1,209 +0,0 @@ -<script> -import * as Sentry from '@sentry/browser'; -import { GlDisclosureDropdown, GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import searchUserProjectsAndGroups from '../graphql/queries/search_user_groups_and_projects.query.graphql'; -import { trackContextAccess, formatContextSwitcherItems } from '../utils'; -import NavItem from './nav_item.vue'; -import ProjectsList from './projects_list.vue'; -import GroupsList from './groups_list.vue'; -import ContextSwitcherToggle from './context_switcher_toggle.vue'; - -export default { - i18n: { - contextNavigation: s__('Navigation|Context navigation'), - switchTo: s__('Navigation|Switch context'), - searchPlaceholder: s__('Navigation|Search your projects or groups'), - searchingLabel: s__('Navigation|Retrieving search results'), - searchError: s__('Navigation|There was an error fetching search results.'), - }, - apollo: { - groupsAndProjects: { - query: searchUserProjectsAndGroups, - manual: true, - variables() { - return { - username: this.username, - search: this.searchString, - }; - }, - result(response) { - this.hasError = false; - try { - const { - data: { - projects: { nodes: projects }, - user: { - groups: { nodes: groups }, - }, - }, - } = response; - - this.projects = formatContextSwitcherItems(projects); - this.groups = formatContextSwitcherItems(groups); - } catch (e) { - this.handleError(e); - } - }, - error(e) { - this.handleError(e); - }, - skip() { - return !this.searchString; - }, - }, - }, - components: { - GlDisclosureDropdown, - ContextSwitcherToggle, - GlSearchBoxByType, - GlLoadingIcon, - GlAlert, - NavItem, - ProjectsList, - GroupsList, - }, - inject: ['contextSwitcherLinks'], - props: { - username: { - type: String, - required: true, - }, - projectsPath: { - type: String, - required: true, - }, - groupsPath: { - type: String, - required: true, - }, - currentContext: { - type: Object, - required: false, - default: () => ({}), - }, - contextHeader: { - type: Object, - required: true, - }, - }, - data() { - return { - searchString: '', - projects: [], - groups: [], - hasError: false, - isOpen: false, - }; - }, - computed: { - isSearch() { - return Boolean(this.searchString); - }, - isSearching() { - return this.$apollo.queries.groupsAndProjects.loading; - }, - }, - watch: { - isOpen(isOpen) { - this.$emit('toggle', isOpen); - - if (isOpen) { - this.focusInput(); - } - }, - }, - created() { - if (this.currentContext.namespace) { - trackContextAccess(this.username, this.currentContext); - } - }, - methods: { - close() { - this.$refs['disclosure-dropdown'].close(); - }, - focusInput() { - this.$refs['search-box'].focusInput(); - }, - handleError(e) { - Sentry.captureException(e); - this.hasError = true; - }, - onDisclosureDropdownShown() { - this.isOpen = true; - }, - onDisclosureDropdownHidden() { - this.isOpen = false; - }, - }, - DEFAULT_DEBOUNCE_AND_THROTTLE_MS, -}; -</script> - -<template> - <gl-disclosure-dropdown - ref="disclosure-dropdown" - class="context-switcher gl-w-full" - placement="center" - @shown="onDisclosureDropdownShown" - @hidden="onDisclosureDropdownHidden" - > - <template #toggle> - <context-switcher-toggle :context="contextHeader" :expanded="isOpen" /> - </template> - <div aria-hidden="true" class="gl-font-sm gl-font-weight-bold gl-px-4 gl-pt-3 gl-pb-4"> - {{ $options.i18n.switchTo }} - </div> - <div class="gl-p-1 gl-border-t gl-border-b gl-border-gray-50 gl-bg-white"> - <gl-search-box-by-type - ref="search-box" - v-model="searchString" - class="context-switcher-search-box" - :placeholder="$options.i18n.searchPlaceholder" - :debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS" - borderless - /> - </div> - <gl-loading-icon - v-if="isSearching" - class="gl-mt-5" - size="md" - :label="$options.i18n.searchingLabel" - /> - <gl-alert v-else-if="hasError" variant="danger" :dismissible="false" class="gl-m-2"> - {{ $options.i18n.searchError }} - </gl-alert> - <nav v-else :aria-label="$options.i18n.contextNavigation" data-testid="context-navigation"> - <ul class="gl-p-0 gl-m-0 gl-list-style-none"> - <li v-if="!isSearch"> - <ul - :aria-label="$options.i18n.switchTo" - class="gl-border-b gl-border-gray-50 gl-px-0 gl-py-2" - > - <nav-item - v-for="item in contextSwitcherLinks" - :key="item.link" - :item="item" - :link-classes="{ [item.link_classes]: item.link_classes }" - is-subitem - /> - </ul> - </li> - <projects-list - :username="username" - :view-all-link="projectsPath" - :is-search="isSearch" - :search-results="projects" - /> - <groups-list - class="gl-border-t gl-border-gray-50" - :username="username" - :view-all-link="groupsPath" - :is-search="isSearch" - :search-results="groups" - /> - </ul> - </nav> - </gl-disclosure-dropdown> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue deleted file mode 100644 index faa7eba6470..00000000000 --- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue +++ /dev/null @@ -1,43 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import ContextHeader from './context_header.vue'; - -export default { - components: { - GlIcon, - ContextHeader, - }, - props: { - /* - * Contains metadata about the current view, e.g. `id`, `title` and `avatar` - */ - context: { - type: Object, - required: true, - }, - expanded: { - type: Boolean, - required: true, - }, - }, - computed: { - collapseIcon() { - return this.expanded ? 'chevron-up' : 'chevron-down'; - }, - }, -}; -</script> - -<template> - <context-header - :context="context" - tag="button" - type="button" - class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-border-0 gl-box-shadow-none gl-text-left" - data-testid="context-switcher" - > - <template #end> - <gl-icon class="gl-text-gray-400" :name="collapseIcon" /> - </template> - </context-header> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue index 3645606515f..d1e96479631 100644 --- a/app/assets/javascripts/super_sidebar/components/create_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue @@ -1,7 +1,7 @@ <script> import { GlDisclosureDropdown, - GlTooltip, + GlTooltipDirective, GlDisclosureDropdownGroup, GlDisclosureDropdownItem, } from '@gitlab/ui'; @@ -14,7 +14,7 @@ import { import { DROPDOWN_Y_OFFSET, IMPERSONATING_OFFSET } from '../constants'; // Left offset required for the dropdown to be aligned with the super sidebar -const DROPDOWN_X_OFFSET_BASE = -147; +const DROPDOWN_X_OFFSET_BASE = -179; const DROPDOWN_X_OFFSET_IMPERSONATING = DROPDOWN_X_OFFSET_BASE + IMPERSONATING_OFFSET; export default { @@ -22,9 +22,11 @@ export default { GlDisclosureDropdown, GlDisclosureDropdownGroup, GlDisclosureDropdownItem, - GlTooltip, InviteMembersTrigger, }, + directives: { + GlTooltip: GlTooltipDirective, + }, i18n: { createNew: __('Create new...'), }, @@ -59,45 +61,35 @@ export default { </script> <template> - <div> - <gl-disclosure-dropdown - category="tertiary" - icon="plus" - no-caret - text-sr-only - :toggle-text="$options.i18n.createNew" - :toggle-id="$options.toggleId" - :dropdown-offset="dropdownOffset" - data-qa-selector="new_menu_toggle" - data-testid="new-menu-toggle" - @shown="dropdownOpen = true" - @hidden="dropdownOpen = false" - > - <gl-disclosure-dropdown-group - v-for="(group, index) in groups" - :key="group.name" - :bordered="index !== 0" - :group="group" - > - <template v-for="groupItem in group.items"> - <invite-members-trigger - v-if="isInvitedMembers(groupItem)" - :key="`${groupItem.text}-trigger`" - trigger-source="top-nav" - :trigger-element="$options.TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN" - /> - <gl-disclosure-dropdown-item v-else :key="groupItem.text" :item="groupItem" /> - </template> - </gl-disclosure-dropdown-group> - </gl-disclosure-dropdown> - <gl-tooltip - v-if="!dropdownOpen" - :target="`#${$options.toggleId}`" - placement="bottom" - container="#super-sidebar" - noninteractive + <gl-disclosure-dropdown + v-gl-tooltip:super-sidebar.hover.bottom="dropdownOpen ? '' : $options.i18n.createNew" + category="tertiary" + icon="plus" + no-caret + text-sr-only + :toggle-text="$options.i18n.createNew" + :toggle-id="$options.toggleId" + :dropdown-offset="dropdownOffset" + data-qa-selector="new_menu_toggle" + data-testid="new-menu-toggle" + @shown="dropdownOpen = true" + @hidden="dropdownOpen = false" + > + <gl-disclosure-dropdown-group + v-for="(group, index) in groups" + :key="group.name" + :bordered="index !== 0" + :group="group" > - {{ $options.i18n.createNew }} - </gl-tooltip> - </div> + <template v-for="groupItem in group.items"> + <invite-members-trigger + v-if="isInvitedMembers(groupItem)" + :key="`${groupItem.text}-trigger`" + trigger-source="top_nav" + :trigger-element="$options.TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN" + /> + <gl-disclosure-dropdown-item v-else :key="groupItem.text" :item="groupItem" /> + </template> + </gl-disclosure-dropdown-group> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/super_sidebar/components/flyout_menu.vue b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue index fa7960da2f4..e73b9b275ee 100644 --- a/app/assets/javascripts/super_sidebar/components/flyout_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue @@ -2,6 +2,23 @@ import { computePosition, autoUpdate, offset, flip, shift } from '@floating-ui/dom'; import NavItem from './nav_item.vue'; +// Flyout menus are shown when the MenuSection's title is hovered with the mouse. +// Their position is dynamically calculated with floating-ui. +// +// Since flyout menus show all NavItems of a section, they can be very long and +// a user might want to move their mouse diagonally from the section title down +// to last nav item in the flyout. But this mouse movement over other sections +// would loose hover and close the flyout, opening another section's flyout. +// To avoid this annoyance, our flyouts come with a "diagonal tolerance". This +// is an area between the current mouse position and the top- and bottom-left +// corner of the flyout itself. While the mouse stays within this area and +// reaches the flyout before a timer expires, the native browser hover stays +// within the component. +// This is done with an transparent SVG positioned left of the flyout menu, +// overlapping the sidebar. The SVG itself ignores pointer events but its two +// triangles, one above the section title, one below, do listen to events, +// keeping hover. + export default { name: 'FlyoutMenu', components: { NavItem }, @@ -15,13 +32,45 @@ export default { required: true, }, }, + data() { + return { + currentMouseX: 0, + flyoutX: 0, + flyoutY: 0, + flyoutHeight: 0, + hoverTimeoutId: null, + showSVG: true, + targetRect: null, + }; + }, cleanupFunction: undefined, + computed: { + topSVGPoints() { + const x = (this.currentMouseX / this.targetRect.width) * 100; + let y = ((this.targetRect.top - this.flyoutY) / this.flyoutHeight) * 100; + y += 1; // overlap title to not loose hover + + return `${x}, ${y} 100, 0 100, ${y}`; + }, + bottomSVGPoints() { + const x = (this.currentMouseX / this.targetRect.width) * 100; + let y = ((this.targetRect.bottom - this.flyoutY) / this.flyoutHeight) * 100; + y -= 1; // overlap title to not loose hover + + return `${x}, ${y} 100, ${y} 100, 100`; + }, + }, + created() { + const target = document.querySelector(`#${this.targetId}`); + target.addEventListener('mousemove', this.onMouseMove); + }, mounted() { const target = document.querySelector(`#${this.targetId}`); const flyout = document.querySelector(`#${this.targetId}-flyout`); + const sidebar = document.querySelector('#super-sidebar'); - function updatePosition() { - return computePosition(target, flyout, { + const updatePosition = () => + computePosition(target, flyout, { middleware: [offset({ alignmentAxis: -12 }), flip(), shift()], placement: 'right-start', strategy: 'fixed', @@ -30,13 +79,46 @@ export default { left: `${x}px`, top: `${y}px`, }); + this.flyoutX = x; + this.flyoutY = y; + this.flyoutHeight = flyout.clientHeight; + + // Flyout coordinates are relative to the sidebar which can be + // shifted down by the performance-bar etc. + // Adjust viewport coordinates from getBoundingClientRect: + const targetRect = target.getBoundingClientRect(); + const sidebarRect = sidebar.getBoundingClientRect(); + this.targetRect = { + top: targetRect.top - sidebarRect.top, + bottom: targetRect.bottom - sidebarRect.top, + width: targetRect.width, + }; }); - } this.$options.cleanupFunction = autoUpdate(target, flyout, updatePosition); }, beforeUnmount() { this.$options.cleanupFunction(); + clearTimeout(this.hoverTimeoutId); + }, + beforeDestroy() { + const target = document.querySelector(`#${this.targetId}`); + target.removeEventListener('mousemove', this.onMouseMove); + }, + methods: { + startHoverTimeout() { + this.hoverTimeoutId = setTimeout(() => { + this.showSVG = false; + this.$emit('mouseleave'); + }, 1000); + }, + stopHoverTimeout() { + clearTimeout(this.hoverTimeoutId); + }, + onMouseMove(e) { + // add some wiggle room to the left of mouse cursor + this.currentMouseX = Math.max(0, e.clientX - 5); + }, }, }; </script> @@ -49,8 +131,8 @@ export default { @mouseleave="$emit('mouseleave')" > <ul - v-if="items.length > 0" class="gl-min-w-20 gl-max-w-34 gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-100 gl-shadow-md gl-bg-white gl-p-2 gl-pb-1 gl-list-style-none" + @mouseenter="showSVG = false" > <nav-item v-for="item of items" @@ -61,5 +143,44 @@ export default { @pin-remove="(itemId) => $emit('pin-remove', itemId)" /> </ul> + <svg + v-if="targetRect && showSVG" + :width="flyoutX" + :height="flyoutHeight" + viewBox="0 0 100 100" + preserveAspectRatio="none" + :style="{ + top: flyoutY + 'px', + }" + > + <polygon + ref="topSVG" + :points="topSVGPoints" + fill="transparent" + @mouseenter="startHoverTimeout" + @mouseleave="stopHoverTimeout" + /> + <polygon + ref="bottomSVG" + :points="bottomSVGPoints" + fill="transparent" + @mouseenter="startHoverTimeout" + @mouseleave="stopHoverTimeout" + /> + </svg> </div> </template> + +<style scoped> +svg { + pointer-events: none; + + position: fixed; + right: 0; +} + +svg polygon, +svg rect { + pointer-events: auto; +} +</style> diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue deleted file mode 100644 index fe1a907bd91..00000000000 --- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue +++ /dev/null @@ -1,106 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { - getItemsFromLocalStorage, - removeItemFromLocalStorage, - formatContextSwitcherItems, -} from '../utils'; -import ItemsList from './items_list.vue'; - -export default { - components: { - GlButton, - ItemsList, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - title: { - type: String, - required: true, - }, - pristineText: { - type: String, - required: true, - }, - storageKey: { - type: String, - required: true, - }, - maxItems: { - type: Number, - required: true, - }, - }, - data() { - return { - cachedFrequentItems: [], - }; - }, - computed: { - isEmpty() { - return !this.cachedFrequentItems.length; - }, - }, - created() { - this.cachedFrequentItems = formatContextSwitcherItems( - getItemsFromLocalStorage({ - storageKey: this.storageKey, - maxItems: this.maxItems, - }), - ); - }, - methods: { - handleItemRemove(item) { - removeItemFromLocalStorage({ - storageKey: this.storageKey, - item, - }); - - this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id); - }, - }, - i18n: { - removeItem: __('Remove'), - }, -}; -</script> - -<template> - <li class="gl-py-3"> - <div - data-testid="list-title" - aria-hidden="true" - class="gl-display-flex gl-align-items-center gl-text-transform-uppercase gl-text-secondary gl-font-weight-semibold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3" - > - <span class="gl-flex-grow-1 gl-px-3">{{ title }}</span> - </div> - <div - v-if="isEmpty" - data-testid="empty-text" - class="gl-text-gray-500 gl-font-sm gl-my-3 gl-mx-3" - > - {{ pristineText }} - </div> - <items-list :aria-label="title" :items="cachedFrequentItems"> - <template #actions="{ item }"> - <gl-button - v-gl-tooltip.right.viewport - size="small" - category="tertiary" - icon="dash" - class="show-on-focus-or-hover--target" - :aria-label="$options.i18n.removeItem" - :title="$options.i18n.removeItem" - data-testid="item-remove" - @click.stop.prevent="handleItemRemove(item)" - /> - </template> - <template #view-all-items> - <slot name="view-all-items"></slot> - </template> - </items-list> - </li> -</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 bd79962f1a1..b85b163cea9 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 @@ -5,6 +5,7 @@ import { GlDisclosureDropdownGroup, GlLoadingIcon } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import Tracking from '~/tracking'; import { getFormattedItem } from '../utils'; import { @@ -18,6 +19,8 @@ import { PATH_GROUP_TITLE, GROUP_TITLES, MAX_ROWS, + TRACKING_ACTIVATE_COMMAND_PALETTE, + TRACKING_HANDLE_LABEL_MAP, } from './constants'; import SearchItem from './search_item.vue'; import { commandMapper, linksReducer, autocompleteQuery, fileMapper } from './utils'; @@ -29,6 +32,7 @@ export default { GlLoadingIcon, SearchItem, }, + mixins: [Tracking.mixin()], inject: [ 'commandPaletteCommands', 'commandPaletteLinks', @@ -134,10 +138,15 @@ export default { immediate: true, }, handle: { - handler() { - this.debouncedSearch(); + handler(value, oldValue) { + // Do not run search immediately on component creation + if (oldValue !== undefined) this.debouncedSearch(); + + // Track immediately on component creation + const label = TRACKING_HANDLE_LABEL_MAP[value] ?? 'unknown'; + this.track(TRACKING_ACTIVATE_COMMAND_PALETTE, { label }); }, - immediate: false, + immediate: true, }, }, updated() { 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 a43e621da44..f6f4e36e43a 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 @@ -6,6 +6,16 @@ export const PROJECT_HANDLE = ':'; export const ISSUE_HANDLE = '#'; export const PATH_HANDLE = '/'; +export const TRACKING_ACTIVATE_COMMAND_PALETTE = 'activate_command_palette'; +export const TRACKING_CLICK_COMMAND_PALETTE_ITEM = 'click_command_palette_item'; +export const TRACKING_HANDLE_LABEL_MAP = { + [COMMAND_HANDLE]: 'command', + [USER_HANDLE]: 'user', + [PROJECT_HANDLE]: 'project', + [PATH_HANDLE]: 'path', + // No ISSUE_HANDLE. See https://gitlab.com/gitlab-org/gitlab/-/issues/417434. +}; + export const COMMON_HANDLES = [COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE]; export const SEARCH_OR_COMMAND_MODE_PLACEHOLDER = sprintf( s__( 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 347a8ffb0b4..32abbbfd3c2 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,6 +1,11 @@ import { isNil, omitBy } from 'lodash'; import { objectToQuery, joinPaths } from '~/lib/utils/url_utility'; -import { SEARCH_SCOPE, GLOBAL_COMMANDS_GROUP_TITLE } from './constants'; +import { TRACKING_UNKNOWN_ID } from '~/super_sidebar/constants'; +import { + SEARCH_SCOPE, + GLOBAL_COMMANDS_GROUP_TITLE, + TRACKING_CLICK_COMMAND_PALETTE_ITEM, +} 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 @@ -12,18 +17,34 @@ export const commandMapper = ({ name, items }) => { }; export const linksReducer = (acc, menuItem) => { + const trackingAttrs = ({ id, title }) => { + return { + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': id || TRACKING_UNKNOWN_ID, + ...(id + ? {} + : { + 'data-track-extra': JSON.stringify({ title }), + }), + }, + }; + }; + acc.push({ text: menuItem.title, keywords: menuItem.title, icon: menuItem.icon, href: menuItem.link, + ...trackingAttrs(menuItem), }); if (menuItem.items?.length) { - const items = menuItem.items.map(({ title, link }) => ({ - keywords: title, - text: [menuItem.title, title].join(' > '), - href: link, + const items = menuItem.items.map((item) => ({ + keywords: item.title, + text: [menuItem.title, item.title].join(' > '), + href: item.link, icon: menuItem.icon, + ...trackingAttrs(item), })); /* eslint-disable-next-line no-param-reassign */ @@ -37,6 +58,10 @@ export const fileMapper = (projectBlobPath, file) => { icon: 'doc-code', text: file, href: joinPaths(projectBlobPath, file), + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': 'file', + }, }; }; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue index 382d844ceee..ddadd6856ca 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue @@ -2,6 +2,8 @@ import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui'; import { truncateNamespace } from '~/lib/utils/text_utility'; import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils'; +import { TRACKING_UNKNOWN_PANEL } from '~/super_sidebar/constants'; +import { TRACKING_CLICK_COMMAND_PALETTE_ITEM } from '../command_palette/constants'; import FrequentItem from './frequent_item.vue'; export default { @@ -65,6 +67,12 @@ export default { // validator, and the href field ensures it renders a link. text: item.name, href: item.webUrl, + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': item.id, + 'data-track-property': TRACKING_UNKNOWN_PANEL, + 'data-track-extra': JSON.stringify({ title: item.name }), + }, }, forRenderer: { id: item.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 b64f3ac52b2..4cfc329f8b8 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 @@ -18,14 +18,12 @@ import { sprintf } from '~/locale'; import { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys'; import { MIN_SEARCH_TERM, - SEARCH_GITLAB, SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, } from '~/vue_shared/global_search/constants'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { darkModeEnabled } from '~/lib/utils/color_utils'; import { SEARCH_INPUT_DESCRIPTION, @@ -52,10 +50,10 @@ export default { name: 'GlobalSearchModal', SEARCH_MODAL_ID, i18n: { - SEARCH_GITLAB, SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, + SEARCH_OR_COMMAND_MODE_PLACEHOLDER, SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, MIN_SEARCH_TERM, @@ -72,7 +70,6 @@ export default { CommandPaletteItems, FakeSearchInput, }, - mixins: [glFeatureFlagMixin()], data() { return { nextFocusedItemIndex: null, @@ -89,9 +86,6 @@ export default { this.setSearch(value); }, }, - searchPlaceholder() { - return this.glFeatures?.commandPalette ? SEARCH_OR_COMMAND_MODE_PLACEHOLDER : SEARCH_GITLAB; - }, showDefaultItems() { return !this.searchText; }, @@ -146,9 +140,8 @@ export default { }, isCommandMode() { return ( - this.glFeatures?.commandPalette && - (COMMON_HANDLES.includes(this.searchTextFirstChar) || - (this.searchContext.project && this.searchTextFirstChar === PATH_HANDLE)) + COMMON_HANDLES.includes(this.searchTextFirstChar) || + (this.searchContext?.project && this.searchTextFirstChar === PATH_HANDLE) ); }, commandPaletteQuery() { @@ -294,7 +287,7 @@ export default { > <form role="search" - :aria-label="searchPlaceholder" + :aria-label="$options.i18n.SEARCH_OR_COMMAND_MODE_PLACEHOLDER" class="gl-relative gl-rounded-base gl-w-full gl-pb-0" > <div class="gl-relative gl-bg-white gl-border-b gl-mb-n1 gl-p-3"> @@ -305,7 +298,7 @@ export default { role="searchbox" data-testid="global-search-input" autocomplete="off" - :placeholder="searchPlaceholder" + :placeholder="$options.i18n.SEARCH_OR_COMMAND_MODE_PLACEHOLDER" :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" borderless @input="getAutocompleteOptions" diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue index 9a375837102..9167be5c1cc 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue @@ -1,6 +1,8 @@ <script> import { GlDisclosureDropdownGroup } from '@gitlab/ui'; import { PLACES } from '~/vue_shared/global_search/constants'; +import { TRACKING_UNKNOWN_ID, TRACKING_UNKNOWN_PANEL } from '~/super_sidebar/constants'; +import { TRACKING_CLICK_COMMAND_PALETTE_ITEM } from '../command_palette/constants'; export default { name: 'DefaultPlaces', @@ -18,7 +20,23 @@ export default { group() { return { name: this.$options.i18n.PLACES, - items: this.contextSwitcherLinks.map(({ title, link }) => ({ text: title, href: link })), + items: this.contextSwitcherLinks.map(({ title, link }) => ({ + text: title, + href: link, + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + // The label and property are hard-coded as unknown for now for + // parity with the existing corresponding context switcher items. + // Once the context switcher is removed, these can be changed. + 'data-track-label': TRACKING_UNKNOWN_ID, + 'data-track-property': TRACKING_UNKNOWN_PANEL, + 'data-track-extra': JSON.stringify({ title }), + + // QA attributes + 'data-testid': 'places-item-link', + 'data-qa-places-item': title, + }, + })), }; }, }, 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 6871dabc9a1..79be56f1427 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 @@ -14,6 +14,7 @@ import { SEARCH_RESULTS_ORDER, } from '~/vue_shared/global_search/constants'; import { getFormattedItem } from '../utils'; +import { TRACKING_CLICK_COMMAND_PALETTE_ITEM } from '../command_palette/constants'; import { ICON_GROUP, @@ -172,6 +173,10 @@ export const scopedSearchOptions = (state, getters) => { scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, href: getters.projectUrl, + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': 'scoped_in_project', + }, }); } @@ -182,6 +187,10 @@ export const scopedSearchOptions = (state, getters) => { scopeCategory: GROUPS_CATEGORY, icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP, href: getters.groupUrl, + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': 'scoped_in_group', + }, }); } @@ -189,6 +198,10 @@ export const scopedSearchOptions = (state, getters) => { text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, href: getters.allUrl, + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': 'scoped_in_all', + }, }); return items; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/utils.js index 11d1fa1ab95..2c369cbdf5f 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/utils.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/utils.js @@ -1,5 +1,5 @@ import { pickBy } from 'lodash'; -import { truncateNamespace } from '~/lib/utils/text_utility'; +import { slugify, truncateNamespace } from '~/lib/utils/text_utility'; import { GROUPS_CATEGORY, PROJECTS_CATEGORY, @@ -7,6 +7,7 @@ import { ISSUES_CATEGORY, RECENT_EPICS_CATEGORY, } from '~/vue_shared/global_search/constants'; +import { TRACKING_CLICK_COMMAND_PALETTE_ITEM } from './command_palette/constants'; import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from './constants'; const getTruncatedNamespace = (string) => { @@ -61,6 +62,15 @@ export const getFormattedItem = (item, searchContext) => { const avatarSize = getAvatarSize(category); const entityId = getEntityId(item, searchContext); const entityName = getEntityName(item, searchContext); + const trackingLabel = slugify(category ?? ''); + const trackingAttrs = trackingLabel + ? { + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': slugify(category, '_'), + }, + } + : {}; return pickBy( { @@ -75,6 +85,7 @@ export const getFormattedItem = (item, searchContext) => { namespace, entity_id: entityId, entity_name: entityName, + ...trackingAttrs, }, (val) => val !== undefined, ); diff --git a/app/assets/javascripts/super_sidebar/components/groups_list.vue b/app/assets/javascripts/super_sidebar/components/groups_list.vue deleted file mode 100644 index 48becacebb7..00000000000 --- a/app/assets/javascripts/super_sidebar/components/groups_list.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script> -import { s__ } from '~/locale'; -import { MAX_FREQUENT_GROUPS_COUNT } from '../constants'; -import FrequentItemsList from './frequent_items_list.vue'; -import SearchResults from './search_results.vue'; -import NavItem from './nav_item.vue'; - -export default { - MAX_FREQUENT_GROUPS_COUNT, - components: { - FrequentItemsList, - SearchResults, - NavItem, - }, - props: { - username: { - type: String, - required: true, - }, - viewAllLink: { - type: String, - required: true, - }, - isSearch: { - type: Boolean, - required: false, - default: false, - }, - searchResults: { - type: Array, - required: false, - default: () => [], - }, - }, - computed: { - storageKey() { - return `${this.username}/frequent-groups`; - }, - viewAllProps() { - return { - item: { - link: this.viewAllLink, - title: s__('Navigation|View all your groups'), - icon: 'group', - }, - linkClasses: { 'dashboard-shortcuts-groups': true }, - }; - }, - }, - i18n: { - title: s__('Navigation|Frequently visited groups'), - searchTitle: s__('Navigation|Groups'), - pristineText: s__('Navigation|Groups you visit often will appear here.'), - noResultsText: s__('Navigation|No group matches found'), - }, -}; -</script> - -<template> - <search-results - v-if="isSearch" - :title="$options.i18n.searchTitle" - :no-results-text="$options.i18n.noResultsText" - :search-results="searchResults" - > - <template #view-all-items> - <nav-item v-bind="viewAllProps" is-subitem /> - </template> - </search-results> - <frequent-items-list - v-else - :title="$options.i18n.title" - :storage-key="storageKey" - :max-items="$options.MAX_FREQUENT_GROUPS_COUNT" - :pristine-text="$options.i18n.pristineText" - > - <template #view-all-items> - <nav-item v-bind="viewAllProps" is-subitem /> - </template> - </frequent-items-list> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue deleted file mode 100644 index 1bad13f91e8..00000000000 --- a/app/assets/javascripts/super_sidebar/components/items_list.vue +++ /dev/null @@ -1,44 +0,0 @@ -<script> -import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; -import NavItem from './nav_item.vue'; - -export default { - components: { - ProjectAvatar, - NavItem, - }, - props: { - items: { - type: Array, - required: false, - default: () => [], - }, - }, -}; -</script> - -<template> - <ul class="gl-p-0 gl-list-style-none"> - <nav-item - v-for="item in items" - :key="item.id" - :item="item" - is-subitem - class="show-on-focus-or-hover--context" - > - <template #icon> - <project-avatar - :project-id="item.id" - :project-name="item.title" - :project-avatar-url="item.avatar" - :size="24" - aria-hidden="true" - /> - </template> - <template #actions> - <slot name="actions" :item="item"></slot> - </template> - </nav-item> - <slot name="view-all-items"></slot> - </ul> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue index d2d45ca7b6e..6b5002e1aa8 100644 --- a/app/assets/javascripts/super_sidebar/components/menu_section.vue +++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue @@ -79,15 +79,26 @@ export default { isExpanded(newIsExpanded) { this.$emit('collapse-toggle', newIsExpanded); this.keepFlyoutClosed = !this.newIsExpanded; + if (!newIsExpanded) { + this.isMouseOverFlyout = false; + } }, }, methods: { handlePointerover(e) { + if (!this.hasFlyout) return; + this.isMouseOverSection = e.pointerType === 'mouse'; }, handlePointerleave() { - this.isMouseOverSection = false; + if (!this.hasFlyout) return; + this.keepFlyoutClosed = false; + // delay state change. otherwise the flyout menu gets removed before it + // has a chance to emit its mouseover event. + setTimeout(() => { + this.isMouseOverSection = false; + }, 5); }, }, }; @@ -129,8 +140,7 @@ export default { </button> <flyout-menu - v-if="hasFlyout" - v-show="isMouseOver && !isExpanded && !keepFlyoutClosed" + v-if="hasFlyout && isMouseOver && !isExpanded && !keepFlyoutClosed && item.items.length > 0" :target-id="`menu-section-button-${itemId}`" :items="item.items" @mouseover="isMouseOverFlyout = true" diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue index 36803a885e7..5e0f8fffb0e 100644 --- a/app/assets/javascripts/super_sidebar/components/nav_item.vue +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -1,6 +1,6 @@ <script> -import { GlButton, GlIcon, GlBadge, GlTooltipDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlAvatar, GlButton, GlIcon, GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; import { CLICK_MENU_ITEM_ACTION, CLICK_PINNED_MENU_ITEM_ACTION, @@ -12,11 +12,14 @@ import NavItemRouterLink from './nav_item_router_link.vue'; export default { i18n: { + pin: s__('Navigation|Pin %{title}'), pinItem: s__('Navigation|Pin item'), + unpin: s__('Navigation|Unpin %{title}'), unpinItem: s__('Navigation|Unpin item'), }, name: 'NavItem', components: { + GlAvatar, GlButton, GlIcon, GlBadge, @@ -62,6 +65,12 @@ export default { default: false, }, }, + data() { + return { + isMouseIn: false, + canClickPinButton: false, + }; + }, computed: { pillData() { return this.item.pill_count; @@ -96,12 +105,27 @@ export default { ...extraData, }; }, + /** + * Some QA specs rely on a stable "Project overview"/"Group overview" nav + * item data-qa-submenu-item attribute value. + * + * This computed ensures that those particular nav items use the `id` of + * the item rather than its title for that QA attribute. + * + * In future, probably all nav items should do this, for consistency. + * See https://gitlab.com/gitlab-org/gitlab/-/issues/422925. + */ + qaSubMenuItem() { + const { id } = this.item; + if (id === 'project_overview' || id === 'group_overview') return id.replace(/_/g, '-'); + return this.item.title; + }, linkProps() { return { ...this.$attrs, ...this.trackingProps, item: this.item, - 'data-qa-submenu-item': this.item.title, + 'data-qa-submenu-item': this.qaSubMenuItem, 'data-method': this.item.data_method ?? null, }; }, @@ -118,26 +142,73 @@ export default { navItemLinkComponent() { return this.item.to ? NavItemRouterLink : NavItemLink; }, + hasAvatar() { + return Boolean(this.item.entity_id); + }, + avatarShape() { + return this.item.avatar_shape || 'rect'; + }, + pinAriaLabel() { + return sprintf(this.$options.i18n.pin, { + title: this.item.title, + }); + }, + unpinAriaLabel() { + return sprintf(this.$options.i18n.unpin, { + title: this.item.title, + }); + }, + activeIndicatorStyle() { + const style = { + width: '3px', + borderRadius: '3px', + marginRight: '1px', + }; + + // The active indicator is too close to the avatar for items with one, so shift + // it left by 1px. + // + // The indicator is absolutely positioned using rem units. This tweak for this + // edge case is in pixel units, so that it does not scale with root font size. + if (this.hasAvatar) style.transform = 'translateX(-1px)'; + + return style; + }, + }, + mounted() { + if (this.item.is_active) { + this.$el.scrollIntoView(false); + } + }, + methods: { + togglePointerEvents() { + this.canClickPinButton = this.isMouseIn; + }, }, }; </script> <template> - <li> + <li + class="gl-relative show-on-focus-or-hover--context hide-on-focus-or-hover--context transition-opacity-on-hover--context" + data-testid="nav-item" + @mouseenter="isMouseIn = true" + @mouseleave="isMouseIn = false" + > <component :is="navItemLinkComponent" #default="{ isActive }" v-bind="linkProps" - class="nav-item-link gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus show-on-focus-or-hover--context" + class="gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus show-on-focus-or-hover--control hide-on-focus-or-hover--control" :class="computedLinkClasses" - data-qa-selector="nav_item_link" data-testid="nav-item-link" + data-qa-selector="nav_item_link" > <div :class="[isActive ? 'gl-opacity-10' : 'gl-opacity-0']" class="active-indicator gl-bg-blue-500 gl-absolute gl-left-2 gl-top-2 gl-bottom-2 gl-transition-slow" aria-hidden="true" - style="width: 3px; border-radius: 3px; margin-right: 1px" + :style="activeIndicatorStyle" data-testid="active-indicator" ></div> <div v-if="!isFlyout" class="gl-flex-shrink-0 gl-w-6 gl-display-flex"> @@ -148,6 +219,14 @@ export default { name="grip" class="gl-m-auto gl-text-gray-400 js-draggable-icon gl-cursor-grab show-on-focus-or-hover--target" /> + <gl-avatar + v-else-if="hasAvatar" + :size="24" + :shape="avatarShape" + :entity-name="item.title" + :entity-id="item.entity_id" + :src="item.avatar" + /> </slot> </div> <div class="gl-flex-grow-1 gl-text-gray-900 gl-truncate-end"> @@ -157,36 +236,47 @@ export default { </div> </div> <slot name="actions"></slot> - <span v-if="hasPill || isPinnable" class="gl-text-right gl-relative"> + <span v-if="hasPill || isPinnable" class="gl-text-right gl-relative gl-min-w-8"> <gl-badge v-if="hasPill" size="sm" variant="neutral" - :class="{ 'nav-item-badge gl-absolute gl-right-0 gl-top-2': isPinnable }" + class="gl-bg-t-gray-a-08!" + :class="{ + 'hide-on-focus-or-hover--target transition-opacity-on-hover--target': isPinnable, + }" > {{ pillData }} </gl-badge> - <gl-button - v-if="isPinnable && !isPinned" - v-gl-tooltip.noninteractive.ds500.right.viewport="$options.i18n.pinItem" - size="small" - category="tertiary" - icon="thumbtack" - class="show-on-focus-or-hover--target" - :aria-label="$options.i18n.pinItem" - @click.prevent="$emit('pin-add', item.id)" - /> - <gl-button - v-else-if="isPinnable && isPinned" - v-gl-tooltip.noninteractive.ds500.right.viewport="$options.i18n.unpinItem" - size="small" - category="tertiary" - :aria-label="$options.i18n.unpinItem" - icon="thumbtack-solid" - class="show-on-focus-or-hover--target" - @click.prevent="$emit('pin-remove', item.id)" - /> </span> </component> + <template v-if="isPinnable"> + <gl-button + v-if="isPinned" + v-gl-tooltip.noninteractive.right.viewport="$options.i18n.unpinItem" + :aria-label="unpinAriaLabel" + category="tertiary" + class="show-on-focus-or-hover--target transition-opacity-on-hover--target always-animate gl-absolute gl-right-3 gl-top-2" + :class="{ 'gl-pointer-events-none': !canClickPinButton }" + data-testid="nav-item-unpin" + icon="thumbtack-solid" + size="small" + @click="$emit('pin-remove', item.id)" + @transitionend="togglePointerEvents" + /> + <gl-button + v-else + v-gl-tooltip.noninteractive.right.viewport="$options.i18n.pinItem" + :aria-label="pinAriaLabel" + category="tertiary" + class="show-on-focus-or-hover--target transition-opacity-on-hover--target always-animate gl-absolute gl-right-3 gl-top-2" + :class="{ 'gl-pointer-events-none': !canClickPinButton }" + data-testid="nav-item-pin" + icon="thumbtack" + size="small" + @click="$emit('pin-add', item.id)" + @transitionend="togglePointerEvents" + /> + </template> </li> </template> diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue index 1e2201fbdff..5da45b52bf4 100644 --- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue +++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue @@ -6,6 +6,13 @@ import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '../cons import MenuSection from './menu_section.vue'; import NavItem from './nav_item.vue'; +const AMBIGUOUS_SETTINGS = { + ci_cd: s__('Navigation|CI/CD settings'), + merge_request_settings: s__('Navigation|Merge requests settings'), + monitor: s__('Navigation|Monitor settings'), + repository: s__('Navigation|Repository settings'), +}; + export default { i18n: { pinned: s__('Navigation|Pinned'), @@ -23,11 +30,6 @@ export default { required: false, default: () => [], }, - separated: { - type: Boolean, - required: false, - default: false, - }, hasFlyout: { type: Boolean, required: false, @@ -37,7 +39,7 @@ export default { data() { return { expanded: getCookie(SIDEBAR_PINS_EXPANDED_COOKIE) !== 'false', - draggableItems: this.items, + draggableItems: this.renameSettings(this.items), }; }, computed: { @@ -63,7 +65,7 @@ export default { }); }, items(newItems) { - this.draggableItems = newItems; + this.draggableItems = this.renameSettings(newItems); }, }, methods: { @@ -76,6 +78,15 @@ export default { event.oldIndex < event.newIndex, ); }, + renameSettings(items) { + return items.map((i) => { + const title = AMBIGUOUS_SETTINGS[i.id] || i.title; + return { ...i, title }; + }); + }, + onPinRemove(itemId) { + this.$emit('pin-remove', itemId); + }, }, }; </script> @@ -84,10 +95,9 @@ export default { <menu-section :item="sectionItem" :expanded="expanded" - :separated="separated" :has-flyout="hasFlyout" @collapse-toggle="expanded = !expanded" - @pin-remove="(itemId) => $emit('pin-remove', itemId)" + @pin-remove="onPinRemove" > <draggable v-if="items.length > 0" @@ -103,7 +113,7 @@ export default { :key="item.id" :item="item" is-in-pinned-section - @pin-remove="(itemId) => $emit('pin-remove', itemId)" + @pin-remove="onPinRemove" /> </draggable> <li v-else class="gl-text-secondary gl-font-sm gl-py-3" style="margin-left: 2.5rem"> diff --git a/app/assets/javascripts/super_sidebar/components/projects_list.vue b/app/assets/javascripts/super_sidebar/components/projects_list.vue deleted file mode 100644 index 8d1a5c825b5..00000000000 --- a/app/assets/javascripts/super_sidebar/components/projects_list.vue +++ /dev/null @@ -1,82 +0,0 @@ -<script> -import { s__ } from '~/locale'; -import { MAX_FREQUENT_PROJECTS_COUNT } from '../constants'; -import FrequentItemsList from './frequent_items_list.vue'; -import SearchResults from './search_results.vue'; -import NavItem from './nav_item.vue'; - -export default { - MAX_FREQUENT_PROJECTS_COUNT, - components: { - FrequentItemsList, - SearchResults, - NavItem, - }, - props: { - username: { - type: String, - required: true, - }, - viewAllLink: { - type: String, - required: true, - }, - isSearch: { - type: Boolean, - required: false, - default: false, - }, - searchResults: { - type: Array, - required: false, - default: () => [], - }, - }, - computed: { - storageKey() { - return `${this.username}/frequent-projects`; - }, - viewAllProps() { - return { - item: { - link: this.viewAllLink, - title: s__('Navigation|View all your projects'), - icon: 'project', - }, - linkClasses: { 'dashboard-shortcuts-projects': true }, - }; - }, - }, - i18n: { - title: s__('Navigation|Frequently visited projects'), - searchTitle: s__('Navigation|Projects'), - pristineText: s__('Navigation|Projects you visit often will appear here.'), - noResultsText: s__('Navigation|No project matches found'), - }, -}; -</script> - -<template> - <search-results - v-if="isSearch" - class="gl-border-t-0" - :title="$options.i18n.searchTitle" - :no-results-text="$options.i18n.noResultsText" - :search-results="searchResults" - > - <template #view-all-items> - <nav-item v-bind="viewAllProps" is-subitem /> - </template> - </search-results> - <frequent-items-list - v-else - :title="$options.i18n.title" - :storage-key="storageKey" - :max-items="$options.MAX_FREQUENT_PROJECTS_COUNT" - :pristine-text="$options.i18n.pristineText" - > - <template #view-all-items> - <nav-item v-bind="viewAllProps" is-subitem /> - </template> - </frequent-items-list> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/search_results.vue b/app/assets/javascripts/super_sidebar/components/search_results.vue deleted file mode 100644 index ff933f341af..00000000000 --- a/app/assets/javascripts/super_sidebar/components/search_results.vue +++ /dev/null @@ -1,99 +0,0 @@ -<script> -import { GlCollapse, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui'; -import uniqueId from 'lodash/uniqueId'; -import ItemsList from './items_list.vue'; - -export default { - components: { - GlCollapse, - GlIcon, - ItemsList, - }, - directives: { - CollapseToggle: GlCollapseToggleDirective, - }, - props: { - title: { - type: String, - required: true, - }, - noResultsText: { - type: String, - required: true, - }, - searchResults: { - type: Array, - required: false, - default: () => [], - }, - }, - data() { - return { - expanded: true, - }; - }, - computed: { - isEmpty() { - return !this.searchResults.length; - }, - collapseIcon() { - return this.expanded ? 'chevron-up' : 'chevron-down'; - }, - }, - created() { - this.collapseId = uniqueId('expandable-section-'); - }, - buttonClasses: [ - // Reset user agent styles - 'gl-appearance-none', - 'gl-border-0', - 'gl-bg-transparent', - // Text styles - 'gl-text-left', - 'gl-text-transform-uppercase', - 'gl-text-secondary', - 'gl-font-weight-semibold', - 'gl-font-xs', - 'gl-line-height-12', - 'gl-letter-spacing-06em', - // Border - 'gl-border-t', - 'gl-border-gray-50', - // Spacing - 'gl-my-3', - 'gl-pt-2', - // Layout - 'gl-display-flex', - 'gl-justify-content-space-between', - 'gl-align-items-center', - ], -}; -</script> - -<template> - <li class="gl-border-t gl-border-gray-50"> - <button - v-collapse-toggle="collapseId" - :class="$options.buttonClasses" - class="gl-mx-3" - data-testid="search-results-toggle" - > - {{ title }} - <gl-icon :name="collapseIcon" :size="16" /> - </button> - <gl-collapse :id="collapseId" v-model="expanded"> - <div - v-if="isEmpty" - data-testid="empty-text" - class="gl-text-gray-500 gl-font-sm gl-mb-3 gl-mx-4" - > - {{ noResultsText }} - </div> - <items-list :aria-label="title" :items="searchResults"> - <template #view-all-items> - <slot name="view-all-items"></slot> - </template> - </items-list> - </gl-collapse> - </li> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_hover_peek_behavior.vue b/app/assets/javascripts/super_sidebar/components/sidebar_hover_peek_behavior.vue new file mode 100644 index 00000000000..df432a1928a --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/sidebar_hover_peek_behavior.vue @@ -0,0 +1,126 @@ +<script> +import { getCssClassDimensions } from '~/lib/utils/css_utils'; +import Tracking from '~/tracking'; +import { + JS_TOGGLE_EXPAND_CLASS, + SUPER_SIDEBAR_PEEK_OPEN_DELAY, + SUPER_SIDEBAR_PEEK_CLOSE_DELAY, + SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED, + SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN, + SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN, + SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE as STATE_WILL_CLOSE, +} from '../constants'; + +export default { + name: 'SidebarHoverPeek', + mixins: [Tracking.mixin()], + props: { + isMouseOverSidebar: { + type: Boolean, + required: false, + default: false, + }, + }, + created() { + // Nothing needs to observe these properties, so they are not reactive. + this.state = null; + this.openTimer = null; + this.closeTimer = null; + this.xSidebarEdge = null; + this.isMouseWithinSidebarArea = false; + }, + async mounted() { + await this.$nextTick(); + this.xSidebarEdge = getCssClassDimensions('super-sidebar').width; + document.addEventListener('mousemove', this.onMouseMove); + document.documentElement.addEventListener('mouseleave', this.onDocumentLeave); + document + .querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`) + .addEventListener('mouseenter', this.onMouseEnter); + document + .querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`) + .addEventListener('mouseleave', this.onMouseLeave); + this.changeState(STATE_CLOSED); + }, + beforeDestroy() { + document.removeEventListener('mousemove', this.onMouseMove); + document.documentElement.removeEventListener('mouseleave', this.onDocumentLeave); + document + .querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`) + .removeEventListener('mouseenter', this.onMouseEnter); + document + .querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`) + .removeEventListener('mouseleave', this.onMouseLeave); + this.clearTimers(); + }, + methods: { + onMouseMove({ clientX }) { + if (clientX < this.xSidebarEdge) { + this.isMouseWithinSidebarArea = true; + } else { + this.isMouseWithinSidebarArea = false; + if (!this.isMouseOverSidebar && this.state === STATE_OPEN) { + this.willClose(); + } + } + }, + onDocumentLeave() { + this.isMouseWithinSidebarArea = false; + if (this.state === STATE_OPEN) { + this.willClose(); + } else if (this.state === STATE_WILL_OPEN) { + this.close(); + } + }, + onMouseEnter() { + clearTimeout(this.closeTimer); + this.willOpen(); + }, + onMouseLeave() { + clearTimeout(this.openTimer); + if (this.isMouseWithinSidebarArea || this.isMouseOverSidebar) return; + this.willClose(); + }, + willClose() { + this.changeState(STATE_WILL_CLOSE); + this.closeTimer = setTimeout(this.close, SUPER_SIDEBAR_PEEK_CLOSE_DELAY); + }, + willOpen() { + this.changeState(STATE_WILL_OPEN); + this.openTimer = setTimeout(this.open, SUPER_SIDEBAR_PEEK_OPEN_DELAY); + }, + open() { + this.changeState(STATE_OPEN); + this.clearTimers(); + this.track('nav_hover_peek', { + label: 'nav_sidebar_toggle', + property: 'nav_sidebar', + }); + }, + close() { + if (this.isMouseWithinSidebarArea) return; + this.changeState(STATE_CLOSED); + this.clearTimers(); + }, + clearTimers() { + clearTimeout(this.closeTimer); + clearTimeout(this.openTimer); + }, + /** + * Switches to the new state, and emits a change event. + * + * If the given state is the current state, do nothing. + * + * @param {string} state The state to transition to. + */ + changeState(state) { + if (this.state === state) return; + this.state = state; + this.$emit('change', state); + }, + }, + render() { + return null; + }, +}; +</script> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue index 821b9dbcb7b..02488e99c0e 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/browser'; import { GlBreakpointInstance, breakpoints } from '@gitlab/ui/dist/utils'; import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { PANELS_WITH_PINS } from '../constants'; import NavItem from './nav_item.vue'; @@ -51,10 +50,6 @@ export default { }, }, - i18n: { - mainNavigation: s__('Navigation|Main navigation'), - }, - data() { return { showFlyoutMenus: false, @@ -109,10 +104,8 @@ export default { }, }, mounted() { - if (this.glFeatures.superSidebarFlyoutMenus) { - this.decideFlyoutState(); - window.addEventListener('resize', this.decideFlyoutState); - } + this.decideFlyoutState(); + window.addEventListener('resize', this.decideFlyoutState); }, beforeDestroy() { window.removeEventListener('resize', this.decideFlyoutState); @@ -164,13 +157,12 @@ export default { </script> <template> - <nav :aria-label="$options.i18n.mainNavigation" class="gl-p-2 gl-relative"> + <div class="gl-p-2 gl-relative"> <ul v-if="hasStaticItems" class="gl-p-0 gl-m-0" data-testid="static-items-section"> <nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static /> </ul> <pinned-section v-if="supportsPins" - separated :items="pinnedItems" :has-flyout="showFlyoutMenus" @pin-remove="destroyPin" @@ -203,5 +195,5 @@ export default { /> </template> </ul> - </nav> + </div> </template> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue index ec728b4af9e..a20e37b945a 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue @@ -1,12 +1,14 @@ <script> import { getCssClassDimensions } from '~/lib/utils/css_utils'; import Tracking from '~/tracking'; -import { SUPER_SIDEBAR_PEEK_OPEN_DELAY, SUPER_SIDEBAR_PEEK_CLOSE_DELAY } from '../constants'; - -export const STATE_CLOSED = 'closed'; -export const STATE_WILL_OPEN = 'will-open'; -export const STATE_OPEN = 'open'; -export const STATE_WILL_CLOSE = 'will-close'; +import { + SUPER_SIDEBAR_PEEK_OPEN_DELAY, + SUPER_SIDEBAR_PEEK_CLOSE_DELAY, + SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED, + SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN, + SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN, + SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE as STATE_WILL_CLOSE, +} from '../constants'; export default { name: 'SidebarPeek', diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index 29a3147e949..fe3e4a8199e 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -2,27 +2,31 @@ import { GlButton } from '@gitlab/ui'; import { Mousetrap } from '~/lib/mousetrap'; import { keysFor, TOGGLE_SUPER_SIDEBAR } from '~/behaviors/shortcuts/keybindings'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; -import { sidebarState } from '../constants'; +import { + sidebarState, + SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED, + SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN, + SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN, +} from '../constants'; import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; +import { trackContextAccess } from '../utils'; import UserBar from './user_bar.vue'; import SidebarPortalTarget from './sidebar_portal_target.vue'; -import ContextHeader from './context_header.vue'; -import ContextSwitcher from './context_switcher.vue'; import HelpCenter from './help_center.vue'; import SidebarMenu from './sidebar_menu.vue'; -import SidebarPeekBehavior, { STATE_CLOSED, STATE_WILL_OPEN } from './sidebar_peek_behavior.vue'; +import SidebarPeekBehavior from './sidebar_peek_behavior.vue'; +import SidebarHoverPeekBehavior from './sidebar_hover_peek_behavior.vue'; export default { components: { GlButton, UserBar, - ContextHeader, - ContextSwitcher, HelpCenter, SidebarMenu, SidebarPeekBehavior, + SidebarHoverPeekBehavior, SidebarPortalTarget, TrialStatusWidget: () => import('ee_component/contextual_sidebar/components/trial_status_widget.vue'), @@ -32,6 +36,7 @@ export default { mixins: [Tracking.mixin()], i18n: { skipToMainContent: __('Skip to main content'), + primary: s__('Navigation|Primary'), }, inject: ['showTrialStatusWidget'], props: { @@ -45,25 +50,34 @@ export default { sidebarState, showPeekHint: false, isMouseover: false, + breakpoint: null, }; }, computed: { + showOverlay() { + return this.sidebarState.isPeek || this.sidebarState.isHoverPeek; + }, menuItems() { return this.sidebarData.current_menu_items || []; }, peekClasses() { return { 'super-sidebar-peek-hint': this.showPeekHint, - 'super-sidebar-peek': this.sidebarState.isPeek, + 'super-sidebar-peek': this.showOverlay, + 'super-sidebar-has-peeked': this.sidebarState.hasPeeked, }; }, }, - watch: { - 'sidebarState.isCollapsed': function isCollapsedWatcher(newIsCollapsed) { - if (newIsCollapsed && this.$refs['context-switcher']) { - this.$refs['context-switcher'].close(); - } - }, + created() { + const { + is_logged_in: isLoggedIn, + current_context: currentContext, + username, + track_visits_path: trackVisitsPath, + } = this.sidebarData; + if (isLoggedIn && currentContext.namespace) { + trackContextAccess(username, currentContext, trackVisitsPath); + } }, mounted() { Mousetrap.bind(keysFor(TOGGLE_SUPER_SIDEBAR), this.toggleSidebar); @@ -88,6 +102,7 @@ export default { this.sidebarState.isCollapsed = true; this.showPeekHint = false; } else if (state === STATE_WILL_OPEN) { + this.sidebarState.hasPeeked = true; this.sidebarState.isPeek = false; this.sidebarState.isCollapsed = true; this.showPeekHint = true; @@ -97,8 +112,15 @@ export default { this.showPeekHint = false; } }, - onContextSwitcherToggled(open) { - this.sidebarState.contextSwitcherOpen = open; + onHoverPeekChange(state) { + if (state === STATE_OPEN) { + this.sidebarState.hasPeeked = true; + this.sidebarState.isHoverPeek = true; + this.sidebarState.isCollapsed = false; + } else if (state === STATE_CLOSED) { + this.sidebarState.isHoverPeek = false; + this.sidebarState.isCollapsed = true; + } }, }, }; @@ -114,8 +136,9 @@ export default { > {{ $options.i18n.skipToMainContent }} </gl-button> - <aside + <nav id="super-sidebar" + :aria-label="$options.i18n.primary" class="super-sidebar" :class="peekClasses" data-testid="super-sidebar" @@ -124,32 +147,23 @@ export default { @mouseenter="isMouseover = true" @mouseleave="isMouseover = false" > - <user-bar :has-collapse-button="!sidebarState.isPeek" :sidebar-data="sidebarData" /> + <user-bar :has-collapse-button="!showOverlay" :sidebar-data="sidebarData" /> <div v-if="showTrialStatusWidget" class="gl-px-2 gl-py-2"> <trial-status-widget - class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-3 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! nav-item-link gl-py-3" + class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-3 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-py-3" /> <trial-status-popover /> </div> <div class="contextual-nav gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden" > - <div - class="gl-flex-grow-1" - :class="{ 'gl-overflow-auto': !sidebarState.contextSwitcherOpen }" - data-testid="nav-container" - > - <context-switcher - v-if="sidebarData.is_logged_in" - ref="context-switcher" - :username="sidebarData.username" - :projects-path="sidebarData.projects_path" - :groups-path="sidebarData.groups_path" - :current-context="sidebarData.current_context" - :context-header="sidebarData.current_context_header" - @toggle="onContextSwitcherToggled" - /> - <context-header v-else :context="sidebarData.current_context_header" /> + <div class="gl-flex-grow-1 gl-overflow-auto" data-testid="nav-container"> + <h2 + class="gl-px-5 gl-pt-3 gl-pb-2 gl-m-0 gl-reset-line-height gl-font-sm super-sidebar-context-header" + > + {{ sidebarData.current_context_header }} + </h2> + <sidebar-menu v-if="menuItems.length" :items="menuItems" @@ -164,7 +178,7 @@ export default { <help-center :sidebar-data="sidebarData" /> </div> </div> - </aside> + </nav> <a v-for="shortcutLink in sidebarData.shortcut_links" :key="shortcutLink.href" @@ -176,13 +190,18 @@ export default { </a> <!-- - Only mount SidebarPeekBehavior if the sidebar is peekable, to avoid + Only mount peek behavior components if the sidebar is peekable, to avoid setting up event listeners unnecessarily. --> <sidebar-peek-behavior - v-if="sidebarState.isPeekable" + v-if="sidebarState.isPeekable && !sidebarState.isHoverPeek" :is-mouse-over-sidebar="isMouseover" @change="onPeekChange" /> + <sidebar-hover-peek-behavior + v-if="sidebarState.isPeekable && !sidebarState.isPeek" + :is-mouse-over-sidebar="isMouseover" + @change="onHoverPeekChange" + /> </div> </template> diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue index 7d5e87805d5..30ee18cc369 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue @@ -27,19 +27,18 @@ export default { }, i18n: { collapseSidebar: __('Hide sidebar'), - expandSidebar: __('Show sidebar'), - navigationSidebar: __('Navigation sidebar'), + expandSidebar: __('Keep sidebar visible'), + primaryNavigationSidebar: __('Primary navigation sidebar'), }, data() { return sidebarState; }, computed: { + canOpen() { + return this.isCollapsed || this.isPeek || this.isHoverPeek; + }, tooltipTitle() { - if (this.isPeek) return ''; - - return this.isCollapsed - ? this.$options.i18n.expandSidebar - : this.$options.i18n.collapseSidebar; + return this.canOpen ? this.$options.i18n.expandSidebar : this.$options.i18n.collapseSidebar; }, tooltip() { return { @@ -49,21 +48,21 @@ export default { }; }, ariaExpanded() { - return String(!this.isCollapsed); + return String(!this.canOpen); }, }, methods: { toggle() { - this.track(this.isCollapsed ? 'nav_show' : 'nav_hide', { + this.track(this.canOpen ? 'nav_show' : 'nav_hide', { label: 'nav_toggle', property: 'nav_sidebar', }); - toggleSuperSidebarCollapsed(!this.isCollapsed, true); + toggleSuperSidebarCollapsed(!this.canOpen, true); this.focusOtherToggle(); }, focusOtherToggle() { this.$nextTick(() => { - const classSelector = this.isCollapsed ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS; + const classSelector = this.canOpen ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS; const otherToggle = document.querySelector(`.${classSelector}`); otherToggle?.focus(); }); @@ -74,13 +73,12 @@ export default { <template> <gl-button - v-gl-tooltip.hover.noninteractive.ds500="tooltip" + v-gl-tooltip.hover="tooltip" aria-controls="super-sidebar" :aria-expanded="ariaExpanded" - :aria-label="$options.i18n.navigationSidebar" + :aria-label="$options.i18n.primaryNavigationSidebar" icon="sidebar" category="tertiary" - :disabled="isPeek" @click="toggle" /> </template> diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index b76ef91b768..49aee4f3470 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -1,5 +1,5 @@ <script> -import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import { destroyUserCountsManager, @@ -34,20 +34,19 @@ export default { ), SuperSidebarToggle, BrandLogo, + GlIcon, }, i18n: { - createNew: __('Create new...'), - homepage: __('Homepage'), issues: __('Issues'), mergeRequests: __('Merge requests'), - search: __('Search'), searchKbdHelp: sprintf( - s__('GlobalSearch|Search GitLab %{kbdOpen}/%{kbdClose}'), + s__('GlobalSearch|Type %{kbdOpen}/%{kbdClose} to search'), { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, false, ), todoList: __('To-Do list'), stopImpersonating: __('Stop impersonating'), + searchBtnText: __('Search or go to…'), }, directives: { GlTooltip: GlTooltipDirective, @@ -103,8 +102,14 @@ export default { </script> <template> - <div class="user-bar"> - <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2"> + <div + class="user-bar gl-display-flex gl-p-3 gl-gap-1" + :class="{ 'gl-flex-direction-column gl-gap-3': sidebarData.is_logged_in }" + > + <div + v-if="hasCollapseButton || sidebarData.is_logged_in" + class="gl-display-flex gl-align-items-center gl-gap-1" + > <template v-if="sidebarData.is_logged_in"> <brand-logo :logo-url="sidebarData.logo_url" /> <gl-badge @@ -112,7 +117,6 @@ export default { variant="success" :href="sidebarData.canary_toggle_com_url" size="sm" - class="gl-ml-2" > {{ $options.NEXT_LABEL }} </gl-badge> @@ -126,24 +130,16 @@ export default { tooltip-container="super-sidebar" data-testid="super-sidebar-collapse-button" /> - <create-menu v-if="sidebarData.is_logged_in" :groups="sidebarData.create_new_menu_groups" /> - - <gl-button - id="super-sidebar-search" - v-gl-tooltip.bottom.hover.noninteractive.ds500.html="searchTooltip" - v-gl-modal="$options.SEARCH_MODAL_ID" - data-testid="super-sidebar-search-button" - icon="search" - :aria-label="$options.i18n.search" - category="tertiary" + <create-menu + v-if="sidebarData.is_logged_in && sidebarData.create_new_menu_groups.length > 0" + :groups="sidebarData.create_new_menu_groups" /> - <search-modal @shown="hideSearchTooltip" @hidden="showSearchTooltip" /> <user-menu v-if="sidebarData.is_logged_in" :data="sidebarData" /> <gl-button v-if="isImpersonating" - v-gl-tooltip.noninteractive.ds500.bottom + v-gl-tooltip.bottom :href="sidebarData.stop_impersonation_path" :title="$options.i18n.stopImpersonating" :aria-label="$options.i18n.stopImpersonating" @@ -155,10 +151,10 @@ export default { </div> <div v-if="sidebarData.is_logged_in" - class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2" + class="gl-display-flex gl-justify-content-space-between gl-gap-2" > <counter - v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.issues" + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues" class="gl-flex-basis-third dashboard-shortcuts-issues" icon="issues" :count="userCounts.assigned_issues" @@ -176,9 +172,7 @@ export default { @hidden="mrMenuShown = false" > <counter - v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom=" - mrMenuShown ? '' : $options.i18n.mergeRequests - " + v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests" class="gl-w-full" icon="merge-request-open" :count="mergeRequestTotalCount" @@ -190,7 +184,7 @@ export default { /> </merge-request-menu> <counter - v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.todoList" + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList" class="gl-flex-basis-third shortcuts-todos js-todos-count" icon="todo-done" :count="userCounts.todos" @@ -202,5 +196,16 @@ export default { data-track-property="nav_core_menu" /> </div> + <button + id="super-sidebar-search" + v-gl-tooltip.bottom.hover.html="searchTooltip" + v-gl-modal="$options.SEARCH_MODAL_ID" + class="counter gl-display-block gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-inset-border-1-gray-a-08 gl-line-height-1 gl-focus--focus gl-w-full" + data-testid="super-sidebar-search-button" + > + <gl-icon name="search" /> + {{ $options.i18n.searchBtnText }} + </button> + <search-modal @shown="hideSearchTooltip" @hidden="showSearchTooltip" /> </div> </template> diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index 869f07520a2..ed6c41e85c6 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -19,7 +19,6 @@ const DROPDOWN_X_OFFSET_BASE = -211; const DROPDOWN_X_OFFSET_IMPERSONATING = DROPDOWN_X_OFFSET_BASE + IMPERSONATING_OFFSET; export default { - feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/409005', i18n: { newNavigation: { sectionTitle: s__('NorthstarNavigation|Navigation redesign'), @@ -31,7 +30,6 @@ export default { buyPipelineMinutes: s__('CurrentUser|Buy Pipeline minutes'), oneOfGroupsRunningOutOfPipelineMinutes: s__('CurrentUser|One of your groups is running out'), gitlabNext: s__('CurrentUser|Switch to GitLab Next'), - provideFeedback: s__('NorthstarNavigation|Provide feedback'), startTrial: s__('CurrentUser|Start an Ultimate trial'), signOut: __('Sign out'), }, @@ -131,17 +129,6 @@ export default { }, }; }, - feedbackItem() { - return { - text: this.$options.i18n.provideFeedback, - href: this.$options.feedbackUrl, - extraAttrs: { - target: '_blank', - ...USER_MENU_TRACKING_DEFAULTS, - 'data-track-label': 'provide_nav_feedback', - }, - }; - }, signOutGroup() { return { items: [ @@ -316,7 +303,6 @@ export default { <span class="gl-font-sm">{{ $options.i18n.newNavigation.sectionTitle }}</span> </template> <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled new-navigation /> - <gl-disclosure-dropdown-item :item="feedbackItem" data-testid="feedback-item" /> </gl-disclosure-dropdown-group> <gl-disclosure-dropdown-group diff --git a/app/assets/javascripts/super_sidebar/components/user_name_group.vue b/app/assets/javascripts/super_sidebar/components/user_name_group.vue index 13f19338610..3c8059387fa 100644 --- a/app/assets/javascripts/super_sidebar/components/user_name_group.vue +++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue @@ -71,7 +71,7 @@ export default { v-if="user.status.customized" ref="statusTooltipTarget" data-testid="user-menu-status" - class="gl-display-flex gl-align-items-center gl-mt-2 gl-font-sm" + class="gl-display-flex gl-align-items-baseline gl-mt-2 gl-font-sm" > <gl-emoji :data-name="user.status.emoji" class="gl-mr-1" /> <span v-safe-html="user.status.message_html" class="gl-text-truncate"></span> diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js index 757bf9c7459..77bd8b4a734 100644 --- a/app/assets/javascripts/super_sidebar/constants.js +++ b/app/assets/javascripts/super_sidebar/constants.js @@ -13,10 +13,12 @@ export const portalState = Vue.observable({ }); export const sidebarState = Vue.observable({ - contextSwitcherOpen: false, isCollapsed: false, + hasPeeked: false, isPeek: false, isPeekable: false, + isHoverPeek: false, + wasHoverPeek: false, }); export const helpCenterState = Vue.observable({ @@ -28,13 +30,17 @@ export const MAX_FREQUENT_GROUPS_COUNT = 3; export const SUPER_SIDEBAR_PEEK_OPEN_DELAY = 200; export const SUPER_SIDEBAR_PEEK_CLOSE_DELAY = 500; +export const SUPER_SIDEBAR_PEEK_STATE_CLOSED = 'closed'; +export const SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN = 'will-open'; +export const SUPER_SIDEBAR_PEEK_STATE_OPEN = 'open'; +export const SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE = 'will-close'; export const TRACKING_UNKNOWN_ID = 'item_without_id'; export const TRACKING_UNKNOWN_PANEL = 'nav_panel_unknown'; export const CLICK_MENU_ITEM_ACTION = 'click_menu_item'; export const CLICK_PINNED_MENU_ITEM_ACTION = 'click_pinned_menu_item'; -export const PANELS_WITH_PINS = ['group', 'project']; +export const PANELS_WITH_PINS = ['group', 'project', 'organization']; export const USER_MENU_TRACKING_DEFAULTS = { 'data-track-property': 'nav_user_menu', diff --git a/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql b/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql deleted file mode 100644 index 4b1e65be3fa..00000000000 --- a/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql +++ /dev/null @@ -1,24 +0,0 @@ -query searchUserProjectsAndGroups($username: String!, $search: String) { - projects(search: $search, sort: "latest_activity_desc", membership: true, first: 20) { - nodes { - id - name - namespace: nameWithNamespace - webUrl - avatarUrl - } - } - - user(username: $username) { - id - groups(search: $search, first: 20) { - nodes { - id - name - namespace: fullPath - webUrl - avatarUrl - } - } - } -} diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index 2b62e7a6ede..de16161efb5 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -1,6 +1,4 @@ import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import { initStatusTriggers } from '../header'; import { JS_TOGGLE_EXPAND_CLASS } from './constants'; @@ -12,12 +10,6 @@ import { import SuperSidebar from './components/super_sidebar.vue'; import SuperSidebarToggle from './components/super_sidebar_toggle.vue'; -Vue.use(VueApollo); - -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - const getTrialStatusWidgetData = (sidebarData) => { if (sidebarData.trial_status_widget_data_attrs && sidebarData.trial_status_popover_data_attrs) { const { @@ -97,7 +89,6 @@ export const initSuperSidebar = () => { return new Vue({ el, name: 'SuperSidebarRoot', - apolloProvider, provide: { rootPath, toggleNewNavEndpoint, diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js index feb7e274b07..9ee78a657b6 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js @@ -26,6 +26,9 @@ export const toggleSuperSidebarCollapsed = (collapsed, saveCookie) => { sidebarState.isPeek = false; sidebarState.isPeekable = collapsed; + sidebarState.hasPeeked = false; + sidebarState.isHoverPeek = false; + sidebarState.wasHoverPeek = false; sidebarState.isCollapsed = collapsed; if (saveCookie && isDesktopBreakpoint()) { diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js index cbf93155fb6..97830a32d78 100644 --- a/app/assets/javascripts/super_sidebar/utils.js +++ b/app/assets/javascripts/super_sidebar/utils.js @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/browser'; import AccessorUtilities from '~/lib/utils/accessor'; import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants'; -import { truncateNamespace } from '~/lib/utils/text_utility'; +import axios from '~/lib/utils/axios_utils'; /** * This takes an array of project or groups that were stored in the local storage, to be shown in @@ -18,7 +18,8 @@ const sortItemsByFrequencyAndLastAccess = (items) => // and then by lastAccessedOn with recent most first if (itemA.frequency !== itemB.frequency) { return itemB.frequency - itemA.frequency; - } else if (itemA.lastAccessedOn !== itemB.lastAccessedOn) { + } + if (itemA.lastAccessedOn !== itemB.lastAccessedOn) { return itemB.lastAccessedOn - itemA.lastAccessedOn; } @@ -36,11 +37,43 @@ export const getTopFrequentItems = (items, maxCount) => { return frequentItems.slice(0, maxCount); }; -const updateItemAccess = (contextItem, { lastAccessedOn, frequency = 0 } = {}) => { +/** + * This tracks projects' and groups' visits in order to suggest a list of frequently visited + * entities to the user. Currently, this track visits in two ways: + * - The legacy approach uses a simple counting algorithm and stores the data in the local storage. + * - The above approach is being migrated to a backend-based one, where visits will be stored in the + * DB, and suggestions will be made through a smarter algorithm. When we are ready to transition + * to the newer approach, the legacy one will be cleaned up. + * @param {object} item The project/group item being tracked. + * @param {string} namespace A string indicating whether the tracked entity is a project or a group. + * @param {string} trackVisitsPath The API endpoint to track visits server-side. + * @returns {void} + */ +const updateItemAccess = ( + contextItem, + { lastAccessedOn, frequency = 0 } = {}, + namespace, + trackVisitsPath, +) => { const now = Date.now(); const neverAccessed = !lastAccessedOn; const shouldUpdate = neverAccessed || Math.abs(now - lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1; + if (shouldUpdate && gon.features?.serverSideFrecentNamespaces) { + try { + axios({ + url: trackVisitsPath, + method: 'POST', + data: { + type: namespace, + id: contextItem.id, + }, + }); + } catch (e) { + Sentry.captureException(e); + } + } + return { ...contextItem, frequency: shouldUpdate ? frequency + 1 : frequency, @@ -48,7 +81,7 @@ const updateItemAccess = (contextItem, { lastAccessedOn, frequency = 0 } = {}) = }; }; -export const trackContextAccess = (username, context) => { +export const trackContextAccess = (username, context, trackVisitsPath) => { if (!AccessorUtilities.canUseLocalStorage()) { return false; } @@ -61,9 +94,19 @@ export const trackContextAccess = (username, context) => { ); if (existingItemIndex > -1) { - storedItems[existingItemIndex] = updateItemAccess(context.item, storedItems[existingItemIndex]); + storedItems[existingItemIndex] = updateItemAccess( + context.item, + storedItems[existingItemIndex], + context.namespace, + trackVisitsPath, + ); } else { - const newItem = updateItemAccess(context.item); + const newItem = updateItemAccess( + context.item, + storedItems[existingItemIndex], + context.namespace, + trackVisitsPath, + ); if (storedItems.length === FREQUENT_ITEMS.MAX_COUNT) { sortItemsByFrequencyAndLastAccess(storedItems); storedItems.pop(); @@ -74,15 +117,6 @@ export const trackContextAccess = (username, context) => { return localStorage.setItem(storageKey, JSON.stringify(storedItems)); }; -export const formatContextSwitcherItems = (items) => - items.map(({ id, name: title, namespace, avatarUrl: avatar, webUrl: link }) => ({ - id, - title, - subtitle: truncateNamespace(namespace), - avatar, - link, - })); - export const getItemsFromLocalStorage = ({ storageKey, maxItems }) => { if (!AccessorUtilities.canUseLocalStorage()) { return []; |