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.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_header.vue56
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher.vue9
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue41
-rw-r--r--app/assets/javascripts/super_sidebar/components/counter.vue1
-rw-r--r--app/assets/javascripts/super_sidebar/components/create_menu.vue1
-rw-r--r--app/assets/javascripts/super_sidebar/components/flyout_menu.vue65
-rw-r--r--app/assets/javascripts/super_sidebar/components/frequent_items_list.vue45
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue8
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue4
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue40
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue64
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue133
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue40
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue26
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue8
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_issuables.vue45
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue63
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue35
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue1
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/constants.js2
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/getters.js26
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/index.js1
-rw-r--r--app/assets/javascripts/super_sidebar/components/items_list.vue8
-rw-r--r--app/assets/javascripts/super_sidebar/components/menu_section.vue42
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item.vue23
-rw-r--r--app/assets/javascripts/super_sidebar/components/pinned_section.vue16
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_menu.vue28
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue11
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar.vue21
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue47
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_name_group.vue1
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js9
-rw-r--r--app/assets/javascripts/super_sidebar/utils.js44
35 files changed, 809 insertions, 159 deletions
diff --git a/app/assets/javascripts/super_sidebar/components/brand_logo.vue b/app/assets/javascripts/super_sidebar/components/brand_logo.vue
index 66381e4da4d..1589f4978e1 100644
--- a/app/assets/javascripts/super_sidebar/components/brand_logo.vue
+++ b/app/assets/javascripts/super_sidebar/components/brand_logo.vue
@@ -26,7 +26,7 @@ export default {
<template>
<a
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage"
+ v-gl-tooltip:super-sidebar.hover.noninteractive.bottom.ds500="$options.i18n.homepage"
class="brand-logo"
:href="rootPath"
:title="$options.i18n.homepage"
diff --git a/app/assets/javascripts/super_sidebar/components/context_header.vue b/app/assets/javascripts/super_sidebar/components/context_header.vue
new file mode 100644
index 00000000000..11b9840a409
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/context_header.vue
@@ -0,0 +1,56 @@
+<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
index c5f3410a68f..d4aa11b6e04 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
@@ -64,11 +64,8 @@ export default {
ProjectsList,
GroupsList,
},
+ inject: ['contextSwitcherLinks'],
props: {
- persistentLinks: {
- type: Array,
- required: true,
- },
username: {
type: String,
required: true,
@@ -177,7 +174,7 @@ export default {
<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-qa-selector="context_navigation">
+ <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
@@ -185,7 +182,7 @@ export default {
class="gl-border-b gl-border-gray-50 gl-px-0 gl-py-2"
>
<nav-item
- v-for="item in persistentLinks"
+ v-for="item in contextSwitcherLinks"
:key="item.link"
:item="item"
:link-classes="{ [item.link_classes]: item.link_classes }"
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
index 17227a2b123..faa7eba6470 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
@@ -1,11 +1,11 @@
<script>
-import { GlTruncate, GlAvatar, GlIcon } from '@gitlab/ui';
+import { GlIcon } from '@gitlab/ui';
+import ContextHeader from './context_header.vue';
export default {
components: {
- GlTruncate,
- GlAvatar,
GlIcon,
+ ContextHeader,
},
props: {
/*
@@ -24,39 +24,20 @@ export default {
collapseIcon() {
return this.expanded ? 'chevron-up' : 'chevron-down';
},
- avatarShape() {
- return this.context.avatar_shape || 'rect';
- },
},
};
</script>
<template>
- <button
+ <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 border-top border-bottom gl-border-gray-a-08! gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-h-8 gl-flex-shrink-0"
- data-qa-selector="context_switcher"
+ 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"
>
- <span
- v-if="context.icon"
- class="gl-avatar avatar-container gl-bg-t-gray-a-08 icon-avatar rect-avatar s24 gl-mr-3 gl-ml-4"
- >
- <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"
- class="gl-mr-3 gl-ml-4"
- />
- <div class="gl-overflow-auto gl-text-gray-900">
- <gl-truncate :text="context.title" />
- </div>
- <span class="gl-flex-grow-1 gl-text-right gl-mr-4">
+ <template #end>
<gl-icon class="gl-text-gray-400" :name="collapseIcon" />
- </span>
- </button>
+ </template>
+ </context-header>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue
index a6f19ff95f3..c0e1959fba4 100644
--- a/app/assets/javascripts/super_sidebar/components/counter.vue
+++ b/app/assets/javascripts/super_sidebar/components/counter.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon } from '@gitlab/ui';
import { highCountTrim } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue
index 82f4fd18e80..3645606515f 100644
--- a/app/assets/javascripts/super_sidebar/components/create_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue
@@ -95,6 +95,7 @@ export default {
:target="`#${$options.toggleId}`"
placement="bottom"
container="#super-sidebar"
+ noninteractive
>
{{ $options.i18n.createNew }}
</gl-tooltip>
diff --git a/app/assets/javascripts/super_sidebar/components/flyout_menu.vue b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue
new file mode 100644
index 00000000000..fa7960da2f4
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue
@@ -0,0 +1,65 @@
+<script>
+import { computePosition, autoUpdate, offset, flip, shift } from '@floating-ui/dom';
+import NavItem from './nav_item.vue';
+
+export default {
+ name: 'FlyoutMenu',
+ components: { NavItem },
+ props: {
+ targetId: {
+ type: String,
+ required: true,
+ },
+ items: {
+ type: Array,
+ required: true,
+ },
+ },
+ cleanupFunction: undefined,
+ mounted() {
+ const target = document.querySelector(`#${this.targetId}`);
+ const flyout = document.querySelector(`#${this.targetId}-flyout`);
+
+ function updatePosition() {
+ return computePosition(target, flyout, {
+ middleware: [offset({ alignmentAxis: -12 }), flip(), shift()],
+ placement: 'right-start',
+ strategy: 'fixed',
+ }).then(({ x, y }) => {
+ Object.assign(flyout.style, {
+ left: `${x}px`,
+ top: `${y}px`,
+ });
+ });
+ }
+
+ this.$options.cleanupFunction = autoUpdate(target, flyout, updatePosition);
+ },
+ beforeUnmount() {
+ this.$options.cleanupFunction();
+ },
+};
+</script>
+
+<template>
+ <div
+ :id="`${targetId}-flyout`"
+ class="gl-fixed gl-p-4 gl-mx-n1 gl-z-index-9999 gl-max-h-full gl-overflow-y-auto"
+ @mouseover="$emit('mouseover')"
+ @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"
+ >
+ <nav-item
+ v-for="item of items"
+ :key="item.id"
+ :item="item"
+ :is-flyout="true"
+ @pin-add="(itemId) => $emit('pin-add', itemId)"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
+ />
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
index 342e1284e86..fe1a907bd91 100644
--- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
@@ -1,9 +1,11 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import * as Sentry from '@sentry/browser';
-import AccessorUtilities from '~/lib/utils/accessor';
import { __ } from '~/locale';
-import { getTopFrequentItems, formatContextSwitcherItems } from '../utils';
+import {
+ getItemsFromLocalStorage,
+ removeItemFromLocalStorage,
+ formatContextSwitcherItems,
+} from '../utils';
import ItemsList from './items_list.vue';
export default {
@@ -43,35 +45,21 @@ export default {
},
},
created() {
- this.getItemsFromLocalStorage();
+ this.cachedFrequentItems = formatContextSwitcherItems(
+ getItemsFromLocalStorage({
+ storageKey: this.storageKey,
+ maxItems: this.maxItems,
+ }),
+ );
},
methods: {
- getItemsFromLocalStorage() {
- if (!AccessorUtilities.canUseLocalStorage()) {
- return;
- }
- try {
- const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(this.storageKey));
- const topFrequentItems = getTopFrequentItems(parsedCachedFrequentItems, this.maxItems);
- this.cachedFrequentItems = formatContextSwitcherItems(topFrequentItems);
- } catch (e) {
- Sentry.captureException(e);
- }
- },
handleItemRemove(item) {
- try {
- // Remove item from local storage
- const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(this.storageKey));
- localStorage.setItem(
- this.storageKey,
- JSON.stringify(parsedCachedFrequentItems.filter((i) => i.id !== item.id)),
- );
+ removeItemFromLocalStorage({
+ storageKey: this.storageKey,
+ item,
+ });
- // Update the list
- this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id);
- } catch (e) {
- Sentry.captureException(e);
- }
+ this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id);
},
},
i18n: {
@@ -103,6 +91,7 @@ export default {
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"
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 a1d0e400b5f..bd79962f1a1 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
@@ -133,6 +133,12 @@ export default {
},
immediate: true,
},
+ handle: {
+ handler() {
+ this.debouncedSearch();
+ },
+ immediate: false,
+ },
},
updated() {
this.$emit('updated');
@@ -180,7 +186,7 @@ export default {
}
},
async getScopedItems() {
- if (this.searchQuery && this.searchQuery.length < 3) return;
+ if (this.searchQuery?.length < 3) return;
this.loading = true;
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue
index efd93e88fa9..28e50dceb48 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue
@@ -36,7 +36,7 @@ export default {
<style scoped>
.fake-input {
- top: 12px;
- left: 33px;
+ top: 18px;
+ left: 39px;
}
</style>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue
new file mode 100644
index 00000000000..6f0a0a1fe79
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue
@@ -0,0 +1,40 @@
+<script>
+import { s__ } from '~/locale';
+import { MAX_FREQUENT_GROUPS_COUNT } from '~/super_sidebar/constants';
+import FrequentItems from './frequent_items.vue';
+
+export default {
+ name: 'FrequentlyVisitedGroups',
+ components: {
+ FrequentItems,
+ },
+ inject: ['groupsPath'],
+ data() {
+ const username = gon.current_username;
+
+ return {
+ storageKey: username ? `${username}/frequent-groups` : null,
+ };
+ },
+ i18n: {
+ groupName: s__('Navigation|Frequently visited groups'),
+ viewAllText: s__('Navigation|View all my groups'),
+ emptyStateText: s__('Navigation|Groups you visit often will appear here.'),
+ },
+ MAX_FREQUENT_GROUPS_COUNT,
+};
+</script>
+
+<template>
+ <frequent-items
+ :empty-state-text="$options.i18n.emptyStateText"
+ :group-name="$options.i18n.groupName"
+ :max-items="$options.MAX_FREQUENT_GROUPS_COUNT"
+ :storage-key="storageKey"
+ view-all-items-icon="group"
+ :view-all-items-text="$options.i18n.viewAllText"
+ :view-all-items-path="groupsPath"
+ v-bind="$attrs"
+ v-on="$listeners"
+ />
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue
new file mode 100644
index 00000000000..5371887ee0f
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
+import { __ } from '~/locale';
+
+export default {
+ name: 'FrequentlyVisitedItem',
+ components: {
+ GlButton,
+ ProjectAvatar,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ onRemove() {
+ this.$emit('remove', this.item);
+ },
+ },
+ i18n: {
+ remove: __('Remove'),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center gl-gap-3">
+ <project-avatar
+ :project-id="item.id"
+ :project-name="item.title"
+ :project-avatar-url="item.avatar"
+ :size="24"
+ aria-hidden="true"
+ />
+
+ <div class="gl-flex-grow-1 gl-truncate-end">
+ {{ item.title }}
+ <div
+ v-if="item.subtitle"
+ data-testid="subtitle"
+ class="gl-font-sm gl-text-gray-500 gl-truncate-end"
+ >
+ {{ item.subtitle }}
+ </div>
+ </div>
+
+ <gl-button
+ v-gl-tooltip.left
+ icon="dash"
+ category="tertiary"
+ :aria-label="$options.i18n.remove"
+ :title="$options.i18n.remove"
+ class="show-on-focus-or-hover--target"
+ @click.stop.prevent="onRemove"
+ @keydown.enter.stop.prevent="onRemove"
+ />
+ </div>
+</template>
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
new file mode 100644
index 00000000000..382d844ceee
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui';
+import { truncateNamespace } from '~/lib/utils/text_utility';
+import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils';
+import FrequentItem from './frequent_item.vue';
+
+export default {
+ name: 'FrequentlyVisitedItems',
+ components: {
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ GlIcon,
+ FrequentItem,
+ },
+ props: {
+ emptyStateText: {
+ type: String,
+ required: true,
+ },
+ groupName: {
+ type: String,
+ required: true,
+ },
+ maxItems: {
+ type: Number,
+ required: true,
+ },
+ storageKey: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ viewAllItemsText: {
+ type: String,
+ required: true,
+ },
+ viewAllItemsIcon: {
+ type: String,
+ required: true,
+ },
+ viewAllItemsPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ items: getItemsFromLocalStorage({
+ storageKey: this.storageKey,
+ maxItems: this.maxItems,
+ }),
+ };
+ },
+ computed: {
+ formattedItems() {
+ // Each item needs two different representations. One is for the
+ // GlDisclosureDropdownItem, and the other is for the FrequentItem
+ // renderer component inside it.
+ return this.items.map((item) => ({
+ forDropdown: {
+ id: item.id,
+
+ // The text field satsifies GlDisclosureDropdownItem's prop
+ // validator, and the href field ensures it renders a link.
+ text: item.name,
+ href: item.webUrl,
+ },
+ forRenderer: {
+ id: item.id,
+ title: item.name,
+ subtitle: truncateNamespace(item.namespace),
+ avatar: item.avatarUrl,
+ },
+ }));
+ },
+ showEmptyState() {
+ return this.items.length === 0;
+ },
+ viewAllItem() {
+ return {
+ text: this.viewAllItemsText,
+ href: this.viewAllItemsPath,
+ };
+ },
+ },
+ created() {
+ if (!this.storageKey) {
+ this.$emit('nothing-to-render');
+ }
+ },
+ methods: {
+ removeItem(item) {
+ removeItemFromLocalStorage({
+ storageKey: this.storageKey,
+ item,
+ });
+
+ this.items = this.items.filter((i) => i.id !== item.id);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown-group v-if="storageKey" v-bind="$attrs">
+ <template #group-label>{{ groupName }}</template>
+
+ <gl-disclosure-dropdown-item
+ v-for="item of formattedItems"
+ :key="item.forDropdown.id"
+ :item="item.forDropdown"
+ class="show-on-focus-or-hover--context"
+ >
+ <template #list-item
+ ><frequent-item :item="item.forRenderer" @remove="removeItem"
+ /></template>
+ </gl-disclosure-dropdown-item>
+
+ <gl-disclosure-dropdown-item v-if="showEmptyState" class="gl-cursor-text">
+ <span class="gl-text-gray-500 gl-font-sm gl-my-3 gl-mx-3">{{ emptyStateText }}</span>
+ </gl-disclosure-dropdown-item>
+
+ <gl-disclosure-dropdown-item key="all" :item="viewAllItem">
+ <template #list-item>
+ <span>
+ <gl-icon :name="viewAllItemsIcon" class="gl-w-6!" />
+ {{ viewAllItemsText }}
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue
new file mode 100644
index 00000000000..35b254099c2
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue
@@ -0,0 +1,40 @@
+<script>
+import { s__ } from '~/locale';
+import { MAX_FREQUENT_PROJECTS_COUNT } from '~/super_sidebar/constants';
+import FrequentItems from './frequent_items.vue';
+
+export default {
+ name: 'FrequentlyVisitedProjects',
+ components: {
+ FrequentItems,
+ },
+ inject: ['projectsPath'],
+ data() {
+ const username = gon.current_username;
+
+ return {
+ storageKey: username ? `${username}/frequent-projects` : null,
+ };
+ },
+ i18n: {
+ groupName: s__('Navigation|Frequently visited projects'),
+ viewAllText: s__('Navigation|View all my projects'),
+ emptyStateText: s__('Navigation|Projects you visit often will appear here.'),
+ },
+ MAX_FREQUENT_PROJECTS_COUNT,
+};
+</script>
+
+<template>
+ <frequent-items
+ :empty-state-text="$options.i18n.emptyStateText"
+ :group-name="$options.i18n.groupName"
+ :max-items="$options.MAX_FREQUENT_PROJECTS_COUNT"
+ :storage-key="storageKey"
+ view-all-items-icon="project"
+ :view-all-items-text="$options.i18n.viewAllText"
+ :view-all-items-path="projectsPath"
+ v-bind="$attrs"
+ v-on="$listeners"
+ />
+</template>
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 bec8c191b31..b64f3ac52b2 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
@@ -8,6 +8,7 @@ import {
GlResizeObserverDirective,
GlModal,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { debounce, clamp } from 'lodash';
import { truncate } from '~/lib/utils/text_utility';
@@ -200,17 +201,21 @@ export default {
const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR);
if (code === HOME_KEY) {
+ if (isSearchInput) return;
+
this.focusItem(0, elements);
} else if (code === END_KEY) {
+ if (isSearchInput) return;
+
this.focusItem(elements.length - 1, elements);
} else if (code === ARROW_UP_KEY) {
if (isSearchInput) return;
if (elements.indexOf(target) === 0) {
this.focusSearchInput();
- return;
+ } else {
+ this.focusNextItem(event, elements, -1);
}
- this.focusNextItem(event, elements, -1);
} else if (code === ARROW_DOWN_KEY) {
this.focusNextItem(event, elements, 1);
} else if (code === ESC_KEY) {
@@ -290,10 +295,9 @@ export default {
<form
role="search"
:aria-label="searchPlaceholder"
- class="gl-relative gl-rounded-base gl-w-full"
- data-testid="global-search-form"
+ class="gl-relative gl-rounded-base gl-w-full gl-pb-0"
>
- <div class="gl-p-1 gl-relative">
+ <div class="gl-relative gl-bg-white gl-border-b gl-mb-n1 gl-p-3">
<gl-search-box-by-type
id="search"
ref="searchInput"
@@ -346,8 +350,7 @@ export default {
</span>
<div
ref="resultsList"
- data-testid="global-search-results"
- class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2"
+ class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-3"
@keydown="onKeydown"
>
<command-palette-items
@@ -357,12 +360,11 @@ export default {
@updated="highlightFirstCommand"
/>
+ <global-search-default-items v-else-if="showDefaultItems" />
+
<template v-else>
- <global-search-default-items v-if="showDefaultItems" />
- <template v-else>
- <global-search-scoped-items v-if="showScopedSearchItems" />
- <global-search-autocomplete-items />
- </template>
+ <global-search-scoped-items v-if="showScopedSearchItems" />
+ <global-search-autocomplete-items />
</template>
</div>
<template v-if="searchContext">
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
index cd623200b03..23ea0af12fc 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
@@ -1,5 +1,6 @@
<script>
import { GlAvatar, GlAlert, GlLoadingIcon, GlDisclosureDropdownGroup } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import highlight from '~/lib/utils/highlight';
@@ -23,9 +24,6 @@ export default {
computed: {
...mapState(['search', 'loading', 'autocompleteError']),
...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']),
- isPrecededByScopedOptions() {
- return this.scopedSearchOptions.length > 1;
- },
},
methods: {
highlightedName(val) {
@@ -40,9 +38,9 @@ export default {
<div>
<ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none">
<gl-disclosure-dropdown-group
- v-for="group in autocompleteGroupedSearchOptions"
+ v-for="(group, index) in autocompleteGroupedSearchOptions"
:key="group.name"
- :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }"
+ :class="{ 'gl-mt-0!': index === 0 }"
:group="group"
bordered
>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_issuables.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_issuables.vue
new file mode 100644
index 00000000000..1b7b8268ee3
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_issuables.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlDisclosureDropdownGroup } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
+import { mapState, mapGetters } from 'vuex';
+import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
+
+export default {
+ name: 'DefaultIssuables',
+ i18n: {
+ ALL_GITLAB,
+ },
+ components: {
+ GlDisclosureDropdownGroup,
+ },
+ computed: {
+ ...mapState(['searchContext']),
+ ...mapGetters(['defaultSearchOptions']),
+ currentContextName() {
+ return (
+ this.searchContext?.project?.name ||
+ this.searchContext?.group?.name ||
+ this.$options.i18n.ALL_GITLAB
+ );
+ },
+ shouldRender() {
+ return this.group.items.length > 0;
+ },
+ group() {
+ return {
+ name: this.currentContextName,
+ items: this.defaultSearchOptions,
+ };
+ },
+ },
+ created() {
+ if (!this.shouldRender) {
+ this.$emit('nothing-to-render');
+ }
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown-group v-if="shouldRender" v-bind="$attrs" :group="group" />
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
index 239c61fd750..27935d92a5c 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
@@ -1,38 +1,53 @@
<script>
-import { GlDisclosureDropdownGroup } from '@gitlab/ui';
-import { mapState, mapGetters } from 'vuex';
-import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
+import DefaultPlaces from './global_search_default_places.vue';
+import DefaultIssuables from './global_search_default_issuables.vue';
+import FrequentGroups from './frequent_groups.vue';
+import FrequentProjects from './frequent_projects.vue';
+
+const components = [DefaultPlaces, FrequentProjects, FrequentGroups, DefaultIssuables];
export default {
name: 'GlobalSearchDefaultItems',
- i18n: {
- ALL_GITLAB,
- },
- components: {
- GlDisclosureDropdownGroup,
+ data() {
+ return {
+ // The components here are expected to:
+ // - be responsible for getting their own data,
+ // - render a GlDisclosureDropdownGroup as the root vnode,
+ // - transparently pass all attrs to it (e.g., `bordered`),
+ // - not render anything if they have no data,
+ // - emit a `nothing-to-render` event if they have nothing to render.
+ // - have a unique `name`
+ componentNames: components.map(({ name }) => name),
+ };
},
- computed: {
- ...mapState(['searchContext']),
- ...mapGetters(['defaultSearchOptions']),
- sectionHeader() {
- return (
- this.searchContext?.project?.name ||
- this.searchContext?.group?.name ||
- this.$options.i18n.ALL_GITLAB
- );
+ methods: {
+ componentFromName(name) {
+ return components.find((component) => component.name === name);
+ },
+ remove(nameToRemove) {
+ const indexToRemove = this.componentNames.findIndex((name) => name === nameToRemove);
+ if (indexToRemove !== -1) this.componentNames.splice(indexToRemove, 1);
},
- defaultItemsGroup() {
- return {
- name: this.sectionHeader,
- items: this.defaultSearchOptions,
- };
+ attrs(index) {
+ return index === 0
+ ? null
+ : {
+ bordered: true,
+ class: 'gl-mt-3',
+ };
},
},
};
</script>
<template>
- <ul class="gl-p-0 gl-m-0 gl-list-style-none">
- <gl-disclosure-dropdown-group :group="defaultItemsGroup" bordered class="gl-mt-0!" />
+ <ul class="gl-p-0 gl-m-0 gl-pt-2 gl-list-style-none">
+ <component
+ :is="componentFromName(name)"
+ v-for="(name, index) in componentNames"
+ :key="name"
+ v-bind="attrs(index)"
+ @nothing-to-render="remove(name)"
+ />
</ul>
</template>
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
new file mode 100644
index 00000000000..9a375837102
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlDisclosureDropdownGroup } from '@gitlab/ui';
+import { PLACES } from '~/vue_shared/global_search/constants';
+
+export default {
+ name: 'DefaultPlaces',
+ i18n: {
+ PLACES,
+ },
+ components: {
+ GlDisclosureDropdownGroup,
+ },
+ inject: ['contextSwitcherLinks'],
+ computed: {
+ shouldRender() {
+ return this.contextSwitcherLinks.length > 0;
+ },
+ group() {
+ return {
+ name: this.$options.i18n.PLACES,
+ items: this.contextSwitcherLinks.map(({ title, link }) => ({ text: title, href: link })),
+ };
+ },
+ },
+ created() {
+ if (!this.shouldRender) {
+ this.$emit('nothing-to-render');
+ }
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown-group v-if="shouldRender" v-bind="$attrs" :group="group" />
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
index 76600f829f6..1f5e7e45cc1 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlToken, GlDisclosureDropdownGroup } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import { s__, sprintf } from '~/locale';
import { truncate } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
index 5a860fcd1ab..dc8fc4d2452 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/constants.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
@@ -21,6 +21,6 @@ export const INPUT_FIELD_PADDING = 84;
export const FETCH_TYPES = ['generic', 'search'];
export const SEARCH_MODAL_ID = 'super-sidebar-search-modal';
-export const SEARCH_INPUT_SELECTOR = '.gl-search-box-by-type-input-borderless';
+export const SEARCH_INPUT_SELECTOR = 'input[role="searchbox"]';
export const SEARCH_RESULTS_ITEM_SELECTOR = '.gl-new-dropdown-item';
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
index 4a42f416206..6871dabc9a1 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
@@ -1,6 +1,8 @@
import { omitBy, isNil } from 'lodash';
import { objectToQuery } from '~/lib/utils/url_utility';
import {
+ ISSUES_CATEGORY,
+ MERGE_REQUEST_CATEGORY,
MSG_ISSUES_ASSIGNED_TO_ME,
MSG_ISSUES_IVE_CREATED,
MSG_MR_ASSIGNED_TO_ME,
@@ -46,7 +48,7 @@ export const scopedIssuesPath = (state) => {
return (
state.searchContext?.project_metadata?.issues_path ||
state.searchContext?.group_metadata?.issues_path ||
- state.issuesPath
+ (gon.current_username ? state.issuesPath : false)
);
};
@@ -54,13 +56,33 @@ export const scopedMRPath = (state) => {
return (
state.searchContext?.project_metadata?.mr_path ||
state.searchContext?.group_metadata?.mr_path ||
- state.mrPath
+ (gon.current_username ? state.mrPath : false)
);
};
export const defaultSearchOptions = (state, getters) => {
const userName = gon.current_username;
+ if (!userName) {
+ const options = [];
+
+ if (getters.scopedIssuesPath) {
+ options.push({
+ text: ISSUES_CATEGORY,
+ href: getters.scopedIssuesPath,
+ });
+ }
+
+ if (getters.scopedMRPath) {
+ options.push({
+ text: MERGE_REQUEST_CATEGORY,
+ href: getters.scopedMRPath,
+ });
+ }
+
+ return options;
+ }
+
const issues = [
{
text: MSG_ISSUES_ASSIGNED_TO_ME,
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/index.js b/app/assets/javascripts/super_sidebar/components/global_search/store/index.js
index b83433c5b49..ca5519f529c 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/store/index.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue
index 764db490751..1bad13f91e8 100644
--- a/app/assets/javascripts/super_sidebar/components/items_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/items_list.vue
@@ -19,7 +19,13 @@ export default {
<template>
<ul class="gl-p-0 gl-list-style-none">
- <nav-item v-for="item in items" :key="item.id" :item="item" is-subitem>
+ <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"
diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue
index 73a899eeb83..d2d45ca7b6e 100644
--- a/app/assets/javascripts/super_sidebar/components/menu_section.vue
+++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue
@@ -2,6 +2,7 @@
import { kebabCase } from 'lodash';
import { GlCollapse, GlIcon } from '@gitlab/ui';
import NavItem from './nav_item.vue';
+import FlyoutMenu from './flyout_menu.vue';
export default {
name: 'MenuSection',
@@ -9,6 +10,7 @@ export default {
GlCollapse,
GlIcon,
NavItem,
+ FlyoutMenu,
},
props: {
item: {
@@ -30,10 +32,18 @@ export default {
required: false,
default: 'div',
},
+ hasFlyout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
isExpanded: Boolean(this.expanded || this.item.is_active),
+ isMouseOverSection: false,
+ isMouseOverFlyout: false,
+ keepFlyoutClosed: false,
};
},
computed: {
@@ -45,6 +55,9 @@ export default {
};
},
collapseIcon() {
+ if (this.hasFlyout) {
+ return this.isExpanded ? 'chevron-down' : 'chevron-right';
+ }
return this.isExpanded ? 'chevron-up' : 'chevron-down';
},
computedLinkClasses() {
@@ -58,10 +71,23 @@ export default {
itemId() {
return kebabCase(this.item.title);
},
+ isMouseOver() {
+ return this.isMouseOverSection || this.isMouseOverFlyout;
+ },
},
watch: {
isExpanded(newIsExpanded) {
this.$emit('collapse-toggle', newIsExpanded);
+ this.keepFlyoutClosed = !this.newIsExpanded;
+ },
+ },
+ methods: {
+ handlePointerover(e) {
+ this.isMouseOverSection = e.pointerType === 'mouse';
+ },
+ handlePointerleave() {
+ this.isMouseOverSection = false;
+ this.keepFlyoutClosed = false;
},
},
};
@@ -71,15 +97,18 @@ export default {
<component :is="tag">
<hr v-if="separated" aria-hidden="true" class="gl-mx-4 gl-my-2" />
<button
+ :id="`menu-section-button-${itemId}`"
class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-2 gl-py-2 gl-px-3 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus"
:class="computedLinkClasses"
data-qa-selector="menu_section_button"
:data-qa-section-name="item.title"
v-bind="buttonProps"
@click="isExpanded = !isExpanded"
+ @pointerover="handlePointerover"
+ @pointerleave="handlePointerleave"
>
<span
- :class="[isActive ? 'gl-bg-blue-500' : 'gl-bg-transparent']"
+ :class="[isActive ? 'active-indicator gl-bg-blue-500' : 'gl-bg-transparent']"
class="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"
@@ -99,6 +128,17 @@ export default {
</span>
</button>
+ <flyout-menu
+ v-if="hasFlyout"
+ v-show="isMouseOver && !isExpanded && !keepFlyoutClosed"
+ :target-id="`menu-section-button-${itemId}`"
+ :items="item.items"
+ @mouseover="isMouseOverFlyout = true"
+ @mouseleave="isMouseOverFlyout = false"
+ @pin-add="(itemId) => $emit('pin-add', itemId)"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
+ />
+
<gl-collapse
:id="itemId"
v-model="isExpanded"
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue
index c1e1f64dbc1..36803a885e7 100644
--- a/app/assets/javascripts/super_sidebar/components/nav_item.vue
+++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue
@@ -56,6 +56,11 @@ export default {
required: false,
default: false,
},
+ isFlyout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
pillData() {
@@ -104,6 +109,8 @@ export default {
return {
'gl-px-2 gl-mx-2 gl-line-height-normal': this.isSubitem,
'gl-px-3': !this.isSubitem,
+ 'gl-pl-5! gl-rounded-small': this.isFlyout,
+ 'gl-rounded-base': !this.isFlyout,
[this.item.link_classes]: this.item.link_classes,
...this.linkClasses,
};
@@ -121,25 +128,25 @@ export default {
:is="navItemLinkComponent"
#default="{ isActive }"
v-bind="linkProps"
- class="nav-item-link gl-rounded-base 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"
+ 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="computedLinkClasses"
data-qa-selector="nav_item_link"
data-testid="nav-item-link"
>
<div
- :class="[isActive ? 'gl-bg-blue-500' : 'gl-bg-transparent']"
- class="gl-absolute gl-left-2 gl-top-2 gl-bottom-2 gl-transition-slow"
+ :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"
data-testid="active-indicator"
></div>
- <div class="gl-flex-shrink-0 gl-w-6 gl-display-flex">
+ <div v-if="!isFlyout" class="gl-flex-shrink-0 gl-w-6 gl-display-flex">
<slot name="icon">
<gl-icon v-if="item.icon" :name="item.icon" class="gl-m-auto item-icon" />
<gl-icon
v-else-if="isInPinnedSection"
name="grip"
- class="gl-m-auto gl-text-gray-400 draggable-icon"
+ class="gl-m-auto gl-text-gray-400 js-draggable-icon gl-cursor-grab show-on-focus-or-hover--target"
/>
</slot>
</div>
@@ -161,20 +168,22 @@ export default {
</gl-badge>
<gl-button
v-if="isPinnable && !isPinned"
- v-gl-tooltip.right.viewport="$options.i18n.pinItem"
+ 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.right.viewport="$options.i18n.unpinItem"
+ 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>
diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
index ccd739c8bb1..1e2201fbdff 100644
--- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue
+++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
@@ -28,6 +28,11 @@ export default {
required: false,
default: false,
},
+ hasFlyout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -40,7 +45,12 @@ export default {
return this.items.some((item) => item.is_active);
},
sectionItem() {
- return { title: this.$options.i18n.pinned, icon: 'thumbtack', is_active: this.isActive };
+ return {
+ title: this.$options.i18n.pinned,
+ icon: 'thumbtack',
+ is_active: this.isActive,
+ items: this.draggableItems,
+ };
},
itemIds() {
return this.draggableItems.map((item) => item.id);
@@ -75,14 +85,16 @@ export default {
:item="sectionItem"
:expanded="expanded"
:separated="separated"
+ :has-flyout="hasFlyout"
@collapse-toggle="expanded = !expanded"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
>
<draggable
v-if="items.length > 0"
v-model="draggableItems"
class="gl-p-0 gl-m-0"
data-testid="pinned-nav-items"
- handle=".draggable-icon"
+ handle=".js-draggable-icon"
tag="ul"
@end="handleDrag"
>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
index 287e4f57d01..821b9dbcb7b 100644
--- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
@@ -1,7 +1,9 @@
<script>
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';
import PinnedSection from './pinned_section.vue';
@@ -14,6 +16,7 @@ export default {
NavItem,
PinnedSection,
},
+ mixins: [glFeatureFlagsMixin()],
provide() {
return {
@@ -27,6 +30,10 @@ export default {
type: Array,
required: true,
},
+ isLoggedIn: {
+ type: Boolean,
+ required: true,
+ },
pinnedItemIds: {
type: Array,
required: false,
@@ -39,7 +46,8 @@ export default {
},
updatePinsUrl: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
},
@@ -49,6 +57,8 @@ export default {
data() {
return {
+ showFlyoutMenus: false,
+
// This is used as a provide and injected into the nav items.
// Note: It has to be an object to be reactive.
changedPinnedItemIds: { ids: this.pinnedItemIds },
@@ -92,12 +102,21 @@ export default {
.filter(Boolean);
},
supportsPins() {
- return PANELS_WITH_PINS.includes(this.panelType);
+ return this.isLoggedIn && PANELS_WITH_PINS.includes(this.panelType);
},
hasStaticItems() {
return this.staticItems.length > 0;
},
},
+ mounted() {
+ if (this.glFeatures.superSidebarFlyoutMenus) {
+ this.decideFlyoutState();
+ window.addEventListener('resize', this.decideFlyoutState);
+ }
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.decideFlyoutState);
+ },
methods: {
createPin(itemId) {
this.changedPinnedItemIds.ids.push(itemId);
@@ -137,6 +156,9 @@ export default {
isSection(navItem) {
return navItem.items?.length;
},
+ decideFlyoutState() {
+ this.showFlyoutMenus = GlBreakpointInstance.windowWidth() >= breakpoints.md;
+ },
},
};
</script>
@@ -150,6 +172,7 @@ export default {
v-if="supportsPins"
separated
:items="pinnedItems"
+ :has-flyout="showFlyoutMenus"
@pin-remove="destroyPin"
@pin-reorder="movePin"
/>
@@ -166,6 +189,7 @@ export default {
:key="item.id"
:item="item"
:separated="item.separated"
+ :has-flyout="showFlyoutMenus"
@pin-add="createPin"
@pin-remove="destroyPin"
/>
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 6058ed3a1cd..ec728b4af9e 100644
--- a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
@@ -11,6 +11,12 @@ export const STATE_WILL_CLOSE = 'will-close';
export default {
name: 'SidebarPeek',
mixins: [Tracking.mixin()],
+ props: {
+ isMouseOverSidebar: {
+ type: Boolean,
+ required: true,
+ },
+ },
created() {
// Nothing needs to observe these properties, so they are not reactive.
this.state = null;
@@ -57,6 +63,11 @@ export default {
this.close();
}
} else if (this.state === STATE_OPEN) {
+ // Do not close the sidebar if it or one of its child elements still
+ // has mouseover. This allows to move the mouse from the sidebar to
+ // one of its flyout menus.
+ if (this.isMouseOverSidebar) return;
+
if (clientX >= this.xAwayFromSidebar) {
this.close();
} else if (clientX >= this.xSidebarEdge) {
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
index c194401ce95..29a3147e949 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
@@ -8,6 +8,7 @@ import { sidebarState } from '../constants';
import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
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';
@@ -17,6 +18,7 @@ export default {
components: {
GlButton,
UserBar,
+ ContextHeader,
ContextSwitcher,
HelpCenter,
SidebarMenu,
@@ -42,6 +44,7 @@ export default {
return {
sidebarState,
showPeekHint: false,
+ isMouseover: false,
};
},
computed: {
@@ -57,7 +60,7 @@ export default {
},
watch: {
'sidebarState.isCollapsed': function isCollapsedWatcher(newIsCollapsed) {
- if (newIsCollapsed) {
+ if (newIsCollapsed && this.$refs['context-switcher']) {
this.$refs['context-switcher'].close();
}
},
@@ -118,6 +121,8 @@ export default {
data-testid="super-sidebar"
data-qa-selector="navbar"
:inert="sidebarState.isCollapsed"
+ @mouseenter="isMouseover = true"
+ @mouseleave="isMouseover = false"
>
<user-bar :has-collapse-button="!sidebarState.isPeek" :sidebar-data="sidebarData" />
<div v-if="showTrialStatusWidget" class="gl-px-2 gl-py-2">
@@ -126,15 +131,17 @@ export default {
/>
<trial-status-popover />
</div>
- <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden">
+ <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"
- :persistent-links="sidebarData.context_switcher_links"
:username="sidebarData.username"
:projects-path="sidebarData.projects_path"
:groups-path="sidebarData.groups_path"
@@ -142,9 +149,11 @@ export default {
:context-header="sidebarData.current_context_header"
@toggle="onContextSwitcherToggled"
/>
+ <context-header v-else :context="sidebarData.current_context_header" />
<sidebar-menu
v-if="menuItems.length"
:items="menuItems"
+ :is-logged-in="sidebarData.is_logged_in"
:panel-type="sidebarData.panel_type"
:pinned-item-ids="sidebarData.pinned_items"
:update-pins-url="sidebarData.update_pins_url"
@@ -170,6 +179,10 @@ export default {
Only mount SidebarPeekBehavior if the sidebar is peekable, to avoid
setting up event listeners unnecessarily.
-->
- <sidebar-peek-behavior v-if="sidebarState.isPeekable" @change="onPeekChange" />
+ <sidebar-peek-behavior
+ v-if="sidebarState.isPeekable"
+ :is-mouse-over-sidebar="isMouseover"
+ @change="onPeekChange"
+ />
</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 87762a62c0f..7d5e87805d5 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
@@ -74,7 +74,7 @@ export default {
<template>
<gl-button
- v-gl-tooltip.hover="tooltip"
+ v-gl-tooltip.hover.noninteractive.ds500="tooltip"
aria-controls="super-sidebar"
:aria-expanded="ariaExpanded"
:aria-label="$options.i18n.navigationSidebar"
diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue
index a882df057fa..b76ef91b768 100644
--- a/app/assets/javascripts/super_sidebar/components/user_bar.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue
@@ -105,17 +105,20 @@ export default {
<template>
<div class="user-bar">
<div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2">
- <brand-logo :logo-url="sidebarData.logo_url" />
- <gl-badge
- v-if="sidebarData.gitlab_com_and_canary"
- variant="success"
- :href="sidebarData.canary_toggle_com_url"
- size="sm"
- class="gl-ml-2"
- >
- {{ $options.NEXT_LABEL }}
- </gl-badge>
- <div class="gl-flex-grow-1"></div>
+ <template v-if="sidebarData.is_logged_in">
+ <brand-logo :logo-url="sidebarData.logo_url" />
+ <gl-badge
+ v-if="sidebarData.gitlab_com_and_canary"
+ variant="success"
+ :href="sidebarData.canary_toggle_com_url"
+ size="sm"
+ class="gl-ml-2"
+ >
+ {{ $options.NEXT_LABEL }}
+ </gl-badge>
+ <div class="gl-flex-grow-1"></div>
+ </template>
+
<super-sidebar-toggle
v-if="hasCollapseButton"
:class="$options.JS_TOGGLE_COLLAPSE_CLASS"
@@ -123,11 +126,11 @@ export default {
tooltip-container="super-sidebar"
data-testid="super-sidebar-collapse-button"
/>
- <create-menu :groups="sidebarData.create_new_menu_groups" />
+ <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.html="searchTooltip"
+ v-gl-tooltip.bottom.hover.noninteractive.ds500.html="searchTooltip"
v-gl-modal="$options.SEARCH_MODAL_ID"
data-testid="super-sidebar-search-button"
icon="search"
@@ -136,24 +139,26 @@ export default {
/>
<search-modal @shown="hideSearchTooltip" @hidden="showSearchTooltip" />
- <user-menu :data="sidebarData" />
+ <user-menu v-if="sidebarData.is_logged_in" :data="sidebarData" />
<gl-button
v-if="isImpersonating"
- v-gl-tooltip
+ v-gl-tooltip.noninteractive.ds500.bottom
:href="sidebarData.stop_impersonation_path"
:title="$options.i18n.stopImpersonating"
:aria-label="$options.i18n.stopImpersonating"
icon="incognito"
- variant="confirm"
category="tertiary"
data-method="delete"
data-testid="stop-impersonation-btn"
/>
</div>
- <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2">
+ <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"
+ >
<counter
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues"
+ v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.issues"
class="gl-flex-basis-third dashboard-shortcuts-issues"
icon="issues"
:count="userCounts.assigned_issues"
@@ -171,7 +176,9 @@ export default {
@hidden="mrMenuShown = false"
>
<counter
- v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests"
+ v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="
+ mrMenuShown ? '' : $options.i18n.mergeRequests
+ "
class="gl-w-full"
icon="merge-request-open"
:count="mergeRequestTotalCount"
@@ -183,7 +190,7 @@ export default {
/>
</merge-request-menu>
<counter
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList"
+ v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.todoList"
class="gl-flex-basis-third shortcuts-todos js-todos-count"
icon="todo-done"
:count="userCounts.todos"
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 f3e8816cd37..13f19338610 100644
--- a/app/assets/javascripts/super_sidebar/components/user_name_group.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
@@ -76,6 +76,7 @@ export default {
<gl-emoji :data-name="user.status.emoji" class="gl-mr-1" />
<span v-safe-html="user.status.message_html" class="gl-text-truncate"></span>
<gl-tooltip
+ v-if="user.status.message_html"
:target="() => $refs.statusTooltipTarget"
boundary="viewport"
placement="bottom"
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
index 322eca72016..2b62e7a6ede 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
@@ -40,6 +40,7 @@ const getTrialStatusWidgetData = (sidebarData) => {
lastName,
companyName,
glmContent,
+ createHandRaiseLeadPath,
} = convertObjectPropsToCamelCase(sidebarData.trial_status_popover_data_attrs);
return {
@@ -53,6 +54,7 @@ const getTrialStatusWidgetData = (sidebarData) => {
plansHref,
daysRemaining,
targetId,
+ createHandRaiseLeadPath,
trialEndDate: new Date(trialEndDate),
user: { namespaceId, userName, firstName, lastName, companyName, glmContent },
};
@@ -79,11 +81,15 @@ export const initSuperSidebar = () => {
const sidebarData = JSON.parse(sidebar);
const searchData = convertObjectPropsToCamelCase(sidebarData.search);
+ const projectsPath = sidebarData.projects_path;
+ const groupsPath = sidebarData.groups_path;
+
const commandPaletteData = JSON.parse(commandPalette);
const projectFilesPath = commandPaletteData.project_files_url;
const projectBlobPath = commandPaletteData.project_blob_url;
const commandPaletteCommands = sidebarData.create_new_menu_groups || [];
const commandPaletteLinks = convertObjectPropsToCamelCase(sidebarData.current_menu_items || []);
+ const contextSwitcherLinks = sidebarData.context_switcher_links;
const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData;
const isImpersonating = parseBoolean(sidebarData.is_impersonating);
@@ -99,10 +105,13 @@ export const initSuperSidebar = () => {
...getTrialStatusWidgetData(sidebarData),
commandPaletteCommands,
commandPaletteLinks,
+ contextSwitcherLinks,
autocompletePath,
searchContext,
projectFilesPath,
projectBlobPath,
+ projectsPath,
+ groupsPath,
},
store: createStore({
searchPath,
diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js
index 3b17a35c5bc..cbf93155fb6 100644
--- a/app/assets/javascripts/super_sidebar/utils.js
+++ b/app/assets/javascripts/super_sidebar/utils.js
@@ -1,3 +1,4 @@
+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';
@@ -35,17 +36,15 @@ export const getTopFrequentItems = (items, maxCount) => {
return frequentItems.slice(0, maxCount);
};
-const updateItemAccess = (item) => {
+const updateItemAccess = (contextItem, { lastAccessedOn, frequency = 0 } = {}) => {
const now = Date.now();
- const neverAccessed = !item.lastAccessedOn;
- const shouldUpdate =
- neverAccessed || Math.abs(now - item.lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1;
- const currentFrequency = item.frequency ?? 0;
+ const neverAccessed = !lastAccessedOn;
+ const shouldUpdate = neverAccessed || Math.abs(now - lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1;
return {
- ...item,
- frequency: shouldUpdate ? currentFrequency + 1 : currentFrequency,
- lastAccessedOn: shouldUpdate ? now : item.lastAccessedOn,
+ ...contextItem,
+ frequency: shouldUpdate ? frequency + 1 : frequency,
+ lastAccessedOn: shouldUpdate ? now : lastAccessedOn,
};
};
@@ -62,7 +61,7 @@ export const trackContextAccess = (username, context) => {
);
if (existingItemIndex > -1) {
- storedItems[existingItemIndex] = updateItemAccess(storedItems[existingItemIndex]);
+ storedItems[existingItemIndex] = updateItemAccess(context.item, storedItems[existingItemIndex]);
} else {
const newItem = updateItemAccess(context.item);
if (storedItems.length === FREQUENT_ITEMS.MAX_COUNT) {
@@ -84,4 +83,31 @@ export const formatContextSwitcherItems = (items) =>
link,
}));
+export const getItemsFromLocalStorage = ({ storageKey, maxItems }) => {
+ if (!AccessorUtilities.canUseLocalStorage()) {
+ return [];
+ }
+
+ try {
+ const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(storageKey));
+ return getTopFrequentItems(parsedCachedFrequentItems, maxItems);
+ } catch (e) {
+ Sentry.captureException(e);
+ return [];
+ }
+};
+
+export const removeItemFromLocalStorage = ({ storageKey, item }) => {
+ try {
+ const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(storageKey));
+ const filteredItems = parsedCachedFrequentItems.filter((i) => i.id !== item.id);
+ localStorage.setItem(storageKey, JSON.stringify(filteredItems));
+
+ return filteredItems;
+ } catch (e) {
+ Sentry.captureException(e);
+ return [];
+ }
+};
+
export const ariaCurrent = (isActive) => (isActive ? 'page' : null);