diff options
Diffstat (limited to 'app/assets/javascripts/super_sidebar')
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); |