Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/super_sidebar')
-rw-r--r--app/assets/javascripts/super_sidebar/components/brand_logo.vue14
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_header.vue56
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher.vue209
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue43
-rw-r--r--app/assets/javascripts/super_sidebar/components/create_menu.vue78
-rw-r--r--app/assets/javascripts/super_sidebar/components/flyout_menu.vue129
-rw-r--r--app/assets/javascripts/super_sidebar/components/frequent_items_list.vue106
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue15
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js10
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js35
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue8
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue17
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue20
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/getters.js13
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/utils.js13
-rw-r--r--app/assets/javascripts/super_sidebar/components/groups_list.vue81
-rw-r--r--app/assets/javascripts/super_sidebar/components/items_list.vue44
-rw-r--r--app/assets/javascripts/super_sidebar/components/menu_section.vue16
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item.vue148
-rw-r--r--app/assets/javascripts/super_sidebar/components/pinned_section.vue30
-rw-r--r--app/assets/javascripts/super_sidebar/components/projects_list.vue82
-rw-r--r--app/assets/javascripts/super_sidebar/components/search_results.vue99
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_hover_peek_behavior.vue126
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_menu.vue16
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue14
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar.vue95
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue26
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue57
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_menu.vue14
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_name_group.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/constants.js10
-rw-r--r--app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql24
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js9
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js3
-rw-r--r--app/assets/javascripts/super_sidebar/utils.js64
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 [];